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 (lib.mdDoc "Syncoid ZFS synchronization service");
89
90 package = lib.mkPackageOptionMD pkgs "sanoid" {};
91
92 interval = mkOption {
93 type = types.str;
94 default = "hourly";
95 example = "*-*-* *:15:00";
96 description = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc "The group for the service.";
123 };
124
125 sshKey = mkOption {
126 type = types.nullOr types.path;
127 # Prevent key from being copied to store
128 apply = mapNullable toString;
129 default = null;
130 description = lib.mdDoc ''
131 SSH private key file to use to login to the remote system. Can be
132 overridden in individual commands.
133 '';
134 };
135
136 localSourceAllow = mkOption {
137 type = types.listOf types.str;
138 # Permissions snapshot and destroy are in case --no-sync-snap is not used
139 default = [ "bookmark" "hold" "send" "snapshot" "destroy" ];
140 description = lib.mdDoc ''
141 Permissions granted for the {option}`services.syncoid.user` user
142 for local source datasets. See
143 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
144 for available permissions.
145 '';
146 };
147
148 localTargetAllow = mkOption {
149 type = types.listOf types.str;
150 default = [ "change-key" "compression" "create" "mount" "mountpoint" "receive" "rollback" ];
151 example = [ "create" "mount" "receive" "rollback" ];
152 description = lib.mdDoc ''
153 Permissions granted for the {option}`services.syncoid.user` user
154 for local target datasets. See
155 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
156 for available permissions.
157 Make sure to include the `change-key` permission if you send raw encrypted datasets,
158 the `compression` permission if you send raw compressed datasets, and so on.
159 For remote target datasets you'll have to set your remote user permissions by yourself.
160 '';
161 };
162
163 commonArgs = mkOption {
164 type = types.listOf types.str;
165 default = [ ];
166 example = [ "--no-sync-snap" ];
167 description = lib.mdDoc ''
168 Arguments to add to every syncoid command, unless disabled for that
169 command. See
170 <https://github.com/jimsalterjrs/sanoid/#syncoid-command-line-options>
171 for available options.
172 '';
173 };
174
175 service = mkOption {
176 type = types.attrs;
177 default = { };
178 description = lib.mdDoc ''
179 Systemd configuration common to all syncoid services.
180 '';
181 };
182
183 commands = mkOption {
184 type = types.attrsOf (types.submodule ({ name, ... }: {
185 options = {
186 source = mkOption {
187 type = types.str;
188 example = "pool/dataset";
189 description = lib.mdDoc ''
190 Source ZFS dataset. Can be either local or remote. Defaults to
191 the attribute name.
192 '';
193 };
194
195 target = mkOption {
196 type = types.str;
197 example = "user@server:pool/dataset";
198 description = lib.mdDoc ''
199 Target ZFS dataset. Can be either local
200 («pool/dataset») or remote
201 («user@server:pool/dataset»).
202 '';
203 };
204
205 recursive = mkEnableOption (lib.mdDoc ''the transfer of child datasets'');
206
207 sshKey = mkOption {
208 type = types.nullOr types.path;
209 # Prevent key from being copied to store
210 apply = mapNullable toString;
211 description = lib.mdDoc ''
212 SSH private key file to use to login to the remote system.
213 Defaults to {option}`services.syncoid.sshKey` option.
214 '';
215 };
216
217 localSourceAllow = mkOption {
218 type = types.listOf types.str;
219 description = lib.mdDoc ''
220 Permissions granted for the {option}`services.syncoid.user` user
221 for local source datasets. See
222 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
223 for available permissions.
224 Defaults to {option}`services.syncoid.localSourceAllow` option.
225 '';
226 };
227
228 localTargetAllow = mkOption {
229 type = types.listOf types.str;
230 description = lib.mdDoc ''
231 Permissions granted for the {option}`services.syncoid.user` user
232 for local target datasets. See
233 <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
234 for available permissions.
235 Make sure to include the `change-key` permission if you send raw encrypted datasets,
236 the `compression` permission if you send raw compressed datasets, and so on.
237 For remote target datasets you'll have to set your remote user permissions by yourself.
238 '';
239 };
240
241 sendOptions = mkOption {
242 type = types.separatedString " ";
243 default = "";
244 example = "Lc e";
245 description = lib.mdDoc ''
246 Advanced options to pass to zfs send. Options are specified
247 without their leading dashes and separated by spaces.
248 '';
249 };
250
251 recvOptions = mkOption {
252 type = types.separatedString " ";
253 default = "";
254 example = "ux recordsize o compression=lz4";
255 description = lib.mdDoc ''
256 Advanced options to pass to zfs recv. Options are specified
257 without their leading dashes and separated by spaces.
258 '';
259 };
260
261 useCommonArgs = mkOption {
262 type = types.bool;
263 default = true;
264 description = lib.mdDoc ''
265 Whether to add the configured common arguments to this command.
266 '';
267 };
268
269 service = mkOption {
270 type = types.attrs;
271 default = { };
272 description = lib.mdDoc ''
273 Systemd configuration specific to this syncoid service.
274 '';
275 };
276
277 extraArgs = mkOption {
278 type = types.listOf types.str;
279 default = [ ];
280 example = [ "--sshport 2222" ];
281 description = lib.mdDoc "Extra syncoid arguments for this command.";
282 };
283 };
284 config = {
285 source = mkDefault name;
286 sshKey = mkDefault cfg.sshKey;
287 localSourceAllow = mkDefault cfg.localSourceAllow;
288 localTargetAllow = mkDefault cfg.localTargetAllow;
289 };
290 }));
291 default = { };
292 example = literalExpression ''
293 {
294 "pool/test".target = "root@target:pool/test";
295 }
296 '';
297 description = lib.mdDoc "Syncoid commands to run.";
298 };
299 };
300
301 # Implementation
302
303 config = mkIf cfg.enable {
304 users = {
305 users = mkIf (cfg.user == "syncoid") {
306 syncoid = {
307 group = cfg.group;
308 isSystemUser = true;
309 # For syncoid to be able to create /var/lib/syncoid/.ssh/
310 # and to use custom ssh_config or known_hosts.
311 home = "/var/lib/syncoid";
312 createHome = false;
313 };
314 };
315 groups = mkIf (cfg.group == "syncoid") {
316 syncoid = { };
317 };
318 };
319
320 systemd.services = mapAttrs'
321 (name: c:
322 nameValuePair "syncoid-${escapeUnitName name}" (mkMerge [
323 {
324 description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}";
325 after = [ "zfs.target" ];
326 startAt = cfg.interval;
327 # syncoid may need zpool to get feature@extensible_dataset
328 path = [ "/run/booted-system/sw/bin/" ];
329 serviceConfig = {
330 ExecStartPre =
331 (map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source)) ++
332 (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target));
333 ExecStopPost =
334 (map (buildUnallowCommand c.localSourceAllow) (localDatasetName c.source)) ++
335 (map (buildUnallowCommand c.localTargetAllow) (localDatasetName c.target));
336 ExecStart = lib.escapeShellArgs ([ "${cfg.package}/bin/syncoid" ]
337 ++ optionals c.useCommonArgs cfg.commonArgs
338 ++ optional c.recursive "-r"
339 ++ optionals (c.sshKey != null) [ "--sshkey" c.sshKey ]
340 ++ c.extraArgs
341 ++ [
342 "--sendoptions"
343 c.sendOptions
344 "--recvoptions"
345 c.recvOptions
346 "--no-privilege-elevation"
347 c.source
348 c.target
349 ]);
350 User = cfg.user;
351 Group = cfg.group;
352 StateDirectory = [ "syncoid" ];
353 StateDirectoryMode = "700";
354 # Prevent SSH control sockets of different syncoid services from interfering
355 PrivateTmp = true;
356 # Permissive access to /proc because syncoid
357 # calls ps(1) to detect ongoing `zfs receive`.
358 ProcSubset = "all";
359 ProtectProc = "default";
360
361 # The following options are only for optimizing:
362 # systemd-analyze security | grep syncoid-'*'
363 AmbientCapabilities = "";
364 CapabilityBoundingSet = "";
365 DeviceAllow = [ "/dev/zfs" ];
366 LockPersonality = true;
367 MemoryDenyWriteExecute = true;
368 NoNewPrivileges = true;
369 PrivateDevices = true;
370 PrivateMounts = true;
371 PrivateNetwork = mkDefault false;
372 PrivateUsers = true;
373 ProtectClock = true;
374 ProtectControlGroups = true;
375 ProtectHome = true;
376 ProtectHostname = true;
377 ProtectKernelLogs = true;
378 ProtectKernelModules = true;
379 ProtectKernelTunables = true;
380 ProtectSystem = "strict";
381 RemoveIPC = true;
382 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
383 RestrictNamespaces = true;
384 RestrictRealtime = true;
385 RestrictSUIDSGID = true;
386 RootDirectory = "/run/syncoid/${escapeUnitName name}";
387 RootDirectoryStartOnly = true;
388 BindPaths = [ "/dev/zfs" ];
389 BindReadOnlyPaths = [ builtins.storeDir "/etc" "/run" "/bin/sh" ];
390 # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
391 InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
392 MountAPIVFS = true;
393 # Create RootDirectory= in the host's mount namespace.
394 RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
395 RuntimeDirectoryMode = "700";
396 SystemCallFilter = [
397 "@system-service"
398 # Groups in @system-service which do not contain a syscall listed by:
399 # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid …
400 # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log
401 # 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 ' '
402 "~@aio"
403 "~@chown"
404 "~@keyring"
405 "~@memlock"
406 "~@privileged"
407 "~@resources"
408 "~@setuid"
409 "~@timer"
410 ];
411 SystemCallArchitectures = "native";
412 # This is for BindPaths= and BindReadOnlyPaths=
413 # to allow traversal of directories they create in RootDirectory=.
414 UMask = "0066";
415 };
416 }
417 cfg.service
418 c.service
419 ]))
420 cfg.commands;
421 };
422
423 meta.maintainers = with maintainers; [ julm lopsided98 ];
424}