at 22.05-pre 25 kB view raw
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}