syncoid: work on PR#236729
authorJulien Moutinho <julm+sourcephile-nix@sourcephile.fr>
Mon, 2 Oct 2023 19:36:38 +0000 (21:36 +0200)
committerJulien Moutinho <julm+sourcephile-nix@sourcephile.fr>
Wed, 4 Oct 2023 22:01:34 +0000 (00:01 +0200)
nixos/modules.nix
nixos/modules/services/backup/syncoid.nix [new file with mode: 0644]

index f2f67170c383720bfeef738944f26c58935fc532..a8d5e1f4e7fe449faabd34bc12432d560d41d99a 100644 (file)
@@ -10,7 +10,7 @@
     modules/services/networking/upnpc.nix
     #modules/services/networking/wireguard.nix
     #modules/services/security/tor.nix
-    #modules/services/backup/syncoid.nix
+    modules/services/backup/syncoid.nix
     #modules/services/backup/sanoid.nix
     #modules/services/torrent/transmission.nix
     #modules/services/logging/logrotate.nix
@@ -40,7 +40,7 @@
     #"security/gnupg.nix"
     #"services/mail/public-inbox.nix"
     #"services/security/tor.nix"
-    #"services/backup/syncoid.nix"
+    "services/backup/syncoid.nix"
     #"services/networking/biboumi.nix"
     #"services/networking/croc.nix"
     #"services/networking/netns.nix"
