at 23.05-pre 16 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 (lib.mdDoc "Syncoid ZFS synchronization service"); 89 90 interval = mkOption { 91 type = types.str; 92 default = "hourly"; 93 example = "*-*-* *:15:00"; 94 description = lib.mdDoc '' 95 Run syncoid at this interval. The default is to run hourly. 96 97 The format is described in 98 {manpage}`systemd.time(7)`. 99 ''; 100 }; 101 102 user = mkOption { 103 type = types.str; 104 default = "syncoid"; 105 example = "backup"; 106 description = lib.mdDoc '' 107 The user for the service. ZFS privilege delegation will be 108 automatically configured for any local pools used by syncoid if this 109 option is set to a user other than root. The user will be given the 110 "hold" and "send" privileges on any pool that has datasets being sent 111 and the "create", "mount", "receive", and "rollback" privileges on 112 any pool that has datasets being received. 113 ''; 114 }; 115 116 group = mkOption { 117 type = types.str; 118 default = "syncoid"; 119 example = "backup"; 120 description = lib.mdDoc "The group for the service."; 121 }; 122 123 sshKey = mkOption { 124 type = types.nullOr types.path; 125 # Prevent key from being copied to store 126 apply = mapNullable toString; 127 default = null; 128 description = lib.mdDoc '' 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" ]; 138 description = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 197 Target ZFS dataset. Can be either local 198 («pool/dataset») or remote 199 («user@server:pool/dataset»). 200 ''; 201 }; 202 203 recursive = mkEnableOption (lib.mdDoc ''the transfer of child datasets''); 204 205 sshKey = mkOption { 206 type = types.nullOr types.path; 207 # Prevent key from being copied to store 208 apply = mapNullable toString; 209 description = lib.mdDoc '' 210 SSH private key file to use to login to the remote system. 211 Defaults to {option}`services.syncoid.sshKey` option. 212 ''; 213 }; 214 215 localSourceAllow = mkOption { 216 type = types.listOf types.str; 217 description = lib.mdDoc '' 218 Permissions granted for the {option}`services.syncoid.user` user 219 for local source datasets. See 220 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html> 221 for available permissions. 222 Defaults to {option}`services.syncoid.localSourceAllow` option. 223 ''; 224 }; 225 226 localTargetAllow = mkOption { 227 type = types.listOf types.str; 228 description = lib.mdDoc '' 229 Permissions granted for the {option}`services.syncoid.user` user 230 for local target datasets. See 231 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html> 232 for available permissions. 233 Make sure to include the `change-key` permission if you send raw encrypted datasets, 234 the `compression` permission if you send raw compressed datasets, and so on. 235 For remote target datasets you'll have to set your remote user permissions by yourself. 236 ''; 237 }; 238 239 sendOptions = mkOption { 240 type = types.separatedString " "; 241 default = ""; 242 example = "Lc e"; 243 description = lib.mdDoc '' 244 Advanced options to pass to zfs send. Options are specified 245 without their leading dashes and separated by spaces. 246 ''; 247 }; 248 249 recvOptions = mkOption { 250 type = types.separatedString " "; 251 default = ""; 252 example = "ux recordsize o compression=lz4"; 253 description = lib.mdDoc '' 254 Advanced options to pass to zfs recv. Options are specified 255 without their leading dashes and separated by spaces. 256 ''; 257 }; 258 259 useCommonArgs = mkOption { 260 type = types.bool; 261 default = true; 262 description = lib.mdDoc '' 263 Whether to add the configured common arguments to this command. 264 ''; 265 }; 266 267 service = mkOption { 268 type = types.attrs; 269 default = { }; 270 description = lib.mdDoc '' 271 Systemd configuration specific to this syncoid service. 272 ''; 273 }; 274 275 extraArgs = mkOption { 276 type = types.listOf types.str; 277 default = [ ]; 278 example = [ "--sshport 2222" ]; 279 description = lib.mdDoc "Extra syncoid arguments for this command."; 280 }; 281 }; 282 config = { 283 source = mkDefault name; 284 sshKey = mkDefault cfg.sshKey; 285 localSourceAllow = mkDefault cfg.localSourceAllow; 286 localTargetAllow = mkDefault cfg.localTargetAllow; 287 }; 288 })); 289 default = { }; 290 example = literalExpression '' 291 { 292 "pool/test".target = "root@target:pool/test"; 293 } 294 ''; 295 description = lib.mdDoc "Syncoid commands to run."; 296 }; 297 }; 298 299 # Implementation 300 301 config = mkIf cfg.enable { 302 users = { 303 users = mkIf (cfg.user == "syncoid") { 304 syncoid = { 305 group = cfg.group; 306 isSystemUser = true; 307 # For syncoid to be able to create /var/lib/syncoid/.ssh/ 308 # and to use custom ssh_config or known_hosts. 309 home = "/var/lib/syncoid"; 310 createHome = false; 311 }; 312 }; 313 groups = mkIf (cfg.group == "syncoid") { 314 syncoid = { }; 315 }; 316 }; 317 318 systemd.services = mapAttrs' 319 (name: c: 320 nameValuePair "syncoid-${escapeUnitName name}" (mkMerge [ 321 { 322 description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}"; 323 after = [ "zfs.target" ]; 324 startAt = cfg.interval; 325 # syncoid may need zpool to get feature@extensible_dataset 326 path = [ "/run/booted-system/sw/bin/" ]; 327 serviceConfig = { 328 ExecStartPre = 329 (map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source)) ++ 330 (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target)); 331 ExecStopPost = 332 (map (buildUnallowCommand c.localSourceAllow) (localDatasetName c.source)) ++ 333 (map (buildUnallowCommand c.localTargetAllow) (localDatasetName c.target)); 334 ExecStart = lib.escapeShellArgs ([ "${pkgs.sanoid}/bin/syncoid" ] 335 ++ optionals c.useCommonArgs cfg.commonArgs 336 ++ optional c.recursive "-r" 337 ++ optionals (c.sshKey != null) [ "--sshkey" c.sshKey ] 338 ++ c.extraArgs 339 ++ [ 340 "--sendoptions" 341 c.sendOptions 342 "--recvoptions" 343 c.recvOptions 344 "--no-privilege-elevation" 345 c.source 346 c.target 347 ]); 348 User = cfg.user; 349 Group = cfg.group; 350 StateDirectory = [ "syncoid" ]; 351 StateDirectoryMode = "700"; 352 # Prevent SSH control sockets of different syncoid services from interfering 353 PrivateTmp = true; 354 # Permissive access to /proc because syncoid 355 # calls ps(1) to detect ongoing `zfs receive`. 356 ProcSubset = "all"; 357 ProtectProc = "default"; 358 359 # The following options are only for optimizing: 360 # systemd-analyze security | grep syncoid-'*' 361 AmbientCapabilities = ""; 362 CapabilityBoundingSet = ""; 363 DeviceAllow = [ "/dev/zfs" ]; 364 LockPersonality = true; 365 MemoryDenyWriteExecute = true; 366 NoNewPrivileges = true; 367 PrivateDevices = true; 368 PrivateMounts = true; 369 PrivateNetwork = mkDefault false; 370 PrivateUsers = true; 371 ProtectClock = true; 372 ProtectControlGroups = true; 373 ProtectHome = true; 374 ProtectHostname = true; 375 ProtectKernelLogs = true; 376 ProtectKernelModules = true; 377 ProtectKernelTunables = true; 378 ProtectSystem = "strict"; 379 RemoveIPC = true; 380 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; 381 RestrictNamespaces = true; 382 RestrictRealtime = true; 383 RestrictSUIDSGID = true; 384 RootDirectory = "/run/syncoid/${escapeUnitName name}"; 385 RootDirectoryStartOnly = true; 386 BindPaths = [ "/dev/zfs" ]; 387 BindReadOnlyPaths = [ builtins.storeDir "/etc" "/run" "/bin/sh" ]; 388 # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace. 389 InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ]; 390 MountAPIVFS = true; 391 # Create RootDirectory= in the host's mount namespace. 392 RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ]; 393 RuntimeDirectoryMode = "700"; 394 SystemCallFilter = [ 395 "@system-service" 396 # Groups in @system-service which do not contain a syscall listed by: 397 # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid … 398 # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log 399 # 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 ' ' 400 "~@aio" 401 "~@chown" 402 "~@keyring" 403 "~@memlock" 404 "~@privileged" 405 "~@resources" 406 "~@setuid" 407 "~@timer" 408 ]; 409 SystemCallArchitectures = "native"; 410 # This is for BindPaths= and BindReadOnlyPaths= 411 # to allow traversal of directories they create in RootDirectory=. 412 UMask = "0066"; 413 }; 414 } 415 cfg.service 416 c.service 417 ])) 418 cfg.commands; 419 }; 420 421 meta.maintainers = with maintainers; [ julm lopsided98 ]; 422}