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