nix: update julm-nix
[sourcephile-nix.git] / nixos / modules / services / backup / syncoid.nix
index a4e3c16ccbf02bf7d77dd7d7bc75e01ebcae9e2b..4d3e6184f10d100b14a2a9faf92685887f1b908f 100644 (file)
@@ -6,7 +6,7 @@ let
   cfg = config.services.syncoid;
   inherit (config.networking) nftables;
 
-  # Extract local dasaset names (so no datasets containing "@")
+  # Extract local dataset names (so no datasets containing "@")
   localDatasetName = d: optionals (d != null) (
     let m = builtins.match "([^/@]+[^@]*)" d; in
     optionals (m != null) m
@@ -14,78 +14,52 @@ 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)
+    concatMapStrings (s: if 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. This should solve the case of
-  # provisoning new datasets.
-  buildAllowCommand = permissions: dataset: (
-    "-+${pkgs.writeShellScript "zfs-allow-${dataset}" ''
-      set -eux
-      # Run a ZFS list on the dataset to check if it exists
-      if zfs list ${lib.escapeShellArg dataset} >/dev/null 2>/dev/null; then
-        zfs allow "$USER" ${lib.escapeShellArgs [
-          (concatStringsSep "," permissions)
-          dataset
-        ]}
-      else
-        zfs allow "$USER" ${lib.escapeShellArgs [
-          (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 and
-  # 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-then-else in the post hook
-  # since the dataset should have been created at this point.
-  buildUnallowCommand = dataset: (
-    "-+${pkgs.writeShellScript "zfs-unallow-${dataset}" ''
-      set -eux
-      zfs unallow "$USER" ${lib.escapeShellArg dataset}
-      zfs unallow "$USER" ${lib.escapeShellArg (builtins.dirOf dataset)}
-    ''}"
-  );
 in
 {
 
   # Interface
 
   options.services.syncoid = {
-    enable = mkEnableOption "Syncoid ZFS synchronization service";
+    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 = ''
+      description = lib.mdDoc ''
         Run syncoid at this interval. The default is to run hourly.
 
         The format is described in
-        <citerefentry><refentrytitle>systemd.time</refentrytitle>
-        <manvolnum>7</manvolnum></citerefentry>.
+        {manpage}`systemd.time(7)`.
       '';
     };
 
     sshKey = mkOption {
-      type = types.nullOr types.path;
-      # Prevent key from being copied to store
-      apply = mapNullable toString;
+      type = types.nullOr types.str;
       default = null;
-      description = ''
-        SSH private key file to use to login to the remote system. Can be
-        overridden in individual commands.
-        For more SSH tuning, you may use syncoid's <literal>--sshoption</literal>
-        in <link linkend="opt-services.syncoid.commonArgs">commonArgs</link>
-        and/or in the <literal>extraArgs<literal> of a specific command.
+      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.
       '';
     };
 
@@ -93,23 +67,23 @@ in
       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 = ''
+      description = lib.mdDoc ''
         Permissions granted for the syncoid user for local source datasets.
-        See <link xlink:href="https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html"/>
+        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" "mount" "mountpoint" "receive" "rollback" ];
+      default = [ "change-key" "compression" "create" "destroy" "mount" "mountpoint" "receive" "rollback" ];
       example = [ "create" "mount" "receive" "rollback" ];
-      description = ''
+      description = lib.mdDoc ''
         Permissions granted for the syncoid user for local target datasets.
-        See <link xlink:href="https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html"/>
+        See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
         for available permissions.
-        Make sure to include the <literal>change-key</literal> permission if you send raw encrypted datasets,
-        the <literal>compression</literal> permission if you send raw compressed datasets, and so on.
+        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.
       '';
     };
@@ -118,10 +92,10 @@ in
       type = types.listOf types.str;
       default = [ ];
       example = [ "--no-sync-snap" ];
-      description = ''
+      description = lib.mdDoc ''
         Arguments to add to every syncoid command, unless disabled for that
         command. See
-        <link xlink:href="https://github.com/jimsalterjrs/sanoid/#syncoid-command-line-options"/>
+        <https://github.com/jimsalterjrs/sanoid/#syncoid-command-line-options>
         for available options.
       '';
     };
@@ -129,7 +103,7 @@ in
     service = mkOption {
       type = types.attrs;
       default = { };
-      description = ''
+      description = lib.mdDoc ''
         Systemd configuration common to all syncoid services.
       '';
     };
@@ -140,7 +114,7 @@ in
           source = mkOption {
             type = types.str;
             example = "pool/dataset";
-            description = ''
+            description = lib.mdDoc ''
               Source ZFS dataset. Can be either local or remote. Defaults to
               the attribute name.
             '';
@@ -149,45 +123,43 @@ in
           target = mkOption {
             type = types.str;
             example = "user@server:pool/dataset";
-            description = ''
+            description = lib.mdDoc ''
               Target ZFS dataset. Can be either local
-              (<replaceable>pool/dataset</replaceable>) or remote
-              (<replaceable>user@server:pool/dataset</replaceable>).
+              («pool/dataset») or remote
+              («user@server:pool/dataset»).
             '';
           };
 
-          recursive = mkEnableOption ''the transfer of child datasets'';
+          recursive = mkEnableOption (lib.mdDoc ''the transfer of child datasets'');
 
           sshKey = mkOption {
-            type = types.nullOr types.path;
-            # Prevent key from being copied to store
-            apply = mapNullable toString;
-            description = ''
+            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> option.
+              Defaults to {option}`services.syncoid.sshKey` option.
             '';
           };
 
           localSourceAllow = mkOption {
             type = types.listOf types.str;
-            description = ''
-              Permissions granted for the <option>services.syncoid.user</option> user
+            description = lib.mdDoc ''
+              Permissions granted for the {option}`services.syncoid.user` user
               for local source datasets. See
-              <link xlink:href="https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html"/>
+              <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
               for available permissions.
-              Defaults to <option>services.syncoid.localSourceAllow</option> option.
+              Defaults to {option}`services.syncoid.localSourceAllow` option.
             '';
           };
 
           localTargetAllow = mkOption {
             type = types.listOf types.str;
-            description = ''
-              Permissions granted for the <option>services.syncoid.user</option> user
+            description = lib.mdDoc ''
+              Permissions granted for the {option}`services.syncoid.user` user
               for local target datasets. See
-              <link xlink:href="https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html"/>
+              <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
               for available permissions.
-              Make sure to include the <literal>change-key</literal> permission if you send raw encrypted datasets,
-              the <literal>compression</literal> permission if you send raw compressed datasets, and so on.
+              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.
             '';
           };
@@ -196,7 +168,7 @@ in
             type = types.separatedString " ";
             default = "";
             example = "Lc e";
-            description = ''
+            description = lib.mdDoc ''
               Advanced options to pass to zfs send. Options are specified
               without their leading dashes and separated by spaces.
             '';
@@ -206,20 +178,21 @@ in
             type = types.separatedString " ";
             default = "";
             example = "ux recordsize o compression=lz4";
-            description = ''
+            description = lib.mdDoc ''
               Advanced options to pass to zfs recv. Options are specified
               without their leading dashes and separated by spaces.
             '';
           };
 
-          useCommonArgs = mkEnableOption ''
-            configured common arguments to this command
-          '' // { default = true; };
+          useCommonArgs = mkEnableOption
+            (lib.mdDoc ''
+              configured common arguments to this command
+            '') // { default = true; };
 
           service = mkOption {
             type = types.attrs;
             default = { };
-            description = ''
+            description = lib.mdDoc ''
               Systemd configuration specific to this syncoid service.
             '';
           };
@@ -228,7 +201,7 @@ in
             type = types.listOf types.str;
             default = [ ];
             example = [ "--sshport 2222" ];
-            description = "Extra syncoid arguments for this command.";
+            description = lib.mdDoc "Extra syncoid arguments for this command.";
           };
         };
         config = {
@@ -239,20 +212,30 @@ in
         };
       }));
       default = { };
