Merge pull request #98455 from ju1m/syncoid-split

nixos/syncoid: split in multiple systemd services and harden them

authored by Elis Hirwing and committed by GitHub 6984e68c a3787403

+127 -47
+124 -45
nixos/modules/services/backup/syncoid.nix
··· 5 let 6 cfg = config.services.syncoid; 7 8 - # Extract pool names of local datasets (ones that don't contain "@") that 9 - # have the specified type (either "source" or "target") 10 - getPools = type: unique (map (d: head (builtins.match "([^/]+).*" d)) ( 11 - # Filter local datasets 12 - filter (d: !hasInfix "@" d) 13 - # Get datasets of the specified type 14 - (catAttrs type (attrValues cfg.commands)) 15 - )); 16 in { 17 18 # Interface ··· 77 ''; 78 }; 79 80 commands = mkOption { 81 type = types.attrsOf (types.submodule ({ name, ... }: { 82 options = { ··· 99 ''; 100 }; 101 102 - recursive = mkOption { 103 - type = types.bool; 104 - default = false; 105 - description = '' 106 - Whether to also transfer child datasets. 107 - ''; 108 - }; 109 110 sshKey = mkOption { 111 type = types.nullOr types.path; ··· 145 ''; 146 }; 147 148 extraArgs = mkOption { 149 type = types.listOf types.str; 150 default = []; ··· 170 # Implementation 171 172 config = mkIf cfg.enable { 173 - users = { 174 users = mkIf (cfg.user == "syncoid") { 175 syncoid = { 176 group = cfg.group; 177 isSystemUser = true; 178 }; 179 }; 180 groups = mkIf (cfg.group == "syncoid") { ··· 182 }; 183 }; 184 185 - systemd.services.syncoid = { 186 - description = "Syncoid ZFS synchronization service"; 187 - script = concatMapStringsSep "\n" (c: lib.escapeShellArgs 188 - ([ "${pkgs.sanoid}/bin/syncoid" ] 189 - ++ (optionals c.useCommonArgs cfg.commonArgs) 190 - ++ (optional c.recursive "-r") 191 - ++ (optionals (c.sshKey != null) [ "--sshkey" c.sshKey ]) 192 - ++ c.extraArgs 193 - ++ [ "--sendoptions" c.sendOptions 194 - "--recvoptions" c.recvOptions 195 - "--no-privilege-elevation" 196 - c.source c.target 197 - ])) (attrValues cfg.commands); 198 - after = [ "zfs.target" ]; 199 - serviceConfig = { 200 - ExecStartPre = let 201 - allowCmd = permissions: pool: lib.escapeShellArgs [ 202 - "+/run/booted-system/sw/bin/zfs" "allow" 203 - cfg.user (concatStringsSep "," permissions) pool 204 - ]; 205 - in 206 - (map (allowCmd [ "hold" "send" "snapshot" "destroy" ]) (getPools "source")) ++ 207 - (map (allowCmd [ "create" "mount" "receive" "rollback" ]) (getPools "target")); 208 - User = cfg.user; 209 - Group = cfg.group; 210 - }; 211 - startAt = cfg.interval; 212 - }; 213 }; 214 215 - meta.maintainers = with maintainers; [ lopsided98 ]; 216 }
··· 5 let 6 cfg = config.services.syncoid; 7 8 + # Extract the pool name of a local dataset (any dataset not containing "@") 9 + localPoolName = d: optionals (d != null) ( 10 + let m = builtins.match "([^/@]+)[^@]*" d; in 11 + optionals (m != null) m); 12 + 13 + # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html 14 + escapeUnitName = name: 15 + lib.concatMapStrings (s: if lib.isList s then "-" else s) 16 + (builtins.split "[^a-zA-Z0-9_.\\-]+" name); 17 in { 18 19 # Interface ··· 78 ''; 79 }; 80 81 + service = mkOption { 82 + type = types.attrs; 83 + default = {}; 84 + description = '' 85 + Systemd configuration common to all syncoid services. 86 + ''; 87 + }; 88 + 89 commands = mkOption { 90 type = types.attrsOf (types.submodule ({ name, ... }: { 91 options = { ··· 108 ''; 109 }; 110 111 + recursive = mkEnableOption ''the transfer of child datasets''; 112 113 sshKey = mkOption { 114 type = types.nullOr types.path; ··· 148 ''; 149 }; 150 151 + service = mkOption { 152 + type = types.attrs; 153 + default = {}; 154 + description = '' 155 + Systemd configuration specific to this syncoid service. 156 + ''; 157 + }; 158 + 159 extraArgs = mkOption { 160 type = types.listOf types.str; 161 default = []; ··· 181 # Implementation 182 183 config = mkIf cfg.enable { 184 + users = { 185 users = mkIf (cfg.user == "syncoid") { 186 syncoid = { 187 group = cfg.group; 188 isSystemUser = true; 189 + # For syncoid to be able to create /var/lib/syncoid/.ssh/ 190 + # and to use custom ssh_config or known_hosts. 191 + home = "/var/lib/syncoid"; 192 + createHome = false; 193 }; 194 }; 195 groups = mkIf (cfg.group == "syncoid") { ··· 197 }; 198 }; 199 200 + systemd.services = mapAttrs' (name: c: 201 + nameValuePair "syncoid-${escapeUnitName name}" (mkMerge [ 202 + { description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}"; 203 + after = [ "zfs.target" ]; 204 + startAt = cfg.interval; 205 + # syncoid may need zpool to get feature@extensible_dataset 206 + path = [ "/run/booted-system/sw/bin/" ]; 207 + serviceConfig = { 208 + ExecStartPre = 209 + map (pool: lib.escapeShellArgs [ 210 + "+/run/booted-system/sw/bin/zfs" "allow" 211 + cfg.user "bookmark,hold,send,snapshot,destroy" pool 212 + # Permissions snapshot and destroy are in case --no-sync-snap is not used 213 + ]) (localPoolName c.source) ++ 214 + map (pool: lib.escapeShellArgs [ 215 + "+/run/booted-system/sw/bin/zfs" "allow" 216 + cfg.user "create,mount,receive,rollback" pool 217 + ]) (localPoolName c.target); 218 + ExecStart = lib.escapeShellArgs ([ "${pkgs.sanoid}/bin/syncoid" ] 219 + ++ optionals c.useCommonArgs cfg.commonArgs 220 + ++ optional c.recursive "-r" 221 + ++ optionals (c.sshKey != null) [ "--sshkey" c.sshKey ] 222 + ++ c.extraArgs 223 + ++ [ "--sendoptions" c.sendOptions 224 + "--recvoptions" c.recvOptions 225 + "--no-privilege-elevation" 226 + c.source c.target 227 + ]); 228 + User = cfg.user; 229 + Group = cfg.group; 230 + StateDirectory = [ "syncoid" ]; 231 + StateDirectoryMode = "700"; 232 + # Prevent SSH control sockets of different syncoid services from interfering 233 + PrivateTmp = true; 234 + # Permissive access to /proc because syncoid 235 + # calls ps(1) to detect ongoing `zfs receive`. 236 + ProcSubset = "all"; 237 + ProtectProc = "default"; 238 + 239 + # The following options are only for optimizing: 240 + # systemd-analyze security | grep syncoid-'*' 241 + AmbientCapabilities = ""; 242 + CapabilityBoundingSet = ""; 243 + DeviceAllow = ["/dev/zfs"]; 244 + LockPersonality = true; 245 + MemoryDenyWriteExecute = true; 246 + NoNewPrivileges = true; 247 + PrivateDevices = true; 248 + PrivateMounts = true; 249 + PrivateNetwork = mkDefault false; 250 + PrivateUsers = true; 251 + ProtectClock = true; 252 + ProtectControlGroups = true; 253 + ProtectHome = true; 254 + ProtectHostname = true; 255 + ProtectKernelLogs = true; 256 + ProtectKernelModules = true; 257 + ProtectKernelTunables = true; 258 + ProtectSystem = "strict"; 259 + RemoveIPC = true; 260 + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; 261 + RestrictNamespaces = true; 262 + RestrictRealtime = true; 263 + RestrictSUIDSGID = true; 264 + RootDirectory = "/run/syncoid/${escapeUnitName name}"; 265 + RootDirectoryStartOnly = true; 266 + BindPaths = [ "/dev/zfs" ]; 267 + BindReadOnlyPaths = [ builtins.storeDir "/etc" "/run" "/bin/sh" ]; 268 + # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace. 269 + InaccessiblePaths = ["-+/run/syncoid/${escapeUnitName name}"]; 270 + MountAPIVFS = true; 271 + # Create RootDirectory= in the host's mount namespace. 272 + RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ]; 273 + RuntimeDirectoryMode = "700"; 274 + SystemCallFilter = [ 275 + "@system-service" 276 + # Groups in @system-service which do not contain a syscall listed by: 277 + # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid … 278 + # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log 279 + # 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 ' ' 280 + "~@aio" "~@chown" "~@keyring" "~@memlock" "~@privileged" 281 + "~@resources" "~@setuid" "~@sync" "~@timer" 282 + ]; 283 + SystemCallArchitectures = "native"; 284 + # This is for BindPaths= and BindReadOnlyPaths= 285 + # to allow traversal of directories they create in RootDirectory=. 286 + UMask = "0066"; 287 + }; 288 + } 289 + cfg.service 290 + c.service 291 + ])) cfg.commands; 292 }; 293 294 + meta.maintainers = with maintainers; [ julm lopsided98 ]; 295 }
+3 -2
nixos/tests/sanoid.nix
··· 44 # Sync snapshot taken by sanoid 45 "pool/sanoid" = { 46 target = "root@target:pool/sanoid"; 47 - extraArgs = [ "--no-sync-snap" ]; 48 }; 49 # Take snapshot and sync 50 "pool/syncoid".target = "root@target:pool/syncoid"; ··· 92 # Sync snapshots 93 target.wait_for_open_port(22) 94 source.succeed("touch /mnt/pool/syncoid/test.txt") 95 - source.systemctl("start --wait syncoid.service") 96 target.succeed("cat /mnt/pool/sanoid/test.txt") 97 target.succeed("cat /mnt/pool/syncoid/test.txt") 98 ''; 99 })
··· 44 # Sync snapshot taken by sanoid 45 "pool/sanoid" = { 46 target = "root@target:pool/sanoid"; 47 + extraArgs = [ "--no-sync-snap" "--create-bookmark" ]; 48 }; 49 # Take snapshot and sync 50 "pool/syncoid".target = "root@target:pool/syncoid"; ··· 92 # Sync snapshots 93 target.wait_for_open_port(22) 94 source.succeed("touch /mnt/pool/syncoid/test.txt") 95 + source.systemctl("start --wait syncoid-pool-sanoid.service") 96 target.succeed("cat /mnt/pool/sanoid/test.txt") 97 + source.systemctl("start --wait syncoid-pool-syncoid.service") 98 target.succeed("cat /mnt/pool/syncoid/test.txt") 99 ''; 100 })