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