1{ config, lib, pkgs, ... }:
2
3with lib;
4with types;
5
6let
7
8 planDescription = ''
9 The znapzend backup plan to use for the source.
10
11 The plan specifies how often to backup and for how long to keep the
12 backups. It consists of a series of retention periods to interval
13 associations:
14
15 ```
16 retA=>intA,retB=>intB,...
17 ```
18
19 Both intervals and retention periods are expressed in standard units
20 of time or multiples of them. You can use both the full name or a
21 shortcut according to the following listing:
22
23 ```
24 second|sec|s, minute|min, hour|h, day|d, week|w, month|mon|m, year|y
25 ```
26
27 See {manpage}`znapzendzetup(1)` for more info.
28 '';
29 planExample = "1h=>10min,1d=>1h,1w=>1d,1m=>1w,1y=>1m";
30
31 # A type for a string of the form number{b|k|M|G}
32 mbufferSizeType = str // {
33 check = x: str.check x && builtins.isList (builtins.match "^[0-9]+[bkMG]$" x);
34 description = "string of the form number{b|k|M|G}";
35 };
36
37 enabledFeatures = concatLists (mapAttrsToList (name: enabled: optional enabled name) cfg.features);
38
39 # Type for a string that must contain certain other strings (the list parameter).
40 # Note that these would need regex escaping.
41 stringContainingStrings = list: let
42 matching = s: map (str: builtins.match ".*${str}.*" s) list;
43 in str // {
44 check = x: str.check x && all isList (matching x);
45 description = "string containing all of the characters ${concatStringsSep ", " list}";
46 };
47
48 timestampType = stringContainingStrings [ "%Y" "%m" "%d" "%H" "%M" "%S" ];
49
50 destType = srcConfig: submodule ({ name, ... }: {
51 options = {
52
53 label = mkOption {
54 type = str;
55 description = lib.mdDoc "Label for this destination. Defaults to the attribute name.";
56 };
57
58 plan = mkOption {
59 type = str;
60 description = lib.mdDoc planDescription;
61 example = planExample;
62 };
63
64 dataset = mkOption {
65 type = str;
66 description = lib.mdDoc "Dataset name to send snapshots to.";
67 example = "tank/main";
68 };
69
70 host = mkOption {
71 type = nullOr str;
72 description = lib.mdDoc ''
73 Host to use for the destination dataset. Can be prefixed with
74 `user@` to specify the ssh user.
75 '';
76 default = null;
77 example = "john@example.com";
78 };
79
80 presend = mkOption {
81 type = nullOr str;
82 description = lib.mdDoc ''
83 Command to run before sending the snapshot to the destination.
84 Intended to run a remote script via {command}`ssh` on the
85 destination, e.g. to bring up a backup disk or server or to put a
86 zpool online/offline. See also {option}`postsend`.
87 '';
88 default = null;
89 example = "ssh root@bserv zpool import -Nf tank";
90 };
91
92 postsend = mkOption {
93 type = nullOr str;
94 description = lib.mdDoc ''
95 Command to run after sending the snapshot to the destination.
96 Intended to run a remote script via {command}`ssh` on the
97 destination, e.g. to bring up a backup disk or server or to put a
98 zpool online/offline. See also {option}`presend`.
99 '';
100 default = null;
101 example = "ssh root@bserv zpool export tank";
102 };
103 };
104
105 config = {
106 label = mkDefault name;
107 plan = mkDefault srcConfig.plan;
108 };
109 });
110
111
112
113 srcType = submodule ({ name, config, ... }: {
114 options = {
115
116 enable = mkOption {
117 type = bool;
118 description = lib.mdDoc "Whether to enable this source.";
119 default = true;
120 };
121
122 recursive = mkOption {
123 type = bool;
124 description = lib.mdDoc "Whether to do recursive snapshots.";
125 default = false;
126 };
127
128 mbuffer = {
129 enable = mkOption {
130 type = bool;
131 description = lib.mdDoc "Whether to use {command}`mbuffer`.";
132 default = false;
133 };
134
135 port = mkOption {
136 type = nullOr ints.u16;
137 description = lib.mdDoc ''
138 Port to use for {command}`mbuffer`.
139
140 If this is null, it will run {command}`mbuffer` through
141 ssh.
142
143 If this is not null, it will run {command}`mbuffer`
144 directly through TCP, which is not encrypted but faster. In that
145 case the given port needs to be open on the destination host.
146 '';
147 default = null;
148 };
149
150 size = mkOption {
151 type = mbufferSizeType;
152 description = lib.mdDoc ''
153 The size for {command}`mbuffer`.
154 Supports the units b, k, M, G.
155 '';
156 default = "1G";
157 example = "128M";
158 };
159 };
160
161 presnap = mkOption {
162 type = nullOr str;
163 description = lib.mdDoc ''
164 Command to run before snapshots are taken on the source dataset,
165 e.g. for database locking/flushing. See also
166 {option}`postsnap`.
167 '';
168 default = null;
169 example = literalExpression ''
170 '''''${pkgs.mariadb}/bin/mysql -e "set autocommit=0;flush tables with read lock;\\! ''${pkgs.coreutils}/bin/sleep 600" & ''${pkgs.coreutils}/bin/echo $! > /tmp/mariadblock.pid ; sleep 10'''
171 '';
172 };
173
174 postsnap = mkOption {
175 type = nullOr str;
176 description = lib.mdDoc ''
177 Command to run after snapshots are taken on the source dataset,
178 e.g. for database unlocking. See also {option}`presnap`.
179 '';
180 default = null;
181 example = literalExpression ''
182 "''${pkgs.coreutils}/bin/kill `''${pkgs.coreutils}/bin/cat /tmp/mariadblock.pid`;''${pkgs.coreutils}/bin/rm /tmp/mariadblock.pid"
183 '';
184 };
185
186 timestampFormat = mkOption {
187 type = timestampType;
188 description = lib.mdDoc ''
189 The timestamp format to use for constructing snapshot names.
190 The syntax is `strftime`-like. The string must
191 consist of the mandatory `%Y %m %d %H %M %S`.
192 Optionally `- _ . :` characters as well as any
193 alphanumeric character are allowed. If suffixed by a
194 `Z`, times will be in UTC.
195 '';
196 default = "%Y-%m-%d-%H%M%S";
197 example = "znapzend-%m.%d.%Y-%H%M%SZ";
198 };
199
200 sendDelay = mkOption {
201 type = int;
202 description = lib.mdDoc ''
203 Specify delay (in seconds) before sending snaps to the destination.
204 May be useful if you want to control sending time.
205 '';
206 default = 0;
207 example = 60;
208 };
209
210 plan = mkOption {
211 type = str;
212 description = lib.mdDoc planDescription;
213 example = planExample;
214 };
215
216 dataset = mkOption {
217 type = str;
218 description = lib.mdDoc "The dataset to use for this source.";
219 example = "tank/home";
220 };
221
222 destinations = mkOption {
223 type = attrsOf (destType config);
224 description = lib.mdDoc "Additional destinations.";
225 default = {};
226 example = literalExpression ''
227 {
228 local = {
229 dataset = "btank/backup";
230 presend = "zpool import -N btank";
231 postsend = "zpool export btank";
232 };
233 remote = {
234 host = "john@example.com";
235 dataset = "tank/john";
236 };
237 };
238 '';
239 };
240 };
241
242 config = {
243 dataset = mkDefault name;
244 };
245
246 });
247
248 ### Generating the configuration from here
249
250 cfg = config.services.znapzend;
251
252 onOff = b: if b then "on" else "off";
253 nullOff = b: if b == null then "off" else toString b;
254 stripSlashes = replaceStrings [ "/" ] [ "." ];
255
256 attrsToFile = config: concatStringsSep "\n" (builtins.attrValues (
257 mapAttrs (n: v: "${n}=${v}") config));
258
259 mkDestAttrs = dst: with dst;
260 mapAttrs' (n: v: nameValuePair "dst_${label}${n}" v) ({
261 "" = optionalString (host != null) "${host}:" + dataset;
262 _plan = plan;
263 } // optionalAttrs (presend != null) {
264 _precmd = presend;
265 } // optionalAttrs (postsend != null) {
266 _pstcmd = postsend;
267 });
268
269 mkSrcAttrs = srcCfg: with srcCfg; {
270 enabled = onOff enable;
271 # mbuffer is not referenced by its full path to accommodate non-NixOS systems or differing mbuffer versions between source and target
272 mbuffer = with mbuffer; if enable then "mbuffer"
273 + optionalString (port != null) ":${toString port}" else "off";
274 mbuffer_size = mbuffer.size;
275 post_znap_cmd = nullOff postsnap;
276 pre_znap_cmd = nullOff presnap;
277 recursive = onOff recursive;
278 src = dataset;
279 src_plan = plan;
280 tsformat = timestampFormat;
281 zend_delay = toString sendDelay;
282 } // foldr (a: b: a // b) {} (
283 map mkDestAttrs (builtins.attrValues destinations)
284 );
285
286 files = mapAttrs' (n: srcCfg: let
287 fileText = attrsToFile (mkSrcAttrs srcCfg);
288 in {
289 name = srcCfg.dataset;
290 value = pkgs.writeText (stripSlashes srcCfg.dataset) fileText;
291 }) cfg.zetup;
292
293in
294{
295 options = {
296 services.znapzend = {
297 enable = mkEnableOption (lib.mdDoc "ZnapZend ZFS backup daemon");
298
299 logLevel = mkOption {
300 default = "debug";
301 example = "warning";
302 type = enum ["debug" "info" "warning" "err" "alert"];
303 description = lib.mdDoc ''
304 The log level when logging to file. Any of debug, info, warning, err,
305 alert. Default in daemonized form is debug.
306 '';
307 };
308
309 logTo = mkOption {
310 type = str;
311 default = "syslog::daemon";
312 example = "/var/log/znapzend.log";
313 description = lib.mdDoc ''
314 Where to log to (syslog::\<facility\> or \<filepath\>).
315 '';
316 };
317
318 noDestroy = mkOption {
319 type = bool;
320 default = false;
321 description = lib.mdDoc "Does all changes to the filesystem except destroy.";
322 };
323
324 autoCreation = mkOption {
325 type = bool;
326 default = false;
327 description = lib.mdDoc "Automatically create the destination dataset if it does not exist.";
328 };
329
330 zetup = mkOption {
331 type = attrsOf srcType;
332 description = lib.mdDoc "Znapzend configuration.";
333 default = {};
334 example = literalExpression ''
335 {
336 "tank/home" = {
337 # Make snapshots of tank/home every hour, keep those for 1 day,
338 # keep every days snapshot for 1 month, etc.
339 plan = "1d=>1h,1m=>1d,1y=>1m";
340 recursive = true;
341 # Send all those snapshots to john@example.com:rtank/john as well
342 destinations.remote = {
343 host = "john@example.com";
344 dataset = "rtank/john";
345 };
346 };
347 };
348 '';
349 };
350
351 pure = mkOption {
352 type = bool;
353 description = lib.mdDoc ''
354 Do not persist any stateful znapzend setups. If this option is
355 enabled, your previously set znapzend setups will be cleared and only
356 the ones defined with this module will be applied.
357 '';
358 default = false;
359 };
360
361 features.oracleMode = mkEnableOption (lib.mdDoc ''
362 Destroy snapshots one by one instead of using one long argument list.
363 If source and destination are out of sync for a long time, you may have
364 so many snapshots to destroy that the argument gets is too long and the
365 command fails.
366 '');
367 features.recvu = mkEnableOption (lib.mdDoc ''
368 recvu feature which uses `-u` on the receiving end to keep the destination
369 filesystem unmounted.
370 '');
371 features.compressed = mkEnableOption (lib.mdDoc ''
372 compressed feature which adds the options `-Lce` to
373 the {command}`zfs send` command. When this is enabled, make
374 sure that both the sending and receiving pool have the same relevant
375 features enabled. Using `-c` will skip unnecessary
376 decompress-compress stages, `-L` is for large block
377 support and -e is for embedded data support. see
378 {manpage}`znapzend(1)`
379 and {manpage}`zfs(8)`
380 for more info.
381 '');
382 features.sendRaw = mkEnableOption (lib.mdDoc ''
383 sendRaw feature which adds the options `-w` to the
384 {command}`zfs send` command. For encrypted source datasets this
385 instructs zfs not to decrypt before sending which results in a remote
386 backup that can't be read without the encryption key/passphrase, useful
387 when the remote isn't fully trusted or not physically secure. This
388 option must be used consistently, raw incrementals cannot be based on
389 non-raw snapshots and vice versa.
390 '');
391 features.skipIntermediates = mkEnableOption (lib.mdDoc ''
392 Enable the skipIntermediates feature to send a single increment
393 between latest common snapshot and the newly made one. It may skip
394 several source snaps if the destination was offline for some time, and
395 it should skip snapshots not managed by znapzend. Normally for online
396 destinations, the new snapshot is sent as soon as it is created on the
397 source, so there are no automatic increments to skip.
398 '');
399 features.lowmemRecurse = mkEnableOption (lib.mdDoc ''
400 use lowmemRecurse on systems where you have too many datasets, so a
401 recursive listing of attributes to find backup plans exhausts the
402 memory available to {command}`znapzend`: instead, go the slower
403 way to first list all impacted dataset names, and then query their
404 configs one by one.
405 '');
406 features.zfsGetType = mkEnableOption (lib.mdDoc ''
407 use zfsGetType if your {command}`zfs get` supports a
408 `-t` argument for filtering by dataset type at all AND
409 lists properties for snapshots by default when recursing, so that there
410 is too much data to process while searching for backup plans.
411 If these two conditions apply to your system, the time needed for a
412 `--recursive` search for backup plans can literally
413 differ by hundreds of times (depending on the amount of snapshots in
414 that dataset tree... and a decent backup plan will ensure you have a lot
415 of those), so you would benefit from requesting this feature.
416 '');
417 };
418 };
419
420 config = mkIf cfg.enable {
421 environment.systemPackages = [ pkgs.znapzend ];
422
423 systemd.services = {
424 znapzend = {
425 description = "ZnapZend - ZFS Backup System";
426 wantedBy = [ "zfs.target" ];
427 after = [ "zfs.target" ];
428
429 path = with pkgs; [ zfs mbuffer openssh ];
430
431 preStart = optionalString cfg.pure ''
432 echo Resetting znapzend zetups
433 ${pkgs.znapzend}/bin/znapzendzetup list \
434 | grep -oP '(?<=\*\*\* backup plan: ).*(?= \*\*\*)' \
435 | xargs -I{} ${pkgs.znapzend}/bin/znapzendzetup delete "{}"
436 '' + concatStringsSep "\n" (mapAttrsToList (dataset: config: ''
437 echo Importing znapzend zetup ${config} for dataset ${dataset}
438 ${pkgs.znapzend}/bin/znapzendzetup import --write ${dataset} ${config} &
439 '') files) + ''
440 wait
441 '';
442
443 serviceConfig = {
444 # znapzendzetup --import apparently tries to connect to the backup
445 # host 3 times with a timeout of 30 seconds, leading to a startup
446 # delay of >90s when the host is down, which is just above the default
447 # service timeout of 90 seconds. Increase the timeout so it doesn't
448 # make the service fail in that case.
449 TimeoutStartSec = 180;
450 # Needs to have write access to ZFS
451 User = "root";
452 ExecStart = let
453 args = concatStringsSep " " [
454 "--logto=${cfg.logTo}"
455 "--loglevel=${cfg.logLevel}"
456 (optionalString cfg.noDestroy "--nodestroy")
457 (optionalString cfg.autoCreation "--autoCreation")
458 (optionalString (enabledFeatures != [])
459 "--features=${concatStringsSep "," enabledFeatures}")
460 ]; in "${pkgs.znapzend}/bin/znapzend ${args}";
461 ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
462 Restart = "on-failure";
463 };
464 };
465 };
466 };
467
468 meta.maintainers = with maintainers; [ infinisil SlothOfAnarchy ];
469}