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