diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md
index 1d2498e206..71df55896a 100644
--- a/docs/ENVIRONMENT.md
+++ b/docs/ENVIRONMENT.md
@@ -76,6 +76,10 @@ All tools:
`/etc/veritytab`. Only useful for debugging. Currently only supported by
`systemd-veritysetup-generator`.
+* `$SYSTEMD_CLONETAB` — if set, use this path instead of
+ `/etc/clonetab`. Only useful for debugging. Currently only supported by
+ `systemd-clonesetup-generator`.
+
* `$SYSTEMD_DEFAULT_HOSTNAME` — override the compiled-in fallback hostname
(relevant in particular for the system manager and `systemd-hostnamed`).
Must be a valid hostname (either a single label or a FQDN).
diff --git a/man/clonetab.xml b/man/clonetab.xml
new file mode 100644
index 0000000000..aa202e57fa
--- /dev/null
+++ b/man/clonetab.xml
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+ clonetab
+ systemd
+
+
+
+ clonetab
+ 5
+
+
+
+ clonetab
+ Configuration for dm-clone block devices
+
+
+
+ /etc/clonetab
+
+
+
+ Description
+
+ The /etc/clonetab file describes
+ dm-clone block devices that are set up during system boot.
+
+ Empty lines and lines starting with the #
+ character are ignored. Each of the remaining lines describes one
+ dm-clone device. Fields are delimited by white space.
+
+ Each line is in the formname source-device destination-device metadata-device [options]
+ The first four fields are mandatory, the fifth is optional.
+
+ The five fields of /etc/clonetab are defined as follows:
+
+
+
+ The first field contains the name of the resulting dm-clone device; its
+ block device is set up below /dev/mapper/.
+
+ The second field contains a path to the read-only source block device.
+ This is the device whose data is cloned to the destination device. Reads to regions not
+ yet hydrated are served directly from this device.
+
+ The third field contains a path to the writable destination block device.
+ The source device's data is copied here in the background. It must be at least as
+ large as the source device.
+
+ The fourth field contains a path to the metadata block device. This
+ small device tracks which regions of the destination have been hydrated and is managed
+ exclusively by dm-clone.
+
+ The fifth field, if present, contains comma-separated key=value
+ options. The following option is supported:
+
+
+
+
+ Controls the granularity of background hydration copying — how much
+ data is copied at a time. Region size is specified in bytes (standard suffixes like
+ K, M, G are supported), and must
+ correspond to a power of two between 4 KiB and 1 GiB. For example,
+ region-size=4K or region-size=4096 sets a 4 KiB region size.
+
+
+ One region is the atomic unit dm-clone tracks: it is either fully hydrated (copied to the destination)
+ or not, never partially. If a copy is interrupted mid-region, that whole region is
+ retried from scratch on next boot. Smaller regions mean finer progress tracking;
+ larger regions reduce metadata overhead. Defaults to 4K. For background, see
+ dm-clone kernel documentation.
+
+
+
+
+
+
+ If no options are needed, the field may be omitted entirely or
+ - may be used as a placeholder.
+
+
+
+
+ At early boot and when the system manager configuration is reloaded, this file is
+ translated into native systemd units by
+ systemd-clonesetup-generator8.
+
+
+
+ Examples
+
+
+ Simple clone without options
+ Clone a source device to a destination, using a separate metadata device:
+ mydevice /dev/sdb /dev/sdc /dev/sdd
+
+
+
+ Clone with custom region size
+ Clone a source device to a destination with a custom region size of 8 KiB:
+ mydevice /dev/sdb /dev/sdc /dev/sdd region-size=8K
+
+
+
+
+ See Also
+
+ systemd1
+ systemd-clonesetup-generator8
+ dmsetup8
+
+
+
+
diff --git a/man/rules/meson.build b/man/rules/meson.build
index 13025b8993..bac836015b 100644
--- a/man/rules/meson.build
+++ b/man/rules/meson.build
@@ -15,6 +15,7 @@ manpages = [
['bootup', '7', [], ''],
['busctl', '1', [], ''],
['capsule@.service', '5', [], ''],
+ ['clonetab', '5', [], ''],
['coredump.conf', '5', ['coredump.conf.d'], 'ENABLE_COREDUMP'],
['coredumpctl', '1', [], 'ENABLE_COREDUMP'],
['crypttab', '5', [], 'HAVE_LIBCRYPTSETUP'],
@@ -1024,6 +1025,11 @@ manpages = [
['systemd-cat', '1', [], ''],
['systemd-cgls', '1', [], ''],
['systemd-cgtop', '1', [], ''],
+ ['systemd-clonesetup',
+ '8',
+ ['systemd-clonesetup@.service'],
+ ''],
+ ['systemd-clonesetup-generator', '8', [], ''],
['systemd-coredump',
'8',
['systemd-coredump.socket', 'systemd-coredump@.service'],
diff --git a/man/systemd-clonesetup-generator.xml b/man/systemd-clonesetup-generator.xml
new file mode 100644
index 0000000000..28c8b5ad00
--- /dev/null
+++ b/man/systemd-clonesetup-generator.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+ systemd-clonesetup-generator
+ systemd
+
+
+
+ systemd-clonesetup-generator
+ 8
+
+
+
+ systemd-clonesetup-generator
+ Unit generator for /etc/clonetab
+
+
+
+ /usr/lib/systemd/system-generators/systemd-clonesetup-generator
+
+
+
+ Description
+
+ systemd-clonesetup-generator is a generator that translates
+ /etc/clonetab into native systemd units early at boot and when
+ configuration of the system manager is reloaded. This will create
+ systemd-clonesetup@.service8
+ units as necessary.
+
+ systemd-clonesetup-generator implements
+ systemd.generator7.
+
+
+
+ See Also
+
+ systemd1
+ clonetab5
+ systemd-clonesetup@.service8
+ dmsetup8
+
+
+
+
diff --git a/man/systemd-clonesetup.xml b/man/systemd-clonesetup.xml
new file mode 100644
index 0000000000..eace5a701f
--- /dev/null
+++ b/man/systemd-clonesetup.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+ systemd-clonesetup
+ systemd
+
+
+
+ systemd-clonesetup
+ 8
+
+
+
+ systemd-clonesetup
+ systemd-clonesetup@.service
+ Set up and tear down dm-clone devices
+
+
+
+
+ systemd-clonesetup
+ add
+ NAME
+ SOURCE-DEVICE
+ DEST-DEVICE
+ META-DEVICE
+ OPTIONS
+
+
+
+ systemd-clonesetup
+ remove
+ NAME
+
+
+ systemd-clonesetup@.service
+
+
+
+ Description
+
+ systemd-clonesetup is used to set up (with add) and tear down
+ (with remove) dm-clone devices. It is primarily used via
+ systemd-clonesetup@.service units generated from
+ /etc/clonetab by
+ systemd-clonesetup-generator8.
+
+
+ The positional arguments NAME, SOURCE-DEVICE,
+ DEST-DEVICE, META-DEVICE, and OPTIONS
+ have the same meaning as the fields in
+ clonetab5.
+
+ Currently, the supported option in OPTIONS is
+ region-size=BYTES, where
+ BYTES is measured in bytes (standard suffixes like
+ K, M, G are supported).
+
+
+
+ See Also
+
+ systemd1
+ clonetab5
+ systemd-clonesetup-generator8
+ systemd.generator7
+ dmsetup8
+
+
+
+
diff --git a/meson.build b/meson.build
index 3097576561..fb34109ec2 100644
--- a/meson.build
+++ b/meson.build
@@ -302,6 +302,7 @@ conf.set_quoted('SYSTEMD_USERWORK_PATH', libexecdir / 'syst
conf.set_quoted('SYSTEMD_MOUNTWORK_PATH', libexecdir / 'systemd-mountwork')
conf.set_quoted('SYSTEMD_NSRESOURCEWORK_PATH', libexecdir / 'systemd-nsresourcework')
conf.set_quoted('SYSTEMD_VERITYSETUP_PATH', libexecdir / 'systemd-veritysetup')
+conf.set_quoted('SYSTEMD_CLONESETUP_PATH', libexecdir / 'systemd-clonesetup')
conf.set_quoted('SYSTEM_CONFIG_UNIT_DIR', pkgsysconfdir / 'system')
conf.set_quoted('SYSTEM_DATA_UNIT_DIR', systemunitdir)
conf.set_quoted('SYSTEM_ENV_GENERATOR_DIR', systemenvgeneratordir)
@@ -2141,6 +2142,7 @@ subdir('src/bootctl')
subdir('src/busctl')
subdir('src/cgls')
subdir('src/cgtop')
+subdir('src/clonesetup')
subdir('src/coredump')
subdir('src/creds')
subdir('src/cryptenroll')
diff --git a/src/clonesetup/clonesetup-generator.c b/src/clonesetup/clonesetup-generator.c
new file mode 100644
index 0000000000..77b1c96d03
--- /dev/null
+++ b/src/clonesetup/clonesetup-generator.c
@@ -0,0 +1,226 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include
+
+#include "alloc-util.h"
+#include "clonesetup-util.h"
+#include "dropin.h"
+#include "errno-util.h"
+#include "fd-util.h"
+#include "fileio.h"
+#include "generator.h"
+#include "log.h"
+#include "path-util.h"
+#include "specifier.h"
+#include "unit-name.h"
+
+static const char *arg_dest = NULL;
+
+/* Generate unit files that call the systemd-clonesetup binary to create or remove clone devices. */
+static int generate_clone_units(
+ const char *clone_name,
+ const char *source_dev,
+ const char *dest_dev,
+ const char *metadata_dev,
+ const char *options) {
+
+ /* unit files for each device */
+ _cleanup_fclose_ FILE *f = NULL;
+ int r;
+
+ assert(clone_name);
+ assert(source_dev);
+ assert(dest_dev);
+ assert(metadata_dev);
+
+ /* Escape clone name for unit specifiers and then ExecStart/ExecStop parsing. */
+ _cleanup_free_ char *clone_name_spec_escaped = NULL;
+ clone_name_spec_escaped = specifier_escape(clone_name);
+ if (!clone_name_spec_escaped)
+ return log_oom();
+
+ /* create clone_dev_path that holds path for new cloned device */
+ _cleanup_free_ char *clone_dev_path = NULL, *clone_dev_path_escaped = NULL,
+ *clone_dev_path_unit_escaped = NULL;
+ clone_dev_path = path_join("/dev/mapper", clone_name);
+ if (!clone_dev_path)
+ return log_oom();
+
+ clone_dev_path_escaped = specifier_escape(clone_dev_path);
+ if (!clone_dev_path_escaped)
+ return log_oom();
+
+ r = unit_name_path_escape(clone_dev_path, &clone_dev_path_unit_escaped);
+ if (r < 0)
+ return log_error_errno(r, "Failed to escape clone device path: %m");
+
+ _cleanup_free_ char *e = NULL, *unit = NULL;
+ /* escape clone name */
+ e = unit_name_escape(clone_name);
+ if (!e)
+ return log_oom();
+
+ /* Generate unit name for the clone service */
+ r = unit_name_build("systemd-clonesetup", e, ".service", &unit);
+ if (r < 0)
+ return log_error_errno(r, "Failed to generate unit name: %m");
+
+ /* Generate unit names for dependencies */
+ _cleanup_free_ char *source_unit = NULL, *dest_unit = NULL, *metadata_unit = NULL;
+ r = unit_name_from_path(source_dev, ".device", &source_unit);
+ if (r < 0)
+ return log_error_errno(r, "Failed to generate source device unit name: %m");
+
+ r = unit_name_from_path(dest_dev, ".device", &dest_unit);
+ if (r < 0)
+ return log_error_errno(r, "Failed to generate dest device unit name: %m");
+
+ r = unit_name_from_path(metadata_dev, ".device", &metadata_unit);
+ if (r < 0)
+ return log_error_errno(r, "Failed to generate metadata device unit name: %m");
+
+ /* Escape device paths for unit specifiers and then ExecStart parsing. */
+ _cleanup_free_ char *source_spec_escaped = NULL,
+ *dest_spec_escaped = NULL, *metadata_spec_escaped = NULL,
+ *options_spec_escaped = NULL;
+ source_spec_escaped = specifier_escape(source_dev);
+ if (!source_spec_escaped)
+ return log_oom();
+
+ dest_spec_escaped = specifier_escape(dest_dev);
+ if (!dest_spec_escaped)
+ return log_oom();
+
+ metadata_spec_escaped = specifier_escape(metadata_dev);
+ if (!metadata_spec_escaped)
+ return log_oom();
+
+ if (options) {
+ options_spec_escaped = specifier_escape(options);
+ if (!options_spec_escaped)
+ return log_oom();
+ }
+
+ r = generator_open_unit_file(arg_dest, /* source = */ NULL, unit, &f);
+ if (r < 0)
+ return r;
+
+ /* With DefaultDependencies=no, order after udev so backing /dev nodes are ready in early boot.
+ * The : exec prefix on ExecStart=/ExecStop= disables $ { } env-var expansion by the manager. */
+ fprintf(f,
+ "[Unit]\n"
+ "Description=Create dm-clone device %1$s\n"
+ "Documentation=man:clonetab(5) man:systemd-clonesetup(8) man:systemd-clonesetup-generator(8)\n"
+ "DefaultDependencies=no\n"
+ "BindsTo=%2$s %3$s %4$s\n"
+ "After=%2$s %3$s %4$s systemd-udevd-kernel.socket\n"
+ "Before=blockdev@%5$s.target clonesetup.target shutdown.target\n"
+ "Wants=blockdev@%5$s.target\n"
+ "Conflicts=shutdown.target\n"
+ "\n"
+ "[Service]\n"
+ "Type=oneshot\n"
+ "RemainAfterExit=yes\n"
+ "ExecStart=:" SYSTEMD_CLONESETUP_PATH " add '%6$s' '%7$s' '%8$s' '%9$s' '%10$s'\n"
+ "ExecStop=:" SYSTEMD_CLONESETUP_PATH " remove '%6$s'\n"
+ "TimeoutSec=0\n",
+ clone_dev_path_escaped,
+ source_unit, dest_unit, metadata_unit,
+ clone_dev_path_unit_escaped,
+ clone_name_spec_escaped, source_spec_escaped, dest_spec_escaped,
+ metadata_spec_escaped, options_spec_escaped ?: "-");
+
+ r = fflush_and_check(f);
+ if (r < 0)
+ return log_error_errno(r, "Failed to write unit %s: %m", unit);
+
+ /* symlink unit file to enable it */
+ _cleanup_free_ char *dmname = NULL;
+ r = unit_name_from_path(clone_dev_path, ".device", &dmname);
+ if (r < 0)
+ return log_error_errno(r, "Failed to generate clone device unit name: %m");
+
+ r = generator_add_symlink(arg_dest, dmname, "requires", unit);
+ if (r < 0)
+ return r;
+
+ /* Extend device timeout to allow clone service to complete */
+ r = write_drop_in(arg_dest, dmname, 40, "device-timeout",
+ "# Automatically generated by systemd-clonesetup-generator\n\n"
+ "[Unit]\n"
+ "JobRunningTimeoutSec=infinity\n");
+ if (r < 0)
+ log_warning_errno(r, "Failed to write device timeout drop-in: %m");
+
+ /* Add to clonesetup.target so it starts at boot */
+ r = generator_add_symlink(arg_dest, "clonesetup.target", "requires", unit);
+ if (r < 0)
+ return r;
+
+ return 0;
+}
+
+static int add_clone_devices(void) {
+ _cleanup_fclose_ FILE *f = NULL;
+ unsigned clone_line = 0;
+ int r, ret = 0;
+ const char *fname;
+
+ fname = secure_getenv("SYSTEMD_CLONETAB") ?: "/etc/clonetab";
+
+ r = fopen_unlocked(fname, "re", &f);
+ if (r < 0) {
+ if (r != -ENOENT)
+ log_error_errno(r, "Failed to open %s: %m", fname);
+ return 0;
+ }
+
+ for (;;) {
+ _cleanup_free_ char *line = NULL, *src = NULL,
+ *name = NULL, *dst = NULL, *meta = NULL, *options = NULL;
+ int k;
+
+ r = read_stripped_line(f, LONG_LINE_MAX, &line);
+ if (r < 0)
+ return log_error_errno(r, "Failed to read %s: %m", fname);
+ if (r == 0)
+ break;
+
+ clone_line++;
+
+ if (IN_SET(line[0], 0, '#'))
+ continue;
+
+ k = sscanf(line, "%ms %ms %ms %ms %ms", &name, &src, &dst, &meta, &options);
+ if (k < 4 || k > 5) {
+ log_warning("Failed to parse %s:%u, ignoring.", fname, clone_line);
+ continue;
+ }
+
+ log_debug("Processing %s:%u", fname, clone_line);
+ r = validate_fields(name, src, dst, meta, options);
+ if (r < 0)
+ continue;
+ RET_GATHER(ret, generate_clone_units(name, src, dst, meta, options));
+ }
+
+ return ret;
+}
+
+/* This generator reads /etc/clonetab and for each entry, writes unit files
+ * (creates systemd-clonesetup@.service and clonesetup.target.requires/systemd-clonesetup@.service)
+ * that clonesetup.target requires, and that run systemd-clonesetup (add device at boot,
+ * remove it at shutdown); systemd-clonesetup (used in systemd-clonesetup@.service) is the binary that
+ * uses device-mapper ioctls to create and remove the dm-clone devices.
+ * clonesetup.target groups these units so they run together at boot.
+ * Boot chain: sysinit.target has clonesetup.target in sysinit.target.wants/ (see units/meson.build),
+ * so at boot clonesetup.target starts and pulls in these units via clonesetup.target.requires/. */
+static int run(const char *dest, const char *dest_early, const char *dest_late) {
+
+ /* dest usually is /run/systemd/generator */
+ assert_se(arg_dest = dest);
+
+ return add_clone_devices();
+}
+
+DEFINE_MAIN_GENERATOR_FUNCTION(run);
diff --git a/src/clonesetup/clonesetup-ioctl.c b/src/clonesetup/clonesetup-ioctl.c
new file mode 100644
index 0000000000..73c52e6e99
--- /dev/null
+++ b/src/clonesetup/clonesetup-ioctl.c
@@ -0,0 +1,276 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include
+#include
+#include
+#include
+
+#include "sd-device.h"
+
+#include "clonesetup-ioctl.h"
+#include "device-private.h"
+#include "errno-util.h"
+#include "fd-util.h"
+#include "log.h"
+#include "memory-util.h"
+#include "string-util.h"
+
+/* Returns the size in bytes of the block device at dev_path.
+ * Loading the dm-clone table needs the source device size in sectors; sysfs
+ * reports size in 512-byte sectors. This reads sysfs and returns bytes so the
+ * caller can divide by 512 and pass the sector count to dm_clone_load_table(). */
+static int get_size(const char *dev_path, uint64_t *ret_size) {
+ _cleanup_(sd_device_unrefp) sd_device *dev = NULL;
+ uint64_t size, temp_size;
+ int r;
+
+ assert(dev_path);
+ assert(ret_size);
+
+ r = sd_device_new_from_devname(&dev, dev_path);
+ if (r < 0)
+ return log_error_errno(r, "Failed to create device from '%s': %m", dev_path);
+
+ r = device_get_sysattr_u64(dev, "size", &size);
+ if (r < 0)
+ return log_error_errno(r, "Failed to get device size for '%s': %m", dev_path);
+
+ /* sysfs 'size' is in 512-byte sectors */
+ temp_size = u64_multiply_safe(size, 512);
+ if (temp_size == 0 && size != 0)
+ return log_error_errno(SYNTHETIC_ERRNO(EOVERFLOW),
+ "Device size overflow for '%s'", dev_path);
+
+ *ret_size = temp_size;
+ return 0;
+}
+
+/* Common helper used to run dm ioctls. */
+static int dm_ioctl_run(const char *name, uint32_t cmd, struct dm_ioctl *data, size_t data_size) {
+ _cleanup_close_ int fd = -EBADF;
+ struct dm_ioctl *dm = data;
+ int r;
+
+ assert(name);
+ assert(data);
+ assert(data_size >= sizeof(struct dm_ioctl));
+
+ dm->version[0] = DM_VERSION_MAJOR;
+ dm->version[1] = DM_VERSION_MINOR;
+ dm->version[2] = DM_VERSION_PATCHLEVEL;
+ dm->data_size = data_size;
+
+ if (strlen(name) >= sizeof_field(struct dm_ioctl, name))
+ return log_error_errno(SYNTHETIC_ERRNO(ENAMETOOLONG),
+ "DM device name too long: %s", name);
+ strncpy_exact(dm->name, name, sizeof(dm->name));
+
+ fd = open("/dev/mapper/control", O_RDWR | O_CLOEXEC);
+ if (fd < 0)
+ return log_error_errno(errno, "Failed to open /dev/mapper/control: %m");
+
+ r = RET_NERRNO(ioctl(fd, cmd, dm));
+ if (r < 0) {
+ if (r == -ENXIO && cmd == DM_DEV_REMOVE) {
+ log_full_errno(LOG_DEBUG, r, "Device \"%s\" is already inactive, ignoring: %m", dm->name);
+ return 0;
+ }
+ return log_error_errno(r, "DM ioctl failed: %m");
+ }
+ return 0;
+}
+
+/* First dm ioctl needed to create a device. */
+static int dm_clone_create(const char *name) {
+ int r;
+ assert(name);
+
+ struct dm_ioctl dm = {};
+ r = dm_ioctl_run(name, DM_DEV_CREATE, &dm, sizeof(dm));
+ if (r < 0) {
+ if (r == -EEXIST)
+ return log_error_errno(r, "Device '/dev/mapper/%s' already exists.", name);
+ return log_error_errno(r, "Failed to create DM device '%s': %m", name);
+ }
+ return 0;
+}
+
+/* Second dm ioctl needed to create a device. */
+static int dm_clone_load_table(const char *name, uint64_t size_sectors, const char *target_params) {
+ char *params_buf;
+ size_t params_len, dm_size;
+ _cleanup_free_ struct dm_ioctl *dm = NULL;
+ struct dm_target_spec *tgt;
+
+ assert(name);
+ assert(target_params);
+
+ params_len = strlen(target_params) + 1;
+ /* ensure that dm_size is always aligned, so it makes the buffer actually match what .next claims */
+ dm_size = ALIGN8(sizeof(struct dm_ioctl)) + ALIGN8(sizeof(struct dm_target_spec) + params_len);
+ dm = malloc0(dm_size);
+ if (!dm)
+ return log_oom();
+ *dm = (struct dm_ioctl) {
+ .data_start = ALIGN8(sizeof(struct dm_ioctl)),
+ .target_count = 1,
+ };
+
+ tgt = CAST_ALIGN_PTR(struct dm_target_spec, (uint8_t *) dm + dm->data_start);
+ *tgt = (struct dm_target_spec) {
+ .length = size_sectors,
+ /* Per linux/dm-ioctl.h: next is the byte offset from this dm_target_spec to the next one,
+ * rounded up to 8-byte alignment. With target_count == 1 next == 0 works, but set it
+ * correctly to avoid silent breakage if a second target is ever added. */
+ .next = ALIGN8(sizeof(struct dm_target_spec) + params_len),
+ };
+ strncpy(tgt->target_type, "clone", sizeof(tgt->target_type));
+
+ params_buf = (char *) tgt + ALIGN8(sizeof(struct dm_target_spec));
+ memcpy(params_buf, target_params, params_len);
+
+ return dm_ioctl_run(name, DM_TABLE_LOAD, dm, dm_size);
+}
+
+/* Third and final dm ioctl needed to create a device. */
+static int dm_clone_activate(const char *name) {
+ assert(name);
+
+ struct dm_ioctl dm = {};
+
+ return dm_ioctl_run(name, DM_DEV_SUSPEND, &dm, sizeof(dm));
+}
+
+/* Calls multiple dm ioctls to create device. */
+int dm_clone_create_device(
+ const char *name,
+ const char *source_dev,
+ const char *dest_dev,
+ const char *metadata_dev,
+ uint64_t region_size_bytes) {
+
+ _cleanup_free_ char *target_params = NULL;
+ uint64_t src_dev_size_sectors, src_dev_size, region_size_sectors;
+ int r;
+
+ assert(name);
+ assert(source_dev);
+ assert(dest_dev);
+ assert(metadata_dev);
+
+ r = get_size(source_dev, &src_dev_size);
+ if (r < 0)
+ return r;
+
+ /* The device mapper kernel API always uses 512-byte sectors, regardless of the
+ * physical block size of the device (all DM targets use sector_t which is 512B).
+ *
+ * get_size internally uses sysfs i.e. /sys/block//size which also reports device size in
+ * 512-byte sectors. Before returning, get_size multiplies the size returned by sysfs to bytes. So we
+ * divide the received byte size by 512 to get the sector count for the DM table. */
+ assert(src_dev_size % 512 == 0);
+ src_dev_size_sectors = src_dev_size / 512;
+
+ assert(region_size_bytes % 512 == 0);
+ region_size_sectors = region_size_bytes / 512;
+
+ /* dm-clone target params: [options]
+ * region_size = region size in sectors, configurable via clonetab (default 8 = 4KB regions with
+ * 512-byte sectors) 1 = hydration threshold (regions to hydrate per batch) no_hydration = don't
+ * start automatic background hydration
+ *
+ * The DM table "target_params" string is passed directly to the kernel via ioctl(fd, DM_TABLE_LOAD,
+ * ...) in dm_clone_load_table as a raw byte buffer. The kernel's DM table parser
+ * (drivers/md/dm-table.c) simply splits the params string on whitespace, so the only constraint is
+ * that the paths in params - metadata_dev, dest_dev, source_dev, and region_size must not contain
+ * spaces, which standard /dev/ paths never do, so the below args do NOT require shell escaping */
+ if (asprintf(&target_params, "%s %s %s %" PRIu64 " 1 no_hydration",
+ metadata_dev, dest_dev, source_dev, region_size_sectors) < 0)
+ return log_oom();
+
+ r = dm_clone_create(name);
+ if (r < 0)
+ return r;
+
+ r = dm_clone_load_table(name, src_dev_size_sectors, target_params);
+ if (r < 0)
+ goto fail;
+
+ r = dm_clone_activate(name);
+ if (r < 0)
+ goto fail;
+
+ log_info("Device %s active.", name);
+ return 0;
+
+fail:
+ (void) dm_clone_remove_device_deferred(name);
+ return r;
+}
+
+/* Calls dm ioctl to send a message to the device. dm_ioctl is the kernel's generic device mapper envelope —
+ * every ioctl needs it. dm_target_msg is specific to the "send a message" operation
+ * */
+int dm_clone_send_message(const char *name, const char *message) {
+ _cleanup_free_ struct dm_ioctl *dm = NULL;
+ struct dm_target_msg *msg;
+ size_t dm_size, msg_len;
+
+ assert(name);
+ assert(message);
+
+ msg_len = strlen(message) + 1;
+ /* need to take into account both headers in size calculation */
+ dm_size = ALIGN8(sizeof(struct dm_ioctl)) + sizeof(struct dm_target_msg) + msg_len;
+ dm = malloc0(dm_size);
+ if (!dm)
+ return log_oom();
+ *dm = (struct dm_ioctl) {
+ /* with ALIGN8 call below, dm_target_msg starts at ALIGN8(sizeof(struct dm_ioctl)) which is
+ * already aligned, so the dm_target_msg struct lands correctly */
+ .data_start = ALIGN8(sizeof(struct dm_ioctl)),
+ };
+
+ msg = CAST_ALIGN_PTR(struct dm_target_msg, (uint8_t *) dm + dm->data_start);
+ memcpy(msg->message, message, msg_len);
+
+ return dm_ioctl_run(name, DM_TARGET_MSG, dm, dm_size);
+}
+
+/* Calls dm ioctl to remove a device. flags if set can be used
+ * for deferred remove - e.g. DM_DEFERRED_REMOVE */
+static int dm_clone_remove_device_full(const char *name, uint32_t flags) {
+ struct dm_ioctl dm = {
+ .flags = flags,
+ };
+
+ assert(name);
+
+ return dm_ioctl_run(name, DM_DEV_REMOVE, &dm, sizeof(dm));
+}
+
+/* Calls dm ioctl to remove a device. */
+int dm_clone_remove_device(const char *name) {
+ int r;
+
+ assert(name);
+ r = dm_clone_remove_device_full(name, 0);
+ if (r < 0)
+ return r;
+
+ log_info("Device %s inactive.", name);
+ return 0;
+}
+
+/* Calls dm ioctl for deferred removal i.e. DM_DEFERRED_REMOVE */
+int dm_clone_remove_device_deferred(const char *name) {
+ int r;
+
+ assert(name);
+ r = dm_clone_remove_device_full(name, DM_DEFERRED_REMOVE);
+ if (r < 0)
+ return r;
+
+ log_info("Device %s marked for deferred removal.", name);
+ return 0;
+}
diff --git a/src/clonesetup/clonesetup-ioctl.h b/src/clonesetup/clonesetup-ioctl.h
new file mode 100644
index 0000000000..ca3a373fc6
--- /dev/null
+++ b/src/clonesetup/clonesetup-ioctl.h
@@ -0,0 +1,16 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+#pragma once
+
+#include
+
+int dm_clone_create_device(
+ const char *name,
+ const char *source_dev,
+ const char *dest_dev,
+ const char *metadata_dev,
+ uint64_t region_size_bytes);
+
+int dm_clone_send_message(const char *name, const char *message);
+
+int dm_clone_remove_device(const char *name);
+int dm_clone_remove_device_deferred(const char *name);
diff --git a/src/clonesetup/clonesetup-util.c b/src/clonesetup/clonesetup-util.c
new file mode 100644
index 0000000000..da9ea634d4
--- /dev/null
+++ b/src/clonesetup/clonesetup-util.c
@@ -0,0 +1,37 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include "clonesetup-util.h"
+#include "log.h"
+#include "path-util.h"
+#include "string-util.h"
+
+int validate_dev_path(const char *what, const char *path) {
+ if (!string_is_safe(path, 0) || !path_is_normalized(path) ||
+ !path_is_absolute(path) || !path_startswith(path, "/dev/"))
+ return log_error_errno(SYNTHETIC_ERRNO(EINVAL),
+ "Invalid %s device path '%s'.", what, path);
+ return 0;
+}
+
+int validate_fields(const char *name, const char *src, const char *dst,
+ const char *meta, const char *options) {
+ int r;
+
+ if (!filename_is_valid(name))
+ return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Invalid clone name '%s'.", name);
+
+ r = validate_dev_path("source", src);
+ if (r < 0)
+ return r;
+ r = validate_dev_path("destination", dst);
+ if (r < 0)
+ return r;
+ r = validate_dev_path("metadata", meta);
+ if (r < 0)
+ return r;
+
+ if (options && !string_is_safe(options, 0))
+ return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Invalid options '%s'.", options);
+
+ return 0;
+}
diff --git a/src/clonesetup/clonesetup-util.h b/src/clonesetup/clonesetup-util.h
new file mode 100644
index 0000000000..ef64edb17a
--- /dev/null
+++ b/src/clonesetup/clonesetup-util.h
@@ -0,0 +1,6 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+#pragma once
+
+int validate_dev_path(const char *what, const char *path);
+int validate_fields(const char *name, const char *src, const char *dst,
+ const char *meta, const char *options);
diff --git a/src/clonesetup/clonesetup.c b/src/clonesetup/clonesetup.c
new file mode 100644
index 0000000000..debd10590a
--- /dev/null
+++ b/src/clonesetup/clonesetup.c
@@ -0,0 +1,192 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+#include
+
+#include "alloc-util.h"
+#include "build.h"
+#include "clonesetup-ioctl.h"
+#include "clonesetup-util.h"
+#include "extract-word.h"
+#include "format-table.h"
+#include "help-util.h"
+#include "log.h"
+#include "main-func.h"
+#include "options.h"
+#include "parse-util.h"
+#include "string-util.h"
+#include "strv.h" /* strv_skip */
+#include "verbs.h"
+
+/* region-size: size of each dm-clone region in bytes. Handled internally in bytes, but must correspond to a
+ * power of 2 between 4K and 1G per dm-clone kernel docs. */
+#define CLONE_REGION_SIZE_DEFAULT_BYTES (UINT64_C(1) << 12) /* 4 KiB */
+#define CLONE_REGION_SIZE_MIN_BYTES (UINT64_C(1) << 12) /* 4 KiB */
+#define CLONE_REGION_SIZE_MAX_BYTES (UINT64_C(1) << 30) /* 1 GiB */
+
+static int parse_clone_options(const char *options, uint64_t *ret_region_size_bytes) {
+ uint64_t region_size_bytes = CLONE_REGION_SIZE_DEFAULT_BYTES;
+
+ assert(ret_region_size_bytes);
+
+ for (;;) {
+ _cleanup_free_ char *word = NULL;
+ const char *val;
+ int r;
+
+ /* extract_first_word: *
+ * Returns > 0 — successfully extracted a word *
+ * Returns 0 — no more words (end of string) *
+ * Returns < 0 — actual error (e.g. memory allocation failure) */
+ r = extract_first_word(&options, &word, ",", EXTRACT_DONT_COALESCE_SEPARATORS);
+ if (r < 0)
+ return log_error_errno(r, "Failed to parse clone options: %m");
+ if (r == 0)
+ break;
+
+ /* treat - as empty — common placeholders for "no options" */
+ if (streq(word, "-"))
+ continue;
+
+ if ((val = startswith(word, "region-size="))) {
+ uint64_t r_size_bytes;
+ /* parse_size handles suffixes like K, M, G automatically */
+ r = parse_size(val, 1024, &r_size_bytes);
+ if (r < 0)
+ log_warning_errno(r, "Failed to parse region-size= value '%s', using default.", val);
+ else if (!ISPOWEROF2(r_size_bytes) || r_size_bytes < CLONE_REGION_SIZE_MIN_BYTES || r_size_bytes > CLONE_REGION_SIZE_MAX_BYTES)
+ log_warning("region-size=%s must be a power of two between 4K and 1G, using default.", val);
+ else
+ region_size_bytes = r_size_bytes;
+ } else
+ /* currently only region-size is supported */
+ log_warning("Unknown clone option '%s', ignoring.", word);
+ }
+ *ret_region_size_bytes = region_size_bytes;
+ return 0;
+}
+
+/* dm-clone device creation workflow:
+ * 1. Create the dm-clone device
+ * 2. Enable background hydration */
+static int clone_device(
+ const char *clone_name,
+ const char *source_dev,
+ const char *dest_dev,
+ const char *metadata_dev,
+ const char *options) {
+
+ int r;
+
+ assert(clone_name);
+ assert(source_dev);
+ assert(dest_dev);
+ assert(metadata_dev);
+
+ uint64_t region_size_bytes;
+ r = parse_clone_options(options, ®ion_size_bytes);
+ if (r < 0)
+ return r;
+
+ r = dm_clone_create_device(clone_name, source_dev, dest_dev, metadata_dev, region_size_bytes);
+ if (r < 0)
+ return r;
+
+ r = dm_clone_send_message(clone_name, "enable_hydration");
+ if (r < 0) {
+ (void) dm_clone_remove_device_deferred(clone_name);
+ return r;
+ }
+ return 0;
+}
+
+VERB(verb_add, "add", "NAME SOURCE DEST METADATA [OPTIONS]", 5, 6, 0, "Create a dm-clone device");
+
+/* Arguments: systemd-clonesetup add NAME SOURCE DEST METADATA [OPTIONS] */
+static int verb_add(int argc, char *argv[], uintptr_t data, void *userdata) {
+ int r;
+ assert(argc >= 5 && argc <= 6);
+
+ const char *name = ASSERT_PTR(argv[1]);
+ const char *src = ASSERT_PTR(argv[2]);
+ const char *dst = ASSERT_PTR(argv[3]);
+ const char *meta = ASSERT_PTR(argv[4]);
+
+ const char *options = argc == 6 ? argv[5] : "";
+
+ r = validate_fields(name, src, dst, meta, options);
+ if (r < 0)
+ return r;
+
+ log_debug("%s %s %s %s %s opts=%s", __func__,
+ name, src, dst, meta, options);
+ return clone_device(name, src, dst, meta, options);
+}
+
+VERB(verb_remove, "remove", "NAME", 2, 2, 0, "Remove a dm-clone device");
+
+static int verb_remove(int argc, char *argv[], uintptr_t data, void *userdata) {
+ const char *name = ASSERT_PTR(argv[1]);
+ int r;
+
+ r = dm_clone_remove_device(name);
+ if (r == -EBUSY)
+ return dm_clone_remove_device_deferred(name);
+ if (r < 0)
+ return r;
+
+ return 0;
+}
+
+static int help(void) {
+ _cleanup_(table_unrefp) Table *options = NULL;
+ int r;
+
+ r = option_parser_get_help_table(&options);
+ if (r < 0)
+ return r;
+
+ help_cmdline("add NAME SOURCE DEST METADATA [OPTIONS]");
+ help_cmdline("remove NAME");
+ help_abstract("Add or remove a dm-clone device.");
+ help_section("Options");
+ r = table_print_or_warn(options);
+ if (r < 0)
+ return r;
+
+ help_man_page_reference("systemd-clonesetup", "8");
+ return 0;
+}
+
+static int parse_argv(int argc, char *argv[]) {
+ assert(argc >= 0);
+ assert(argv);
+
+ OptionParser opts = { argc, argv };
+ FOREACH_OPTION_OR_RETURN(c, &opts)
+ switch (c) {
+
+ OPTION_COMMON_HELP:
+ return help();
+
+ OPTION_COMMON_VERSION:
+ return version();
+ }
+
+ return 1;
+}
+
+/* systemd-clonesetup uses device-mapper ioctls to create and remove the
+ * dm-clone devices. */
+static int run(int argc, char *argv[]) {
+ int r;
+
+ log_setup();
+ umask(0022);
+
+ r = parse_argv(argc, argv);
+ if (r <= 0)
+ return r;
+
+ return dispatch_verb(strv_skip(argv, 1), /* userdata= */ NULL);
+}
+
+DEFINE_MAIN_FUNCTION(run);
diff --git a/src/clonesetup/meson.build b/src/clonesetup/meson.build
new file mode 100644
index 0000000000..044f69b813
--- /dev/null
+++ b/src/clonesetup/meson.build
@@ -0,0 +1,19 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+systemd_clonesetup_sources = files(
+ 'clonesetup-ioctl.c',
+ 'clonesetup.c',
+)
+
+executables += [
+ libexec_template + {
+ 'name' : 'systemd-clonesetup',
+ 'sources' : systemd_clonesetup_sources,
+ 'export' : files('clonesetup-util.c'),
+ },
+ generator_template + {
+ 'name' : 'systemd-clonesetup-generator',
+ 'sources' : files('clonesetup-generator.c'),
+ 'import' : ['systemd-clonesetup'],
+ },
+]
diff --git a/test/integration-tests/TEST-93-CLONESETUP/meson.build b/test/integration-tests/TEST-93-CLONESETUP/meson.build
new file mode 100644
index 0000000000..77370ce458
--- /dev/null
+++ b/test/integration-tests/TEST-93-CLONESETUP/meson.build
@@ -0,0 +1,8 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+integration_tests += [
+ integration_test_template + {
+ 'name' : fs.name(meson.current_source_dir()),
+ 'vm' : true,
+ },
+]
diff --git a/test/integration-tests/meson.build b/test/integration-tests/meson.build
index 3991b11e79..228d808f51 100644
--- a/test/integration-tests/meson.build
+++ b/test/integration-tests/meson.build
@@ -105,6 +105,7 @@ foreach dirname : [
'TEST-90-RESTRICT-FSACCESS',
'TEST-91-LIVEUPDATE',
'TEST-92-TPM2-SWTPM',
+ 'TEST-93-CLONESETUP',
]
subdir(dirname)
endforeach
diff --git a/test/units/TEST-93-CLONESETUP.sh b/test/units/TEST-93-CLONESETUP.sh
new file mode 100755
index 0000000000..537c40b972
--- /dev/null
+++ b/test/units/TEST-93-CLONESETUP.sh
@@ -0,0 +1,344 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: LGPL-2.1-or-later
+set -eux
+set -o pipefail
+set -E
+
+# shellcheck source=test/units/test-control.sh
+. "$(dirname "$0")"/test-control.sh
+
+CLONESETUP_BIN=/usr/lib/systemd/systemd-clonesetup
+# Test clonesetup generator and systemd-clonesetup
+# Disable pager usage so interactive/manual runs do not block in `less`.
+export SYSTEMD_PAGER=cat
+export SYSTEMD_LESS=
+
+create_loop_triplet() {
+ local workdir="${1:?}"
+ local src_img dst_img meta_img
+ local loop_src loop_dst loop_meta
+
+ src_img="$workdir/source.img"
+ dst_img="$workdir/dest.img"
+ meta_img="$workdir/meta.img"
+
+ truncate -s 32M "$src_img"
+ truncate -s 32M "$dst_img"
+ truncate -s 8M "$meta_img"
+
+ loop_src="$(losetup --show --find "$src_img")"
+ loop_dst="$(losetup --show --find "$dst_img")"
+ loop_meta="$(losetup --show --find "$meta_img")"
+
+ # Wait for udev to process new loop devices before they are consumed.
+ udevadm settle --timeout=60
+
+ printf '%s %s %s\n' "$loop_src" "$loop_dst" "$loop_meta"
+}
+
+cleanup_loop_triplet() {
+ local loop_src="${1:-}"
+ local loop_dst="${2:-}"
+ local loop_meta="${3:-}"
+
+ [[ -n "$loop_src" ]] && losetup -d "$loop_src"
+ [[ -n "$loop_dst" ]] && losetup -d "$loop_dst"
+ [[ -n "$loop_meta" ]] && losetup -d "$loop_meta"
+}
+
+at_exit() {
+ set +e
+
+ rm -f /etc/clonetab
+ [[ -e /tmp/clonetab.bak ]] && cp -fv /tmp/clonetab.bak /etc/clonetab
+ dmsetup remove testclonesetup 2>/dev/null || true
+
+ systemctl daemon-reload
+}
+
+at_error() {
+ local rc="$?"
+ local source="${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}"
+ local line="${BASH_LINENO[0]:-0}"
+ local func="${FUNCNAME[1]:-main}"
+
+ echo "ERROR: rc=$rc at $source:$line ($func): $BASH_COMMAND" >&2
+ return "$rc"
+}
+
+trap at_exit EXIT
+trap at_error ERR
+
+clonesetup_start_and_check() {
+ local volume unit
+
+ volume="${1:?}"
+ unit="systemd-clonesetup@$volume.service"
+
+ # The unit existence check should always pass
+ [[ "$(systemctl show -P LoadState "$unit")" == loaded ]]
+ systemctl list-unit-files "$unit"
+
+ systemctl start "$unit"
+ # wait for udev to create /dev/mapper/ node after DM device activation
+ udevadm settle --timeout=10
+ systemctl status "$unit"
+ test -e "/dev/mapper/$volume"
+ dmsetup status "$volume"
+
+ systemctl stop "$unit"
+ # wait for udev to finish processing so the device node state is in sync
+ # before the API returns.
+ udevadm settle --timeout=10
+ test ! -e "/dev/mapper/$volume"
+}
+
+prereq() {
+ # Skip when kernel lacks dm-clone (CONFIG_DM_CLONE)
+ modprobe dm_clone 2>/dev/null || true
+ if [[ ! -d /sys/module/dm_clone ]]; then
+ echo "no dm-clone" >/skipped
+ exit 77
+ fi
+ echo "Found required kernel module: dm_clone"
+}
+
+prereq
+
+# Backup existing clonetab if any
+[[ -e /etc/clonetab ]] && cp -fv /etc/clonetab /tmp/clonetab.bak
+
+# Create test clonetab
+clonetab_create() {
+ local loop_name="${1:?}"
+ local loop_src="${2:?}"
+ local loop_dst="${3:?}"
+ local loop_meta="${4:?}"
+ local loop_options="${5:?}"
+
+ cat >/etc/clonetab </etc/clonetab </etc/clonetab </etc/clonetab </etc/clonetab </etc/clonetab <