diff --git a/nixos/modules/services/backup/syncoid.nix b/nixos/modules/services/backup/syncoid.nix
new file mode 100644 (file)
index 0000000..4d3e618
--- /dev/null
@@ -0,0 +1,416 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.syncoid;
+  inherit (config.networking) nftables;
+
+  # Extract local dataset names (so no datasets containing "@")
+  localDatasetName = d: optionals (d != null) (
+    let m = builtins.match "([^/@]+[^@]*)" d; in
+    optionals (m != null) m
+  );
+
+  # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
+  escapeUnitName = name:
+    concatMapStrings (s: if isList s then "-" else s)
+      (builtins.split "[^a-zA-Z0-9_.\\-]+" name);
+in
+{
+
+  # Interface
+
+  options.services.syncoid = {
+    enable = mkEnableOption (lib.mdDoc "Syncoid ZFS synchronization service");
+
+    package = lib.mkPackageOptionMD pkgs "sanoid" { };
+
+    nftables.enable = mkEnableOption (lib.mdDoc ''
+      maintaining an nftables set of the active syncoid UIDs.
+
+      This can be used like so (assuming `output-net`
+      is being called by the output chain):
+      ```
+      networking.nftables.ruleset = "table inet filter { chain output-net { skuid @nixos-syncoid-uids meta l4proto tcp accept } }";
+      ```
+    '');
+
+    interval = mkOption {
+      type = types.str;
+      default = "hourly";
+      example = "*-*-* *:15:00";
+      description = lib.mdDoc ''
+        Run syncoid at this interval. The default is to run hourly.
+
+        The format is described in
+        {manpage}`systemd.time(7)`.
+      '';
+    };
+
+    sshKey = mkOption {
+      type = types.nullOr types.str;
+      default = null;
+      description = lib.mdDoc ''
+        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 = mkOption {
+      type = types.listOf types.str;
+      # Permissions snapshot and destroy are in case --no-sync-snap is not used
+      default = [ "bookmark" "hold" "send" "snapshot" "destroy" ];
+      description = lib.mdDoc ''
+        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 = mkOption {
+      type = types.listOf types.str;
+      default = [ "change-key" "compression" "create" "destroy" "mount" "mountpoint" "receive" "rollback" ];
+      example = [ "create" "mount" "receive" "rollback" ];
+      description = lib.mdDoc ''
+        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.
+        For remote target datasets you'll have to set your remote user permissions by yourself.
+      '';
+    };
+
+    commonArgs = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      example = [ "--no-sync-snap" ];
+      description = lib.mdDoc ''
+        Arguments to add to every syncoid command, unless disabled for that
+        command. See
+        <https://github.com/jimsalterjrs/sanoid/#syncoid-command-line-options>
+        for available options.
+      '';
+    };
+
+    service = mkOption {
+      type = types.attrs;
+      default = { };
+      description = lib.mdDoc ''
+        Systemd configuration common to all syncoid services.
+      '';
+    };
+
+    commands = mkOption {
+      type = types.attrsOf (types.submodule ({ name, ... }: {
+        options = {
+          source = mkOption {
+            type = types.str;
+            example = "pool/dataset";
+            description = lib.mdDoc ''
+              Source ZFS dataset. Can be either local or remote. Defaults to
+              the attribute name.
+            '';
+          };
+
+          target = mkOption {
+            type = types.str;
+            example = "user@server:pool/dataset";
+            description = lib.mdDoc ''
+              Target ZFS dataset. Can be either local
+              («pool/dataset») or remote
+              («user@server:pool/dataset»).
+            '';
+          };
+
+          recursive = mkEnableOption (lib.mdDoc ''the transfer of child datasets'');
+
+          sshKey = mkOption {
+            type = types.nullOr types.str;
+            description = lib.mdDoc ''
+              SSH private key file to use to login to the remote system.
+              Defaults to {option}`services.syncoid.sshKey` option.
+            '';
+          };
+
+          localSourceAllow = mkOption {
+            type = types.listOf types.str;
+            description = lib.mdDoc ''
+              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>
+              for available permissions.
+              Defaults to {option}`services.syncoid.localSourceAllow` option.
+            '';
+          };
+
+          localTargetAllow = mkOption {
+            type = types.listOf types.str;
+            description = lib.mdDoc ''
+              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>
+              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.
+              For remote target datasets you'll have to set your remote user permissions by yourself.
+            '';
+          };
+
+          sendOptions = mkOption {
+            type = types.separatedString " ";
+            default = "";
+            example = "Lc e";
+            description = lib.mdDoc ''
+              Advanced options to pass to zfs send. Options are specified
+              without their leading dashes and separated by spaces.
+            '';
+          };
+
+          recvOptions = mkOption {
+            type = types.separatedString " ";
+            default = "";
+            example = "ux recordsize o compression=lz4";
+            description = lib.mdDoc ''
+              Advanced options to pass to zfs recv. Options are specified
+              without their leading dashes and separated by spaces.
+            '';
+          };
+
+          useCommonArgs = mkEnableOption
+            (lib.mdDoc ''
+              configured common arguments to this command
+            '') // { default = true; };
+
+          service = mkOption {
+            type = types.attrs;
+            default = { };
+            description = lib.mdDoc ''
+              Systemd configuration specific to this syncoid service.
+            '';
+          };
+
+          extraArgs = mkOption {
+            type = types.listOf types.str;
+            default = [ ];
+            example = [ "--sshport 2222" ];
+            description = lib.mdDoc "Extra syncoid arguments for this command.";
+          };
+        };
+        config = {
+          source = mkDefault name;
+          sshKey = mkDefault cfg.sshKey;
+          localSourceAllow = mkDefault cfg.localSourceAllow;
+          localTargetAllow = mkDefault cfg.localTargetAllow;
+        };
+      }));
+      default = { };
+      example = literalExpression ''
+        {
+          "pool/test".target = "root@target:pool/test";
+        }
+      '';
+      description = lib.mdDoc "Syncoid commands to run.";
+    };
+  };
+
+  # Implementation
+
+  config = mkIf cfg.enable {
+    assertions = [
+      {
+        assertion = cfg.nftables.enable -> config.networking.nftables.enable;
+        message = "config.networking.nftables.enable must be set when config.services.syncoid.nftables.enable is set";
+      }
+    ];
+
+    systemd.services = mapAttrs'
+      (name: c:
+        let
+          sshKeyCred = builtins.split ":" c.sshKey;
+        in
+        nameValuePair "syncoid-${escapeUnitName name}" (mkMerge [
+          {
+            description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}";
+            after = [ "zfs.target" ];
+            startAt = cfg.interval;
+            # 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"
+                      }
+                    '' + " " + 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 " +
+                      escapeShellArgs [ (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" ${escapeShellArg (concatStringsSep "," c.localTargetAllow)} "$dataset"
+                    '' + " " + escapeShellArg dataset
+                  )
+                  (localDatasetName c.target) ++
+                # Adding a user to an nftables set will not persist across a reboot,
+                # hence there is no need to cleanup residual dynamic users remaining in it after a crash.
+                optional cfg.nftables.enable
+                  "+${pkgs.nftables}/bin/nft add element inet filter nixos-syncoid-uids { $USER }";
+              ExecStopPost =
+                let
+                  zfsUnallow = dataset: "+/run/booted-system/sw/bin/zfs unallow $USER " + 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.
+                  concatMap
+                    (dataset: [ (zfsUnallow dataset) (zfsUnallow (builtins.dirOf dataset)) ])
+                    (localDatasetName c.target) ++
+                  optional cfg.nftables.enable
+                    "+${pkgs.nftables}/bin/nft delete element inet filter nixos-syncoid-uids { $USER }";
+              ExecStart = lib.escapeShellArgs ([ "${cfg.package}/bin/syncoid" ]
+                ++ optionals c.useCommonArgs cfg.commonArgs
+                ++ optional c.recursive "--recursive"
+                ++ optionals (c.sshKey != null) [ "--sshkey" "\${CREDENTIALS_DIRECTORY}/${if length sshKeyCred > 1 then head sshKeyCred else "sshKey"}" ]
+                ++ c.extraArgs
+                ++ [
+                "--sendoptions"
+                c.sendOptions
+                "--recvoptions"
+                c.recvOptions
+                "--no-privilege-elevation"
+                c.source
+                c.target
+              ]);
+              DynamicUser = true;
+              # 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 = mkDefault false;
+              PrivateUsers = true;
+              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"
+              ];
+              SystemCallArchitectures = "native";
+              # This is for BindPaths= and BindReadOnlyPaths=
+              # to allow traversal of directories they create in RootDirectory=.
+              UMask = "0066";
+            } //
+            (
+              if length sshKeyCred > 1
+              then { LoadCredentialEncrypted = [ c.sshKey ]; }
+              else { LoadCredential = [ "sshKey:${c.sshKey}" ]; }
+            );
+          }
+          cfg.service
+          c.service
+        ]))
+      cfg.commands;
+
+    networking.nftables.ruleset = optionalString cfg.nftables.enable (mkBefore ''
+      table inet filter {
+        # A set containing the dynamic UIDs of the syncoid services currently active
+        set nixos-syncoid-uids { type uid; }
+      }
+    '');
+  };
+
+  meta.maintainers = with maintainers; [ julm lopsided98 ];
+}