1 From c911e43e341b4c0963012afa6671d508732bf165 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/3] nixos/syncoid: use DynamicUser=
 
   6 The following changes were previously in separate commits
 
   7 but have been squashed to be able to rebase them
 
   8 onto a treewide change applying nixfmt-rfc-style
 
   9 on nixos/modules/services/backup/syncoid.nix
 
  10 which introduced multiple untrackable conflicts.
 
  14 - nixos/syncoid: add destroy to localTargetAllow's default
 
  16 syncoid[3282027]: Resuming interrupted zfs send/receive from rpool/var/mail to losurdo/backup/mermet/var/mail (~ UNKNOWN remaining):
 
  17 syncoid[3282885]: cannot resume send: 'rpool/var/mail@autosnap_2021-10-17_15:00:19_hourly' used in the initial send no longer exists
 
  18 syncoid[3282897]: cannot receive: failed to read from stream
 
  19 syncoid[3282027]: WARN: resetting partially receive state because the snapshot source no longer exists
 
  20 syncoid[3283326]: cannot destroy 'losurdo/backup/mermet/var/mail/%recv': permission denied
 
  22 - nixos/syncoid: allow @timer syscalls
 
  24 - nixos/syncoid: set TZ envvar
 
  26 - nixos/syncoid: test sending to multiple targets
 
  28 - nixos/syncoid: don't delegate permissions to source's parent
 
  29 See https://github.com/NixOS/nixpkgs/pull/165204
 
  30 and https://github.com/NixOS/nixpkgs/pull/180111
 
  32 - nixos/syncoid: add support for LoadCredentialEncrypted=
 
  34 - nixos/syncoid: zfs-unallow unused dynamic users
 
  36 - nixos/syncoid: use NFTSet=
 
  38  nixos/modules/services/backup/syncoid.nix | 463 +++++++++++-----------
 
  39  nixos/tests/sanoid.nix                    |  78 ++--
 
  40  2 files changed, 269 insertions(+), 272 deletions(-)
 
  42 diff --git a/nixos/modules/services/backup/syncoid.nix b/nixos/modules/services/backup/syncoid.nix
 
  43 index 8b4c59155f4d..6e92ffb707ab 100644
 
  44 --- a/nixos/modules/services/backup/syncoid.nix
 
  45 +++ b/nixos/modules/services/backup/syncoid.nix
 
  48    cfg = config.services.syncoid;
 
  50 -  # Extract local dasaset names (so no datasets containing "@")
 
  51 +  # Extract local dataset names (so no datasets containing "@")
 
  54      lib.optionals (d != null) (
 
  55 @@ -20,81 +20,9 @@ let
 
  56    # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
 
  59 -    lib.concatMapStrings (s: if lib.isList s then "-" else s) (
 
  60 +    lib.concatMapStrings (s: if builtins.isList s then "-" else s) (
 
  61        builtins.split "[^a-zA-Z0-9_.\\-]+" name
 
  64 -  # Function to build "zfs allow" commands for the filesystems we've delegated
 
  65 -  # permissions to. It also checks if the target dataset exists before
 
  66 -  # delegating permissions, if it doesn't exist we delegate it to the parent
 
  67 -  # dataset (if it exists). This should solve the case of provisoning new
 
  70 -    permissions: dataset:
 
  72 -      "-+${pkgs.writeShellScript "zfs-allow-${dataset}" ''
 
  73 -        # Here we explicitly use the booted system to guarantee the stable API needed by ZFS
 
  75 -        # Run a ZFS list on the dataset to check if it exists
 
  77 -          lib.escapeShellArgs [
 
  78 -            "/run/booted-system/sw/bin/zfs"
 
  82 -        } 2> /dev/null; then
 
  83 -          ${lib.escapeShellArgs [
 
  84 -            "/run/booted-system/sw/bin/zfs"
 
  87 -            (lib.concatStringsSep "," permissions)
 
  90 -        ${lib.optionalString ((builtins.dirOf dataset) != ".") ''
 
  92 -            ${lib.escapeShellArgs [
 
  93 -              "/run/booted-system/sw/bin/zfs"
 
  96 -              (lib.concatStringsSep "," permissions)
 
  97 -              # Remove the last part of the path
 
  98 -              (builtins.dirOf dataset)
 
 105 -  # Function to build "zfs unallow" commands for the filesystems we've
 
 106 -  # delegated permissions to. Here we unallow both the target but also
 
 107 -  # on the parent dataset because at this stage we have no way of
 
 108 -  # knowing if the allow command did execute on the parent dataset or
 
 109 -  # not in the pre-hook. We can't run the same if in the post hook
 
 110 -  # since the dataset should have been created at this point.
 
 111 -  buildUnallowCommand =
 
 112 -    permissions: dataset:
 
 114 -      "-+${pkgs.writeShellScript "zfs-unallow-${dataset}" ''
 
 115 -        # Here we explicitly use the booted system to guarantee the stable API needed by ZFS
 
 116 -        ${lib.escapeShellArgs [
 
 117 -          "/run/booted-system/sw/bin/zfs"
 
 120 -          (lib.concatStringsSep "," permissions)
 
 123 -        ${lib.optionalString ((builtins.dirOf dataset) != ".") (
 
 124 -          lib.escapeShellArgs [
 
 125 -            "/run/booted-system/sw/bin/zfs"
 
 128 -            (lib.concatStringsSep "," permissions)
 
 129 -            # Remove the last part of the path
 
 130 -            (builtins.dirOf dataset)
 
 138 @@ -106,52 +34,34 @@ in
 
 139      package = lib.mkPackageOption pkgs "sanoid" { };
 
 141      interval = lib.mkOption {
 
 142 -      type = with lib.types; either str (listOf str);
 
 143 +      type = lib.types.str;
 
 145        example = "*-*-* *:15:00";
 
 147          Run syncoid at this interval. The default is to run hourly.
 
 149 -        Must be in the format described in {manpage}`systemd.time(7)`.  This is
 
 150 -        equivalent to adding a corresponding timer unit with
 
 151 -        {option}`OnCalendar` set to the value given here.
 
 153 -        Set to an empty list to avoid starting syncoid automatically.
 
 154 +        The format is described in
 
 155 +        {manpage}`systemd.time(7)`.
 
 159 -    user = lib.mkOption {
 
 160 -      type = lib.types.str;
 
 161 -      default = "syncoid";
 
 162 -      example = "backup";
 
 164 -        The user for the service. ZFS privilege delegation will be
 
 165 -        automatically configured for any local pools used by syncoid if this
 
 166 -        option is set to a user other than root. The user will be given the
 
 167 -        "hold" and "send" privileges on any pool that has datasets being sent
 
 168 -        and the "create", "mount", "receive", and "rollback" privileges on
 
 169 -        any pool that has datasets being received.
 
 173 -    group = lib.mkOption {
 
 174 -      type = lib.types.str;
 
 175 -      default = "syncoid";
 
 176 -      example = "backup";
 
 177 -      description = "The group for the service.";
 
 180      sshKey = lib.mkOption {
 
 181        type = with lib.types; nullOr (coercedTo path toString str);
 
 184 -        SSH private key file to use to login to the remote system. Can be
 
 185 -        overridden in individual commands.
 
 186 +        SSH private key file to use to login to the remote system.
 
 187 +        It can be overridden in individual commands.
 
 188 +        It is loaded using `LoadCredentialEncrypted=`
 
 189 +        when its path is prefixed by a credential name and colon,
 
 190 +        otherwise `LoadCredential=` is used.
 
 191 +        For more SSH tuning, you may use syncoid's `--sshoption`
 
 192 +        in {option}`services.syncoid.commonArgs`
 
 193 +        and/or in the `extraArgs` of a specific command.
 
 197      localSourceAllow = lib.mkOption {
 
 198 -      type = lib.types.listOf lib.types.str;
 
 199 +      type = with lib.types; listOf str;
 
 200        # Permissions snapshot and destroy are in case --no-sync-snap is not used
 
 203 @@ -162,19 +72,22 @@ in
 
 207 -        Permissions granted for the {option}`services.syncoid.user` user
 
 208 -        for local source datasets. See
 
 209 -        <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
 
 210 +        Permissions granted for the syncoid user for local source datasets.
 
 211 +        See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
 
 212          for available permissions.
 
 216      localTargetAllow = lib.mkOption {
 
 217 -      type = lib.types.listOf lib.types.str;
 
 218 +      type = with lib.types; listOf str;
 
 219 +      # Permission destroy is required to reset broken receive states (zfs receive -A),
 
 220 +      # which syncoid does when it fails to resume a receive state,
 
 221 +      # when the snapshot it refers to has been destroyed on the source.
 
 230 @@ -187,9 +100,8 @@ in
 
 234 -        Permissions granted for the {option}`services.syncoid.user` user
 
 235 -        for local target datasets. See
 
 236 -        <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
 
 237 +        Permissions granted for the syncoid user for local target datasets.
 
 238 +        See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
 
 239          for available permissions.
 
 240          Make sure to include the `change-key` permission if you send raw encrypted datasets,
 
 241          the `compression` permission if you send raw compressed datasets, and so on.
 
 242 @@ -198,7 +110,7 @@ in
 
 245      commonArgs = lib.mkOption {
 
 246 -      type = lib.types.listOf lib.types.str;
 
 247 +      type = with lib.types; listOf str;
 
 249        example = [ "--no-sync-snap" ];
 
 251 @@ -296,13 +208,13 @@ in
 
 255 -              useCommonArgs = lib.mkOption {
 
 256 -                type = lib.types.bool;
 
 259 -                  Whether to add the configured common arguments to this command.
 
 263 +                lib.mkEnableOption ''
 
 264 +                  configured common arguments to this command
 
 270                service = lib.mkOption {
 
 271                  type = lib.types.attrs;
 
 272 @@ -341,139 +253,216 @@ in
 
 275    config = lib.mkIf cfg.enable {
 
 277 -      users = lib.mkIf (cfg.user == "syncoid") {
 
 280 -          isSystemUser = true;
 
 281 -          # For syncoid to be able to create /var/lib/syncoid/.ssh/
 
 282 -          # and to use custom ssh_config or known_hosts.
 
 283 -          home = "/var/lib/syncoid";
 
 284 -          createHome = false;
 
 287 -      groups = lib.mkIf (cfg.group == "syncoid") {
 
 292      systemd.services = lib.mapAttrs' (
 
 295 +        sshKeyCred = builtins.split ":" c.sshKey;
 
 297        lib.nameValuePair "syncoid-${escapeUnitName name}" (
 
 300              description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}";
 
 301              after = [ "zfs.target" ];
 
 302              startAt = cfg.interval;
 
 303 -            # syncoid may need zpool to get feature@extensible_dataset
 
 304 -            path = [ "/run/booted-system/sw/bin/" ];
 
 307 -                (map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source))
 
 308 -                ++ (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target));
 
 310 -                (map (buildUnallowCommand c.localSourceAllow) (localDatasetName c.source))
 
 311 -                ++ (map (buildUnallowCommand c.localTargetAllow) (localDatasetName c.target));
 
 312 -              ExecStart = lib.escapeShellArgs (
 
 313 -                [ "${cfg.package}/bin/syncoid" ]
 
 314 -                ++ lib.optionals c.useCommonArgs cfg.commonArgs
 
 315 -                ++ lib.optional c.recursive "-r"
 
 316 -                ++ lib.optionals (c.sshKey != null) [
 
 326 -                  "--no-privilege-elevation"
 
 333 -              StateDirectory = [ "syncoid" ];
 
 334 -              StateDirectoryMode = "700";
 
 335 -              # Prevent SSH control sockets of different syncoid services from interfering
 
 337 -              # Permissive access to /proc because syncoid
 
 338 -              # calls ps(1) to detect ongoing `zfs receive`.
 
 339 -              ProcSubset = "all";
 
 340 -              ProtectProc = "default";
 
 341 +            # Here we explicitly use the booted system to guarantee the stable API needed by ZFS.
 
 342 +            # Moreover syncoid may need zpool to get feature@extensible_dataset.
 
 343 +            path = [ "/run/booted-system/sw" ];
 
 344 +            # Prevents missing snapshots during DST changes
 
 345 +            environment.TZ = "UTC";
 
 346 +            # A custom LD_LIBRARY_PATH is needed to access in `getent passwd`
 
 347 +            # the systemd's entry about the DynamicUser=,
 
 348 +            # so that ssh won't fail with: "No user exists for uid $UID".
 
 349 +            environment.LD_LIBRARY_PATH = config.system.nssModules.path;
 
 353 +                  # Recursively remove any residual permissions
 
 354 +                  # given on local+descendant datasets (source, target or target's parent)
 
 355 +                  # to any currently unknown (hence unused) systemd dynamic users (UID/GID range 61184…65519),
 
 356 +                  # which happens when a crash has occurred
 
 357 +                  # during any previous run of a syncoid-*.service (not only this one).
 
 362 +                      + pkgs.writeShellScript "zfs-unallow-unused-dynamic-users" ''
 
 365 +                        sed -ne 's/^\t\(user\|group\) (unknown: \([0-9]\+\)).*/\1 \2/p' |
 
 368 +                          while read -r role id; do
 
 369 +                            if [ "$id" -ge 61184 ] && [ "$id" -le 65519 ]; then
 
 371 +                                (user) uids+=("$id");;
 
 375 +                          zfs unallow -r -u "$(printf %s, "''${uids[@]}")" "$1"
 
 379 +                      + lib.escapeShellArg dataset
 
 382 +                      localDatasetName c.source
 
 383 +                      ++ localDatasetName c.target
 
 384 +                      ++ map builtins.dirOf (localDatasetName c.target)
 
 387 +                    # For a local source, allow the localSourceAllow ZFS permissions.
 
 390 +                      "+/run/booted-system/sw/bin/zfs allow $USER "
 
 391 +                      + lib.escapeShellArgs [
 
 392 +                        (lib.concatStringsSep "," c.localSourceAllow)
 
 395 +                    ) (localDatasetName c.source)
 
 397 +                    # For a local target, check if the dataset exists before delegating permissions,
 
 398 +                    # and if it doesn't exist, delegate it to the parent dataset.
 
 399 +                    # This should solve the case of provisioning new datasets.
 
 403 +                      + pkgs.writeShellScript "zfs-allow-target" ''
 
 405 +                        # Run a ZFS list on the dataset to check if it exists
 
 406 +                        zfs list "$dataset" >/dev/null 2>/dev/null ||
 
 407 +                          dataset="$(dirname "$dataset")"
 
 408 +                        zfs allow "$USER" ${lib.escapeShellArg (lib.concatStringsSep "," c.localTargetAllow)} "$dataset"
 
 411 +                      + lib.escapeShellArg dataset
 
 412 +                    ) (localDatasetName c.target);
 
 415 +                    zfsUnallow = dataset: "+/run/booted-system/sw/bin/zfs unallow $USER " + lib.escapeShellArg dataset;
 
 417 +                  map zfsUnallow (localDatasetName c.source)
 
 419 +                    # For a local target, unallow both the dataset and its parent,
 
 420 +                    # because at this stage we have no way of knowing if the allow command
 
 421 +                    # did execute on the parent dataset or not in the ExecStartPre=.
 
 422 +                    # We can't run the same if-then-else in the post hook
 
 423 +                    # since the dataset should have been created at this point.
 
 424 +                    lib.concatMap (dataset: [
 
 425 +                      (zfsUnallow dataset)
 
 426 +                      (zfsUnallow (builtins.dirOf dataset))
 
 427 +                    ]) (localDatasetName c.target);
 
 428 +                ExecStart = lib.escapeShellArgs (
 
 429 +                  [ "${cfg.package}/bin/syncoid" ]
 
 430 +                  ++ lib.optionals c.useCommonArgs cfg.commonArgs
 
 431 +                  ++ lib.optional c.recursive "--recursive"
 
 432 +                  ++ lib.optionals (c.sshKey != null) [
 
 434 +                    "\${CREDENTIALS_DIRECTORY}/${if lib.length sshKeyCred > 1 then lib.head sshKeyCred else "sshKey"}"
 
 442 +                    "--no-privilege-elevation"
 
 447 +                DynamicUser = true;
 
 448 +                NFTSet = lib.optional config.networking.nftables.enable "user:inet:filter:nixos_syncoid_uids";
 
 449 +                # Prevent SSH control sockets of different syncoid services from interfering
 
 451 +                # Permissive access to /proc because syncoid
 
 452 +                # calls ps(1) to detect ongoing `zfs receive`.
 
 453 +                ProcSubset = "all";
 
 454 +                ProtectProc = "default";
 
 456 -              # The following options are only for optimizing:
 
 457 -              # systemd-analyze security | grep syncoid-'*'
 
 458 -              AmbientCapabilities = "";
 
 459 -              CapabilityBoundingSet = "";
 
 460 -              DeviceAllow = [ "/dev/zfs" ];
 
 461 -              LockPersonality = true;
 
 462 -              MemoryDenyWriteExecute = true;
 
 463 -              NoNewPrivileges = true;
 
 464 -              PrivateDevices = true;
 
 465 -              PrivateMounts = true;
 
 466 -              PrivateNetwork = lib.mkDefault false;
 
 467 -              PrivateUsers = false; # Enabling this breaks on zfs-2.2.0
 
 468 -              ProtectClock = true;
 
 469 -              ProtectControlGroups = true;
 
 470 -              ProtectHome = true;
 
 471 -              ProtectHostname = true;
 
 472 -              ProtectKernelLogs = true;
 
 473 -              ProtectKernelModules = true;
 
 474 -              ProtectKernelTunables = true;
 
 475 -              ProtectSystem = "strict";
 
 477 -              RestrictAddressFamilies = [
 
 482 -              RestrictNamespaces = true;
 
 483 -              RestrictRealtime = true;
 
 484 -              RestrictSUIDSGID = true;
 
 485 -              RootDirectory = "/run/syncoid/${escapeUnitName name}";
 
 486 -              RootDirectoryStartOnly = true;
 
 487 -              BindPaths = [ "/dev/zfs" ];
 
 488 -              BindReadOnlyPaths = [
 
 494 -              # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
 
 495 -              InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
 
 496 -              MountAPIVFS = true;
 
 497 -              # Create RootDirectory= in the host's mount namespace.
 
 498 -              RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
 
 499 -              RuntimeDirectoryMode = "700";
 
 500 -              SystemCallFilter = [
 
 502 -                # Groups in @system-service which do not contain a syscall listed by:
 
 503 -                # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid …
 
 504 -                # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log
 
 505 -                # 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 ' '
 
 515 -              SystemCallArchitectures = "native";
 
 516 -              # This is for BindPaths= and BindReadOnlyPaths=
 
 517 -              # to allow traversal of directories they create in RootDirectory=.
 
 520 +                # The following options are only for optimizing:
 
 521 +                # systemd-analyze security | grep syncoid-'*'
 
 522 +                AmbientCapabilities = "";
 
 523 +                CapabilityBoundingSet = "";
 
 524 +                DeviceAllow = [ "/dev/zfs" ];
 
 525 +                LockPersonality = true;
 
 526 +                MemoryDenyWriteExecute = true;
 
 527 +                NoNewPrivileges = true;
 
 528 +                PrivateDevices = true;
 
 529 +                PrivateMounts = true;
 
 530 +                PrivateNetwork = lib.mkDefault false;
 
 531 +                PrivateUsers = false; # Enabling this breaks on zfs-2.2.0
 
 532 +                ProtectClock = true;
 
 533 +                ProtectControlGroups = true;
 
 534 +                ProtectHome = true;
 
 535 +                ProtectHostname = true;
 
 536 +                ProtectKernelLogs = true;
 
 537 +                ProtectKernelModules = true;
 
 538 +                ProtectKernelTunables = true;
 
 539 +                ProtectSystem = "strict";
 
 541 +                RestrictAddressFamilies = [
 
 546 +                RestrictNamespaces = true;
 
 547 +                RestrictRealtime = true;
 
 548 +                RestrictSUIDSGID = true;
 
 549 +                RootDirectory = "/run/syncoid/${escapeUnitName name}";
 
 550 +                BindPaths = [ "/dev/zfs" ];
 
 551 +                BindReadOnlyPaths = [
 
 555 +                  # Some programs hardcode /var/run
 
 556 +                  # eg. /var/run/avahi-daemon/socket to resolve *.local mDNS domains
 
 560 +                # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
 
 561 +                InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
 
 562 +                MountAPIVFS = true;
 
 563 +                # Create RootDirectory= in the host's mount namespace.
 
 564 +                RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
 
 565 +                RuntimeDirectoryMode = "700";
 
 566 +                SystemCallFilter = [
 
 568 +                  # Groups in @system-service which do not contain a syscall listed by:
 
 569 +                  # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid …
 
 570 +                  # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log
 
 571 +                  # 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 ' '
 
 580 +                SystemCallArchitectures = "native";
 
 581 +                # This is for BindPaths= and BindReadOnlyPaths=
 
 582 +                # to allow traversal of directories they create in RootDirectory=.
 
 586 +                if lib.length sshKeyCred > 1 then
 
 587 +                  { LoadCredentialEncrypted = [ c.sshKey ]; }
 
 589 +                  { LoadCredential = [ "sshKey:${c.sshKey}" ]; }
 
 598 +    networking.nftables.ruleset = lib.mkBefore ''
 
 599 +      table inet filter {
 
 600 +        # A set containing the dynamic UIDs of the syncoid services currently active
 
 601 +        set nixos_syncoid_uids { typeof meta skuid; }
 
 606    meta.maintainers = with lib.maintainers; [
 
 607 diff --git a/nixos/tests/sanoid.nix b/nixos/tests/sanoid.nix
 
 608 index 227a95e9471d..9f5d52c2c527 100644
 
 609 --- a/nixos/tests/sanoid.nix
 
 610 +++ b/nixos/tests/sanoid.nix
 
 611 @@ -51,17 +51,21 @@ import ./make-test-python.nix (
 
 613              sshKey = "/var/lib/syncoid/id_ecdsa";
 
 615 -              # Sync snapshot taken by sanoid
 
 617 -                target = "root@target:pool/sanoid";
 
 620 -                  "--create-bookmark"
 
 623                # Take snapshot and sync
 
 624                "pool/syncoid".target = "root@target:pool/syncoid";
 
 626 +              # Sync the same dataset to different targets
 
 628 +                source = "pool/sanoid";
 
 629 +                target = "root@target:pool/sanoid1";
 
 630 +                extraArgs = [ "--no-sync-snap" "--create-bookmark" ];
 
 633 +                source = "pool/sanoid";
 
 634 +                target = "root@target:pool/sanoid2";
 
 635 +                extraArgs = [ "--no-sync-snap" "--create-bookmark" ];
 
 638                # Test pool without parent (regression test for https://github.com/NixOS/nixpkgs/pull/180111)
 
 639                "pool".target = "root@target:pool/full-pool";
 
 641 @@ -95,6 +99,7 @@ import ./make-test-python.nix (
 
 642            "zfs create pool/syncoid",
 
 648            "parted --script /dev/vdb -- mklabel msdos mkpart primary 1024M -1s",
 
 649 @@ -107,42 +112,45 @@ import ./make-test-python.nix (
 
 650            "mkdir -m 700 -p /var/lib/syncoid",
 
 651            "cat '${snakeOilPrivateKey}' > /var/lib/syncoid/id_ecdsa",
 
 652            "chmod 600 /var/lib/syncoid/id_ecdsa",
 
 653 -          "chown -R syncoid:syncoid /var/lib/syncoid/",
 
 656 -      assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set before snapshotting"
 
 657 -      assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set before snapshotting"
 
 658 -      assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set before snapshotting"
 
 659 +      with subtest("Take snapshots with sanoid"):
 
 660 +        source.succeed("touch /mnt/pool/sanoid/test.txt")
 
 661 +        source.succeed("touch /mnt/pool/compat/test.txt")
 
 662 +        source.systemctl("start --wait sanoid.service")
 
 664 -      # Take snapshot with sanoid
 
 665 -      source.succeed("touch /mnt/pool/sanoid/test.txt")
 
 666 -      source.succeed("touch /mnt/pool/compat/test.txt")
 
 667 -      source.systemctl("start --wait sanoid.service")
 
 668 +      # Add some unused dynamic users to the stateful allow list of ZFS datasets,
 
 669 +      # simulating a state where they remain after the system crashed,
 
 670 +      # to check they'll be correctly removed by the syncoid services.
 
 671 +      # Each syncoid service run from now may reuse at most one of them for itself.
 
 673 +          "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool",
 
 674 +          "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool/sanoid",
 
 675 +          "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool/syncoid",
 
 678 +      with subtest("sync snapshots"):
 
 679 +        target.wait_for_open_port(22)
 
 680 +        source.succeed("touch /mnt/pool/syncoid/test.txt")
 
 682 +        source.systemctl("start --wait syncoid-pool-syncoid.service")
 
 683 +        target.succeed("cat /mnt/pool/syncoid/test.txt")
 
 685 +        source.systemctl("start --wait syncoid-pool-sanoid{1,2}.service")
 
 686 +        target.succeed("cat /mnt/pool/sanoid1/test.txt")
 
 687 +        target.succeed("cat /mnt/pool/sanoid2/test.txt")
 
 689 +        source.systemctl("start --wait syncoid-pool.service")
 
 690 +        target.succeed("[[ -d /mnt/pool/full-pool/syncoid ]]")
 
 692 +        source.systemctl("start --wait syncoid-pool-compat.service")
 
 693 +        target.succeed("cat /mnt/pool/compat/test.txt")
 
 695        assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set after snapshotting"
 
 696        assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set after snapshotting"
 
 697        assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set after snapshotting"
 
 700 -      target.wait_for_open_port(22)
 
 701 -      source.succeed("touch /mnt/pool/syncoid/test.txt")
 
 702 -      source.systemctl("start --wait syncoid-pool-sanoid.service")
 
 703 -      target.succeed("cat /mnt/pool/sanoid/test.txt")
 
 704 -      source.systemctl("start --wait syncoid-pool-syncoid.service")
 
 705 -      source.systemctl("start --wait syncoid-pool-syncoid.service")
 
 706 -      target.succeed("cat /mnt/pool/syncoid/test.txt")
 
 708        assert(len(source.succeed("zfs list -H -t snapshot pool/syncoid").splitlines()) == 1), "Syncoid should only retain one sync snapshot"
 
 710 -      source.systemctl("start --wait syncoid-pool.service")
 
 711 -      target.succeed("[[ -d /mnt/pool/full-pool/syncoid ]]")
 
 713 -      source.systemctl("start --wait syncoid-pool-compat.service")
 
 714 -      target.succeed("cat /mnt/pool/compat/test.txt")
 
 716 -      assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set after syncing snapshots"
 
 717 -      assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set after syncing snapshots"
 
 718 -      assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set after syncing snapshots"