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 };
242 }
243 ));
244
245 default = {};
246
247 example = literalExample ''
248 {
249 nixos =
250 { directories = [ "/home" "/root/ssl" ];
251 };
252
253 gamedata =
254 { directories = [ "/var/lib/minecraft" ];
255 period = "*:30";
256 };
257 }
258 '';
259
260 description = ''
261 Tarsnap archive configurations. Each attribute names an archive
262 to be created at a given time interval, according to the options
263 associated with it. When uploading to the tarsnap server,
264 archive names are suffixed by a 1 second resolution timestamp.
265
266 For each member of the set is created a timer which triggers the
267 instanced <literal>tarsnap-archive-name</literal> service unit. You may use
268 <command>systemctl start tarsnap-archive-name</command> to
269 manually trigger creation of <literal>archive-name</literal> at
270 any time.
271 '';
272 };
273 };
274 };
275
276 config = mkIf gcfg.enable {
277 assertions =
278 (mapAttrsToList (name: cfg:
279 { assertion = cfg.directories != [];
280 message = "Must specify paths for tarsnap to back up";
281 }) gcfg.archives) ++
282 (mapAttrsToList (name: cfg:
283 { assertion = !(cfg.lowmem && cfg.verylowmem);
284 message = "You cannot set both lowmem and verylowmem";
285 }) gcfg.archives);
286
287 systemd.services =
288 mapAttrs' (name: cfg: nameValuePair "tarsnap-${name}" {
289 description = "Tarsnap archive '${name}'";
290 requires = [ "network-online.target" ];
291 after = [ "network-online.target" ];
292
293 path = [ pkgs.iputils pkgs.tarsnap pkgs.utillinux ];
294
295 # In order for the persistent tarsnap timer to work reliably, we have to
296 # make sure that the tarsnap server is reachable after systemd starts up
297 # the service - therefore we sleep in a loop until we can ping the
298 # endpoint.
299 preStart = ''
300 while ! ping -q -c 1 v1-0-0-server.tarsnap.com &> /dev/null; do sleep 3; done
301 '';
302
303 script =
304 let run = ''tarsnap --configfile "/etc/tarsnap/${name}.conf" \
305 -c -f "${name}-$(date +"%Y%m%d%H%M%S")" \
306 ${optionalString cfg.verbose "-v"} \
307 ${concatStringsSep " " cfg.directories}'';
308 in if (cfg.cachedir != null) then ''
309 mkdir -p ${cfg.cachedir}
310 chmod 0700 ${cfg.cachedir}
311
312 ( flock 9
313 if [ ! -e ${cfg.cachedir}/firstrun ]; then
314 ( flock 10
315 flock -u 9
316 tarsnap --configfile "/etc/tarsnap/${name}.conf" --fsck
317 flock 9
318 ) 10>${cfg.cachedir}/firstrun
319 fi
320 ) 9>${cfg.cachedir}/lockf
321
322 exec flock ${cfg.cachedir}/firstrun ${run}
323 '' else "exec ${run}";
324
325 serviceConfig = {
326 Type = "oneshot";
327 IOSchedulingClass = "idle";
328 NoNewPrivileges = "true";
329 CapabilityBoundingSet = [ "CAP_DAC_READ_SEARCH" ];
330 PermissionsStartOnly = "true";
331 };
332 }) gcfg.archives;
333
334 # Note: the timer must be Persistent=true, so that systemd will start it even
335 # if e.g. your laptop was asleep while the latest interval occurred.
336 systemd.timers = mapAttrs' (name: cfg: nameValuePair "tarsnap-${name}"
337 { timerConfig.OnCalendar = cfg.period;
338 timerConfig.Persistent = "true";
339 wantedBy = [ "timers.target" ];
340 }) gcfg.archives;
341
342 environment.etc =
343 mapAttrs' (name: cfg: nameValuePair "tarsnap/${name}.conf"
344 { text = configFile name cfg;
345 }) gcfg.archives;
346
347 environment.systemPackages = [ pkgs.tarsnap ];
348 };
349}