1 { config, lib, pkgs, ... }:
3 cfg = config.services.syncoid;
5 # Extract local dasaset names (so no datasets containing "@")
6 localDatasetName = d: lib.optionals (d != null) (
7 let m = builtins.match "([^/@]+[^@]*)" d; in
8 lib.optionals (m != null) m
11 # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
12 escapeUnitName = name:
13 lib.concatMapStrings (s: if lib.isList s then "-" else s)
14 (builtins.split "[^a-zA-Z0-9_.\\-]+" name);
16 # Function to build "zfs allow" commands for the filesystems we've delegated
17 # permissions to. It also checks if the target dataset exists before
18 # delegating permissions, if it doesn't exist we delegate it to the parent
19 # dataset (if it exists). This should solve the case of provisoning new
21 buildAllowCommand = permissions: dataset: (
22 "-+${pkgs.writeShellScript "zfs-allow-${dataset}" ''
23 # Here we explicitly use the booted system to guarantee the stable API needed by ZFS
25 # Run a ZFS list on the dataset to check if it exists
26 if ${lib.escapeShellArgs [
27 "/run/booted-system/sw/bin/zfs"
31 ${lib.escapeShellArgs [
32 "/run/booted-system/sw/bin/zfs"
35 (lib.concatStringsSep "," permissions)
38 ${lib.optionalString ((builtins.dirOf dataset) != ".") ''
40 ${lib.escapeShellArgs [
41 "/run/booted-system/sw/bin/zfs"
44 (lib.concatStringsSep "," permissions)
45 # Remove the last part of the path
46 (builtins.dirOf dataset)
53 # Function to build "zfs unallow" commands for the filesystems we've
54 # delegated permissions to. Here we unallow both the target but also
55 # on the parent dataset because at this stage we have no way of
56 # knowing if the allow command did execute on the parent dataset or
57 # not in the pre-hook. We can't run the same if in the post hook
58 # since the dataset should have been created at this point.
59 buildUnallowCommand = permissions: dataset: (
60 "-+${pkgs.writeShellScript "zfs-unallow-${dataset}" ''
61 # Here we explicitly use the booted system to guarantee the stable API needed by ZFS
62 ${lib.escapeShellArgs [
63 "/run/booted-system/sw/bin/zfs"
66 (lib.concatStringsSep "," permissions)
69 ${lib.optionalString ((builtins.dirOf dataset) != ".") (lib.escapeShellArgs [
70 "/run/booted-system/sw/bin/zfs"
73 (lib.concatStringsSep "," permissions)
74 # Remove the last part of the path
75 (builtins.dirOf dataset)
84 options.services.syncoid = {
85 enable = lib.mkEnableOption "Syncoid ZFS synchronization service";
87 package = lib.mkPackageOption pkgs "sanoid" {};
89 interval = lib.mkOption {
92 example = "*-*-* *:15:00";
94 Run syncoid at this interval. The default is to run hourly.
96 The format is described in
97 {manpage}`systemd.time(7)`.
101 user = lib.mkOption {
102 type = lib.types.str;
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.
115 group = lib.mkOption {
116 type = lib.types.str;
119 description = "The group for the service.";
122 sshKey = lib.mkOption {
123 type = with lib.types; nullOr (coercedTo path toString str);
126 SSH private key file to use to login to the remote system. Can be
127 overridden in individual commands.
131 localSourceAllow = lib.mkOption {
132 type = lib.types.listOf lib.types.str;
133 # Permissions snapshot and destroy are in case --no-sync-snap is not used
134 default = [ "bookmark" "hold" "send" "snapshot" "destroy" "mount" ];
136 Permissions granted for the {option}`services.syncoid.user` user
137 for local source datasets. See
138 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
139 for available permissions.
143 localTargetAllow = lib.mkOption {
144 type = lib.types.listOf lib.types.str;
145 default = [ "change-key" "compression" "create" "mount" "mountpoint" "receive" "rollback" ];
146 example = [ "create" "mount" "receive" "rollback" ];
148 Permissions granted for the {option}`services.syncoid.user` user
149 for local target datasets. See
150 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
151 for available permissions.
152 Make sure to include the `change-key` permission if you send raw encrypted datasets,
153 the `compression` permission if you send raw compressed datasets, and so on.
154 For remote target datasets you'll have to set your remote user permissions by yourself.
158 commonArgs = lib.mkOption {
159 type = lib.types.listOf lib.types.str;
161 example = [ "--no-sync-snap" ];
163 Arguments to add to every syncoid command, unless disabled for that
165 <https://github.com/jimsalterjrs/sanoid/#syncoid-command-line-options>
166 for available options.
170 service = lib.mkOption {
171 type = lib.types.attrs;
174 Systemd configuration common to all syncoid services.
178 commands = lib.mkOption {
179 type = lib.types.attrsOf (lib.types.submodule ({ name, ... }: {
181 source = lib.mkOption {
182 type = lib.types.str;
183 example = "pool/dataset";
185 Source ZFS dataset. Can be either local or remote. Defaults to
190 target = lib.mkOption {
191 type = lib.types.str;
192 example = "user@server:pool/dataset";
194 Target ZFS dataset. Can be either local
195 («pool/dataset») or remote
196 («user@server:pool/dataset»).
200 recursive = lib.mkEnableOption ''the transfer of child datasets'';
202 sshKey = lib.mkOption {
203 type = with lib.types; nullOr (coercedTo path toString str);
205 SSH private key file to use to login to the remote system.
206 Defaults to {option}`services.syncoid.sshKey` option.
210 localSourceAllow = lib.mkOption {
211 type = lib.types.listOf lib.types.str;
213 Permissions granted for the {option}`services.syncoid.user` user
214 for local source datasets. See
215 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
216 for available permissions.
217 Defaults to {option}`services.syncoid.localSourceAllow` option.
221 localTargetAllow = lib.mkOption {
222 type = lib.types.listOf lib.types.str;
224 Permissions granted for the {option}`services.syncoid.user` user
225 for local target datasets. See
226 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
227 for available permissions.
228 Make sure to include the `change-key` permission if you send raw encrypted datasets,
229 the `compression` permission if you send raw compressed datasets, and so on.
230 For remote target datasets you'll have to set your remote user permissions by yourself.
234 sendOptions = lib.mkOption {
235 type = lib.types.separatedString " ";
239 Advanced options to pass to zfs send. Options are specified
240 without their leading dashes and separated by spaces.
244 recvOptions = lib.mkOption {
245 type = lib.types.separatedString " ";
247 example = "ux recordsize o compression=lz4";
249 Advanced options to pass to zfs recv. Options are specified
250 without their leading dashes and separated by spaces.
254 useCommonArgs = lib.mkOption {
255 type = lib.types.bool;
258 Whether to add the configured common arguments to this command.
262 service = lib.mkOption {
263 type = lib.types.attrs;
266 Systemd configuration specific to this syncoid service.
270 extraArgs = lib.mkOption {
271 type = lib.types.listOf lib.types.str;
273 example = [ "--sshport 2222" ];
274 description = "Extra syncoid arguments for this command.";
278 source = lib.mkDefault name;
279 sshKey = lib.mkDefault cfg.sshKey;
280 localSourceAllow = lib.mkDefault cfg.localSourceAllow;
281 localTargetAllow = lib.mkDefault cfg.localTargetAllow;
285 example = lib.literalExpression ''
287 "pool/test".target = "root@target:pool/test";
290 description = "Syncoid commands to run.";
296 config = lib.mkIf cfg.enable {
298 users = lib.mkIf (cfg.user == "syncoid") {
302 # For syncoid to be able to create /var/lib/syncoid/.ssh/
303 # and to use custom ssh_config or known_hosts.
304 home = "/var/lib/syncoid";
308 groups = lib.mkIf (cfg.group == "syncoid") {
313 systemd.services = lib.mapAttrs'
315 lib.nameValuePair "syncoid-${escapeUnitName name}" (lib.mkMerge [
317 description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}";
318 after = [ "zfs.target" ];
319 startAt = cfg.interval;
320 # syncoid may need zpool to get feature@extensible_dataset
321 path = [ "/run/booted-system/sw/bin/" ];
324 (map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source)) ++
325 (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target));
327 (map (buildUnallowCommand c.localSourceAllow) (localDatasetName c.source)) ++
328 (map (buildUnallowCommand c.localTargetAllow) (localDatasetName c.target));
329 ExecStart = lib.escapeShellArgs ([ "${cfg.package}/bin/syncoid" ]
330 ++ lib.optionals c.useCommonArgs cfg.commonArgs
331 ++ lib.optional c.recursive "-r"
332 ++ lib.optionals (c.sshKey != null) [ "--sshkey" c.sshKey ]
339 "--no-privilege-elevation"
345 StateDirectory = [ "syncoid" ];
346 StateDirectoryMode = "700";
347 # Prevent SSH control sockets of different syncoid services from interfering
349 # Permissive access to /proc because syncoid
350 # calls ps(1) to detect ongoing `zfs receive`.
352 ProtectProc = "default";
354 # The following options are only for optimizing:
355 # systemd-analyze security | grep syncoid-'*'
356 AmbientCapabilities = "";
357 CapabilityBoundingSet = "";
358 DeviceAllow = [ "/dev/zfs" ];
359 LockPersonality = true;
360 MemoryDenyWriteExecute = true;
361 NoNewPrivileges = true;
362 PrivateDevices = true;
363 PrivateMounts = true;
364 PrivateNetwork = lib.mkDefault false;
365 PrivateUsers = false; # Enabling this breaks on zfs-2.2.0
367 ProtectControlGroups = true;
369 ProtectHostname = true;
370 ProtectKernelLogs = true;
371 ProtectKernelModules = true;
372 ProtectKernelTunables = true;
373 ProtectSystem = "strict";
375 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
376 RestrictNamespaces = true;
377 RestrictRealtime = true;
378 RestrictSUIDSGID = true;
379 RootDirectory = "/run/syncoid/${escapeUnitName name}";
380 RootDirectoryStartOnly = true;
381 BindPaths = [ "/dev/zfs" ];
382 BindReadOnlyPaths = [ builtins.storeDir "/etc" "/run" "/bin/sh" ];
383 # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
384 InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
386 # Create RootDirectory= in the host's mount namespace.
387 RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
388 RuntimeDirectoryMode = "700";
391 # Groups in @system-service which do not contain a syscall listed by:
392 # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid …
393 # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log
394 # 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 ' '
404 SystemCallArchitectures = "native";
405 # This is for BindPaths= and BindReadOnlyPaths=
406 # to allow traversal of directories they create in RootDirectory=.
416 meta.maintainers = with lib.maintainers; [ julm lopsided98 ];