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