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