]> Git — Sourcephile - julm/julm-nix.git/blob - nixos/modules/services/backup/syncoid.nix
syncoid: import module isntead of patching nixpkgs
[julm/julm-nix.git] / nixos / modules / services / backup / syncoid.nix
1 {
2 config,
3 lib,
4 pkgs,
5 ...
6 }:
7 let
8 cfg = config.services.syncoid;
9
10 # Extract local dataset 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 builtins.isList s then "-" else s) (
24 builtins.split "[^a-zA-Z0-9_.\\-]+" name
25 );
26 in
27 {
28
29 # Interface
30
31 options.services.syncoid = {
32 enable = lib.mkEnableOption "Syncoid ZFS synchronization service";
33
34 package = lib.mkPackageOption pkgs "sanoid" { };
35
36 interval = lib.mkOption {
37 type = with lib.types; either str (listOf str);
38 default = "hourly";
39 example = "*-*-* *:15:00";
40 description = ''
41 Run syncoid at this interval. The default is to run hourly.
42
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.
46
47 Set to an empty list to avoid starting syncoid automatically.
48 '';
49 };
50
51 sshKey = lib.mkOption {
52 type = with lib.types; nullOr (coercedTo path toString str);
53 default = null;
54 description = ''
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.
63 '';
64 };
65
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
69 default = [
70 "bookmark"
71 "hold"
72 "send"
73 "snapshot"
74 "destroy"
75 "mount"
76 ];
77 description = ''
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.
81 '';
82 };
83
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.
89 default = [
90 "change-key"
91 "compression"
92 "create"
93 "destroy"
94 "mount"
95 "mountpoint"
96 "receive"
97 "rollback"
98 ];
99 example = [
100 "create"
101 "mount"
102 "receive"
103 "rollback"
104 ];
105 description = ''
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.
112 '';
113 };
114
115 commonArgs = lib.mkOption {
116 type = with lib.types; listOf str;
117 default = [ ];
118 example = [ "--no-sync-snap" ];
119 description = ''
120 Arguments to add to every syncoid command, unless disabled for that
121 command. See
122 <https://github.com/jimsalterjrs/sanoid/#syncoid-command-line-options>
123 for available options.
124 '';
125 };
126
127 service = lib.mkOption {
128 type = lib.types.attrs;
129 default = { };
130 description = ''
131 Systemd configuration common to all syncoid services.
132 '';
133 };
134
135 commands = lib.mkOption {
136 type = lib.types.attrsOf (
137 lib.types.submodule (
138 { name, ... }:
139 {
140 options = {
141 source = lib.mkOption {
142 type = lib.types.str;
143 example = "pool/dataset";
144 description = ''
145 Source ZFS dataset. Can be either local or remote. Defaults to
146 the attribute name.
147 '';
148 };
149
150 target = lib.mkOption {
151 type = lib.types.str;
152 example = "user@server:pool/dataset";
153 description = ''
154 Target ZFS dataset. Can be either local
155 («pool/dataset») or remote
156 («user@server:pool/dataset»).
157 '';
158 };
159
160 recursive = lib.mkEnableOption ''the transfer of child datasets'';
161
162 sshKey = lib.mkOption {
163 type = with lib.types; nullOr (coercedTo path toString str);
164 description = ''
165 SSH private key file to use to login to the remote system.
166 Defaults to {option}`services.syncoid.sshKey` option.
167 '';
168 };
169
170 localSourceAllow = lib.mkOption {
171 type = lib.types.listOf lib.types.str;
172 description = ''
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.
178 '';
179 };
180
181 localTargetAllow = lib.mkOption {
182 type = lib.types.listOf lib.types.str;
183 description = ''
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.
191 '';
192 };
193
194 sendOptions = lib.mkOption {
195 type = lib.types.separatedString " ";
196 default = "";
197 example = "Lc e";
198 description = ''
199 Advanced options to pass to zfs send. Options are specified
200 without their leading dashes and separated by spaces.
201 '';
202 };
203
204 recvOptions = lib.mkOption {
205 type = lib.types.separatedString " ";
206 default = "";
207 example = "ux recordsize o compression=lz4";
208 description = ''
209 Advanced options to pass to zfs recv. Options are specified
210 without their leading dashes and separated by spaces.
211 '';
212 };
213
214 useCommonArgs =
215 lib.mkEnableOption ''
216 configured common arguments to this command
217 ''
218 // {
219 default = true;
220 };
221
222 service = lib.mkOption {
223 type = lib.types.attrs;
224 default = { };
225 description = ''
226 Systemd configuration specific to this syncoid service.
227 '';
228 };
229
230 extraArgs = lib.mkOption {
231 type = lib.types.listOf lib.types.str;
232 default = [ ];
233 example = [ "--sshport 2222" ];
234 description = "Extra syncoid arguments for this command.";
235 };
236 };
237 config = {
238 source = lib.mkDefault name;
239 sshKey = lib.mkDefault cfg.sshKey;
240 localSourceAllow = lib.mkDefault cfg.localSourceAllow;
241 localTargetAllow = lib.mkDefault cfg.localTargetAllow;
242 };
243 }
244 )
245 );
246 default = { };
247 example = lib.literalExpression ''
248 {
249 "pool/test".target = "root@target:pool/test";
250 }
251 '';
252 description = "Syncoid commands to run.";
253 };
254 };
255
256 # Implementation
257
258 config = lib.mkIf cfg.enable {
259 systemd.services = lib.mapAttrs' (
260 name: c:
261 let
262 sshKeyCred = builtins.split ":" c.sshKey;
263 in
264 lib.nameValuePair "syncoid-${escapeUnitName name}" (
265 lib.mkMerge [
266 {
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;
279 serviceConfig = {
280 ExecStartPre =
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).
286 map
287 (
288 dataset:
289 "+"
290 + pkgs.writeShellScript "zfs-unallow-unused-dynamic-users" ''
291 set -eu
292 if zfs status "$1" 2>/dev/null; then
293 zfs allow "$1" |
294 sed -ne 's/^\t\(user\|group\) (unknown: \([0-9]\+\)).*/\1 \2/p' |
295 {
296 declare -a uids
297 while read -r role id; do
298 if [ "$id" -ge 61184 ] && [ "$id" -le 65519 ]; then
299 case "$role" in
300 (user) uids+=("$id");;
301 esac
302 fi
303 done
304 zfs unallow -r -u "$(printf %s, "''${uids[@]}")" "$1"
305 }
306 fi
307 ''
308 + " "
309 + lib.escapeShellArg dataset
310 )
311 (
312 localDatasetName c.source
313 ++ localDatasetName c.target
314 ++ map builtins.dirOf (localDatasetName c.target)
315 )
316 ++
317 # For a local source, allow the localSourceAllow ZFS permissions.
318 map (
319 dataset:
320 "+/run/booted-system/sw/bin/zfs allow $USER "
321 + lib.escapeShellArgs [
322 (lib.concatStringsSep "," c.localSourceAllow)
323 dataset
324 ]
325 ) (localDatasetName c.source)
326 ++
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.
330 map (
331 dataset:
332 "+"
333 + pkgs.writeShellScript "zfs-allow-target" ''
334 dataset="$1"
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"
339 ''
340 + " "
341 + lib.escapeShellArg dataset
342 ) (localDatasetName c.target);
343 ExecStopPost =
344 let
345 zfsUnallow = dataset: "+/run/booted-system/sw/bin/zfs unallow $USER " + lib.escapeShellArg dataset;
346 in
347 map zfsUnallow (localDatasetName c.source)
348 ++
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: [
355 (zfsUnallow 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) [
363 "--sshkey"
364 "\${CREDENTIALS_DIRECTORY}/${if lib.length sshKeyCred > 1 then lib.head sshKeyCred else "sshKey"}"
365 ]
366 ++ c.extraArgs
367 ++ [
368 "--sendoptions"
369 c.sendOptions
370 "--recvoptions"
371 c.recvOptions
372 "--no-privilege-elevation"
373 c.source
374 c.target
375 ]
376 );
377 DynamicUser = true;
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
380 PrivateTmp = true;
381 # Permissive access to /proc because syncoid
382 # calls ps(1) to detect ongoing `zfs receive`.
383 ProcSubset = "all";
384 ProtectProc = "default";
385
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
398 ProtectClock = true;
399 ProtectControlGroups = true;
400 ProtectHome = true;
401 ProtectHostname = true;
402 ProtectKernelLogs = true;
403 ProtectKernelModules = true;
404 ProtectKernelTunables = true;
405 ProtectSystem = "strict";
406 RemoveIPC = true;
407 RestrictAddressFamilies = [
408 "AF_UNIX"
409 "AF_INET"
410 "AF_INET6"
411 ];
412 RestrictNamespaces = true;
413 RestrictRealtime = true;
414 RestrictSUIDSGID = true;
415 RootDirectory = "/run/syncoid/${escapeUnitName name}";
416 BindPaths = [ "/dev/zfs" ];
417 BindReadOnlyPaths = [
418 builtins.storeDir
419 "/etc"
420 "/run"
421 # Some programs hardcode /var/run
422 # eg. /var/run/avahi-daemon/socket to resolve *.local mDNS domains
423 "/var/run"
424 "/bin/sh"
425 ];
426 # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
427 InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
428 MountAPIVFS = true;
429 # Create RootDirectory= in the host's mount namespace.
430 RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
431 RuntimeDirectoryMode = "700";
432 SystemCallFilter = [
433 "@system-service"
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 ' '
438 "~@aio"
439 "~@chown"
440 "~@keyring"
441 "~@memlock"
442 "~@privileged"
443 "~@resources"
444 "~@setuid"
445 ];
446 SystemCallArchitectures = "native";
447 # This is for BindPaths= and BindReadOnlyPaths=
448 # to allow traversal of directories they create in RootDirectory=.
449 UMask = "0066";
450 }
451 // (
452 if lib.length sshKeyCred > 1 then
453 { LoadCredentialEncrypted = [ c.sshKey ]; }
454 else
455 { LoadCredential = [ "sshKey:${c.sshKey}" ]; }
456 );
457 }
458 cfg.service
459 c.service
460 ]
461 )
462 ) cfg.commands;
463
464 networking.nftables.ruleset = lib.mkBefore ''
465 table inet filter {
466 # A set containing the dynamic UIDs of the syncoid services currently active
467 set nixos_syncoid_uids { typeof meta skuid; }
468 }
469 '';
470 };
471
472 meta.maintainers = with lib.maintainers; [
473 julm
474 lopsided98
475 ];
476 }