-      example = literalExample ''
+      example = literalExpression ''
         {
           "pool/test".target = "root@target:pool/test";
         }
       '';
-      description = "Syncoid commands to run.";
+      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}";
@@ -261,19 +244,83 @@ in
             # 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 =
-                (map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source)) ++
-                (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target)) ++
-                optional nftables.enable "+${pkgs.nftables}/bin/nft add element inet filter nixos-syncoid-uids { $USER }";
+                # 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 =
-                (map buildUnallowCommand (localDatasetName c.source)) ++
-                (map buildUnallowCommand (localDatasetName c.target)) ++
-                optional nftables.enable "+${pkgs.nftables}/bin/nft delete element inet filter nixos-syncoid-uids { $USER }";
-              ExecStart = lib.escapeShellArgs ([ "${pkgs.sanoid}/bin/syncoid" ]
+                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}/ssh-key" ]
+                ++ optionals (c.sshKey != null) [ "--sshkey" "\${CREDENTIALS_DIRECTORY}/${if length sshKeyCred > 1 then head sshKeyCred else "sshKey"}" ]
                 ++ c.extraArgs
                 ++ [
                 "--sendoptions"
@@ -285,7 +332,6 @@ in
                 c.target
               ]);
               DynamicUser = true;
-              LoadCredential = [ "ssh-key:${c.sshKey}" ];
               # Prevent SSH control sockets of different syncoid services from interfering
               PrivateTmp = true;
               # Permissive access to /proc because syncoid
@@ -321,23 +367,7 @@ in
               RootDirectory = "/run/syncoid/${escapeUnitName name}";
               RootDirectoryStartOnly = true;
               BindPaths = [ "/dev/zfs" ];
-              BindReadOnlyPaths = [ builtins.storeDir "/etc" "/run" "/bin/sh"
-                # 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".
-                # Unfortunately, Bash is incompatible with libnss_systemd.so:
-                # https://www.mail-archive.com/bug-bash@gnu.org/msg24306.html
-                # Hence the wrapping of ssh is done here as a mounted path,
-                # because Nixpkgs' wrapping of syncoid enforces the use
-                # of the ${pkgs.openssh}/bin/ssh path.
-                # This problem does not arise on NixOS systems where stdenv.hostPlatform.libc == "musl",
-                # because then Bash is built with --without-bash-malloc
-                ("${pkgs.writeShellScript "ssh-with-support-for-DynamicUser" ''
-                  export LD_LIBRARY_PATH="${config.system.nssModules.path}"
-                  exec -a ${pkgs.openssh}/bin/ssh /bin/ssh "$@"
-                ''}:${pkgs.openssh}/bin/ssh")
-                "${pkgs.openssh}/bin/ssh:/bin/ssh"
-              ];
+              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;
@@ -357,24 +387,29 @@ in
                 "~@privileged"
                 "~@resources"
                 "~@setuid"
-                "~@timer"
               ];
               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 = ''
-      # A set containing the dynamic UIDs of the syncoid services currently active
-      add set inet filter nixos-syncoid-uids { type uid; }
-      # Example of use (assuming fw2net is being called by the output chain):
-      #add rule inet filter fw2net meta skuid @nixos-syncoid-uids meta l4proto tcp accept
-    '';
+
+    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 ];