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