]> Git — Sourcephile - julm/julm-nix.git/blob - nixpkgs/patches/syncoid.diff
nix: update inputs
[julm/julm-nix.git] / nixpkgs / patches / syncoid.diff
1 diff --git a/nixos/modules/services/backup/sanoid.nix b/nixos/modules/services/backup/sanoid.nix
2 index aae77cee07d0..feb258da943b 100644
3 --- a/nixos/modules/services/backup/sanoid.nix
4 +++ b/nixos/modules/services/backup/sanoid.nix
5 @@ -199,6 +199,13 @@ in
6 after = [ "zfs.target" ];
7 startAt = cfg.interval;
8 };
9 +
10 + # Put those packages in the global environment
11 + # so that syncoid can find them when targeting this host through ssh.
12 + environment.systemPackages = with pkgs; [
13 + lzop
14 + mbuffer
15 + ];
16 };
17
18 meta.maintainers = with maintainers; [ lopsided98 ];
19 diff --git a/nixos/modules/services/backup/syncoid.nix b/nixos/modules/services/backup/syncoid.nix
20 index 0f375455e7ed..387df20939ae 100644
21 --- a/nixos/modules/services/backup/syncoid.nix
22 +++ b/nixos/modules/services/backup/syncoid.nix
23 @@ -4,8 +4,9 @@ with lib;
24
25 let
26 cfg = config.services.syncoid;
27 + inherit (config.networking) nftables;
28
29 - # Extract local dasaset names (so no datasets containing "@")
30 + # Extract local dataset names (so no datasets containing "@")
31 localDatasetName = d: optionals (d != null) (
32 let m = builtins.match "([^/@]+[^@]*)" d; in
33 optionals (m != null) m
34 @@ -13,72 +14,8 @@ let
35
36 # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
37 escapeUnitName = name:
38 - lib.concatMapStrings (s: if lib.isList s then "-" else s)
39 + concatMapStrings (s: if isList s then "-" else s)
40 (builtins.split "[^a-zA-Z0-9_.\\-]+" name);
41 -
42 - # Function to build "zfs allow" commands for the filesystems we've delegated
43 - # permissions to. It also checks if the target dataset exists before
44 - # delegating permissions, if it doesn't exist we delegate it to the parent
45 - # dataset (if it exists). This should solve the case of provisoning new
46 - # datasets.
47 - buildAllowCommand = permissions: dataset: (
48 - "-+${pkgs.writeShellScript "zfs-allow-${dataset}" ''
49 - # Here we explicitly use the booted system to guarantee the stable API needed by ZFS
50 -
51 - # Run a ZFS list on the dataset to check if it exists
52 - if ${lib.escapeShellArgs [
53 - "/run/booted-system/sw/bin/zfs"
54 - "list"
55 - dataset
56 - ]} 2> /dev/null; then
57 - ${lib.escapeShellArgs [
58 - "/run/booted-system/sw/bin/zfs"
59 - "allow"
60 - cfg.user
61 - (concatStringsSep "," permissions)
62 - dataset
63 - ]}
64 - ${lib.optionalString ((builtins.dirOf dataset) != ".") ''
65 - else
66 - ${lib.escapeShellArgs [
67 - "/run/booted-system/sw/bin/zfs"
68 - "allow"
69 - cfg.user
70 - (concatStringsSep "," permissions)
71 - # Remove the last part of the path
72 - (builtins.dirOf dataset)
73 - ]}
74 - ''}
75 - fi
76 - ''}"
77 - );
78 -
79 - # Function to build "zfs unallow" commands for the filesystems we've
80 - # delegated permissions to. Here we unallow both the target but also
81 - # on the parent dataset because at this stage we have no way of
82 - # knowing if the allow command did execute on the parent dataset or
83 - # not in the pre-hook. We can't run the same if in the post hook
84 - # since the dataset should have been created at this point.
85 - buildUnallowCommand = permissions: dataset: (
86 - "-+${pkgs.writeShellScript "zfs-unallow-${dataset}" ''
87 - # Here we explicitly use the booted system to guarantee the stable API needed by ZFS
88 - ${lib.escapeShellArgs [
89 - "/run/booted-system/sw/bin/zfs"
90 - "unallow"
91 - cfg.user
92 - (concatStringsSep "," permissions)
93 - dataset
94 - ]}
95 - ${lib.optionalString ((builtins.dirOf dataset) != ".") (lib.escapeShellArgs [
96 - "/run/booted-system/sw/bin/zfs"
97 - "unallow"
98 - cfg.user
99 - (concatStringsSep "," permissions)
100 - # Remove the last part of the path
101 - (builtins.dirOf dataset)
102 - ])}
103 - ''}"
104 - );
105 in
106 {
107
108 @@ -89,6 +26,16 @@ in
109
110 package = lib.mkPackageOptionMD pkgs "sanoid" {};
111
112 + nftables.enable = mkEnableOption (lib.mdDoc ''
113 + nftables integration.
114 +
115 + This can be used like so (assuming `output-net`
116 + is being called by the output chain):
117 + ```
118 + networking.nftables.ruleset = "table inet filter { chain output-net { skuid @nixos-syncoid-uids meta l4proto tcp accept } }";
119 + ```
120 + '');
121 +
122 interval = mkOption {
123 type = types.str;
124 default = "hourly";
125 @@ -101,35 +48,17 @@ in
126 '';
127 };
128
129 - user = mkOption {
130 - type = types.str;
131 - default = "syncoid";
132 - example = "backup";
133 - description = lib.mdDoc ''
134 - The user for the service. ZFS privilege delegation will be
135 - automatically configured for any local pools used by syncoid if this
136 - option is set to a user other than root. The user will be given the
137 - "hold" and "send" privileges on any pool that has datasets being sent
138 - and the "create", "mount", "receive", and "rollback" privileges on
139 - any pool that has datasets being received.
140 - '';
141 - };
142 -
143 - group = mkOption {
144 - type = types.str;
145 - default = "syncoid";
146 - example = "backup";
147 - description = lib.mdDoc "The group for the service.";
148 - };
149 -
150 sshKey = mkOption {
151 - type = types.nullOr types.path;
152 - # Prevent key from being copied to store
153 - apply = mapNullable toString;
154 + type = types.nullOr types.str;
155 default = null;
156 description = lib.mdDoc ''
157 SSH private key file to use to login to the remote system. Can be
158 overridden in individual commands.
159 + The key is decrypted using `LoadCredentialEncrypted`
160 + whenever the file begins with a credential name and a colon.
161 + For more SSH tuning, you may use syncoid's `--sshoption`
162 + in {option}`services.syncoid.commonArgs`
163 + and/or in the `extraArgs` of a specific command.
164 '';
165 };
166
167 @@ -138,21 +67,19 @@ in
168 # Permissions snapshot and destroy are in case --no-sync-snap is not used
169 default = [ "bookmark" "hold" "send" "snapshot" "destroy" ];
170 description = lib.mdDoc ''
171 - Permissions granted for the {option}`services.syncoid.user` user
172 - for local source datasets. See
173 - <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
174 + Permissions granted for the syncoid user for local source datasets.
175 + See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
176 for available permissions.
177 '';
178 };
179
180 localTargetAllow = mkOption {
181 type = types.listOf types.str;
182 - default = [ "change-key" "compression" "create" "mount" "mountpoint" "receive" "rollback" ];
183 + default = [ "change-key" "compression" "create" "destroy" "mount" "mountpoint" "receive" "rollback" ];
184 example = [ "create" "mount" "receive" "rollback" ];
185 description = lib.mdDoc ''
186 - Permissions granted for the {option}`services.syncoid.user` user
187 - for local target datasets. See
188 - <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
189 + Permissions granted for the syncoid user for local target datasets.
190 + See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
191 for available permissions.
192 Make sure to include the `change-key` permission if you send raw encrypted datasets,
193 the `compression` permission if you send raw compressed datasets, and so on.
194 @@ -205,9 +132,7 @@ in
195 recursive = mkEnableOption (lib.mdDoc ''the transfer of child datasets'');
196
197 sshKey = mkOption {
198 - type = types.nullOr types.path;
199 - # Prevent key from being copied to store
200 - apply = mapNullable toString;
201 + type = types.nullOr types.str;
202 description = lib.mdDoc ''
203 SSH private key file to use to login to the remote system.
204 Defaults to {option}`services.syncoid.sshKey` option.
205 @@ -258,13 +183,9 @@ in
206 '';
207 };
208
209 - useCommonArgs = mkOption {
210 - type = types.bool;
211 - default = true;
212 - description = lib.mdDoc ''
213 - Whether to add the configured common arguments to this command.
214 - '';
215 - };
216 + useCommonArgs = mkEnableOption (lib.mdDoc ''
217 + configured common arguments to this command
218 + '') // { default = true; };
219
220 service = mkOption {
221 type = types.attrs;
222 @@ -301,42 +222,68 @@ in
223 # Implementation
224
225 config = mkIf cfg.enable {
226 - users = {
227 - users = mkIf (cfg.user == "syncoid") {
228 - syncoid = {
229 - group = cfg.group;
230 - isSystemUser = true;
231 - # For syncoid to be able to create /var/lib/syncoid/.ssh/
232 - # and to use custom ssh_config or known_hosts.
233 - home = "/var/lib/syncoid";
234 - createHome = false;
235 - };
236 - };
237 - groups = mkIf (cfg.group == "syncoid") {
238 - syncoid = { };
239 - };
240 - };
241 + assertions = [
242 + { assertion = cfg.nftables.enable -> config.networking.nftables.enable;
243 + message = "config.networking.nftables.enable must be set when config.services.syncoid.nftables.enable is set";
244 + }
245 + ];
246
247 systemd.services = mapAttrs'
248 - (name: c:
249 + (name: c: let
250 + sshKeyCred = builtins.split ":" c.sshKey;
251 + in
252 nameValuePair "syncoid-${escapeUnitName name}" (mkMerge [
253 {
254 description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}";
255 after = [ "zfs.target" ];
256 startAt = cfg.interval;
257 - # syncoid may need zpool to get feature@extensible_dataset
258 - path = [ "/run/booted-system/sw/bin/" ];
259 + # Here we explicitly use the booted system to guarantee the stable API needed by ZFS.
260 + # Moreover syncoid may need zpool to get feature@extensible_dataset.
261 + path = [ "/run/booted-system/sw" ];
262 + # Prevents missing snapshots during DST changes
263 + environment.TZ = "UTC";
264 + # A custom LD_LIBRARY_PATH is needed to access in `getent passwd`
265 + # the systemd's entry about the DynamicUser=,
266 + # so that ssh won't fail with: "No user exists for uid $UID".
267 + environment.LD_LIBRARY_PATH = config.system.nssModules.path;
268 serviceConfig = {
269 ExecStartPre =
270 - (map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source)) ++
271 - (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target));
272 - ExecStopPost =
273 - (map (buildUnallowCommand c.localSourceAllow) (localDatasetName c.source)) ++
274 - (map (buildUnallowCommand c.localTargetAllow) (localDatasetName c.target));
275 + map (dataset:
276 + "+/run/booted-system/sw/bin/zfs allow $USER " +
277 + escapeShellArgs [ (concatStringsSep "," c.localSourceAllow) dataset ]
278 + ) (localDatasetName c.source) ++
279 + # For a local target, check if the dataset exists before delegating permissions,
280 + # and if it doesn't exist, delegate it to the parent dataset.
281 + # This should solve the case of provisioning new datasets.
282 + map (dataset:
283 + "+" + pkgs.writeShellScript "zfs-allow-target-${dataset}" ''
284 + # Run a ZFS list on the dataset to check if it exists
285 + if zfs list ${escapeShellArg dataset} >/dev/null 2>/dev/null; then
286 + zfs allow "$USER" ${escapeShellArgs [ (concatStringsSep "," c.localTargetAllow) dataset ]}
287 + else
288 + zfs allow "$USER" ${escapeShellArgs [ (concatStringsSep "," c.localTargetAllow) (builtins.dirOf dataset) ]}
289 + fi
290 + '') (localDatasetName c.target) ++
291 + optional cfg.nftables.enable
292 + "+${pkgs.nftables}/bin/nft add element inet filter nixos-syncoid-uids { $USER }";
293 + ExecStopPost = let
294 + zfsUnallow = dataset: "+/run/booted-system/sw/bin/zfs unallow $USER " + escapeShellArg dataset;
295 + in
296 + map zfsUnallow (localDatasetName c.source) ++
297 + # For a local target, unallow both the dataset and its parent,
298 + # because at this stage we have no way of knowing if the allow command
299 + # did execute on the parent dataset or not in the ExecStartPre=.
300 + # We can't run the same if-then-else in the post hook
301 + # since the dataset should have been created at this point.
302 + concatMap
303 + (dataset: [ (zfsUnallow dataset) (zfsUnallow (builtins.dirOf dataset)) ])
304 + (localDatasetName c.target) ++
305 + optional cfg.nftables.enable
306 + "+${pkgs.nftables}/bin/nft delete element inet filter nixos-syncoid-uids { $USER }";
307 ExecStart = lib.escapeShellArgs ([ "${cfg.package}/bin/syncoid" ]
308 ++ optionals c.useCommonArgs cfg.commonArgs
309 - ++ optional c.recursive "-r"
310 - ++ optionals (c.sshKey != null) [ "--sshkey" c.sshKey ]
311 + ++ optional c.recursive "--recursive"
312 + ++ optionals (c.sshKey != null) [ "--sshkey" "\${CREDENTIALS_DIRECTORY}/${if length sshKeyCred > 1 then head sshKeyCred else "sshKey"}" ]
313 ++ c.extraArgs
314 ++ [
315 "--sendoptions"
316 @@ -347,10 +294,7 @@ in
317 c.source
318 c.target
319 ]);
320 - User = cfg.user;
321 - Group = cfg.group;
322 - StateDirectory = [ "syncoid" ];
323 - StateDirectoryMode = "700";
324 + DynamicUser = true;
325 # Prevent SSH control sockets of different syncoid services from interfering
326 PrivateTmp = true;
327 # Permissive access to /proc because syncoid
328 @@ -406,18 +350,28 @@ in
329 "~@privileged"
330 "~@resources"
331 "~@setuid"
332 - "~@timer"
333 ];
334 SystemCallArchitectures = "native";
335 # This is for BindPaths= and BindReadOnlyPaths=
336 # to allow traversal of directories they create in RootDirectory=.
337 UMask = "0066";
338 - };
339 + } //
340 + (
341 + if length sshKeyCred > 1
342 + then { LoadCredentialEncrypted = [ c.sshKey ]; }
343 + else { LoadCredential = [ "sshKey:${c.sshKey}" ]; });
344 }
345 cfg.service
346 c.service
347 ]))
348 cfg.commands;
349 +
350 + networking.nftables.ruleset = optionalString cfg.nftables.enable (mkBefore ''
351 + table inet filter {
352 + # A set containing the dynamic UIDs of the syncoid services currently active
353 + set nixos-syncoid-uids { type uid; }
354 + }
355 + '');
356 };
357
358 meta.maintainers = with maintainers; [ julm lopsided98 ];
359 diff --git a/nixos/tests/sanoid.nix b/nixos/tests/sanoid.nix
360 index 411ebcead9f6..ccd16dae2e95 100644
361 --- a/nixos/tests/sanoid.nix
362 +++ b/nixos/tests/sanoid.nix
363 @@ -47,6 +47,17 @@ in {
364 target = "root@target:pool/sanoid";
365 extraArgs = [ "--no-sync-snap" "--create-bookmark" ];
366 };
367 + # Sync the same dataset to different targets
368 + "pool/sanoid1" = {
369 + source = "pool/sanoid";
370 + target = "root@target:pool/sanoid1";
371 + extraArgs = [ "--no-sync-snap" "--create-bookmark" ];
372 + };
373 + "pool/sanoid2" = {
374 + source = "pool/sanoid";
375 + target = "root@target:pool/sanoid2";
376 + extraArgs = [ "--no-sync-snap" "--create-bookmark" ];
377 + };
378 # Take snapshot and sync
379 "pool/syncoid".target = "root@target:pool/syncoid";
380
381 @@ -93,7 +104,6 @@ in {
382 "mkdir -m 700 -p /var/lib/syncoid",
383 "cat '${snakeOilPrivateKey}' > /var/lib/syncoid/id_ecdsa",
384 "chmod 600 /var/lib/syncoid/id_ecdsa",
385 - "chown -R syncoid:syncoid /var/lib/syncoid/",
386 )
387
388 assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set before snapshotting"
389 @@ -116,6 +126,9 @@ in {
390 target.succeed("cat /mnt/pool/sanoid/test.txt")
391 source.systemctl("start --wait syncoid-pool-syncoid.service")
392 target.succeed("cat /mnt/pool/syncoid/test.txt")
393 + source.systemctl("start --wait syncoid-pool-sanoid{1,2}.service")
394 + target.succeed("cat /mnt/pool/sanoid1/test.txt")
395 + target.succeed("cat /mnt/pool/sanoid2/test.txt")
396
397 source.systemctl("start --wait syncoid-pool.service")
398 target.succeed("[[ -d /mnt/pool/full-pool/syncoid ]]")