1{
2 config,
3 lib,
4 options,
5 pkgs,
6 utils,
7 ...
8}:
9let
10 gcfg = config.services.tarsnap;
11 opt = options.services.tarsnap;
12
13 configFile = name: cfg: ''
14 keyfile ${cfg.keyfile}
15 ${lib.optionalString (cfg.cachedir != null) "cachedir ${cfg.cachedir}"}
16 ${lib.optionalString cfg.nodump "nodump"}
17 ${lib.optionalString cfg.printStats "print-stats"}
18 ${lib.optionalString cfg.printStats "humanize-numbers"}
19 ${lib.optionalString (cfg.checkpointBytes != null) ("checkpoint-bytes " + cfg.checkpointBytes)}
20 ${lib.optionalString cfg.aggressiveNetworking "aggressive-networking"}
21 ${lib.concatStringsSep "\n" (map (v: "exclude ${v}") cfg.excludes)}
22 ${lib.concatStringsSep "\n" (map (v: "include ${v}") cfg.includes)}
23 ${lib.optionalString cfg.lowmem "lowmem"}
24 ${lib.optionalString cfg.verylowmem "verylowmem"}
25 ${lib.optionalString (cfg.maxbw != null) "maxbw ${toString cfg.maxbw}"}
26 ${lib.optionalString (cfg.maxbwRateUp != null) "maxbw-rate-up ${toString cfg.maxbwRateUp}"}
27 ${lib.optionalString (cfg.maxbwRateDown != null) "maxbw-rate-down ${toString cfg.maxbwRateDown}"}
28 '';
29in
30{
31 imports = [
32 (lib.mkRemovedOptionModule [
33 "services"
34 "tarsnap"
35 "cachedir"
36 ] "Use services.tarsnap.archives.<name>.cachedir")
37 ];
38
39 options = {
40 services.tarsnap = {
41 enable = lib.mkEnableOption "periodic tarsnap backups";
42
43 package = lib.mkPackageOption pkgs "tarsnap" { };
44
45 keyfile = lib.mkOption {
46 type = lib.types.str;
47 default = "/root/tarsnap.key";
48 description = ''
49 The keyfile which associates this machine with your tarsnap
50 account.
51 Create the keyfile with {command}`tarsnap-keygen`.
52
53 Note that each individual archive (specified below) may also have its
54 own individual keyfile specified. Tarsnap does not allow multiple
55 concurrent backups with the same cache directory and key (starting a
56 new backup will cause another one to fail). If you have multiple
57 archives specified, you should either spread out your backups to be
58 far apart, or specify a separate key for each archive. By default
59 every archive defaults to using
60 `"/root/tarsnap.key"`.
61
62 It's recommended for backups that you generate a key for every archive
63 using {manpage}`tarsnap-keygen(1)`, and then generate a
64 write-only tarsnap key using {manpage}`tarsnap-keymgmt(1)`,
65 and keep your master key(s) for a particular machine off-site.
66
67 The keyfile name should be given as a string and not a path, to
68 avoid the key being copied into the Nix store.
69 '';
70 };
71
72 archives = lib.mkOption {
73 type = lib.types.attrsOf (
74 lib.types.submodule (
75 { config, options, ... }:
76 {
77 options = {
78 keyfile = lib.mkOption {
79 type = lib.types.str;
80 default = gcfg.keyfile;
81 defaultText = lib.literalExpression "config.${opt.keyfile}";
82 description = ''
83 Set a specific keyfile for this archive. This defaults to
84 `"/root/tarsnap.key"` if left unspecified.
85
86 Use this option if you want to run multiple backups
87 concurrently - each archive must have a unique key. You can
88 generate a write-only key derived from your master key (which
89 is recommended) using {manpage}`tarsnap-keymgmt(1)`.
90
91 Note: every archive must have an individual master key. You
92 must generate multiple keys with
93 {manpage}`tarsnap-keygen(1)`, and then generate write
94 only keys from those.
95
96 The keyfile name should be given as a string and not a path, to
97 avoid the key being copied into the Nix store.
98 '';
99 };
100
101 cachedir = lib.mkOption {
102 type = lib.types.nullOr lib.types.path;
103 default = "/var/cache/tarsnap/${utils.escapeSystemdPath config.keyfile}";
104 defaultText = lib.literalExpression ''
105 "/var/cache/tarsnap/''${utils.escapeSystemdPath config.${options.keyfile}}"
106 '';
107 description = ''
108 The cache allows tarsnap to identify previously stored data
109 blocks, reducing archival time and bandwidth usage.
110
111 Should the cache become desynchronized or corrupted, tarsnap
112 will refuse to run until you manually rebuild the cache with
113 {command}`tarsnap --fsck`.
114
115 Set to `null` to disable caching.
116 '';
117 };
118
119 nodump = lib.mkOption {
120 type = lib.types.bool;
121 default = true;
122 description = ''
123 Exclude files with the `nodump` flag.
124 '';
125 };
126
127 printStats = lib.mkOption {
128 type = lib.types.bool;
129 default = true;
130 description = ''
131 Print global archive statistics upon completion.
132 The output is available via
133 {command}`systemctl status tarsnap-archive-name`.
134 '';
135 };
136
137 checkpointBytes = lib.mkOption {
138 type = lib.types.nullOr lib.types.str;
139 default = "1GB";
140 description = ''
141 Create a checkpoint every `checkpointBytes`
142 of uploaded data (optionally specified using an SI prefix).
143
144 1GB is the minimum value. A higher value is recommended,
145 as checkpointing is expensive.
146
147 Set to `null` to disable checkpointing.
148 '';
149 };
150
151 period = lib.mkOption {
152 type = lib.types.str;
153 default = "01:15";
154 example = "hourly";
155 description = ''
156 Create archive at this interval.
157
158 The format is described in
159 {manpage}`systemd.time(7)`.
160 '';
161 };
162
163 aggressiveNetworking = lib.mkOption {
164 type = lib.types.bool;
165 default = false;
166 description = ''
167 Upload data over multiple TCP connections, potentially
168 increasing tarsnap's bandwidth utilisation at the cost
169 of slowing down all other network traffic. Not
170 recommended unless TCP congestion is the dominant
171 limiting factor.
172 '';
173 };
174
175 directories = lib.mkOption {
176 type = lib.types.listOf lib.types.path;
177 default = [ ];
178 description = "List of filesystem paths to archive.";
179 };
180
181 excludes = lib.mkOption {
182 type = lib.types.listOf lib.types.str;
183 default = [ ];
184 description = ''
185 Exclude files and directories matching these patterns.
186 '';
187 };
188
189 includes = lib.mkOption {
190 type = lib.types.listOf lib.types.str;
191 default = [ ];
192 description = ''
193 Include only files and directories matching these
194 patterns (the empty list includes everything).
195
196 Exclusions have precedence over inclusions.
197 '';
198 };
199
200 lowmem = lib.mkOption {
201 type = lib.types.bool;
202 default = false;
203 description = ''
204 Reduce memory consumption by not caching small files.
205 Possibly beneficial if the average file size is smaller
206 than 1 MB and the number of files is lower than the
207 total amount of RAM in KB.
208 '';
209 };
210
211 verylowmem = lib.mkOption {
212 type = lib.types.bool;
213 default = false;
214 description = ''
215 Reduce memory consumption by a factor of 2 beyond what
216 `lowmem` does, at the cost of significantly
217 slowing down the archiving process.
218 '';
219 };
220
221 maxbw = lib.mkOption {
222 type = lib.types.nullOr lib.types.int;
223 default = null;
224 description = ''
225 Abort archival if upstream bandwidth usage in bytes
226 exceeds this threshold.
227 '';
228 };
229
230 maxbwRateUp = lib.mkOption {
231 type = lib.types.nullOr lib.types.int;
232 default = null;
233 example = lib.literalExpression "25 * 1000";
234 description = ''
235 Upload bandwidth rate limit in bytes.
236 '';
237 };
238
239 maxbwRateDown = lib.mkOption {
240 type = lib.types.nullOr lib.types.int;
241 default = null;
242 example = lib.literalExpression "50 * 1000";
243 description = ''
244 Download bandwidth rate limit in bytes.
245 '';
246 };
247
248 verbose = lib.mkOption {
249 type = lib.types.bool;
250 default = false;
251 description = ''
252 Whether to produce verbose logging output.
253 '';
254 };
255 explicitSymlinks = lib.mkOption {
256 type = lib.types.bool;
257 default = false;
258 description = ''
259 Whether to follow symlinks specified as archives.
260 '';
261 };
262 followSymlinks = lib.mkOption {
263 type = lib.types.bool;
264 default = false;
265 description = ''
266 Whether to follow all symlinks in archive trees.
267 '';
268 };
269 };
270 }
271 )
272 );
273
274 default = { };
275
276 example = lib.literalExpression ''
277 {
278 nixos =
279 { directories = [ "/home" "/root/ssl" ];
280 };
281
282 gamedata =
283 { directories = [ "/var/lib/minecraft" ];
284 period = "*:30";
285 };
286 }
287 '';
288
289 description = ''
290 Tarsnap archive configurations. Each attribute names an archive
291 to be created at a given time interval, according to the options
292 associated with it. When uploading to the tarsnap server,
293 archive names are suffixed by a 1 second resolution timestamp,
294 with the format `%Y%m%d%H%M%S`.
295
296 For each member of the set is created a timer which triggers the
297 instanced `tarsnap-archive-name` service unit. You may use
298 {command}`systemctl start tarsnap-archive-name` to
299 manually trigger creation of `archive-name` at
300 any time.
301 '';
302 };
303 };
304 };
305
306 config = lib.mkIf gcfg.enable {
307 assertions =
308 (lib.mapAttrsToList (name: cfg: {
309 assertion = cfg.directories != [ ];
310 message = "Must specify paths for tarsnap to back up";
311 }) gcfg.archives)
312 ++ (lib.mapAttrsToList (name: cfg: {
313 assertion = !(cfg.lowmem && cfg.verylowmem);
314 message = "You cannot set both lowmem and verylowmem";
315 }) gcfg.archives);
316
317 systemd.services =
318 (lib.mapAttrs' (
319 name: cfg:
320 lib.nameValuePair "tarsnap-${name}" {
321 description = "Tarsnap archive '${name}'";
322 requires = [ "network-online.target" ];
323 after = [ "network-online.target" ];
324
325 path = with pkgs; [
326 iputils
327 gcfg.package
328 util-linux
329 ];
330
331 # In order for the persistent tarsnap timer to work reliably, we have to
332 # make sure that the tarsnap server is reachable after systemd starts up
333 # the service - therefore we sleep in a loop until we can ping the
334 # endpoint.
335 preStart = ''
336 while ! ping -4 -q -c 1 v1-0-0-server.tarsnap.com &> /dev/null; do sleep 3; done
337 '';
338
339 script =
340 let
341 tarsnap = ''${lib.getExe gcfg.package} --configfile "/etc/tarsnap/${name}.conf"'';
342 run = ''
343 ${tarsnap} -c -f "${name}-$(date +"%Y%m%d%H%M%S")" \
344 ${lib.optionalString cfg.verbose "-v"} \
345 ${lib.optionalString cfg.explicitSymlinks "-H"} \
346 ${lib.optionalString cfg.followSymlinks "-L"} \
347 ${lib.concatStringsSep " " cfg.directories}'';
348 cachedir = lib.escapeShellArg cfg.cachedir;
349 in
350 if (cfg.cachedir != null) then
351 ''
352 mkdir -p ${cachedir}
353 chmod 0700 ${cachedir}
354
355 ( flock 9
356 if [ ! -e ${cachedir}/firstrun ]; then
357 ( flock 10
358 flock -u 9
359 ${tarsnap} --fsck
360 flock 9
361 ) 10>${cachedir}/firstrun
362 fi
363 ) 9>${cachedir}/lockf
364
365 exec flock ${cachedir}/firstrun ${run}
366 ''
367 else
368 "exec ${run}";
369
370 serviceConfig = {
371 Type = "oneshot";
372 IOSchedulingClass = "idle";
373 NoNewPrivileges = "true";
374 CapabilityBoundingSet = [ "CAP_DAC_READ_SEARCH" ];
375 PermissionsStartOnly = "true";
376 };
377 }
378 ) gcfg.archives)
379 //
380
381 (lib.mapAttrs' (
382 name: cfg:
383 lib.nameValuePair "tarsnap-restore-${name}" {
384 description = "Tarsnap restore '${name}'";
385 requires = [ "network-online.target" ];
386
387 path = with pkgs; [
388 iputils
389 gcfg.package
390 util-linux
391 ];
392
393 script =
394 let
395 tarsnap = ''${lib.getExe gcfg.package} --configfile "/etc/tarsnap/${name}.conf"'';
396 lastArchive = "$(${tarsnap} --list-archives | sort | tail -1)";
397 run = ''${tarsnap} -x -f "${lastArchive}" ${lib.optionalString cfg.verbose "-v"}'';
398 cachedir = lib.escapeShellArg cfg.cachedir;
399
400 in
401 if (cfg.cachedir != null) then
402 ''
403 mkdir -p ${cachedir}
404 chmod 0700 ${cachedir}
405
406 ( flock 9
407 if [ ! -e ${cachedir}/firstrun ]; then
408 ( flock 10
409 flock -u 9
410 ${tarsnap} --fsck
411 flock 9
412 ) 10>${cachedir}/firstrun
413 fi
414 ) 9>${cachedir}/lockf
415
416 exec flock ${cachedir}/firstrun ${run}
417 ''
418 else
419 "exec ${run}";
420
421 serviceConfig = {
422 Type = "oneshot";
423 IOSchedulingClass = "idle";
424 NoNewPrivileges = "true";
425 CapabilityBoundingSet = [ "CAP_DAC_READ_SEARCH" ];
426 PermissionsStartOnly = "true";
427 };
428 }
429 ) gcfg.archives);
430
431 # Note: the timer must be Persistent=true, so that systemd will start it even
432 # if e.g. your laptop was asleep while the latest interval occurred.
433 systemd.timers = lib.mapAttrs' (
434 name: cfg:
435 lib.nameValuePair "tarsnap-${name}" {
436 timerConfig.OnCalendar = cfg.period;
437 timerConfig.Persistent = "true";
438 wantedBy = [ "timers.target" ];
439 }
440 ) gcfg.archives;
441
442 environment.etc = lib.mapAttrs' (
443 name: cfg:
444 lib.nameValuePair "tarsnap/${name}.conf" {
445 text = configFile name cfg;
446 }
447 ) gcfg.archives;
448
449 environment.systemPackages = [ gcfg.package ];
450 };
451}