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