Skip to content

Commit 63786c5

Browse files
committed
linstor: fix encrypted volume snapshot backup and restore
Encrypted Linstor volumes use a LUKS layer inside the DRBD stack, so the storage-layer snapshot device holds ciphertext while the DRBD device CloudStack restores to is the decrypted view. Backing up the raw snapshot and writing it back to the decrypted device corrupted the volume (different data, unbootable root). Back up encrypted snapshots from the decrypted DRBD device (forcing the temporary-resource path) and store them as a LUKS-encrypted qcow2 using the volume passphrase, so snapshots are not kept in clear text on secondary storage. On revert, decrypt the qcow2 and write plaintext to the DRBD device; the LUKS layer re-encrypts it. The qemu-img shrink is skipped for encrypted volumes (the DRBD device is already net-sized). Add an integration test (test_linstor_encrypted_snapshots.py): the encrypted-root snapshot revert round-trip, that create-volume-from-encrypted-snapshot is rejected by CloudStack core, and a best-effort check that the backed-up qcow2 is LUKS-encrypted at rest.
1 parent 288f9a9 commit 63786c5

5 files changed

Lines changed: 537 additions & 13 deletions

File tree

plugins/storage/volume/linstor/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LinstorBackupSnapshotCommandWrapper.java

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818

1919
import java.io.File;
2020
import java.io.IOException;
21+
import java.util.ArrayList;
22+
import java.util.HashMap;
23+
import java.util.List;
24+
import java.util.Map;
2125

