8 cfg = config.services.syncoid;
10 # Extract local dataset names (so no datasets containing "@")
13 lib.optionals (d != null) (
15 m = builtins.match "([^/@]+[^@]*)" d;
17 lib.optionals (m != null) m
20 # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
23 lib.concatMapStrings (s: if builtins.isList s then "-" else s) (
24 builtins.split "[^a-zA-Z0-9_.\\-]+" name
31 options.services.syncoid = {
32 enable = lib.mkEnableOption "Syncoid ZFS synchronization service";
34 package = lib.mkPackageOption pkgs "sanoid" { };
36 interval = lib.mkOption {
37 type = with lib.types; either str (listOf str);
39 example = "*-*-* *:15:00";
41 Run syncoid at this interval. The default is to run hourly.
43 Must be in the format described in {manpage}`systemd.time(7)`. This is
44 equivalent to adding a corresponding timer unit with
45 {option}`OnCalendar` set to the value given here.
47 Set to an empty list to avoid starting syncoid automatically.
51 sshKey = lib.mkOption {
52 type = with lib.types; nullOr (coercedTo path toString str);
55 SSH private key file to use to login to the remote system.
56 It can be overridden in individual commands.
57 It is loaded using `LoadCredentialEncrypted=`
58 when its path is prefixed by a credential name and colon,
59 otherwise `LoadCredential=` is used.
60 For more SSH tuning, you may use syncoid's `--sshoption`
61 in {option}`services.syncoid.commonArgs`
62 and/or in the `extraArgs` of a specific command.
66 localSourceAllow = lib.mkOption {
67 type = with lib.types; listOf str;
68 # Permissions snapshot and destroy are in case --no-sync-snap is not used
78 Permissions granted for the syncoid user for local source datasets.
79 See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
80 for available permissions.
84 localTargetAllow = lib.mkOption {
85 type = with lib.types; listOf str;
86 # Permission destroy is required to reset broken receive states (zfs receive -A),
87 # which syncoid does when it fails to resume a receive state,
88 # when the snapshot it refers to has been destroyed on the source.
106 Permissions granted for the syncoid user for local target datasets.
107 See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
108 for available permissions.
109 Make sure to include the `change-key` permission if you send raw encrypted datasets,
110 the `compression` permission if you send raw compressed datasets, and so on.
111 For remote target datasets you'll have to set your remote user permissions by yourself.
115 commonArgs = lib.mkOption {
116 type = with lib.types; listOf str;
118 example = [ "--no-sync-snap" ];
120 Arguments to add to every syncoid command, unless disabled for that
122 <https://github.com/jimsalterjrs/sanoid/#syncoid-command-line-options>
123 for available options.
127 service = lib.mkOption {
128 type = lib.types.attrs;
131 Systemd configuration common to all syncoid services.
135 commands = lib.mkOption {
136 type = lib.types.attrsOf (
137 lib.types.submodule (
141 source = lib.mkOption {
142 type = lib.types.str;
143 example = "pool/dataset";
145 Source ZFS dataset. Can be either local or remote. Defaults to
150 target = lib.mkOption {
151 type = lib.types.str;
152 example = "user@server:pool/dataset";
154 Target ZFS dataset. Can be either local
155 («pool/dataset») or remote
156 («user@server:pool/dataset»).
160 recursive = lib.mkEnableOption ''the transfer of child datasets'';
162 sshKey = lib.mkOption {
163 type = with lib.types; nullOr (coercedTo path toString str);
165 SSH private key file to use to login to the remote system.
166 Defaults to {option}`services.syncoid.sshKey` option.
170 localSourceAllow = lib.mkOption {
171 type = lib.types.listOf lib.types.str;
173 Permissions granted for the {option}`services.syncoid.user` user
174 for local source datasets. See
175 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
176 for available permissions.
177 Defaults to {option}`services.syncoid.localSourceAllow` option.
181 localTargetAllow = lib.mkOption {
182 type = lib.types.listOf lib.types.str;
184 Permissions granted for the {option}`services.syncoid.user` user
185 for local target datasets. See
186 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
187 for available permissions.
188 Make sure to include the `change-key` permission if you send raw encrypted datasets,
189 the `compression` permission if you send raw compressed datasets, and so on.
190 For remote target datasets you'll have to set your remote user permissions by yourself.
194 sendOptions = lib.mkOption {
195 type = lib.types.separatedString " ";
199 Advanced options to pass to zfs send. Options are specified
200 without their leading dashes and separated by spaces.
204 recvOptions = lib.mkOption {
205 type = lib.types.separatedString " ";
207 example = "ux recordsize o compression=lz4";
209 Advanced options to pass to zfs recv. Options are specified
210 without their leading dashes and separated by spaces.
215 lib.mkEnableOption ''
216 configured common arguments to this command
222 service = lib.mkOption {
223 type = lib.types.attrs;
226 Systemd configuration specific to this syncoid service.
230 extraArgs = lib.mkOption {
231 type = lib.types.listOf lib.types.str;
233 example = [ "--sshport 2222" ];
234 description = "Extra syncoid arguments for this command.";
238 source = lib.mkDefault name;
239 sshKey = lib.mkDefault cfg.sshKey;
240 localSourceAllow = lib.mkDefault cfg.localSourceAllow;
241 localTargetAllow = lib.mkDefault cfg.localTargetAllow;
247 example = lib.literalExpression ''
249 "pool/test".target = "root@target:pool/test";
252 description = "Syncoid commands to run.";
258 config = lib.mkIf cfg.enable {
259 systemd.services = lib.mapAttrs' (
262 sshKeyCred = builtins.split ":" c.sshKey;
264 lib.nameValuePair "syncoid-${escapeUnitName name}" (
267 description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}";
268 after = [ "zfs.target" ];
269 startAt = cfg.interval;
270 # Here we explicitly use the booted system to guarantee the stable API needed by ZFS.
271 # Moreover syncoid may need zpool to get feature@extensible_dataset.
272 path = [ "/run/booted-system/sw" ];
273 # Prevents missing snapshots during DST changes
274 environment.TZ = "UTC";
275 # A custom LD_LIBRARY_PATH is needed to access in `getent passwd`
276 # the systemd's entry about the DynamicUser=,
277 # so that ssh won't fail with: "No user exists for uid $UID".
278 environment.LD_LIBRARY_PATH = config.system.nssModules.path;
281 # Recursively remove any residual permissions
282 # given on local+descendant datasets (source, target or target's parent)
283 # to any currently unknown (hence unused) systemd dynamic users (UID/GID range 61184…65519),
284 # which happens when a crash has occurred
285 # during any previous run of a syncoid-*.service (not only this one).
290 + pkgs.writeShellScript "zfs-unallow-unused-dynamic-users" ''
292 if zfs status "$1" 2>/dev/null; then
294 sed -ne 's/^\t\(user\|group\) (unknown: \([0-9]\+\)).*/\1 \2/p' |
297 while read -r role id; do
298 if [ "$id" -ge 61184 ] && [ "$id" -le 65519 ]; then
300 (user) uids+=("$id");;
304 zfs unallow -r -u "$(printf %s, "''${uids[@]}")" "$1"
309 + lib.escapeShellArg dataset
312 localDatasetName c.source
313 ++ localDatasetName c.target
314 ++ map builtins.dirOf (localDatasetName c.target)
317 # For a local source, allow the localSourceAllow ZFS permissions.
320 "+/run/booted-system/sw/bin/zfs allow $USER "
321 + lib.escapeShellArgs [
322 (lib.concatStringsSep "," c.localSourceAllow)
325 ) (localDatasetName c.source)
327 # For a local target, check if the dataset exists before delegating permissions,
328 # and if it doesn't exist, delegate it to the parent dataset.
329 # This should solve the case of provisioning new datasets.
333 + pkgs.writeShellScript "zfs-allow-target" ''
335 # Run a ZFS list on the dataset to check if it exists
336 zfs list "$dataset" >/dev/null 2>/dev/null ||
337 dataset="$(dirname "$dataset")"
338 zfs allow "$USER" ${lib.escapeShellArg (lib.concatStringsSep "," c.localTargetAllow)} "$dataset"
341 + lib.escapeShellArg dataset
342 ) (localDatasetName c.target);
345 zfsUnallow = dataset: "+/run/booted-system/sw/bin/zfs unallow $USER " + lib.escapeShellArg dataset;
347 map zfsUnallow (localDatasetName c.source)
349 # For a local target, unallow both the dataset and its parent,
350 # because at this stage we have no way of knowing if the allow command
351 # did execute on the parent dataset or not in the ExecStartPre=.
352 # We can't run the same if-then-else in the post hook
353 # since the dataset should have been created at this point.
354 lib.concatMap (dataset: [
356 (zfsUnallow (builtins.dirOf dataset))
357 ]) (localDatasetName c.target);
358 ExecStart = lib.escapeShellArgs (
359 [ "${cfg.package}/bin/syncoid" ]
360 ++ lib.optionals c.useCommonArgs cfg.commonArgs
361 ++ lib.optional c.recursive "--recursive"
362 ++ lib.optionals (c.sshKey != null) [
364 "\${CREDENTIALS_DIRECTORY}/${if lib.length sshKeyCred > 1 then lib.head sshKeyCred else "sshKey"}"
372 "--no-privilege-elevation"
378 NFTSet = lib.optional config.networking.nftables.enable "user:inet:filter:nixos_syncoid_uids";
379 # Prevent SSH control sockets of different syncoid services from interfering
381 # Permissive access to /proc because syncoid
382 # calls ps(1) to detect ongoing `zfs receive`.
384 ProtectProc = "default";
386 # The following options are only for optimizing:
387 # systemd-analyze security | grep syncoid-'*'
388 AmbientCapabilities = "";
389 CapabilityBoundingSet = "";
390 DeviceAllow = [ "/dev/zfs" ];
391 LockPersonality = true;
392 MemoryDenyWriteExecute = true;
393 NoNewPrivileges = true;
394 PrivateDevices = true;
395 PrivateMounts = true;
396 PrivateNetwork = lib.mkDefault false;
397 PrivateUsers = false; # Enabling this breaks on zfs-2.2.0
399 ProtectControlGroups = true;
401 ProtectHostname = true;
402 ProtectKernelLogs = true;
403 ProtectKernelModules = true;
404 ProtectKernelTunables = true;
405 ProtectSystem = "strict";
407 RestrictAddressFamilies = [
412 RestrictNamespaces = true;
413 RestrictRealtime = true;
414 RestrictSUIDSGID = true;
415 RootDirectory = "/run/syncoid/${escapeUnitName name}";
416 BindPaths = [ "/dev/zfs" ];
417 BindReadOnlyPaths = [
421 # Some programs hardcode /var/run
422 # eg. /var/run/avahi-daemon/socket to resolve *.local mDNS domains
426 # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
427 InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
429 # Create RootDirectory= in the host's mount namespace.
430 RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
431 RuntimeDirectoryMode = "700";
434 # Groups in @system-service which do not contain a syscall listed by:
435 # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid …
436 # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log
437 # 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 ' '
446 SystemCallArchitectures = "native";
447 # This is for BindPaths= and BindReadOnlyPaths=
448 # to allow traversal of directories they create in RootDirectory=.
452 if lib.length sshKeyCred > 1 then
453 { LoadCredentialEncrypted = [ c.sshKey ]; }
455 { LoadCredential = [ "sshKey:${c.sshKey}" ]; }
464 networking.nftables.ruleset = lib.mkBefore ''
466 # A set containing the dynamic UIDs of the syncoid services currently active
467 set nixos_syncoid_uids { typeof meta skuid; }
472 meta.maintainers = with lib.maintainers; [