at 21.11-pre 24 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 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}