2226
import com.cloud.agent.api.to.DataStoreTO;
2327
import com.cloud.agent.api.to.NfsTO;
@@ -31,9 +35,11 @@
3135
import com.cloud.utils.script.Script;
3236
import org.apache.cloudstack.storage.command.CopyCmdAnswer;
3337
import org.apache.cloudstack.storage.to.SnapshotObjectTO;
38+
import org.apache.cloudstack.utils.cryptsetup.KeyFile;
3439
import org.apache.cloudstack.utils.qemu.QemuImg;
3540
import org.apache.cloudstack.utils.qemu.QemuImgException;
3641
import org.apache.cloudstack.utils.qemu.QemuImgFile;
42+
import org.apache.cloudstack.utils.qemu.QemuObject;
3743
import org.apache.commons.io.FileUtils;
3844
import org.apache.logging.log4j.LogManager;
3945
import org.apache.logging.log4j.Logger;
@@ -83,6 +89,7 @@ private String convertImageToQCow2(
8389
final String srcPath,
8490
final SnapshotObjectTO dst,
8591
final KVMStoragePool secondaryPool,
92+
final byte[] passphrase,
8693
int waitMilliSeconds
8794
)
8895
throws LibvirtException, QemuImgException, IOException
@@ -94,9 +101,22 @@ private String convertImageToQCow2(
94101
final QemuImgFile srcFile = new QemuImgFile(srcPath, QemuImg.PhysicalDiskFormat.RAW);
95102
final QemuImgFile dstFile = new QemuImgFile(dstPath, QemuImg.PhysicalDiskFormat.QCOW2);
96103

97-
// NOTE: the qemu img will also contain the drbd metadata at the end
98104
final QemuImg qemu = new QemuImg(waitMilliSeconds);
99-
qemu.convert(srcFile, dstFile);
105+
if (passphrase != null && passphrase.length > 0) {
106+
// Encrypted volumes are backed up from their decrypted DRBD device, so the snapshot
107+
// data here is plaintext. Encrypt the destination qcow2 with the volume's passphrase
108+
// (LUKS), so the snapshot is not stored in clear text on secondary storage.
109+
try (KeyFile keyFile = new KeyFile(passphrase)) {
110+
final Map<String, String> options = new HashMap<>();
111+
final List<QemuObject> qemuObjects = new ArrayList<>();
112+
qemuObjects.add(QemuObject.prepareSecretForQemuImg(QemuImg.PhysicalDiskFormat.QCOW2,
113+
QemuObject.EncryptFormat.LUKS, keyFile.toString(), "sec0", options));
114+
qemu.convert(srcFile, dstFile, options, qemuObjects, null, true);
115+
}
116+
} else {
117+
// NOTE: the qemu img will also contain the drbd metadata at the end
118+
qemu.convert(srcFile, dstFile);
119+
}
100120
LOGGER.info("Backup snapshot '{}' to '{}'", srcPath, dstPath);
101121
return dstPath;
102122
}
@@ -153,14 +173,21 @@ public CopyCmdAnswer execute(LinstorBackupSnapshotCommand cmd, LibvirtComputingR
153173

154174
secondaryPool = storagePoolMgr.getStoragePoolByURI(dstDataStore.getUrl());
155175

156-
String dstPath = convertImageToQCow2(srcPath, dst, secondaryPool, cmd.getWaitInMillSeconds());
176+
final byte[] passphrase = src.getVolume() != null ? src.getVolume().getPassphrase() : null;
177+
final boolean encrypted = passphrase != null && passphrase.length > 0;
157178

158-
// resize to real volume size, cutting of drbd metadata
159-
String result = qemuShrink(dstPath, src.getVolume().getSize(), cmd.getWaitInMillSeconds());
160-
if (result != null) {
161-
return new CopyCmdAnswer("qemu-img shrink failed: " + result);
179+
String dstPath = convertImageToQCow2(srcPath, dst, secondaryPool, passphrase, cmd.getWaitInMillSeconds());
180+
181+
if (!encrypted) {
182+
// resize to real volume size, cutting of drbd metadata
183+
// For encrypted volumes the source is the decrypted DRBD device (already net-sized,
184+
// no drbd metadata to cut); shrinking an encrypted qcow2 would also need the secret.
185+
String result = qemuShrink(dstPath, src.getVolume().getSize(), cmd.getWaitInMillSeconds());
186+
if (result != null) {
187+
return new CopyCmdAnswer("qemu-img shrink failed: " + result);
188+
}
189+
LOGGER.info("Backup shrunk " + dstPath + " to actual size " + src.getVolume().getSize());
162190
}
163-
LOGGER.info("Backup shrunk " + dstPath + " to actual size " + src.getVolume().getSize());
164191

165192
SnapshotObjectTO snapshot = setCorrectSnapshotSize(dst, dstPath);
166193
LOGGER.info("Actual file size for '{}' is {}", dstPath, snapshot.getPhysicalSize());
@@ -171,6 +198,9 @@ public CopyCmdAnswer execute(LinstorBackupSnapshotCommand cmd, LibvirtComputingR
171198
LOGGER.error(error);
172199
return new CopyCmdAnswer(cmd, e);
173200
} finally {
201+
if (src.getVolume() != null) {
202+
src.getVolume().clearPassphrase();
203+
}
174204
cleanupSecondaryPool(secondaryPool);
175205
if (zfsHidden) {
176206
zfsSnapdev(true, src.getPath());

plugins/storage/volume/linstor/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LinstorRevertBackupSnapshotCommandWrapper.java

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.cloud.hypervisor.kvm.resource.wrapper;
1818

1919
import java.io.File;
20+
import java.util.Collections;
2021

2122
import com.cloud.agent.api.to.DataStoreTO;
2223
import com.cloud.api.storage.LinstorRevertBackupSnapshotCommand;
@@ -31,9 +32,12 @@
3132
import org.apache.cloudstack.storage.datastore.util.LinstorUtil;
3233
import org.apache.cloudstack.storage.to.SnapshotObjectTO;
3334
import org.apache.cloudstack.storage.to.VolumeObjectTO;
35+
import org.apache.cloudstack.utils.cryptsetup.KeyFile;
36+
import org.apache.cloudstack.utils.qemu.QemuImageOptions;
3437
import org.apache.cloudstack.utils.qemu.QemuImg;
3538
import org.apache.cloudstack.utils.qemu.QemuImgException;
3639
import org.apache.cloudstack.utils.qemu.QemuImgFile;
40+
import org.apache.cloudstack.utils.qemu.QemuObject;
3741
import org.joda.time.Duration;
3842
import org.libvirt.LibvirtException;
3943

@@ -43,8 +47,9 @@ public final class LinstorRevertBackupSnapshotCommandWrapper
4347
{
4448

4549
private void convertQCow2ToRAW(
46-
KVMStoragePool pool, final String srcPath, final String dstUuid, int waitMilliSeconds)
47-
throws LibvirtException, QemuImgException
50+
KVMStoragePool pool, final String srcPath, final String dstUuid, final byte[] passphrase,
51+
int waitMilliSeconds)
52+
throws LibvirtException, QemuImgException, java.io.IOException
4853
{
4954
final String dstPath = pool.getPhysicalDisk(dstUuid).getPath();
5055
final QemuImgFile srcQemuFile = new QemuImgFile(
@@ -60,7 +65,20 @@ private void convertQCow2ToRAW(
6065
}
6166
final QemuImg qemu = new QemuImg(waitMilliSeconds, zeroedDevice, true);
6267
final QemuImgFile dstFile = new QemuImgFile(dstPath, QemuImg.PhysicalDiskFormat.RAW);
63-
qemu.convert(srcQemuFile, dstFile);
68+
if (passphrase != null && passphrase.length > 0) {
69+
// The backed-up qcow2 is LUKS-encrypted with the volume's passphrase. Decrypt it while
70+
// writing plaintext to the (decrypted) DRBD device; the Linstor LUKS layer re-encrypts it,
71+
// so no qemu encryption must be applied to the destination.
72+
try (KeyFile keyFile = new KeyFile(passphrase)) {
73+
final QemuObject srcSecret = QemuObject.prepareSecretForQemuImg(
74+
QemuImg.PhysicalDiskFormat.QCOW2, QemuObject.EncryptFormat.LUKS, keyFile.toString(), "sec0", null);
75+
final QemuImageOptions srcImageOpts = new QemuImageOptions(
76+
QemuImg.PhysicalDiskFormat.QCOW2, srcPath, "sec0");
77+
qemu.convert(srcQemuFile, dstFile, null, Collections.singletonList(srcSecret), srcImageOpts, null, false);
78+
}
79+
} else {
80+
qemu.convert(srcQemuFile, dstFile);
81+
}
6482
}
6583

6684
@Override
@@ -84,10 +102,13 @@ public CopyCmdAnswer execute(LinstorRevertBackupSnapshotCommand cmd, LibvirtComp
84102
secondaryPool = storagePoolMgr.getStoragePoolByURI(
85103
srcDataStore.getUrl() + File.separator + srcFile.getParent());
86104

105+
// The destination volume is the (same) original volume, whose passphrase the backed-up
106+
// qcow2 was encrypted with; use it to decrypt while restoring.
87107
convertQCow2ToRAW(
88108
linstorPool,
89109
secondaryPool.getLocalPath() + File.separator + srcFile.getName(),
90110
dst.getPath(),
111+
dst.getPassphrase(),
91112
cmd.getWaitInMillSeconds());
92113

93114
final VolumeObjectTO dstVolume = new VolumeObjectTO();
@@ -99,6 +120,7 @@ public CopyCmdAnswer execute(LinstorRevertBackupSnapshotCommand cmd, LibvirtComp
99120
logger.error(error);
100121
return new CopyCmdAnswer(cmd, e);
101122
} finally {
123+
dst.clearPassphrase();
102124
LinstorBackupSnapshotCommandWrapper.cleanupSecondaryPool(secondaryPool);
103125
}
104126
}

plugins/storage/volume/linstor/src/main/java/org/apache/cloudstack/storage/datastore/driver/LinstorPrimaryDataStoreDriverImpl.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,12 +1088,22 @@ protected Answer copySnapshot(DataObject srcData, DataObject destData) {
10881088
VirtualMachineManager.ExecuteInSequence.value());
10891089
cmd.setOptions(options);
10901090

1091-
Optional<RemoteHostEndPoint> optEP = getDiskfullEP(api, pool, rscName);
1091+
// For encrypted volumes Linstor adds a LUKS layer (DRBD -> LUKS -> STORAGE). The storage
1092+
// layer snapshot device (getSnapshotPath) therefore only exposes the raw LUKS ciphertext,
1093+
// while restore writes onto the decrypted DRBD device (/dev/drbd/by-res/.../0). Backing up
1094+
// the ciphertext and writing it back to the decrypted layer corrupts the volume (and the
1095+
// shrink to the net volume size would even truncate the ciphertext). So for encrypted
1096+
// volumes we never read the storage snapshot directly: restore the snapshot into a temporary
1097+
// resource and back up its decrypted DRBD device instead, symmetric to the restore path.
1098+
final boolean encrypted = snapshotObject.getBaseVolume().getPassphraseId() != null;
1099+
Optional<RemoteHostEndPoint> optEP = encrypted ?
1100+
Optional.empty() : getDiskfullEP(api, pool, rscName);
10921101
Answer answer;
10931102
if (optEP.isPresent()) {
10941103
answer = optEP.get().sendMessage(cmd);
10951104
} else {
1096-
logger.debug("No diskfull endpoint found to copy image, creating diskless endpoint");
1105+
logger.debug("No diskfull endpoint used to copy image (encrypted={}), using temporary resource",
1106+
encrypted);
10971107
answer = copyFromTemporaryResource(api, pool, rscName, snapshotName, snapshotObject, cmd);
10981108
}
10991109
return answer;

test/integration/plugins/linstor/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,21 @@ nosetests --with-marvin --marvin-config=<marvin-cfg-file> <cloudstack-dir>/test/
4848
```
4949

5050
You can also run these tests out of the box with PyDev or PyCharm or whatever.
51+
52+
## Encrypted snapshot tests
53+
54+
`test_linstor_encrypted_snapshots.py` covers the encrypted-volume snapshot round trip
55+
(create encrypted root disk -> snapshot -> revert / create-volume-from-snapshot) and that the
56+
backed-up qcow2 on secondary storage is itself LUKS encrypted.
57+
58+
Extra prerequisites:
59+
60+
* At least one KVM host with volume-encryption support (`host.encryptionsupported == true`, i.e.
61+
cryptsetup/qemu LUKS available). Tests self-skip if none is found.
62+
* The Linstor resource group used (`acs-basic`) must be able to add a LUKS layer to its volumes.
63+
* `lin.backup.snapshots` must be enabled (default) so snapshots are backed up to secondary storage;
64+
the test sets it. With it disabled the qcow2 path is not exercised.
65+
66+
```
67+
nosetests --with-marvin --marvin-config=<marvin-cfg-file> <cloudstack-dir>/test/integration/plugins/linstor/test_linstor_encrypted_snapshots.py --zone=<zone> --hypervisor=kvm
68+
```

0 commit comments

Comments
 (0)