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