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