diff options
Diffstat (limited to 'nixos/modules/services/backup')
-rw-r--r-- | nixos/modules/services/backup/sanoid.nix | 221 | ||||
-rw-r--r-- | nixos/modules/services/backup/syncoid.nix | 473 | ||||
-rw-r--r-- | nixos/modules/services/backup/znapzend.nix | 2 |
3 files changed, 405 insertions, 291 deletions
diff --git a/nixos/modules/services/backup/sanoid.nix b/nixos/modules/services/backup/sanoid.nix index be44a43b6d3..41d0e2e1df6 100644 --- a/nixos/modules/services/backup/sanoid.nix +++ b/nixos/modules/services/backup/sanoid.nix @@ -52,7 +52,7 @@ let use_template = mkOption { description = "Names of the templates to use for this dataset."; type = types.listOf (types.enum (attrNames cfg.templates)); - default = []; + default = [ ]; }; useTemplate = use_template; @@ -70,116 +70,127 @@ let processChildrenOnly = process_children_only; }; - # Extract pool names from configured datasets - pools = unique (map (d: head (builtins.match "([^/]+).*" d)) (attrNames cfg.datasets)); - - configFile = let - mkValueString = v: - if builtins.isList v then concatStringsSep "," v - else generators.mkValueStringDefault {} v; - - mkKeyValue = k: v: if v == null then "" - else if k == "processChildrenOnly" then "" - else if k == "useTemplate" then "" - else generators.mkKeyValueDefault { inherit mkValueString; } "=" k v; - in generators.toINI { inherit mkKeyValue; } cfg.settings; - -in { - - # Interface - - options.services.sanoid = { - enable = mkEnableOption "Sanoid ZFS snapshotting service"; - - interval = mkOption { - type = types.str; - default = "hourly"; - example = "daily"; - description = '' - Run sanoid at this interval. The default is to run hourly. - - The format is described in - <citerefentry><refentrytitle>systemd.time</refentrytitle> - <manvolnum>7</manvolnum></citerefentry>. - ''; - }; + # Extract unique dataset names + datasets = unique (attrNames cfg.datasets); + + # Function to build "zfs allow" and "zfs unallow" commands for the + # filesystems we've delegated permissions to. + buildAllowCommand = zfsAction: permissions: dataset: lib.escapeShellArgs [ + # Here we explicitly use the booted system to guarantee the stable API needed by ZFS + "-+/run/booted-system/sw/bin/zfs" + zfsAction + "sanoid" + (concatStringsSep "," permissions) + dataset + ]; + + configFile = + let + mkValueString = v: + if builtins.isList v then concatStringsSep "," v + else generators.mkValueStringDefault { } v; + + mkKeyValue = k: v: + if v == null then "" + else if k == "processChildrenOnly" then "" + else if k == "useTemplate" then "" + else generators.mkKeyValueDefault { inherit mkValueString; } "=" k v; + in + generators.toINI { inherit mkKeyValue; } cfg.settings; + +in +{ + + # Interface + + options.services.sanoid = { + enable = mkEnableOption "Sanoid ZFS snapshotting service"; + + interval = mkOption { + type = types.str; + default = "hourly"; + example = "daily"; + description = '' + Run sanoid at this interval. The default is to run hourly. + + The format is described in + <citerefentry><refentrytitle>systemd.time</refentrytitle> + <manvolnum>7</manvolnum></citerefentry>. + ''; + }; - datasets = mkOption { - type = types.attrsOf (types.submodule ({config, options, ...}: { - freeformType = datasetSettingsType; - options = commonOptions // datasetOptions; - config.use_template = mkAliasDefinitions (options.useTemplate or {}); - config.process_children_only = mkAliasDefinitions (options.processChildrenOnly or {}); - })); - default = {}; - description = "Datasets to snapshot."; - }; + datasets = mkOption { + type = types.attrsOf (types.submodule ({ config, options, ... }: { + freeformType = datasetSettingsType; + options = commonOptions // datasetOptions; + config.use_template = mkAliasDefinitions (mkDefault options.useTemplate or { }); + config.process_children_only = mkAliasDefinitions (mkDefault options.processChildrenOnly or { }); + })); + default = { }; + description = "Datasets to snapshot."; + }; - templates = mkOption { - type = types.attrsOf (types.submodule { - freeformType = datasetSettingsType; - options = commonOptions; - }); - default = {}; - description = "Templates for datasets."; - }; + templates = mkOption { + type = types.attrsOf (types.submodule { + freeformType = datasetSettingsType; + options = commonOptions; + }); + default = { }; + description = "Templates for datasets."; + }; - settings = mkOption { - type = types.attrsOf datasetSettingsType; - description = '' - Free-form settings written directly to the config file. See - <link xlink:href="https://github.com/jimsalterjrs/sanoid/blob/master/sanoid.defaults.conf"/> - for allowed values. - ''; - }; + settings = mkOption { + type = types.attrsOf datasetSettingsType; + description = '' + Free-form settings written directly to the config file. See + <link xlink:href="https://github.com/jimsalterjrs/sanoid/blob/master/sanoid.defaults.conf"/> + for allowed values. + ''; + }; - extraArgs = mkOption { - type = types.listOf types.str; - default = []; - example = [ "--verbose" "--readonly" "--debug" ]; - description = '' - Extra arguments to pass to sanoid. See - <link xlink:href="https://github.com/jimsalterjrs/sanoid/#sanoid-command-line-options"/> - for allowed options. - ''; - }; + extraArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "--verbose" "--readonly" "--debug" ]; + description = '' + Extra arguments to pass to sanoid. See + <link xlink:href="https://github.com/jimsalterjrs/sanoid/#sanoid-command-line-options"/> + for allowed options. + ''; }; + }; - # Implementation - - config = mkIf cfg.enable { - services.sanoid.settings = mkMerge [ - (mapAttrs' (d: v: nameValuePair ("template_" + d) v) cfg.templates) - (mapAttrs (d: v: v) cfg.datasets) - ]; - - systemd.services.sanoid = { - description = "Sanoid snapshot service"; - serviceConfig = { - ExecStartPre = map (pool: lib.escapeShellArgs [ - "+/run/booted-system/sw/bin/zfs" "allow" - "sanoid" "snapshot,mount,destroy" pool - ]) pools; - ExecStart = lib.escapeShellArgs ([ - "${pkgs.sanoid}/bin/sanoid" - "--cron" - "--configdir" (pkgs.writeTextDir "sanoid.conf" configFile) - ] ++ cfg.extraArgs); - ExecStopPost = map (pool: lib.escapeShellArgs [ - "+/run/booted-system/sw/bin/zfs" "unallow" "sanoid" pool - ]) pools; - User = "sanoid"; - Group = "sanoid"; - DynamicUser = true; - RuntimeDirectory = "sanoid"; - CacheDirectory = "sanoid"; - }; - # Prevents missing snapshots during DST changes - environment.TZ = "UTC"; - after = [ "zfs.target" ]; - startAt = cfg.interval; + # Implementation + + config = mkIf cfg.enable { + services.sanoid.settings = mkMerge [ + (mapAttrs' (d: v: nameValuePair ("template_" + d) v) cfg.templates) + (mapAttrs (d: v: v) cfg.datasets) + ]; + + systemd.services.sanoid = { + description = "Sanoid snapshot service"; + serviceConfig = { + ExecStartPre = (map (buildAllowCommand "allow" [ "snapshot" "mount" "destroy" ]) datasets); + ExecStopPost = (map (buildAllowCommand "unallow" [ "snapshot" "mount" "destroy" ]) datasets); + ExecStart = lib.escapeShellArgs ([ + "${pkgs.sanoid}/bin/sanoid" + "--cron" + "--configdir" + (pkgs.writeTextDir "sanoid.conf" configFile) + ] ++ cfg.extraArgs); + User = "sanoid"; + Group = "sanoid"; + DynamicUser = true; + RuntimeDirectory = "sanoid"; + CacheDirectory = "sanoid"; }; + # Prevents missing snapshots during DST changes + environment.TZ = "UTC"; + after = [ "zfs.target" ]; + startAt = cfg.interval; }; + }; - meta.maintainers = with maintainers; [ lopsided98 ]; - } + meta.maintainers = with maintainers; [ lopsided98 ]; +} diff --git a/nixos/modules/services/backup/syncoid.nix b/nixos/modules/services/backup/syncoid.nix index b764db1f14e..73b01d4b53f 100644 --- a/nixos/modules/services/backup/syncoid.nix +++ b/nixos/modules/services/backup/syncoid.nix @@ -5,212 +5,315 @@ with lib; let cfg = config.services.syncoid; - # Extract pool names of local datasets (ones that don't contain "@") that - # have the specified type (either "source" or "target") - getPools = type: unique (map (d: head (builtins.match "([^/]+).*" d)) ( - # Filter local datasets - filter (d: !hasInfix "@" d) - # Get datasets of the specified type - (catAttrs type (attrValues cfg.commands)) - )); -in { - - # Interface - - options.services.syncoid = { - enable = mkEnableOption "Syncoid ZFS synchronization service"; - - interval = mkOption { - type = types.str; - default = "hourly"; - example = "*-*-* *:15:00"; - description = '' - 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>. - ''; - }; + # Extract local dasaset names (so no datasets containing "@") + localDatasetName = d: optionals (d != null) ( + let m = builtins.match "([^/@]+[^@]*)" d; in + optionals (m != null) m + ); - user = mkOption { - type = types.str; - default = "syncoid"; - example = "backup"; - description = '' - The user for the service. ZFS privilege delegation will be - automatically configured for any local pools used by syncoid if this - option is set to a user other than root. The user will be given the - "hold" and "send" privileges on any pool that has datasets being sent - and the "create", "mount", "receive", and "rollback" privileges on - any pool that has datasets being received. - ''; - }; + # 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) + (builtins.split "[^a-zA-Z0-9_.\\-]+" name); - group = mkOption { - type = types.str; - default = "syncoid"; - example = "backup"; - description = "The group for the service."; - }; + # Function to build "zfs allow" and "zfs unallow" commands for the + # filesystems we've delegated permissions to. + buildAllowCommand = zfsAction: permissions: dataset: lib.escapeShellArgs [ + # Here we explicitly use the booted system to guarantee the stable API needed by ZFS + "-+/run/booted-system/sw/bin/zfs" + zfsAction + cfg.user + (concatStringsSep "," permissions) + dataset + ]; +in +{ - sshKey = mkOption { - type = types.nullOr types.path; - # Prevent key from being copied to store - apply = mapNullable toString; - default = null; - description = '' - SSH private key file to use to login to the remote system. Can be - overridden in individual commands. - ''; - }; + # Interface - commonArgs = mkOption { - type = types.listOf types.str; - default = []; - example = [ "--no-sync-snap" ]; - description = '' - 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"/> - for available options. - ''; - }; + options.services.syncoid = { + enable = mkEnableOption "Syncoid ZFS synchronization service"; - commands = mkOption { - type = types.attrsOf (types.submodule ({ name, ... }: { - options = { - source = mkOption { - type = types.str; - example = "pool/dataset"; - description = '' - Source ZFS dataset. Can be either local or remote. Defaults to - the attribute name. - ''; - }; + interval = mkOption { + type = types.str; + default = "hourly"; + example = "*-*-* *:15:00"; + description = '' + Run syncoid at this interval. The default is to run hourly. - target = mkOption { - type = types.str; - example = "user@server:pool/dataset"; - description = '' - Target ZFS dataset. Can be either local - (<replaceable>pool/dataset</replaceable>) or remote - (<replaceable>user@server:pool/dataset</replaceable>). - ''; - }; + The format is described in + <citerefentry><refentrytitle>systemd.time</refentrytitle> + <manvolnum>7</manvolnum></citerefentry>. + ''; + }; - recursive = mkOption { - type = types.bool; - default = false; - description = '' - Whether to also transfer child datasets. - ''; - }; + user = mkOption { + type = types.str; + default = "syncoid"; + example = "backup"; + description = '' + The user for the service. ZFS privilege delegation will be + automatically configured for any local pools used by syncoid if this + option is set to a user other than root. The user will be given the + "hold" and "send" privileges on any pool that has datasets being sent + and the "create", "mount", "receive", and "rollback" privileges on + any pool that has datasets being received. + ''; + }; - sshKey = mkOption { - type = types.nullOr types.path; - # Prevent key from being copied to store - apply = mapNullable toString; - description = '' - SSH private key file to use to login to the remote system. - Defaults to <option>services.syncoid.sshKey</option> option. - ''; - }; + group = mkOption { + type = types.str; + default = "syncoid"; + example = "backup"; + description = "The group for the service."; + }; - sendOptions = mkOption { - type = types.separatedString " "; - default = ""; - example = "Lc e"; - description = '' - Advanced options to pass to zfs send. Options are specified - without their leading dashes and separated by spaces. - ''; - }; + sshKey = mkOption { + type = types.nullOr types.path; + # Prevent key from being copied to store + apply = mapNullable toString; + default = null; + description = '' + SSH private key file to use to login to the remote system. Can be + overridden in individual commands. + ''; + }; - recvOptions = mkOption { - type = types.separatedString " "; - default = ""; - example = "ux recordsize o compression=lz4"; - description = '' - Advanced options to pass to zfs recv. Options are specified - without their leading dashes and separated by spaces. - ''; - }; + commonArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "--no-sync-snap" ]; + description = '' + 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"/> + for available options. + ''; + }; - useCommonArgs = mkOption { - type = types.bool; - default = true; - description = '' - Whether to add the configured common arguments to this command. - ''; - }; + service = mkOption { + type = types.attrs; + default = { }; + description = '' + Systemd configuration common to all syncoid services. + ''; + }; - extraArgs = mkOption { - type = types.listOf types.str; - default = []; - example = [ "--sshport 2222" ]; - description = "Extra syncoid arguments for this command."; - }; + commands = mkOption { + type = types.attrsOf (types.submodule ({ name, ... }: { + options = { + source = mkOption { + type = types.str; + example = "pool/dataset"; + description = '' + Source ZFS dataset. Can be either local or remote. Defaults to + the attribute name. + ''; }; - config = { - source = mkDefault name; - sshKey = mkDefault cfg.sshKey; + + target = mkOption { + type = types.str; + example = "user@server:pool/dataset"; + description = '' + Target ZFS dataset. Can be either local + (<replaceable>pool/dataset</replaceable>) or remote + (<replaceable>user@server:pool/dataset</replaceable>). + ''; }; - })); - default = {}; - example = literalExample '' - { - "pool/test".target = "root@target:pool/test"; - } - ''; - description = "Syncoid commands to run."; - }; - }; - # Implementation + recursive = mkEnableOption ''the transfer of child datasets''; + + sshKey = mkOption { + type = types.nullOr types.path; + # Prevent key from being copied to store + apply = mapNullable toString; + description = '' + SSH private key file to use to login to the remote system. + Defaults to <option>services.syncoid.sshKey</option> option. + ''; + }; + + sendOptions = mkOption { + type = types.separatedString " "; + default = ""; + example = "Lc e"; + description = '' + Advanced options to pass to zfs send. Options are specified + without their leading dashes and separated by spaces. + ''; + }; - config = mkIf cfg.enable { - users = { - users = mkIf (cfg.user == "syncoid") { - syncoid = { - group = cfg.group; - isSystemUser = true; + recvOptions = mkOption { + type = types.separatedString " "; + default = ""; + example = "ux recordsize o compression=lz4"; + description = '' + Advanced options to pass to zfs recv. Options are specified + without their leading dashes and separated by spaces. + ''; + }; + + useCommonArgs = mkOption { + type = types.bool; + default = true; + description = '' + Whether to add the configured common arguments to this command. + ''; + }; + + service = mkOption { + type = types.attrs; + default = { }; + description = '' + Systemd configuration specific to this syncoid service. + ''; + }; + + extraArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "--sshport 2222" ]; + description = "Extra syncoid arguments for this command."; }; }; - groups = mkIf (cfg.group == "syncoid") { - syncoid = {}; + config = { + source = mkDefault name; + sshKey = mkDefault cfg.sshKey; }; - }; + })); + default = { }; + example = literalExample '' + { + "pool/test".target = "root@target:pool/test"; + } + ''; + description = "Syncoid commands to run."; + }; + }; - systemd.services.syncoid = { - description = "Syncoid ZFS synchronization service"; - script = concatMapStringsSep "\n" (c: lib.escapeShellArgs - ([ "${pkgs.sanoid}/bin/syncoid" ] - ++ (optionals c.useCommonArgs cfg.commonArgs) - ++ (optional c.recursive "-r") - ++ (optionals (c.sshKey != null) [ "--sshkey" c.sshKey ]) - ++ c.extraArgs - ++ [ "--sendoptions" c.sendOptions - "--recvoptions" c.recvOptions - "--no-privilege-elevation" - c.source c.target - ])) (attrValues cfg.commands); - after = [ "zfs.target" ]; - serviceConfig = { - ExecStartPre = let - allowCmd = permissions: pool: lib.escapeShellArgs [ - "+/run/booted-system/sw/bin/zfs" "allow" - cfg.user (concatStringsSep "," permissions) pool - ]; - in - (map (allowCmd [ "hold" "send" "snapshot" "destroy" ]) (getPools "source")) ++ - (map (allowCmd [ "create" "mount" "receive" "rollback" ]) (getPools "target")); - User = cfg.user; - Group = cfg.group; + # Implementation + + config = mkIf cfg.enable { + users = { + users = mkIf (cfg.user == "syncoid") { + syncoid = { + group = cfg.group; + isSystemUser = true; + # For syncoid to be able to create /var/lib/syncoid/.ssh/ + # and to use custom ssh_config or known_hosts. + home = "/var/lib/syncoid"; + createHome = false; }; - startAt = cfg.interval; + }; + groups = mkIf (cfg.group == "syncoid") { + syncoid = { }; }; }; - meta.maintainers = with maintainers; [ lopsided98 ]; - } + systemd.services = mapAttrs' + (name: c: + nameValuePair "syncoid-${escapeUnitName name}" (mkMerge [ + { + description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}"; + after = [ "zfs.target" ]; + startAt = cfg.interval; + # syncoid may need zpool to get feature@extensible_dataset + path = [ "/run/booted-system/sw/bin/" ]; + serviceConfig = { + ExecStartPre = + # Permissions snapshot and destroy are in case --no-sync-snap is not used + (map (buildAllowCommand "allow" [ "bookmark" "hold" "send" "snapshot" "destroy" ]) (localDatasetName c.source)) ++ + (map (buildAllowCommand "allow" [ "create" "mount" "receive" "rollback" ]) (localDatasetName c.target)); + ExecStopPost = + # Permissions snapshot and destroy are in case --no-sync-snap is not used + (map (buildAllowCommand "unallow" [ "bookmark" "hold" "send" "snapshot" "destroy" ]) (localDatasetName c.source)) ++ + (map (buildAllowCommand "unallow" [ "create" "mount" "receive" "rollback" ]) (localDatasetName c.target)); + ExecStart = lib.escapeShellArgs ([ "${pkgs.sanoid}/bin/syncoid" ] + ++ optionals c.useCommonArgs cfg.commonArgs + ++ optional c.recursive "-r" + ++ optionals (c.sshKey != null) [ "--sshkey" c.sshKey ] + ++ c.extraArgs + ++ [ + "--sendoptions" + c.sendOptions + "--recvoptions" + c.recvOptions + "--no-privilege-elevation" + c.source + c.target + ]); + User = cfg.user; + Group = cfg.group; + StateDirectory = [ "syncoid" ]; + StateDirectoryMode = "700"; + # Prevent SSH control sockets of different syncoid services from interfering + PrivateTmp = true; + # Permissive access to /proc because syncoid + # calls ps(1) to detect ongoing `zfs receive`. + ProcSubset = "all"; + ProtectProc = "default"; + + # 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" + "~@timer" + ]; + SystemCallArchitectures = "native"; + # This is for BindPaths= and BindReadOnlyPaths= + # to allow traversal of directories they create in RootDirectory=. + UMask = "0066"; + }; + } + cfg.service + c.service + ])) + cfg.commands; + }; + + meta.maintainers = with maintainers; [ julm lopsided98 ]; +} diff --git a/nixos/modules/services/backup/znapzend.nix b/nixos/modules/services/backup/znapzend.nix index 0ca71b413ce..debb2a39705 100644 --- a/nixos/modules/services/backup/znapzend.nix +++ b/nixos/modules/services/backup/znapzend.nix @@ -279,7 +279,7 @@ let src_plan = plan; tsformat = timestampFormat; zend_delay = toString sendDelay; - } // fold (a: b: a // b) {} ( + } // foldr (a: b: a // b) {} ( map mkDestAttrs (builtins.attrValues destinations) ); |