at 25.11-pre 17 kB view raw
1{ 2 config, 3 lib, 4 pkgs, 5 ... 6}: 7let 8 cfg = config.services.syncoid; 9 10 # Extract local dasaset names (so no datasets containing "@") 11 localDatasetName = 12 d: 13 lib.optionals (d != null) ( 14 let 15 m = builtins.match "([^/@]+[^@]*)" d; 16 in 17 lib.optionals (m != null) m 18 ); 19 20 # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html 21 escapeUnitName = 22 name: 23 lib.concatMapStrings (s: if lib.isList s then "-" else s) ( 24 builtins.split "[^a-zA-Z0-9_.\\-]+" name 25 ); 26 27 # Function to build "zfs allow" commands for the filesystems we've delegated 28 # permissions to. It also checks if the target dataset exists before 29 # delegating permissions, if it doesn't exist we delegate it to the parent 30 # dataset (if it exists). This should solve the case of provisoning new 31 # datasets. 32 buildAllowCommand = 33 permissions: dataset: 34 ( 35 "-+${pkgs.writeShellScript "zfs-allow-${dataset}" '' 36 # Here we explicitly use the booted system to guarantee the stable API needed by ZFS 37 38 # Run a ZFS list on the dataset to check if it exists 39 if ${ 40 lib.escapeShellArgs [ 41 "/run/booted-system/sw/bin/zfs" 42 "list" 43 dataset 44 ] 45 } 2> /dev/null; then 46 ${lib.escapeShellArgs [ 47 "/run/booted-system/sw/bin/zfs" 48 "allow" 49 cfg.user 50 (lib.concatStringsSep "," permissions) 51 dataset 52 ]} 53 ${lib.optionalString ((builtins.dirOf dataset) != ".") '' 54 else 55 ${lib.escapeShellArgs [ 56 "/run/booted-system/sw/bin/zfs" 57 "allow" 58 cfg.user 59 (lib.concatStringsSep "," permissions) 60 # Remove the last part of the path 61 (builtins.dirOf dataset) 62 ]} 63 ''} 64 fi 65 ''}" 66 ); 67 68 # Function to build "zfs unallow" commands for the filesystems we've 69 # delegated permissions to. Here we unallow both the target but also 70 # on the parent dataset because at this stage we have no way of 71 # knowing if the allow command did execute on the parent dataset or 72 # not in the pre-hook. We can't run the same if in the post hook 73 # since the dataset should have been created at this point. 74 buildUnallowCommand = 75 permissions: dataset: 76 ( 77 "-+${pkgs.writeShellScript "zfs-unallow-${dataset}" '' 78 # Here we explicitly use the booted system to guarantee the stable API needed by ZFS 79 ${lib.escapeShellArgs [ 80 "/run/booted-system/sw/bin/zfs" 81 "unallow" 82 cfg.user 83 (lib.concatStringsSep "," permissions) 84 dataset 85 ]} 86 ${lib.optionalString ((builtins.dirOf dataset) != ".") ( 87 lib.escapeShellArgs [ 88 "/run/booted-system/sw/bin/zfs" 89 "unallow" 90 cfg.user 91 (lib.concatStringsSep "," permissions) 92 # Remove the last part of the path 93 (builtins.dirOf dataset) 94 ] 95 )} 96 ''}" 97 ); 98in 99{ 100 101 # Interface 102 103 options.services.syncoid = { 104 enable = lib.mkEnableOption "Syncoid ZFS synchronization service"; 105 106 package = lib.mkPackageOption pkgs "sanoid" { }; 107 108 interval = lib.mkOption { 109 type = with lib.types; either str (listOf str); 110 default = "hourly"; 111 example = "*-*-* *:15:00"; 112 description = '' 113 Run syncoid at this interval. The default is to run hourly. 114 115 Must be in the format described in {manpage}`systemd.time(7)`. This is 116 equivalent to adding a corresponding timer unit with 117 {option}`OnCalendar` set to the value given here. 118 119 Set to an empty list to avoid starting syncoid automatically. 120 ''; 121 }; 122 123 user = lib.mkOption { 124 type = lib.types.str; 125 default = "syncoid"; 126 example = "backup"; 127 description = '' 128 The user for the service. ZFS privilege delegation will be 129 automatically configured for any local pools used by syncoid if this 130 option is set to a user other than root. The user will be given the 131 "hold" and "send" privileges on any pool that has datasets being sent 132 and the "create", "mount", "receive", and "rollback" privileges on 133 any pool that has datasets being received. 134 ''; 135 }; 136 137 group = lib.mkOption { 138 type = lib.types.str; 139 default = "syncoid"; 140 example = "backup"; 141 description = "The group for the service."; 142 }; 143 144 sshKey = lib.mkOption { 145 type = with lib.types; nullOr (coercedTo path toString str); 146 default = null; 147 description = '' 148 SSH private key file to use to login to the remote system. Can be 149 overridden in individual commands. 150 ''; 151 }; 152 153 localSourceAllow = lib.mkOption { 154 type = lib.types.listOf lib.types.str; 155 # Permissions snapshot and destroy are in case --no-sync-snap is not used 156 default = [ 157 "bookmark" 158 "hold" 159 "send" 160 "snapshot" 161 "destroy" 162 "mount" 163 ]; 164 description = '' 165 Permissions granted for the {option}`services.syncoid.user` user 166 for local source datasets. See 167 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html> 168 for available permissions. 169 ''; 170 }; 171 172 localTargetAllow = lib.mkOption { 173 type = lib.types.listOf lib.types.str; 174 default = [ 175 "change-key" 176 "compression" 177 "create" 178 "mount" 179 "mountpoint" 180 "receive" 181 "rollback" 182 ]; 183 example = [ 184 "create" 185 "mount" 186 "receive" 187 "rollback" 188 ]; 189 description = '' 190 Permissions granted for the {option}`services.syncoid.user` user 191 for local target datasets. See 192 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html> 193 for available permissions. 194 Make sure to include the `change-key` permission if you send raw encrypted datasets, 195 the `compression` permission if you send raw compressed datasets, and so on. 196 For remote target datasets you'll have to set your remote user permissions by yourself. 197 ''; 198 }; 199 200 commonArgs = lib.mkOption { 201 type = lib.types.listOf lib.types.str; 202 default = [ ]; 203 example = [ "--no-sync-snap" ]; 204 description = '' 205 Arguments to add to every syncoid command, unless disabled for that 206 command. See 207 <https://github.com/jimsalterjrs/sanoid/#syncoid-command-line-options> 208 for available options. 209 ''; 210 }; 211 212 service = lib.mkOption { 213 type = lib.types.attrs; 214 default = { }; 215 description = '' 216 Systemd configuration common to all syncoid services. 217 ''; 218 }; 219 220 commands = lib.mkOption { 221 type = lib.types.attrsOf ( 222 lib.types.submodule ( 223 { name, ... }: 224 { 225 options = { 226 source = lib.mkOption { 227 type = lib.types.str; 228 example = "pool/dataset"; 229 description = '' 230 Source ZFS dataset. Can be either local or remote. Defaults to 231 the attribute name. 232 ''; 233 }; 234 235 target = lib.mkOption { 236 type = lib.types.str; 237 example = "user@server:pool/dataset"; 238 description = '' 239 Target ZFS dataset. Can be either local 240 («pool/dataset») or remote 241 («user@server:pool/dataset»). 242 ''; 243 }; 244 245 recursive = lib.mkEnableOption ''the transfer of child datasets''; 246 247 sshKey = lib.mkOption { 248 type = with lib.types; nullOr (coercedTo path toString str); 249 description = '' 250 SSH private key file to use to login to the remote system. 251 Defaults to {option}`services.syncoid.sshKey` option. 252 ''; 253 }; 254 255 localSourceAllow = lib.mkOption { 256 type = lib.types.listOf lib.types.str; 257 description = '' 258 Permissions granted for the {option}`services.syncoid.user` user 259 for local source datasets. See 260 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html> 261 for available permissions. 262 Defaults to {option}`services.syncoid.localSourceAllow` option. 263 ''; 264 }; 265 266 localTargetAllow = lib.mkOption { 267 type = lib.types.listOf lib.types.str; 268 description = '' 269 Permissions granted for the {option}`services.syncoid.user` user 270 for local target datasets. See 271 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html> 272 for available permissions. 273 Make sure to include the `change-key` permission if you send raw encrypted datasets, 274 the `compression` permission if you send raw compressed datasets, and so on. 275 For remote target datasets you'll have to set your remote user permissions by yourself. 276 ''; 277 }; 278 279 sendOptions = lib.mkOption { 280 type = lib.types.separatedString " "; 281 default = ""; 282 example = "Lc e"; 283 description = '' 284 Advanced options to pass to zfs send. Options are specified 285 without their leading dashes and separated by spaces. 286 ''; 287 }; 288 289 recvOptions = lib.mkOption { 290 type = lib.types.separatedString " "; 291 default = ""; 292 example = "ux recordsize o compression=lz4"; 293 description = '' 294 Advanced options to pass to zfs recv. Options are specified 295 without their leading dashes and separated by spaces. 296 ''; 297 }; 298 299 useCommonArgs = lib.mkOption { 300 type = lib.types.bool; 301 default = true; 302 description = '' 303 Whether to add the configured common arguments to this command. 304 ''; 305 }; 306 307 service = lib.mkOption { 308 type = lib.types.attrs; 309 default = { }; 310 description = '' 311 Systemd configuration specific to this syncoid service. 312 ''; 313 }; 314 315 extraArgs = lib.mkOption { 316 type = lib.types.listOf lib.types.str; 317 default = [ ]; 318 example = [ "--sshport 2222" ]; 319 description = "Extra syncoid arguments for this command."; 320 }; 321 }; 322 config = { 323 source = lib.mkDefault name; 324 sshKey = lib.mkDefault cfg.sshKey; 325 localSourceAllow = lib.mkDefault cfg.localSourceAllow; 326 localTargetAllow = lib.mkDefault cfg.localTargetAllow; 327 }; 328 } 329 ) 330 ); 331 default = { }; 332 example = lib.literalExpression '' 333 { 334 "pool/test".target = "root@target:pool/test"; 335 } 336 ''; 337 description = "Syncoid commands to run."; 338 }; 339 }; 340 341 # Implementation 342 343 config = lib.mkIf cfg.enable { 344 users = { 345 users = lib.mkIf (cfg.user == "syncoid") { 346 syncoid = { 347 group = cfg.group; 348 isSystemUser = true; 349 # For syncoid to be able to create /var/lib/syncoid/.ssh/ 350 # and to use custom ssh_config or known_hosts. 351 home = "/var/lib/syncoid"; 352 createHome = false; 353 }; 354 }; 355 groups = lib.mkIf (cfg.group == "syncoid") { 356 syncoid = { }; 357 }; 358 }; 359 360 systemd.services = lib.mapAttrs' ( 361 name: c: 362 lib.nameValuePair "syncoid-${escapeUnitName name}" ( 363 lib.mkMerge [ 364 { 365 description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}"; 366 after = [ "zfs.target" ]; 367 startAt = cfg.interval; 368 # syncoid may need zpool to get feature@extensible_dataset 369 path = [ "/run/booted-system/sw/bin/" ]; 370 serviceConfig = { 371 ExecStartPre = 372 (map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source)) 373 ++ (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target)); 374 ExecStopPost = 375 (map (buildUnallowCommand c.localSourceAllow) (localDatasetName c.source)) 376 ++ (map (buildUnallowCommand c.localTargetAllow) (localDatasetName c.target)); 377 ExecStart = lib.escapeShellArgs ( 378 [ "${cfg.package}/bin/syncoid" ] 379 ++ lib.optionals c.useCommonArgs cfg.commonArgs 380 ++ lib.optional c.recursive "-r" 381 ++ lib.optionals (c.sshKey != null) [ 382 "--sshkey" 383 c.sshKey 384 ] 385 ++ c.extraArgs 386 ++ [ 387 "--sendoptions" 388 c.sendOptions 389 "--recvoptions" 390 c.recvOptions 391 "--no-privilege-elevation" 392 c.source 393 c.target 394 ] 395 ); 396 User = cfg.user; 397 Group = cfg.group; 398 StateDirectory = [ "syncoid" ]; 399 StateDirectoryMode = "700"; 400 # Prevent SSH control sockets of different syncoid services from interfering 401 PrivateTmp = true; 402 # Permissive access to /proc because syncoid 403 # calls ps(1) to detect ongoing `zfs receive`. 404 ProcSubset = "all"; 405 ProtectProc = "default"; 406 407 # The following options are only for optimizing: 408 # systemd-analyze security | grep syncoid-'*' 409 AmbientCapabilities = ""; 410 CapabilityBoundingSet = ""; 411 DeviceAllow = [ "/dev/zfs" ]; 412 LockPersonality = true; 413 MemoryDenyWriteExecute = true; 414 NoNewPrivileges = true; 415 PrivateDevices = true; 416 PrivateMounts = true; 417 PrivateNetwork = lib.mkDefault false; 418 PrivateUsers = false; # Enabling this breaks on zfs-2.2.0 419 ProtectClock = true; 420 ProtectControlGroups = true; 421 ProtectHome = true; 422 ProtectHostname = true; 423 ProtectKernelLogs = true; 424 ProtectKernelModules = true; 425 ProtectKernelTunables = true; 426 ProtectSystem = "strict"; 427 RemoveIPC = true; 428 RestrictAddressFamilies = [ 429 "AF_UNIX" 430 "AF_INET" 431 "AF_INET6" 432 ]; 433 RestrictNamespaces = true; 434 RestrictRealtime = true; 435 RestrictSUIDSGID = true; 436 RootDirectory = "/run/syncoid/${escapeUnitName name}"; 437 RootDirectoryStartOnly = true; 438 BindPaths = [ "/dev/zfs" ]; 439 BindReadOnlyPaths = [ 440 builtins.storeDir 441 "/etc" 442 "/run" 443 "/bin/sh" 444 ]; 445 # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace. 446 InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ]; 447 MountAPIVFS = true; 448 # Create RootDirectory= in the host's mount namespace. 449 RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ]; 450 RuntimeDirectoryMode = "700"; 451 SystemCallFilter = [ 452 "@system-service" 453 # Groups in @system-service which do not contain a syscall listed by: 454 # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid … 455 # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log 456 # 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 ' ' 457 "~@aio" 458 "~@chown" 459 "~@keyring" 460 "~@memlock" 461 "~@privileged" 462 "~@resources" 463 "~@setuid" 464 "~@timer" 465 ]; 466 SystemCallArchitectures = "native"; 467 # This is for BindPaths= and BindReadOnlyPaths= 468 # to allow traversal of directories they create in RootDirectory=. 469 UMask = "0066"; 470 }; 471 } 472 cfg.service 473 c.service 474 ] 475 ) 476 ) cfg.commands; 477 }; 478 479 meta.maintainers = with lib.maintainers; [ 480 julm 481 lopsided98 482 ]; 483}