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