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