1 { config, lib, pkgs, ... }:
6 cfg = config.services.syncoid;
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
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);
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
28 # Run a ZFS list on the dataset to check if it exists
29 if ${lib.escapeShellArgs [
30 "/run/booted-system/sw/bin/zfs"
34 ${lib.escapeShellArgs [
35 "/run/booted-system/sw/bin/zfs"
38 (concatStringsSep "," permissions)
42 ${lib.escapeShellArgs [
43 "/run/booted-system/sw/bin/zfs"
46 (concatStringsSep "," permissions)
47 # Remove the last part of the path
48 (builtins.dirOf dataset)
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"
67 (concatStringsSep "," permissions)
70 ${lib.escapeShellArgs [
71 "/run/booted-system/sw/bin/zfs"
74 (concatStringsSep "," permissions)
75 # Remove the last part of the path
76 (builtins.dirOf dataset)
85 options.services.syncoid = {
86 enable = mkEnableOption "Syncoid ZFS synchronization service";
91 example = "*-*-* *:15:00";
93 Run syncoid at this interval. The default is to run hourly.
95 The format is described in
96 <citerefentry><refentrytitle>systemd.time</refentrytitle>
97 <manvolnum>7</manvolnum></citerefentry>.
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.
119 description = "The group for the service.";
123 type = types.nullOr types.path;
124 # Prevent key from being copied to store
125 apply = mapNullable toString;
128 SSH private key file to use to login to the remote system. Can be
129 overridden in individual commands.
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" ];
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.
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" ];
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.
160 commonArgs = mkOption {
161 type = types.listOf types.str;
163 example = [ "--no-sync-snap" ];
165 Arguments to add to every syncoid command, unless disabled for that
167 <link xlink:href="https://github.com/jimsalterjrs/sanoid/#syncoid-command-line-options"/>
168 for available options.
176 Systemd configuration common to all syncoid services.
180 commands = mkOption {
181 type = types.attrsOf (types.submodule ({ name, ... }: {
185 example = "pool/dataset";
187 Source ZFS dataset. Can be either local or remote. Defaults to
194 example = "user@server:pool/dataset";
196 Target ZFS dataset. Can be either local
197 (<replaceable>pool/dataset</replaceable>) or remote
198 (<replaceable>user@server:pool/dataset</replaceable>).
202 recursive = mkEnableOption ''the transfer of child datasets'';
205 type = types.nullOr types.path;
206 # Prevent key from being copied to store
207 apply = mapNullable toString;
209 SSH private key file to use to login to the remote system.
210 Defaults to <option>services.syncoid.sshKey</option> option.
214 localSourceAllow = mkOption {
215 type = types.listOf types.str;
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.
225 localTargetAllow = mkOption {
226 type = types.listOf types.str;
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.
238 sendOptions = mkOption {
239 type = types.separatedString " ";
243 Advanced options to pass to zfs send. Options are specified
244 without their leading dashes and separated by spaces.
248 recvOptions = mkOption {
249 type = types.separatedString " ";
251 example = "ux recordsize o compression=lz4";
253 Advanced options to pass to zfs recv. Options are specified
254 without their leading dashes and separated by spaces.
258 useCommonArgs = mkOption {
262 Whether to add the configured common arguments to this command.
270 Systemd configuration specific to this syncoid service.
274 extraArgs = mkOption {
275 type = types.listOf types.str;
277 example = [ "--sshport 2222" ];
278 description = "Extra syncoid arguments for this command.";
282 source = mkDefault name;
283 sshKey = mkDefault cfg.sshKey;
284 localSourceAllow = mkDefault cfg.localSourceAllow;
285 localTargetAllow = mkDefault cfg.localTargetAllow;
289 example = literalExample ''
291 "pool/test".target = "root@target:pool/test";
294 description = "Syncoid commands to run.";
300 config = mkIf cfg.enable {
302 users = mkIf (cfg.user == "syncoid") {
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";
312 groups = mkIf (cfg.group == "syncoid") {
317 systemd.services = mapAttrs'
319 nameValuePair "syncoid-${escapeUnitName name}" (mkMerge [
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/" ];
328 (map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source)) ++
329 (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target));
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 ]
343 "--no-privilege-elevation"
349 StateDirectory = [ "syncoid" ];
350 StateDirectoryMode = "700";
351 # Prevent SSH control sockets of different syncoid services from interfering
353 # Permissive access to /proc because syncoid
354 # calls ps(1) to detect ongoing `zfs receive`.
356 ProtectProc = "default";
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;
371 ProtectControlGroups = true;
373 ProtectHostname = true;
374 ProtectKernelLogs = true;
375 ProtectKernelModules = true;
376 ProtectKernelTunables = true;
377 ProtectSystem = "strict";
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}" ];
390 # Create RootDirectory= in the host's mount namespace.
391 RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
392 RuntimeDirectoryMode = "700";
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 ' '
408 SystemCallArchitectures = "native";
409 # This is for BindPaths= and BindReadOnlyPaths=
410 # to allow traversal of directories they create in RootDirectory=.
420 meta.maintainers = with maintainers; [ julm lopsided98 ];