1 From 0e837ecd5ffb5e8366a5e2878838ba691b31cd99 Mon Sep 17 00:00:00 2001
 
   2 From: Julien Moutinho <julm+nixpkgs@sourcephile.fr>
 
   3 Date: Sat, 27 Nov 2021 02:50:39 +0100
 
   4 Subject: [PATCH 2/2] nixos/syncoid: use DynamicUser=
 
   6 HistoryNote: the following changes
 
   7 were previously in separate commits
 
   8 but have been squashed and nixfmt-rfc-style
 
   9 on be able to rebase them
 
  10 onto a treewide change applying nixfmt-rfc-style
 
  11 on nixos/modules/services/backup/syncoid.nix
 
  12 which introduced multiple untrackable conflicts.
 
  16 - nixos/syncoid: add destroy to localTargetAllow's default
 
  18   syncoid[3282027]: Resuming interrupted zfs send/receive from rpool/var/mail to losurdo/backup/mermet/var/mail (~ UNKNOWN remaining):
 
  19   syncoid[3282885]: cannot resume send: 'rpool/var/mail@autosnap_2021-10-17_15:00:19_hourly' used in the initial send no longer exists
 
  20   syncoid[3282897]: cannot receive: failed to read from stream
 
  21   syncoid[3282027]: WARN: resetting partially receive state because the snapshot source no longer exists
 
  22   syncoid[3283326]: cannot destroy 'losurdo/backup/mermet/var/mail/%recv': permission denied
 
  24 - nixos/syncoid: allow @timer syscalls
 
  26 - nixos/syncoid: set TZ envvar
 
  28 - nixos/syncoid: test sending to multiple targets
 
  30 - nixos/syncoid: don't delegate permissions to source's parent
 
  32   See https://github.com/NixOS/nixpkgs/pull/165204
 
  33   and https://github.com/NixOS/nixpkgs/pull/180111
 
  35 - nixos/syncoid: add support for LoadCredentialEncrypted=
 
  37 - nixos/syncoid: zfs-unallow unused dynamic users
 
  39 - nixos/syncoid: use NFTSet=
 
  41 Changes between the treewide nixfmt-rfc-style
 
  42 and those changes have been preserved:
 
  44 - 71967c47e54c56557c0ee7bec7fe554c018e37c4 nixos/syncoid: allow interval to be list of strings
 
  45 - f34483be5ee2418a563545a56743b7b59c549935 nixosTests: handleTest -> runTest, batch 1
 
  47  nixos/modules/services/backup/syncoid.nix | 454 +++++++++++-----------
 
  48  nixos/tests/sanoid.nix                    |  78 ++--
 
  49  2 files changed, 269 insertions(+), 263 deletions(-)
 
  51 diff --git a/nixos/modules/services/backup/syncoid.nix b/nixos/modules/services/backup/syncoid.nix
 
  52 index 8b4c59155f..7d344a1ad0 100644
 
  53 --- a/nixos/modules/services/backup/syncoid.nix
 
  54 +++ b/nixos/modules/services/backup/syncoid.nix
 
  57    cfg = config.services.syncoid;
 
  59 -  # Extract local dasaset names (so no datasets containing "@")
 
  60 +  # Extract local dataset names (so no datasets containing "@")
 
  63      lib.optionals (d != null) (
 
  64 @@ -20,81 +20,9 @@ let
 
  65    # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
 
  68 -    lib.concatMapStrings (s: if lib.isList s then "-" else s) (
 
  69 +    lib.concatMapStrings (s: if builtins.isList s then "-" else s) (
 
  70        builtins.split "[^a-zA-Z0-9_.\\-]+" name
 
  73 -  # Function to build "zfs allow" commands for the filesystems we've delegated
 
  74 -  # permissions to. It also checks if the target dataset exists before
 
  75 -  # delegating permissions, if it doesn't exist we delegate it to the parent
 
  76 -  # dataset (if it exists). This should solve the case of provisoning new
 
  79 -    permissions: dataset:
 
  81 -      "-+${pkgs.writeShellScript "zfs-allow-${dataset}" ''
 
  82 -        # Here we explicitly use the booted system to guarantee the stable API needed by ZFS
 
  84 -        # Run a ZFS list on the dataset to check if it exists
 
  86 -          lib.escapeShellArgs [
 
  87 -            "/run/booted-system/sw/bin/zfs"
 
  91 -        } 2> /dev/null; then
 
  92 -          ${lib.escapeShellArgs [
 
  93 -            "/run/booted-system/sw/bin/zfs"
 
  96 -            (lib.concatStringsSep "," permissions)
 
  99 -        ${lib.optionalString ((builtins.dirOf dataset) != ".") ''
 
 101 -            ${lib.escapeShellArgs [
 
 102 -              "/run/booted-system/sw/bin/zfs"
 
 105 -              (lib.concatStringsSep "," permissions)
 
 106 -              # Remove the last part of the path
 
 107 -              (builtins.dirOf dataset)
 
 114 -  # Function to build "zfs unallow" commands for the filesystems we've
 
 115 -  # delegated permissions to. Here we unallow both the target but also
 
 116 -  # on the parent dataset because at this stage we have no way of
 
 117 -  # knowing if the allow command did execute on the parent dataset or
 
 118 -  # not in the pre-hook. We can't run the same if in the post hook
 
 119 -  # since the dataset should have been created at this point.
 
 120 -  buildUnallowCommand =
 
 121 -    permissions: dataset:
 
 123 -      "-+${pkgs.writeShellScript "zfs-unallow-${dataset}" ''
 
 124 -        # Here we explicitly use the booted system to guarantee the stable API needed by ZFS
 
 125 -        ${lib.escapeShellArgs [
 
 126 -          "/run/booted-system/sw/bin/zfs"
 
 129 -          (lib.concatStringsSep "," permissions)
 
 132 -        ${lib.optionalString ((builtins.dirOf dataset) != ".") (
 
 133 -          lib.escapeShellArgs [
 
 134 -            "/run/booted-system/sw/bin/zfs"
 
 137 -            (lib.concatStringsSep "," permissions)
 
 138 -            # Remove the last part of the path
 
 139 -            (builtins.dirOf dataset)
 
 147 @@ -120,38 +48,23 @@ in
 
 151 -    user = lib.mkOption {
 
 152 -      type = lib.types.str;
 
 153 -      default = "syncoid";
 
 154 -      example = "backup";
 
 156 -        The user for the service. ZFS privilege delegation will be
 
 157 -        automatically configured for any local pools used by syncoid if this
 
 158 -        option is set to a user other than root. The user will be given the
 
 159 -        "hold" and "send" privileges on any pool that has datasets being sent
 
 160 -        and the "create", "mount", "receive", and "rollback" privileges on
 
 161 -        any pool that has datasets being received.
 
 165 -    group = lib.mkOption {
 
 166 -      type = lib.types.str;
 
 167 -      default = "syncoid";
 
 168 -      example = "backup";
 
 169 -      description = "The group for the service.";
 
 172      sshKey = lib.mkOption {
 
 173        type = with lib.types; nullOr (coercedTo path toString str);
 
 176 -        SSH private key file to use to login to the remote system. Can be
 
 177 -        overridden in individual commands.
 
 178 +        SSH private key file to use to login to the remote system.
 
 179 +        It can be overridden in individual commands.
 
 180 +        It is loaded using `LoadCredentialEncrypted=`
 
 181 +        when its path is prefixed by a credential name and colon,
 
 182 +        otherwise `LoadCredential=` is used.
 
 183 +        For more SSH tuning, you may use syncoid's `--sshoption`
 
 184 +        in {option}`services.syncoid.commonArgs`
 
 185 +        and/or in the `extraArgs` of a specific command.
 
 189      localSourceAllow = lib.mkOption {
 
 190 -      type = lib.types.listOf lib.types.str;
 
 191 +      type = with lib.types; listOf str;
 
 192        # Permissions snapshot and destroy are in case --no-sync-snap is not used
 
 195 @@ -162,19 +75,22 @@ in
 
 199 -        Permissions granted for the {option}`services.syncoid.user` user
 
 200 -        for local source datasets. See
 
 201 -        <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
 
 202 +        Permissions granted for the syncoid user for local source datasets.
 
 203 +        See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
 
 204          for available permissions.
 
 208      localTargetAllow = lib.mkOption {
 
 209 -      type = lib.types.listOf lib.types.str;
 
 210 +      type = with lib.types; listOf str;
 
 211 +      # Permission destroy is required to reset broken receive states (zfs receive -A),
 
 212 +      # which syncoid does when it fails to resume a receive state,
 
 213 +      # when the snapshot it refers to has been destroyed on the source.
 
 222 @@ -187,9 +103,8 @@ in
 
 226 -        Permissions granted for the {option}`services.syncoid.user` user
 
 227 -        for local target datasets. See
 
 228 -        <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
 
 229 +        Permissions granted for the syncoid user for local target datasets.
 
 230 +        See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
 
 231          for available permissions.
 
 232          Make sure to include the `change-key` permission if you send raw encrypted datasets,
 
 233          the `compression` permission if you send raw compressed datasets, and so on.
 
 234 @@ -198,7 +113,7 @@ in
 
 237      commonArgs = lib.mkOption {
 
 238 -      type = lib.types.listOf lib.types.str;
 
 239 +      type = with lib.types; listOf str;
 
 241        example = [ "--no-sync-snap" ];
 
 243 @@ -296,13 +211,13 @@ in
 
 247 -              useCommonArgs = lib.mkOption {
 
 248 -                type = lib.types.bool;
 
 251 -                  Whether to add the configured common arguments to this command.
 
 255 +                lib.mkEnableOption ''
 
 256 +                  configured common arguments to this command
 
 262                service = lib.mkOption {
 
 263                  type = lib.types.attrs;
 
 264 @@ -341,139 +256,216 @@ in
 
 267    config = lib.mkIf cfg.enable {
 
 269 -      users = lib.mkIf (cfg.user == "syncoid") {
 
 272 -          isSystemUser = true;
 
 273 -          # For syncoid to be able to create /var/lib/syncoid/.ssh/
 
 274 -          # and to use custom ssh_config or known_hosts.
 
 275 -          home = "/var/lib/syncoid";
 
 276 -          createHome = false;
 
 279 -      groups = lib.mkIf (cfg.group == "syncoid") {
 
 284      systemd.services = lib.mapAttrs' (
 
 287 +        sshKeyCred = builtins.split ":" c.sshKey;
 
 289        lib.nameValuePair "syncoid-${escapeUnitName name}" (
 
 292              description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}";
 
 293              after = [ "zfs.target" ];
 
 294              startAt = cfg.interval;
 
 295 -            # syncoid may need zpool to get feature@extensible_dataset
 
 296 -            path = [ "/run/booted-system/sw/bin/" ];
 
 299 -                (map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source))
 
 300 -                ++ (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target));
 
 302 -                (map (buildUnallowCommand c.localSourceAllow) (localDatasetName c.source))
 
 303 -                ++ (map (buildUnallowCommand c.localTargetAllow) (localDatasetName c.target));
 
 304 -              ExecStart = lib.escapeShellArgs (
 
 305 -                [ "${cfg.package}/bin/syncoid" ]
 
 306 -                ++ lib.optionals c.useCommonArgs cfg.commonArgs
 
 307 -                ++ lib.optional c.recursive "-r"
 
 308 -                ++ lib.optionals (c.sshKey != null) [
 
 318 -                  "--no-privilege-elevation"
 
 325 -              StateDirectory = [ "syncoid" ];
 
 326 -              StateDirectoryMode = "700";
 
 327 -              # Prevent SSH control sockets of different syncoid services from interfering
 
 329 -              # Permissive access to /proc because syncoid
 
 330 -              # calls ps(1) to detect ongoing `zfs receive`.
 
 331 -              ProcSubset = "all";
 
 332 -              ProtectProc = "default";
 
 333 +            # Here we explicitly use the booted system to guarantee the stable API needed by ZFS.
 
 334 +            # Moreover syncoid may need zpool to get feature@extensible_dataset.
 
 335 +            path = [ "/run/booted-system/sw" ];
 
 336 +            # Prevents missing snapshots during DST changes
 
 337 +            environment.TZ = "UTC";
 
 338 +            # A custom LD_LIBRARY_PATH is needed to access in `getent passwd`
 
 339 +            # the systemd's entry about the DynamicUser=,
 
 340 +            # so that ssh won't fail with: "No user exists for uid $UID".
 
 341 +            environment.LD_LIBRARY_PATH = config.system.nssModules.path;
 
 345 +                  # Recursively remove any residual permissions
 
 346 +                  # given on local+descendant datasets (source, target or target's parent)
 
 347 +                  # to any currently unknown (hence unused) systemd dynamic users (UID/GID range 61184…65519),
 
 348 +                  # which happens when a crash has occurred
 
 349 +                  # during any previous run of a syncoid-*.service (not only this one).
 
 354 +                      + pkgs.writeShellScript "zfs-unallow-unused-dynamic-users" ''
 
 357 +                        sed -ne 's/^\t\(user\|group\) (unknown: \([0-9]\+\)).*/\1 \2/p' |
 
 360 +                          while read -r role id; do
 
 361 +                            if [ "$id" -ge 61184 ] && [ "$id" -le 65519 ]; then
 
 363 +                                (user) uids+=("$id");;
 
 367 +                          zfs unallow -r -u "$(printf %s, "''${uids[@]}")" "$1"
 
 371 +                      + lib.escapeShellArg dataset
 
 374 +                      localDatasetName c.source
 
 375 +                      ++ localDatasetName c.target
 
 376 +                      ++ map builtins.dirOf (localDatasetName c.target)
 
 379 +                    # For a local source, allow the localSourceAllow ZFS permissions.
 
 382 +                      "+/run/booted-system/sw/bin/zfs allow $USER "
 
 383 +                      + lib.escapeShellArgs [
 
 384 +                        (lib.concatStringsSep "," c.localSourceAllow)
 
 387 +                    ) (localDatasetName c.source)
 
 389 +                    # For a local target, check if the dataset exists before delegating permissions,
 
 390 +                    # and if it doesn't exist, delegate it to the parent dataset.
 
 391 +                    # This should solve the case of provisioning new datasets.
 
 395 +                      + pkgs.writeShellScript "zfs-allow-target" ''
 
 397 +                        # Run a ZFS list on the dataset to check if it exists
 
 398 +                        zfs list "$dataset" >/dev/null 2>/dev/null ||
 
 399 +                          dataset="$(dirname "$dataset")"
 
 400 +                        zfs allow "$USER" ${lib.escapeShellArg (lib.concatStringsSep "," c.localTargetAllow)} "$dataset"
 
 403 +                      + lib.escapeShellArg dataset
 
 404 +                    ) (localDatasetName c.target);
 
 407 +                    zfsUnallow = dataset: "+/run/booted-system/sw/bin/zfs unallow $USER " + lib.escapeShellArg dataset;
 
 409 +                  map zfsUnallow (localDatasetName c.source)
 
 411 +                    # For a local target, unallow both the dataset and its parent,
 
 412 +                    # because at this stage we have no way of knowing if the allow command
 
 413 +                    # did execute on the parent dataset or not in the ExecStartPre=.
 
 414 +                    # We can't run the same if-then-else in the post hook
 
 415 +                    # since the dataset should have been created at this point.
 
 416 +                    lib.concatMap (dataset: [
 
 417 +                      (zfsUnallow dataset)
 
 418 +                      (zfsUnallow (builtins.dirOf dataset))
 
 419 +                    ]) (localDatasetName c.target);
 
 420 +                ExecStart = lib.escapeShellArgs (
 
 421 +                  [ "${cfg.package}/bin/syncoid" ]
 
 422 +                  ++ lib.optionals c.useCommonArgs cfg.commonArgs
 
 423 +                  ++ lib.optional c.recursive "--recursive"
 
 424 +                  ++ lib.optionals (c.sshKey != null) [
 
 426 +                    "\${CREDENTIALS_DIRECTORY}/${if lib.length sshKeyCred > 1 then lib.head sshKeyCred else "sshKey"}"
 
 434 +                    "--no-privilege-elevation"
 
 439 +                DynamicUser = true;
 
 440 +                NFTSet = lib.optional config.networking.nftables.enable "user:inet:filter:nixos_syncoid_uids";
 
 441 +                # Prevent SSH control sockets of different syncoid services from interfering
 
 443 +                # Permissive access to /proc because syncoid
 
 444 +                # calls ps(1) to detect ongoing `zfs receive`.
 
 445 +                ProcSubset = "all";
 
 446 +                ProtectProc = "default";
 
 448 -              # The following options are only for optimizing:
 
 449 -              # systemd-analyze security | grep syncoid-'*'
 
 450 -              AmbientCapabilities = "";
 
 451 -              CapabilityBoundingSet = "";
 
 452 -              DeviceAllow = [ "/dev/zfs" ];
 
 453 -              LockPersonality = true;
 
 454 -              MemoryDenyWriteExecute = true;
 
 455 -              NoNewPrivileges = true;
 
 456 -              PrivateDevices = true;
 
 457 -              PrivateMounts = true;
 
 458 -              PrivateNetwork = lib.mkDefault false;
 
 459 -              PrivateUsers = false; # Enabling this breaks on zfs-2.2.0
 
 460 -              ProtectClock = true;
 
 461 -              ProtectControlGroups = true;
 
 462 -              ProtectHome = true;
 
 463 -              ProtectHostname = true;
 
 464 -              ProtectKernelLogs = true;
 
 465 -              ProtectKernelModules = true;
 
 466 -              ProtectKernelTunables = true;
 
 467 -              ProtectSystem = "strict";
 
 469 -              RestrictAddressFamilies = [
 
 474 -              RestrictNamespaces = true;
 
 475 -              RestrictRealtime = true;
 
 476 -              RestrictSUIDSGID = true;
 
 477 -              RootDirectory = "/run/syncoid/${escapeUnitName name}";
 
 478 -              RootDirectoryStartOnly = true;
 
 479 -              BindPaths = [ "/dev/zfs" ];
 
 480 -              BindReadOnlyPaths = [
 
 486 -              # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
 
 487 -              InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
 
 488 -              MountAPIVFS = true;
 
 489 -              # Create RootDirectory= in the host's mount namespace.
 
 490 -              RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
 
 491 -              RuntimeDirectoryMode = "700";
 
 492 -              SystemCallFilter = [
 
 494 -                # Groups in @system-service which do not contain a syscall listed by:
 
 495 -                # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid …
 
 496 -                # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log
 
 497 -                # 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 ' '
 
 507 -              SystemCallArchitectures = "native";
 
 508 -              # This is for BindPaths= and BindReadOnlyPaths=
 
 509 -              # to allow traversal of directories they create in RootDirectory=.
 
 512 +                # The following options are only for optimizing:
 
 513 +                # systemd-analyze security | grep syncoid-'*'
 
 514 +                AmbientCapabilities = "";
 
 515 +                CapabilityBoundingSet = "";
 
 516 +                DeviceAllow = [ "/dev/zfs" ];
 
 517 +                LockPersonality = true;
 
 518 +                MemoryDenyWriteExecute = true;
 
 519 +                NoNewPrivileges = true;
 
 520 +                PrivateDevices = true;
 
 521 +                PrivateMounts = true;
 
 522 +                PrivateNetwork = lib.mkDefault false;
 
 523 +                PrivateUsers = false; # Enabling this breaks on zfs-2.2.0
 
 524 +                ProtectClock = true;
 
 525 +                ProtectControlGroups = true;
 
 526 +                ProtectHome = true;
 
 527 +                ProtectHostname = true;
 
 528 +                ProtectKernelLogs = true;
 
 529 +                ProtectKernelModules = true;
 
 530 +                ProtectKernelTunables = true;
 
 531 +                ProtectSystem = "strict";
 
 533 +                RestrictAddressFamilies = [
 
 538 +                RestrictNamespaces = true;
 
 539 +                RestrictRealtime = true;
 
 540 +                RestrictSUIDSGID = true;
 
 541 +                RootDirectory = "/run/syncoid/${escapeUnitName name}";
 
 542 +                BindPaths = [ "/dev/zfs" ];
 
 543 +                BindReadOnlyPaths = [
 
 547 +                  # Some programs hardcode /var/run
 
 548 +                  # eg. /var/run/avahi-daemon/socket to resolve *.local mDNS domains
 
 552 +                # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
 
 553 +                InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
 
 554 +                MountAPIVFS = true;
 
 555 +                # Create RootDirectory= in the host's mount namespace.
 
 556 +                RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
 
 557 +                RuntimeDirectoryMode = "700";
 
 558 +                SystemCallFilter = [
 
 560 +                  # Groups in @system-service which do not contain a syscall listed by:
 
 561 +                  # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid …
 
 562 +                  # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log
 
 563 +                  # 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 ' '
 
 572 +                SystemCallArchitectures = "native";
 
 573 +                # This is for BindPaths= and BindReadOnlyPaths=
 
 574 +                # to allow traversal of directories they create in RootDirectory=.
 
 578 +                if lib.length sshKeyCred > 1 then
 
 579 +                  { LoadCredentialEncrypted = [ c.sshKey ]; }
 
 581 +                  { LoadCredential = [ "sshKey:${c.sshKey}" ]; }
 
 590 +    networking.nftables.ruleset = lib.mkBefore ''
 
 591 +      table inet filter {
 
 592 +        # A set containing the dynamic UIDs of the syncoid services currently active
 
 593 +        set nixos_syncoid_uids { typeof meta skuid; }
 
 598    meta.maintainers = with lib.maintainers; [
 
 599 diff --git a/nixos/tests/sanoid.nix b/nixos/tests/sanoid.nix
 
 600 index e42fd54dfd..eabe88b6be 100644
 
 601 --- a/nixos/tests/sanoid.nix
 
 602 +++ b/nixos/tests/sanoid.nix
 
 603 @@ -50,16 +50,26 @@ in
 
 605            sshKey = "/var/lib/syncoid/id_ecdsa";
 
 607 -            # Sync snapshot taken by sanoid
 
 609 -              target = "root@target:pool/sanoid";
 
 610 +            # Take snapshot and sync
 
 611 +            "pool/syncoid".target = "root@target:pool/syncoid";
 
 613 +            # Sync the same dataset to different targets
 
 615 +              source = "pool/sanoid";
 
 616 +              target = "root@target:pool/sanoid1";
 
 619 +                "--create-bookmark"
 
 623 +              source = "pool/sanoid";
 
 624 +              target = "root@target:pool/sanoid2";
 
 630 -            # Take snapshot and sync
 
 631 -            "pool/syncoid".target = "root@target:pool/syncoid";
 
 633              # Test pool without parent (regression test for https://github.com/NixOS/nixpkgs/pull/180111)
 
 634              "pool".target = "root@target:pool/full-pool";
 
 635 @@ -94,6 +104,7 @@ in
 
 636          "zfs create pool/syncoid",
 
 642          "parted --script /dev/vdb -- mklabel msdos mkpart primary 1024M -1s",
 
 643 @@ -106,41 +117,44 @@ in
 
 644          "mkdir -m 700 -p /var/lib/syncoid",
 
 645          "cat '${snakeOilPrivateKey}' > /var/lib/syncoid/id_ecdsa",
 
 646          "chmod 600 /var/lib/syncoid/id_ecdsa",
 
 647 -        "chown -R syncoid:syncoid /var/lib/syncoid/",
 
 650 -    assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set before snapshotting"
 
 651 -    assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set before snapshotting"
 
 652 -    assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set before snapshotting"
 
 653 +    with subtest("Take snapshots with sanoid"):
 
 654 +      source.succeed("touch /mnt/pool/sanoid/test.txt")
 
 655 +      source.succeed("touch /mnt/pool/compat/test.txt")
 
 656 +      source.systemctl("start --wait sanoid.service")
 
 658 -    # Take snapshot with sanoid
 
 659 -    source.succeed("touch /mnt/pool/sanoid/test.txt")
 
 660 -    source.succeed("touch /mnt/pool/compat/test.txt")
 
 661 -    source.systemctl("start --wait sanoid.service")
 
 662 +    # Add some unused dynamic users to the stateful allow list of ZFS datasets,
 
 663 +    # simulating a state where they remain after the system crashed,
 
 664 +    # to check they'll be correctly removed by the syncoid services.
 
 665 +    # Each syncoid service run from now may reuse at most one of them for itself.
 
 667 +        "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool",
 
 668 +        "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool/sanoid",
 
 669 +        "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool/syncoid",
 
 672 +    with subtest("sync snapshots"):
 
 673 +      target.wait_for_open_port(22)
 
 674 +      source.succeed("touch /mnt/pool/syncoid/test.txt")
 
 676 +      source.systemctl("start --wait syncoid-pool-syncoid.service")
 
 677 +      target.succeed("cat /mnt/pool/syncoid/test.txt")
 
 679 +      source.systemctl("start --wait syncoid-pool-sanoid{1,2}.service")
 
 680 +      target.succeed("cat /mnt/pool/sanoid1/test.txt")
 
 681 +      target.succeed("cat /mnt/pool/sanoid2/test.txt")
 
 683 +      source.systemctl("start --wait syncoid-pool.service")
 
 684 +      target.succeed("[[ -d /mnt/pool/full-pool/syncoid ]]")
 
 686 +      source.systemctl("start --wait syncoid-pool-compat.service")
 
 687 +      target.succeed("cat /mnt/pool/compat/test.txt")
 
 689      assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set after snapshotting"
 
 690      assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set after snapshotting"
 
 691      assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set after snapshotting"
 
 694 -    target.wait_for_open_port(22)
 
 695 -    source.succeed("touch /mnt/pool/syncoid/test.txt")
 
 696 -    source.systemctl("start --wait syncoid-pool-sanoid.service")
 
 697 -    target.succeed("cat /mnt/pool/sanoid/test.txt")
 
 698 -    source.systemctl("start --wait syncoid-pool-syncoid.service")
 
 699 -    source.systemctl("start --wait syncoid-pool-syncoid.service")
 
 700 -    target.succeed("cat /mnt/pool/syncoid/test.txt")
 
 702      assert(len(source.succeed("zfs list -H -t snapshot pool/syncoid").splitlines()) == 1), "Syncoid should only retain one sync snapshot"
 
 704 -    source.systemctl("start --wait syncoid-pool.service")
 
 705 -    target.succeed("[[ -d /mnt/pool/full-pool/syncoid ]]")
 
 707 -    source.systemctl("start --wait syncoid-pool-compat.service")
 
 708 -    target.succeed("cat /mnt/pool/compat/test.txt")
 
 710 -    assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set after syncing snapshots"
 
 711 -    assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set after syncing snapshots"
 
 712 -    assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set after syncing snapshots"