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