1{ config, lib, pkgs, ... }:
2
3with lib;
4
5let
6
7 isLocalPath = x:
8 builtins.substring 0 1 x == "/" # absolute path
9 || builtins.substring 0 1 x == "." # relative path
10 || builtins.match "[.*:.*]" == null; # not machine:path
11
12 mkExcludeFile = cfg:
13 # Write each exclude pattern to a new line
14 pkgs.writeText "excludefile" (concatStringsSep "\n" cfg.exclude);
15
16 mkKeepArgs = cfg:
17 # If cfg.prune.keep e.g. has a yearly attribute,
18 # its content is passed on as --keep-yearly
19 concatStringsSep " "
20 (mapAttrsToList (x: y: "--keep-${x}=${toString y}") cfg.prune.keep);
21
22 mkBackupScript = cfg: ''
23 on_exit()
24 {
25 exitStatus=$?
26 # Reset the EXIT handler, or else we're called again on 'exit' below
27 trap - EXIT
28 ${cfg.postHook}
29 exit $exitStatus
30 }
31 trap 'on_exit' INT TERM QUIT EXIT
32
33 archiveName="${cfg.archiveBaseName}-$(date ${cfg.dateFormat})"
34 archiveSuffix="${optionalString cfg.appendFailedSuffix ".failed"}"
35 ${cfg.preHook}
36 '' + optionalString cfg.doInit ''
37 # Run borg init if the repo doesn't exist yet
38 if ! borg list $extraArgs > /dev/null; then
39 borg init $extraArgs \
40 --encryption ${cfg.encryption.mode} \
41 $extraInitArgs
42 ${cfg.postInit}
43 fi
44 '' + ''
45 borg create $extraArgs \
46 --compression ${cfg.compression} \
47 --exclude-from ${mkExcludeFile cfg} \
48 $extraCreateArgs \
49 "::$archiveName$archiveSuffix" \
50 ${escapeShellArgs cfg.paths}
51 '' + optionalString cfg.appendFailedSuffix ''
52 borg rename $extraArgs \
53 "::$archiveName$archiveSuffix" "$archiveName"
54 '' + ''
55 ${cfg.postCreate}
56 '' + optionalString (cfg.prune.keep != { }) ''
57 borg prune $extraArgs \
58 ${mkKeepArgs cfg} \
59 --prefix ${escapeShellArg cfg.prune.prefix} \
60 $extraPruneArgs
61 ${cfg.postPrune}
62 '';
63
64 mkPassEnv = cfg: with cfg.encryption;
65 if passCommand != null then
66 { BORG_PASSCOMMAND = passCommand; }
67 else if passphrase != null then
68 { BORG_PASSPHRASE = passphrase; }
69 else { };
70
71 mkBackupService = name: cfg:
72 let
73 userHome = config.users.users.${cfg.user}.home;
74 in nameValuePair "borgbackup-job-${name}" {
75 description = "BorgBackup job ${name}";
76 path = with pkgs; [
77 borgbackup openssh
78 ];
79 script = mkBackupScript cfg;
80 serviceConfig = {
81 User = cfg.user;
82 Group = cfg.group;
83 # Only run when no other process is using CPU or disk
84 CPUSchedulingPolicy = "idle";
85 IOSchedulingClass = "idle";
86 ProtectSystem = "strict";
87 ReadWritePaths =
88 [ "${userHome}/.config/borg" "${userHome}/.cache/borg" ]
89 ++ cfg.readWritePaths
90 # Borg needs write access to repo if it is not remote
91 ++ optional (isLocalPath cfg.repo) cfg.repo;
92 PrivateTmp = cfg.privateTmp;
93 };
94 environment = {
95 BORG_REPO = cfg.repo;
96 inherit (cfg) extraArgs extraInitArgs extraCreateArgs extraPruneArgs;
97 } // (mkPassEnv cfg) // cfg.environment;
98 inherit (cfg) startAt;
99 };
100
101 # Paths listed in ReadWritePaths must exist before service is started
102 mkActivationScript = name: cfg:
103 let
104 install = "install -o ${cfg.user} -g ${cfg.group}";
105 in
106 nameValuePair "borgbackup-job-${name}" (stringAfter [ "users" ] (''
107 # Eensure that the home directory already exists
108 # We can't assert createHome == true because that's not the case for root
109 cd "${config.users.users.${cfg.user}.home}"
110 ${install} -d .config/borg
111 ${install} -d .cache/borg
112 '' + optionalString (isLocalPath cfg.repo) ''
113 ${install} -d ${escapeShellArg cfg.repo}
114 ''));
115
116 mkPassAssertion = name: cfg: {
117 assertion = with cfg.encryption;
118 mode != "none" -> passCommand != null || passphrase != null;
119 message =
120 "passCommand or passphrase has to be specified because"
121 + '' borgbackup.jobs.${name}.encryption != "none"'';
122 };
123
124 mkRepoService = name: cfg:
125 nameValuePair "borgbackup-repo-${name}" {
126 description = "Create BorgBackup repository ${name} directory";
127 script = ''
128 mkdir -p ${escapeShellArg cfg.path}
129 chown ${cfg.user}:${cfg.group} ${escapeShellArg cfg.path}
130 '';
131 serviceConfig = {
132 # The service's only task is to ensure that the specified path exists
133 Type = "oneshot";
134 };
135 wantedBy = [ "multi-user.target" ];
136 };
137
138 mkAuthorizedKey = cfg: appendOnly: key:
139 let
140 # Because of the following line, clients do not need to specify an absolute repo path
141 cdCommand = "cd ${escapeShellArg cfg.path}";
142 restrictedArg = "--restrict-to-${if cfg.allowSubRepos then "path" else "repository"} .";
143 appendOnlyArg = optionalString appendOnly "--append-only";
144 quotaArg = optionalString (cfg.quota != null) "--storage-quota ${cfg.quota}";
145 serveCommand = "borg serve ${restrictedArg} ${appendOnlyArg} ${quotaArg}";
146 in
147 ''command="${cdCommand} && ${serveCommand}",restrict ${key}'';
148
149 mkUsersConfig = name: cfg: {
150 users.${cfg.user} = {
151 openssh.authorizedKeys.keys =
152 (map (mkAuthorizedKey cfg false) cfg.authorizedKeys
153 ++ map (mkAuthorizedKey cfg true) cfg.authorizedKeysAppendOnly);
154 useDefaultShell = true;
155 };
156 groups.${cfg.group} = { };
157 };
158
159 mkKeysAssertion = name: cfg: {
160 assertion = cfg.authorizedKeys != [ ] || cfg.authorizedKeysAppendOnly != [ ];
161 message =
162 "borgbackup.repos.${name} does not make sense"
163 + " without at least one public key";
164 };
165
166in {
167 meta.maintainers = with maintainers; [ dotlambda ];
168
169 ###### interface
170
171 options.services.borgbackup.jobs = mkOption {
172 description = "Deduplicating backups using BorgBackup.";
173 default = { };
174 example = literalExample ''
175 {
176 rootBackup = {
177 paths = "/";
178 exclude = [ "/nix" ];
179 repo = "/path/to/local/repo";
180 encryption = {
181 mode = "repokey";
182 passphrase = "secret";
183 };
184 compression = "auto,lzma";
185 startAt = "weekly";
186 };
187 }
188 '';
189 type = types.attrsOf (types.submodule (let globalConfig = config; in
190 { name, config, ... }: {
191 options = {
192
193 paths = mkOption {
194 type = with types; either path (nonEmptyListOf path);
195 description = "Path(s) to back up.";
196 example = "/home/user";
197 apply = x: if isList x then x else [ x ];
198 };
199
200 repo = mkOption {
201 type = types.str;
202 description = "Remote or local repository to back up to.";
203 example = "user@machine:/path/to/repo";
204 };
205
206 archiveBaseName = mkOption {
207 type = types.strMatching "[^/{}]+";
208 default = "${globalConfig.networking.hostName}-${name}";
209 defaultText = "\${config.networking.hostName}-<name>";
210 description = ''
211 How to name the created archives. A timestamp, whose format is
212 determined by <option>dateFormat</option>, will be appended. The full
213 name can be modified at runtime (<literal>$archiveName</literal>).
214 Placeholders like <literal>{hostname}</literal> must not be used.
215 '';
216 };
217
218 dateFormat = mkOption {
219 type = types.str;
220 description = ''
221 Arguments passed to <command>date</command>
222 to create a timestamp suffix for the archive name.
223 '';
224 default = "+%Y-%m-%dT%H:%M:%S";
225 example = "-u +%s";
226 };
227
228 startAt = mkOption {
229 type = with types; either str (listOf str);
230 default = "daily";
231 description = ''
232 When or how often the backup should run.
233 Must be in the format described in
234 <citerefentry><refentrytitle>systemd.time</refentrytitle>
235 <manvolnum>7</manvolnum></citerefentry>.
236 If you do not want the backup to start
237 automatically, use <literal>[ ]</literal>.
238 '';
239 };
240
241 user = mkOption {
242 type = types.str;
243 description = ''
244 The user <command>borg</command> is run as.
245 User or group need read permission
246 for the specified <option>paths</option>.
247 '';
248 default = "root";
249 };
250
251 group = mkOption {
252 type = types.str;
253 description = ''
254 The group borg is run as. User or group needs read permission
255 for the specified <option>paths</option>.
256 '';
257 default = "root";
258 };
259
260 encryption.mode = mkOption {
261 type = types.enum [
262 "repokey" "keyfile"
263 "repokey-blake2" "keyfile-blake2"
264 "authenticated" "authenticated-blake2"
265 "none"
266 ];
267 description = ''
268 Encryption mode to use. Setting a mode
269 other than <literal>"none"</literal> requires
270 you to specify a <option>passCommand</option>
271 or a <option>passphrase</option>.
272 '';
273 };
274
275 encryption.passCommand = mkOption {
276 type = with types; nullOr str;
277 description = ''
278 A command which prints the passphrase to stdout.
279 Mutually exclusive with <option>passphrase</option>.
280 '';
281 default = null;
282 example = "cat /path/to/passphrase_file";
283 };
284
285 encryption.passphrase = mkOption {
286 type = with types; nullOr str;
287 description = ''
288 The passphrase the backups are encrypted with.
289 Mutually exclusive with <option>passCommand</option>.
290 If you do not want the passphrase to be stored in the
291 world-readable Nix store, use <option>passCommand</option>.
292 '';
293 default = null;
294 };
295
296 compression = mkOption {
297 # "auto" is optional,
298 # compression mode must be given,
299 # compression level is optional
300 type = types.strMatching "none|(auto,)?(lz4|zstd|zlib|lzma)(,[[:digit:]]{1,2})?";
301 description = ''
302 Compression method to use. Refer to
303 <command>borg help compression</command>
304 for all available options.
305 '';
306 default = "lz4";
307 example = "auto,lzma";
308 };
309
310 exclude = mkOption {
311 type = with types; listOf str;
312 description = ''
313 Exclude paths matching any of the given patterns. See
314 <command>borg help patterns</command> for pattern syntax.
315 '';
316 default = [ ];
317 example = [
318 "/home/*/.cache"
319 "/nix"
320 ];
321 };
322
323 readWritePaths = mkOption {
324 type = with types; listOf path;
325 description = ''
326 By default, borg cannot write anywhere on the system but
327 <literal>$HOME/.config/borg</literal> and <literal>$HOME/.cache/borg</literal>.
328 If, for example, your preHook script needs to dump files
329 somewhere, put those directories here.
330 '';
331 default = [ ];
332 example = [
333 "/var/backup/mysqldump"
334 ];
335 };
336
337 privateTmp = mkOption {
338 type = types.bool;
339 description = ''
340 Set the <literal>PrivateTmp</literal> option for
341 the systemd-service. Set to false if you need sockets
342 or other files from global /tmp.
343 '';
344 default = true;
345 };
346
347 doInit = mkOption {
348 type = types.bool;
349 description = ''
350 Run <command>borg init</command> if the
351 specified <option>repo</option> does not exist.
352 You should set this to <literal>false</literal>
353 if the repository is located on an external drive
354 that might not always be mounted.
355 '';
356 default = true;
357 };
358
359 appendFailedSuffix = mkOption {
360 type = types.bool;
361 description = ''
362 Append a <literal>.failed</literal> suffix
363 to the archive name, which is only removed if
364 <command>borg create</command> has a zero exit status.
365 '';
366 default = true;
367 };
368
369 prune.keep = mkOption {
370 # Specifying e.g. `prune.keep.yearly = -1`
371 # means there is no limit of yearly archives to keep
372 # The regex is for use with e.g. --keep-within 1y
373 type = with types; attrsOf (either int (strMatching "[[:digit:]]+[Hdwmy]"));
374 description = ''
375 Prune a repository by deleting all archives not matching any of the
376 specified retention options. See <command>borg help prune</command>
377 for the available options.
378 '';
379 default = { };
380 example = literalExample ''
381 {
382 within = "1d"; # Keep all archives from the last day
383 daily = 7;
384 weekly = 4;
385 monthly = -1; # Keep at least one archive for each month
386 }
387 '';
388 };
389
390 prune.prefix = mkOption {
391 type = types.str;
392 description = ''
393 Only consider archive names starting with this prefix for pruning.
394 By default, only archives created by this job are considered.
395 Use <literal>""</literal> to consider all archives.
396 '';
397 default = config.archiveBaseName;
398 defaultText = "\${archiveBaseName}";
399 };
400
401 environment = mkOption {
402 type = with types; attrsOf str;
403 description = ''
404 Environment variables passed to the backup script.
405 You can for example specify which SSH key to use.
406 '';
407 default = { };
408 example = { BORG_RSH = "ssh -i /path/to/key"; };
409 };
410
411 preHook = mkOption {
412 type = types.lines;
413 description = ''
414 Shell commands to run before the backup.
415 This can for example be used to mount file systems.
416 '';
417 default = "";
418 example = ''
419 # To add excluded paths at runtime
420 extraCreateArgs="$extraCreateArgs --exclude /some/path"
421 '';
422 };
423
424 postInit = mkOption {
425 type = types.lines;
426 description = ''
427 Shell commands to run after <command>borg init</command>.
428 '';
429 default = "";
430 };
431
432 postCreate = mkOption {
433 type = types.lines;
434 description = ''
435 Shell commands to run after <command>borg create</command>. The name
436 of the created archive is stored in <literal>$archiveName</literal>.
437 '';
438 default = "";
439 };
440
441 postPrune = mkOption {
442 type = types.lines;
443 description = ''
444 Shell commands to run after <command>borg prune</command>.
445 '';
446 default = "";
447 };
448
449 postHook = mkOption {
450 type = types.lines;
451 description = ''
452 Shell commands to run just before exit. They are executed
453 even if a previous command exits with a non-zero exit code.
454 The latter is available as <literal>$exitStatus</literal>.
455 '';
456 default = "";
457 };
458
459 extraArgs = mkOption {
460 type = types.str;
461 description = ''
462 Additional arguments for all <command>borg</command> calls the
463 service has. Handle with care.
464 '';
465 default = "";
466 example = "--remote-path=/path/to/borg";
467 };
468
469 extraInitArgs = mkOption {
470 type = types.str;
471 description = ''
472 Additional arguments for <command>borg init</command>.
473 Can also be set at runtime using <literal>$extraInitArgs</literal>.
474 '';
475 default = "";
476 example = "--append-only";
477 };
478
479 extraCreateArgs = mkOption {
480 type = types.str;
481 description = ''
482 Additional arguments for <command>borg create</command>.
483 Can also be set at runtime using <literal>$extraCreateArgs</literal>.
484 '';
485 default = "";
486 example = "--stats --checkpoint-interval 600";
487 };
488
489 extraPruneArgs = mkOption {
490 type = types.str;
491 description = ''
492 Additional arguments for <command>borg prune</command>.
493 Can also be set at runtime using <literal>$extraPruneArgs</literal>.
494 '';
495 default = "";
496 example = "--save-space";
497 };
498
499 };
500 }
501 ));
502 };
503
504 options.services.borgbackup.repos = mkOption {
505 description = ''
506 Serve BorgBackup repositories to given public SSH keys,
507 restricting their access to the repository only.
508 Also, clients do not need to specify the absolute path when accessing the repository,
509 i.e. <literal>user@machine:.</literal> is enough. (Note colon and dot.)
510 '';
511 default = { };
512 type = types.attrsOf (types.submodule (
513 { ... }: {
514 options = {
515
516 path = mkOption {
517 type = types.path;
518 description = ''
519 Where to store the backups. Note that the directory
520 is created automatically, with correct permissions.
521 '';
522 default = "/var/lib/borgbackup";
523 };
524
525 user = mkOption {
526 type = types.str;
527 description = ''
528 The user <command>borg serve</command> is run as.
529 User or group needs write permission
530 for the specified <option>path</option>.
531 '';
532 default = "borg";
533 };
534
535 group = mkOption {
536 type = types.str;
537 description = ''
538 The group <command>borg serve</command> is run as.
539 User or group needs write permission
540 for the specified <option>path</option>.
541 '';
542 default = "borg";
543 };
544
545 authorizedKeys = mkOption {
546 type = with types; listOf str;
547 description = ''
548 Public SSH keys that are given full write access to this repository.
549 You should use a different SSH key for each repository you write to, because
550 the specified keys are restricted to running <command>borg serve</command>
551 and can only access this single repository.
552 '';
553 default = [ ];
554 };
555
556 authorizedKeysAppendOnly = mkOption {
557 type = with types; listOf str;
558 description = ''
559 Public SSH keys that can only be used to append new data (archives) to the repository.
560 Note that archives can still be marked as deleted and are subsequently removed from disk
561 upon accessing the repo with full write access, e.g. when pruning.
562 '';
563 default = [ ];
564 };
565
566 allowSubRepos = mkOption {
567 type = types.bool;
568 description = ''
569 Allow clients to create repositories in subdirectories of the
570 specified <option>path</option>. These can be accessed using
571 <literal>user@machine:path/to/subrepo</literal>. Note that a
572 <option>quota</option> applies to repositories independently.
573 Therefore, if this is enabled, clients can create multiple
574 repositories and upload an arbitrary amount of data.
575 '';
576 default = false;
577 };
578
579 quota = mkOption {
580 # See the definition of parse_file_size() in src/borg/helpers/parseformat.py
581 type = with types; nullOr (strMatching "[[:digit:].]+[KMGTP]?");
582 description = ''
583 Storage quota for the repository. This quota is ensured for all
584 sub-repositories if <option>allowSubRepos</option> is enabled
585 but not for the overall storage space used.
586 '';
587 default = null;
588 example = "100G";
589 };
590
591 };
592 }
593 ));
594 };
595
596 ###### implementation
597
598 config = mkIf (with config.services.borgbackup; jobs != { } || repos != { })
599 (with config.services.borgbackup; {
600 assertions =
601 mapAttrsToList mkPassAssertion jobs
602 ++ mapAttrsToList mkKeysAssertion repos;
603
604 system.activationScripts = mapAttrs' mkActivationScript jobs;
605
606 systemd.services =
607 # A job named "foo" is mapped to systemd.services.borgbackup-job-foo
608 mapAttrs' mkBackupService jobs
609 # A repo named "foo" is mapped to systemd.services.borgbackup-repo-foo
610 // mapAttrs' mkRepoService repos;
611
612 users = mkMerge (mapAttrsToList mkUsersConfig repos);
613
614 environment.systemPackages = with pkgs; [ borgbackup ];
615 });
616}