From d05a1ab1e4c21a956d883af2252818c4ca486d1e Mon Sep 17 00:00:00 2001 From: Julien Moutinho Date: Tue, 22 Sep 2020 11:29:21 +0200 Subject: [PATCH] nixos/syncoid: split in multiple systemd services --- nixos/modules/services/backup/syncoid.nix | 169 ++++++++++++++++------ nixos/tests/sanoid.nix | 5 +- 2 files changed, 127 insertions(+), 47 deletions(-) diff --git a/nixos/modules/services/backup/syncoid.nix b/nixos/modules/services/backup/syncoid.nix index b764db1f14e4..888ef20f642e 100644 --- a/nixos/modules/services/backup/syncoid.nix +++ b/nixos/modules/services/backup/syncoid.nix @@ -5,14 +5,15 @@ 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)) - )); + # Extract the pool name of a local dataset (any dataset not containing "@") + localPoolName = 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: + lib.concatMapStrings (s: if lib.isList s then "-" else s) + (builtins.split "[^a-zA-Z0-9_.\\-]+" name); in { # Interface @@ -77,6 +78,14 @@ in { ''; }; + service = mkOption { + type = types.attrs; + default = {}; + description = '' + Systemd configuration common to all syncoid services. + ''; + }; + commands = mkOption { type = types.attrsOf (types.submodule ({ name, ... }: { options = { @@ -99,13 +108,7 @@ in { ''; }; - recursive = mkOption { - type = types.bool; - default = false; - description = '' - Whether to also transfer child datasets. - ''; - }; + recursive = mkEnableOption ''the transfer of child datasets''; sshKey = mkOption { type = types.nullOr types.path; @@ -145,6 +148,14 @@ in { ''; }; + service = mkOption { + type = types.attrs; + default = {}; + description = '' + Systemd configuration specific to this syncoid service. + ''; + }; + extraArgs = mkOption { type = types.listOf types.str; default = []; @@ -170,11 +181,15 @@ in { # Implementation config = mkIf cfg.enable { - users = { + 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; }; }; groups = mkIf (cfg.group == "syncoid") { @@ -182,35 +197,99 @@ in { }; }; - 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; - }; - startAt = cfg.interval; - }; + 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 = + map (pool: lib.escapeShellArgs [ + "+/run/booted-system/sw/bin/zfs" "allow" + cfg.user "bookmark,hold,send,snapshot,destroy" pool + # Permissions snapshot and destroy are in case --no-sync-snap is not used + ]) (localPoolName c.source) ++ + map (pool: lib.escapeShellArgs [ + "+/run/booted-system/sw/bin/zfs" "allow" + cfg.user "create,mount,receive,rollback" pool + ]) (localPoolName 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" "~@sync" "~@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; [ lopsided98 ]; + meta.maintainers = with maintainers; [ julm lopsided98 ]; } diff --git a/nixos/tests/sanoid.nix b/nixos/tests/sanoid.nix index c691bfc08ef7..cac6d8101f54 100644 --- a/nixos/tests/sanoid.nix +++ b/nixos/tests/sanoid.nix @@ -44,7 +44,7 @@ in { # Sync snapshot taken by sanoid "pool/sanoid" = { target = "root@target:pool/sanoid"; - extraArgs = [ "--no-sync-snap" ]; + extraArgs = [ "--no-sync-snap" "--create-bookmark" ]; }; # Take snapshot and sync "pool/syncoid".target = "root@target:pool/syncoid"; @@ -92,8 +92,9 @@ in { # Sync snapshots target.wait_for_open_port(22) source.succeed("touch /mnt/pool/syncoid/test.txt") - source.systemctl("start --wait syncoid.service") + source.systemctl("start --wait syncoid-pool-sanoid.service") target.succeed("cat /mnt/pool/sanoid/test.txt") + source.systemctl("start --wait syncoid-pool-syncoid.service") target.succeed("cat /mnt/pool/syncoid/test.txt") ''; })