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