+From 0e837ecd5ffb5e8366a5e2878838ba691b31cd99 Mon Sep 17 00:00:00 2001
+From: Julien Moutinho <julm+nixpkgs@sourcephile.fr>
+Date: Sat, 27 Nov 2021 02:50:39 +0100
+Subject: [PATCH 2/2] nixos/syncoid: use DynamicUser=
+
+HistoryNote: the following changes
+were previously in separate commits
+but have been squashed and nixfmt-rfc-style
+on be able to rebase them
+onto a treewide change applying nixfmt-rfc-style
+on nixos/modules/services/backup/syncoid.nix
+which introduced multiple untrackable conflicts.
+
+Changes:
+
+- nixos/syncoid: add destroy to localTargetAllow's default
+
+ syncoid[3282027]: Resuming interrupted zfs send/receive from rpool/var/mail to losurdo/backup/mermet/var/mail (~ UNKNOWN remaining):
+ syncoid[3282885]: cannot resume send: 'rpool/var/mail@autosnap_2021-10-17_15:00:19_hourly' used in the initial send no longer exists
+ syncoid[3282897]: cannot receive: failed to read from stream
+ syncoid[3282027]: WARN: resetting partially receive state because the snapshot source no longer exists
+ syncoid[3283326]: cannot destroy 'losurdo/backup/mermet/var/mail/%recv': permission denied
+
+- nixos/syncoid: allow @timer syscalls
+
+- nixos/syncoid: set TZ envvar
+
+- nixos/syncoid: test sending to multiple targets
+
+- nixos/syncoid: don't delegate permissions to source's parent
+
+ See https://github.com/NixOS/nixpkgs/pull/165204
+ and https://github.com/NixOS/nixpkgs/pull/180111
+
+- nixos/syncoid: add support for LoadCredentialEncrypted=
+
+- nixos/syncoid: zfs-unallow unused dynamic users
+
+- nixos/syncoid: use NFTSet=
+
+Changes between the treewide nixfmt-rfc-style
+and those changes have been preserved:
+
+- 71967c47e54c56557c0ee7bec7fe554c018e37c4 nixos/syncoid: allow interval to be list of strings
+- f34483be5ee2418a563545a56743b7b59c549935 nixosTests: handleTest -> runTest, batch 1
+---
+ nixos/modules/services/backup/syncoid.nix | 454 +++++++++++-----------
+ nixos/tests/sanoid.nix | 78 ++--
+ 2 files changed, 269 insertions(+), 263 deletions(-)
+
+diff --git a/nixos/modules/services/backup/syncoid.nix b/nixos/modules/services/backup/syncoid.nix
+index 8b4c59155f..7d344a1ad0 100644
+--- a/nixos/modules/services/backup/syncoid.nix
++++ b/nixos/modules/services/backup/syncoid.nix
+@@ -7,7 +7,7 @@
+ let
+ cfg = config.services.syncoid;
+
+- # Extract local dasaset names (so no datasets containing "@")
++ # Extract local dataset names (so no datasets containing "@")
+ localDatasetName =
+ d:
+ lib.optionals (d != null) (
+@@ -20,81 +20,9 @@ let
+ # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
+ escapeUnitName =
+ name:
+- lib.concatMapStrings (s: if lib.isList s then "-" else s) (
++ lib.concatMapStrings (s: if builtins.isList s then "-" else s) (
+ builtins.split "[^a-zA-Z0-9_.\\-]+" name
+ );
+-
+- # Function to build "zfs allow" commands for the filesystems we've delegated
+- # permissions to. It also checks if the target dataset exists before
+- # delegating permissions, if it doesn't exist we delegate it to the parent
+- # dataset (if it exists). This should solve the case of provisoning new
+- # datasets.
+- buildAllowCommand =
+- permissions: dataset:
+- (
+- "-+${pkgs.writeShellScript "zfs-allow-${dataset}" ''
+- # Here we explicitly use the booted system to guarantee the stable API needed by ZFS
+-
+- # Run a ZFS list on the dataset to check if it exists
+- if ${
+- lib.escapeShellArgs [
+- "/run/booted-system/sw/bin/zfs"
+- "list"
+- dataset
+- ]
+- } 2> /dev/null; then
+- ${lib.escapeShellArgs [
+- "/run/booted-system/sw/bin/zfs"
+- "allow"
+- cfg.user
+- (lib.concatStringsSep "," permissions)
+- dataset
+- ]}
+- ${lib.optionalString ((builtins.dirOf dataset) != ".") ''
+- else
+- ${lib.escapeShellArgs [
+- "/run/booted-system/sw/bin/zfs"
+- "allow"
+- cfg.user
+- (lib.concatStringsSep "," permissions)
+- # Remove the last part of the path
+- (builtins.dirOf dataset)
+- ]}
+- ''}
+- fi
+- ''}"
+- );
+-
+- # Function to build "zfs unallow" commands for the filesystems we've
+- # delegated permissions to. Here we unallow both the target but also
+- # on the parent dataset because at this stage we have no way of
+- # knowing if the allow command did execute on the parent dataset or
+- # not in the pre-hook. We can't run the same if in the post hook
+- # since the dataset should have been created at this point.
+- buildUnallowCommand =
+- permissions: dataset:
+- (
+- "-+${pkgs.writeShellScript "zfs-unallow-${dataset}" ''
+- # Here we explicitly use the booted system to guarantee the stable API needed by ZFS
+- ${lib.escapeShellArgs [
+- "/run/booted-system/sw/bin/zfs"
+- "unallow"
+- cfg.user
+- (lib.concatStringsSep "," permissions)
+- dataset
+- ]}
+- ${lib.optionalString ((builtins.dirOf dataset) != ".") (
+- lib.escapeShellArgs [
+- "/run/booted-system/sw/bin/zfs"
+- "unallow"
+- cfg.user
+- (lib.concatStringsSep "," permissions)
+- # Remove the last part of the path
+- (builtins.dirOf dataset)
+- ]
+- )}
+- ''}"
+- );
+ in
+ {
+
+@@ -120,38 +48,23 @@ in
+ '';
+ };
+
+- user = lib.mkOption {
+- type = lib.types.str;
+- default = "syncoid";
+- example = "backup";
+- description = ''
+- The user for the service. ZFS privilege delegation will be
+- automatically configured for any local pools used by syncoid if this
+- option is set to a user other than root. The user will be given the
+- "hold" and "send" privileges on any pool that has datasets being sent
+- and the "create", "mount", "receive", and "rollback" privileges on
+- any pool that has datasets being received.
+- '';
+- };
+-
+- group = lib.mkOption {
+- type = lib.types.str;
+- default = "syncoid";
+- example = "backup";
+- description = "The group for the service.";
+- };
+-
+ sshKey = lib.mkOption {
+ type = with lib.types; nullOr (coercedTo path toString str);
+ default = null;
+ description = ''
+- SSH private key file to use to login to the remote system. Can be
+- overridden in individual commands.
++ SSH private key file to use to login to the remote system.
++ It can be overridden in individual commands.
++ It is loaded using `LoadCredentialEncrypted=`
++ when its path is prefixed by a credential name and colon,
++ otherwise `LoadCredential=` is used.
++ For more SSH tuning, you may use syncoid's `--sshoption`
++ in {option}`services.syncoid.commonArgs`
++ and/or in the `extraArgs` of a specific command.
+ '';
+ };
+
+ localSourceAllow = lib.mkOption {
+- type = lib.types.listOf lib.types.str;
++ type = with lib.types; listOf str;
+ # Permissions snapshot and destroy are in case --no-sync-snap is not used
+ default = [
+ "bookmark"
+@@ -162,19 +75,22 @@ in
+ "mount"
+ ];
+ description = ''
+- Permissions granted for the {option}`services.syncoid.user` user
+- for local source datasets. See
+- <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
++ Permissions granted for the syncoid user for local source datasets.
++ See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
+ for available permissions.
+ '';
+ };
+
+ localTargetAllow = lib.mkOption {
+- type = lib.types.listOf lib.types.str;
++ type = with lib.types; listOf str;
++ # Permission destroy is required to reset broken receive states (zfs receive -A),
++ # which syncoid does when it fails to resume a receive state,
++ # when the snapshot it refers to has been destroyed on the source.
+ default = [
+ "change-key"
+ "compression"
+ "create"
++ "destroy"
+ "mount"
+ "mountpoint"
+ "receive"
+@@ -187,9 +103,8 @@ in
+ "rollback"
+ ];
+ description = ''
+- Permissions granted for the {option}`services.syncoid.user` user
+- for local target datasets. See
+- <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
++ Permissions granted for the syncoid user for local target datasets.
++ See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
+ for available permissions.
+ Make sure to include the `change-key` permission if you send raw encrypted datasets,
+ the `compression` permission if you send raw compressed datasets, and so on.
+@@ -198,7 +113,7 @@ in
+ };
+
+ commonArgs = lib.mkOption {
+- type = lib.types.listOf lib.types.str;
++ type = with lib.types; listOf str;
+ default = [ ];
+ example = [ "--no-sync-snap" ];
+ description = ''
+@@ -296,13 +211,13 @@ in
+ '';
+ };
+
+- useCommonArgs = lib.mkOption {
+- type = lib.types.bool;
+- default = true;
+- description = ''
+- Whether to add the configured common arguments to this command.
+- '';
+- };
++ useCommonArgs =
++ lib.mkEnableOption ''
++ configured common arguments to this command
++ ''
++ // {
++ default = true;
++ };
+
+ service = lib.mkOption {
+ type = lib.types.attrs;
+@@ -341,139 +256,216 @@ in
+ # Implementation
+
+ config = lib.mkIf cfg.enable {
+- users = {
+- users = lib.mkIf (cfg.user == "syncoid") {
+- syncoid = {
+- group = cfg.group;
+- isSystemUser = true;
+- # For syncoid to be able to create /var/lib/syncoid/.ssh/
+- # and to use custom ssh_config or known_hosts.
+- home = "/var/lib/syncoid";
+- createHome = false;
+- };
+- };
+- groups = lib.mkIf (cfg.group == "syncoid") {
+- syncoid = { };
+- };
+- };
+-
+ systemd.services = lib.mapAttrs' (
+ name: c:
++ let
++ sshKeyCred = builtins.split ":" c.sshKey;
++ in
+ lib.nameValuePair "syncoid-${escapeUnitName name}" (
+ lib.mkMerge [
+ {
+ description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}";
+ after = [ "zfs.target" ];
+ startAt = cfg.interval;
+- # syncoid may need zpool to get feature@extensible_dataset
+- path = [ "/run/booted-system/sw/bin/" ];
+- serviceConfig = {
+- ExecStartPre =
+- (map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source))
+- ++ (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target));
+- ExecStopPost =
+- (map (buildUnallowCommand c.localSourceAllow) (localDatasetName c.source))
+- ++ (map (buildUnallowCommand c.localTargetAllow) (localDatasetName c.target));
+- ExecStart = lib.escapeShellArgs (
+- [ "${cfg.package}/bin/syncoid" ]
+- ++ lib.optionals c.useCommonArgs cfg.commonArgs
+- ++ lib.optional c.recursive "-r"
+- ++ lib.optionals (c.sshKey != null) [
+- "--sshkey"
+- c.sshKey
+- ]
+- ++ c.extraArgs
+- ++ [
+- "--sendoptions"
+- c.sendOptions
+- "--recvoptions"
+- c.recvOptions
+- "--no-privilege-elevation"
+- c.source
+- c.target
+- ]
+- );
+- User = cfg.user;
+- Group = cfg.group;
+- StateDirectory = [ "syncoid" ];
+- StateDirectoryMode = "700";
+- # Prevent SSH control sockets of different syncoid services from interfering
+- PrivateTmp = true;
+- # Permissive access to /proc because syncoid
+- # calls ps(1) to detect ongoing `zfs receive`.
+- ProcSubset = "all";
+- ProtectProc = "default";
++ # Here we explicitly use the booted system to guarantee the stable API needed by ZFS.
++ # Moreover syncoid may need zpool to get feature@extensible_dataset.
++ path = [ "/run/booted-system/sw" ];
++ # Prevents missing snapshots during DST changes
++ environment.TZ = "UTC";
++ # A custom LD_LIBRARY_PATH is needed to access in `getent passwd`
++ # the systemd's entry about the DynamicUser=,
++ # so that ssh won't fail with: "No user exists for uid $UID".
++ environment.LD_LIBRARY_PATH = config.system.nssModules.path;
++ serviceConfig =
++ {
++ ExecStartPre =
++ # Recursively remove any residual permissions
++ # given on local+descendant datasets (source, target or target's parent)
++ # to any currently unknown (hence unused) systemd dynamic users (UID/GID range 61184…65519),
++ # which happens when a crash has occurred
++ # during any previous run of a syncoid-*.service (not only this one).
++ map
++ (
++ dataset:
++ "+"
++ + pkgs.writeShellScript "zfs-unallow-unused-dynamic-users" ''
++ set -eu
++ zfs allow "$1" |
++ sed -ne 's/^\t\(user\|group\) (unknown: \([0-9]\+\)).*/\1 \2/p' |
++ {
++ declare -a uids
++ while read -r role id; do
++ if [ "$id" -ge 61184 ] && [ "$id" -le 65519 ]; then
++ case "$role" in
++ (user) uids+=("$id");;
++ esac
++ fi
++ done
++ zfs unallow -r -u "$(printf %s, "''${uids[@]}")" "$1"
++ }
++ ''
++ + " "
++ + lib.escapeShellArg dataset
++ )
++ (
++ localDatasetName c.source
++ ++ localDatasetName c.target
++ ++ map builtins.dirOf (localDatasetName c.target)
++ )
++ ++
++ # For a local source, allow the localSourceAllow ZFS permissions.
++ map (
++ dataset:
++ "+/run/booted-system/sw/bin/zfs allow $USER "
++ + lib.escapeShellArgs [
++ (lib.concatStringsSep "," c.localSourceAllow)
++ dataset
++ ]
++ ) (localDatasetName c.source)
++ ++
++ # For a local target, check if the dataset exists before delegating permissions,
++ # and if it doesn't exist, delegate it to the parent dataset.
++ # This should solve the case of provisioning new datasets.
++ map (
++ dataset:
++ "+"
++ + pkgs.writeShellScript "zfs-allow-target" ''
++ dataset="$1"
++ # Run a ZFS list on the dataset to check if it exists
++ zfs list "$dataset" >/dev/null 2>/dev/null ||
++ dataset="$(dirname "$dataset")"
++ zfs allow "$USER" ${lib.escapeShellArg (lib.concatStringsSep "," c.localTargetAllow)} "$dataset"
++ ''
++ + " "
++ + lib.escapeShellArg dataset
++ ) (localDatasetName c.target);
++ ExecStopPost =
++ let
++ zfsUnallow = dataset: "+/run/booted-system/sw/bin/zfs unallow $USER " + lib.escapeShellArg dataset;
++ in
++ map zfsUnallow (localDatasetName c.source)
++ ++
++ # For a local target, unallow both the dataset and its parent,
++ # because at this stage we have no way of knowing if the allow command
++ # did execute on the parent dataset or not in the ExecStartPre=.
++ # We can't run the same if-then-else in the post hook
++ # since the dataset should have been created at this point.
++ lib.concatMap (dataset: [
++ (zfsUnallow dataset)
++ (zfsUnallow (builtins.dirOf dataset))
++ ]) (localDatasetName c.target);
++ ExecStart = lib.escapeShellArgs (
++ [ "${cfg.package}/bin/syncoid" ]
++ ++ lib.optionals c.useCommonArgs cfg.commonArgs
++ ++ lib.optional c.recursive "--recursive"
++ ++ lib.optionals (c.sshKey != null) [
++ "--sshkey"
++ "\${CREDENTIALS_DIRECTORY}/${if lib.length sshKeyCred > 1 then lib.head sshKeyCred else "sshKey"}"
++ ]
++ ++ c.extraArgs
++ ++ [
++ "--sendoptions"
++ c.sendOptions
++ "--recvoptions"
++ c.recvOptions
++ "--no-privilege-elevation"
++ c.source
++ c.target
++ ]
++ );
++ DynamicUser = true;
++ NFTSet = lib.optional config.networking.nftables.enable "user:inet:filter:nixos_syncoid_uids";
++ # Prevent SSH control sockets of different syncoid services from interfering
++ PrivateTmp = true;
++ # Permissive access to /proc because syncoid
++ # calls ps(1) to detect ongoing `zfs receive`.
++ ProcSubset = "all";
++ ProtectProc = "default";
+
+- # The following options are only for optimizing:
+- # systemd-analyze security | grep syncoid-'*'
+- AmbientCapabilities = "";
+- CapabilityBoundingSet = "";
+- DeviceAllow = [ "/dev/zfs" ];
+- LockPersonality = true;
+- MemoryDenyWriteExecute = true;
+- NoNewPrivileges = true;
+- PrivateDevices = true;
+- PrivateMounts = true;
+- PrivateNetwork = lib.mkDefault false;
+- PrivateUsers = false; # Enabling this breaks on zfs-2.2.0
+- ProtectClock = true;
+- ProtectControlGroups = true;
+- ProtectHome = true;
+- ProtectHostname = true;
+- ProtectKernelLogs = true;
+- ProtectKernelModules = true;
+- ProtectKernelTunables = true;
+- ProtectSystem = "strict";
+- RemoveIPC = true;
+- RestrictAddressFamilies = [
+- "AF_UNIX"
+- "AF_INET"
+- "AF_INET6"
+- ];
+- RestrictNamespaces = true;
+- RestrictRealtime = true;
+- RestrictSUIDSGID = true;
+- RootDirectory = "/run/syncoid/${escapeUnitName name}";
+- RootDirectoryStartOnly = true;
+- BindPaths = [ "/dev/zfs" ];
+- BindReadOnlyPaths = [
+- builtins.storeDir
+- "/etc"
+- "/run"
+- "/bin/sh"
+- ];
+- # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
+- InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
+- MountAPIVFS = true;
+- # Create RootDirectory= in the host's mount namespace.
+- RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
+- RuntimeDirectoryMode = "700";
+- SystemCallFilter = [
+- "@system-service"
+- # Groups in @system-service which do not contain a syscall listed by:
+- # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid …
+- # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log
+- # systemd-analyze syscall-filter | grep -v -e '#' | sed -e ':loop; /^[^ ]/N; s/\n //; t loop' | grep $(printf ' -e \\<%s\\>' $(cat perf.syscalls)) | cut -f 1 -d ' '
+- "~@aio"
+- "~@chown"
+- "~@keyring"
+- "~@memlock"
+- "~@privileged"
+- "~@resources"
+- "~@setuid"
+- "~@timer"
+- ];
+- SystemCallArchitectures = "native";
+- # This is for BindPaths= and BindReadOnlyPaths=
+- # to allow traversal of directories they create in RootDirectory=.
+- UMask = "0066";
+- };
++ # The following options are only for optimizing:
++ # systemd-analyze security | grep syncoid-'*'
++ AmbientCapabilities = "";
++ CapabilityBoundingSet = "";
++ DeviceAllow = [ "/dev/zfs" ];
++ LockPersonality = true;
++ MemoryDenyWriteExecute = true;
++ NoNewPrivileges = true;
++ PrivateDevices = true;
++ PrivateMounts = true;
++ PrivateNetwork = lib.mkDefault false;
++ PrivateUsers = false; # Enabling this breaks on zfs-2.2.0
++ ProtectClock = true;
++ ProtectControlGroups = true;
++ ProtectHome = true;
++ ProtectHostname = true;
++ ProtectKernelLogs = true;
++ ProtectKernelModules = true;
++ ProtectKernelTunables = true;
++ ProtectSystem = "strict";
++ RemoveIPC = true;
++ RestrictAddressFamilies = [
++ "AF_UNIX"
++ "AF_INET"
++ "AF_INET6"
++ ];
++ RestrictNamespaces = true;
++ RestrictRealtime = true;
++ RestrictSUIDSGID = true;
++ RootDirectory = "/run/syncoid/${escapeUnitName name}";
++ BindPaths = [ "/dev/zfs" ];
++ BindReadOnlyPaths = [
++ builtins.storeDir
++ "/etc"
++ "/run"
++ # Some programs hardcode /var/run
++ # eg. /var/run/avahi-daemon/socket to resolve *.local mDNS domains
++ "/var/run"
++ "/bin/sh"
++ ];
++ # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
++ InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
++ MountAPIVFS = true;
++ # Create RootDirectory= in the host's mount namespace.
++ RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
++ RuntimeDirectoryMode = "700";
++ SystemCallFilter = [
++ "@system-service"
++ # Groups in @system-service which do not contain a syscall listed by:
++ # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid …
++ # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log
++ # systemd-analyze syscall-filter | grep -v -e '#' | sed -e ':loop; /^[^ ]/N; s/\n //; t loop' | grep $(printf ' -e \\<%s\\>' $(cat perf.syscalls)) | cut -f 1 -d ' '
++ "~@aio"
++ "~@chown"
++ "~@keyring"
++ "~@memlock"
++ "~@privileged"
++ "~@resources"
++ "~@setuid"
++ ];
++ SystemCallArchitectures = "native";
++ # This is for BindPaths= and BindReadOnlyPaths=
++ # to allow traversal of directories they create in RootDirectory=.
++ UMask = "0066";
++ }
++ // (
++ if lib.length sshKeyCred > 1 then
++ { LoadCredentialEncrypted = [ c.sshKey ]; }
++ else
++ { LoadCredential = [ "sshKey:${c.sshKey}" ]; }
++ );
+ }
+ cfg.service
+ c.service
+ ]
+ )
+ ) cfg.commands;
++
++ networking.nftables.ruleset = lib.mkBefore ''
++ table inet filter {
++ # A set containing the dynamic UIDs of the syncoid services currently active
++ set nixos_syncoid_uids { typeof meta skuid; }
++ }
++ '';
+ };
+
+ meta.maintainers = with lib.maintainers; [
+diff --git a/nixos/tests/sanoid.nix b/nixos/tests/sanoid.nix
+index e42fd54dfd..eabe88b6be 100644
+--- a/nixos/tests/sanoid.nix
++++ b/nixos/tests/sanoid.nix
+@@ -50,16 +50,26 @@ in
+ enable = true;
+ sshKey = "/var/lib/syncoid/id_ecdsa";
+ commands = {
+- # Sync snapshot taken by sanoid
+- "pool/sanoid" = {
+- target = "root@target:pool/sanoid";
++ # Take snapshot and sync
++ "pool/syncoid".target = "root@target:pool/syncoid";
++
++ # Sync the same dataset to different targets
++ "pool/sanoid1" = {
++ source = "pool/sanoid";
++ target = "root@target:pool/sanoid1";
++ extraArgs = [
++ "--no-sync-snap"
++ "--create-bookmark"
++ ];
++ };
++ "pool/sanoid2" = {
++ source = "pool/sanoid";
++ target = "root@target:pool/sanoid2";
+ extraArgs = [
+ "--no-sync-snap"
+ "--create-bookmark"
+ ];
+ };
+- # Take snapshot and sync
+- "pool/syncoid".target = "root@target:pool/syncoid";
+
+ # Test pool without parent (regression test for https://github.com/NixOS/nixpkgs/pull/180111)
+ "pool".target = "root@target:pool/full-pool";
+@@ -94,6 +104,7 @@ in
+ "zfs create pool/syncoid",
+ "udevadm settle",
+ )
++
+ target.succeed(
+ "mkdir /mnt",
+ "parted --script /dev/vdb -- mklabel msdos mkpart primary 1024M -1s",
+@@ -106,41 +117,44 @@ in
+ "mkdir -m 700 -p /var/lib/syncoid",
+ "cat '${snakeOilPrivateKey}' > /var/lib/syncoid/id_ecdsa",
+ "chmod 600 /var/lib/syncoid/id_ecdsa",
+- "chown -R syncoid:syncoid /var/lib/syncoid/",
+ )
+
+- assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set before snapshotting"
+- assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set before snapshotting"
+- assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set before snapshotting"
++ with subtest("Take snapshots with sanoid"):
++ source.succeed("touch /mnt/pool/sanoid/test.txt")
++ source.succeed("touch /mnt/pool/compat/test.txt")
++ source.systemctl("start --wait sanoid.service")
+
+- # Take snapshot with sanoid
+- source.succeed("touch /mnt/pool/sanoid/test.txt")
+- source.succeed("touch /mnt/pool/compat/test.txt")
+- source.systemctl("start --wait sanoid.service")
++ # Add some unused dynamic users to the stateful allow list of ZFS datasets,
++ # simulating a state where they remain after the system crashed,
++ # to check they'll be correctly removed by the syncoid services.
++ # Each syncoid service run from now may reuse at most one of them for itself.
++ source.succeed(
++ "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool",
++ "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool/sanoid",
++ "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool/syncoid",
++ )
++
++ with subtest("sync snapshots"):
++ target.wait_for_open_port(22)
++ source.succeed("touch /mnt/pool/syncoid/test.txt")
++
++ source.systemctl("start --wait syncoid-pool-syncoid.service")
++ target.succeed("cat /mnt/pool/syncoid/test.txt")
++
++ source.systemctl("start --wait syncoid-pool-sanoid{1,2}.service")
++ target.succeed("cat /mnt/pool/sanoid1/test.txt")
++ target.succeed("cat /mnt/pool/sanoid2/test.txt")
++
++ source.systemctl("start --wait syncoid-pool.service")
++ target.succeed("[[ -d /mnt/pool/full-pool/syncoid ]]")
++
++ source.systemctl("start --wait syncoid-pool-compat.service")
++ target.succeed("cat /mnt/pool/compat/test.txt")
+
+ assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set after snapshotting"
+ assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set after snapshotting"
+ assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set after snapshotting"
+-
+- # Sync snapshots
+- target.wait_for_open_port(22)
+- source.succeed("touch /mnt/pool/syncoid/test.txt")
+- source.systemctl("start --wait syncoid-pool-sanoid.service")
+- target.succeed("cat /mnt/pool/sanoid/test.txt")
+- source.systemctl("start --wait syncoid-pool-syncoid.service")
+- source.systemctl("start --wait syncoid-pool-syncoid.service")
+- target.succeed("cat /mnt/pool/syncoid/test.txt")
+-
+ assert(len(source.succeed("zfs list -H -t snapshot pool/syncoid").splitlines()) == 1), "Syncoid should only retain one sync snapshot"
+
+- source.systemctl("start --wait syncoid-pool.service")
+- target.succeed("[[ -d /mnt/pool/full-pool/syncoid ]]")
+-
+- source.systemctl("start --wait syncoid-pool-compat.service")
+- target.succeed("cat /mnt/pool/compat/test.txt")
+-
+- assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set after syncing snapshots"
+- assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set after syncing snapshots"
+- assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set after syncing snapshots"
+ '';
+ }
+--
+2.47.2
+