{ lib, config, pkgs, ... }: let cfg = config.vhack.services.backup; snapshotDir = "/srv/last_snapshot"; in { options.vhack.services.backup = { exclude = lib.mkOption { type = lib.types.listOf lib.types.str; description = "Which paths to exclude from the backup. Should be absolute."; example = [ "/var/lib/postgresql" ]; }; backupPrepare = lib.mkOption { type = lib.types.attrsOf lib.types.str; example = '' { postgresql = "pg_dumpall"; } ''; default = {}; }; storagebox = { enable = lib.mkEnableOption "remote backups"; user = lib.mkOption { type = lib.types.str; description = "The storagebox-user to use"; example = "u384702-sub2"; }; sshKey = lib.mkOption { type = lib.types.path; description = "The age-encrypted ssh-key, passed to agenix"; }; sshHostKey = lib.mkOption { type = lib.types.str; description = "The ssh public host key, to authenticate that the server matches."; example = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIICf9svRenC/PLKIL9nk6K/pxQgoiFC41wTNvoIncOxs"; }; repositoryPassword = lib.mkOption { type = lib.types.path; description = "The age-encrypted restic password, passed to agenix"; }; }; local = { enable = lib.mkEnableOption "local backups"; repositoryPassword = lib.mkOption { type = lib.types.path; description = "The age-encrypted restic password, passed to agenix"; }; backupMountPoint = lib.mkOption { type = lib.types.path; description = "The path where to expect the mounted backup disk"; default = "/mnt/backup"; }; }; }; config = lib.mkIf (cfg.local.enable || cfg.storagebox.enable) { vhack.persist.directories = lib.mkMerge [ (lib.mkIf cfg.storagebox.enable [ "/var/cache/restic-backups-storagebox" # TODO: Ideally we would not save the admin cache and normal cache separately, as # they both access the exactly same data. The admin one just also has write # access. But because the `systemd.mounts` thing does not work, they are saved # separately now. <2026-10-31> # Generate the base systemd.mount from impermanence (we will override this, so it # points to the non-admin one instead) "/var/cache/restic-backups-storagebox-admin" ]) (lib.mkIf cfg.local.enable [ "/var/cache/restic-backups-local" ]) ]; # TODO: Make this work (`systemd.mounts` is a list, so _this_ doesn't work.) <2026-07-07> # systemd.mounts."/var/cache/restic-backups-storagebox-admin" = { # # TODO: Don't hardcode the path, but use # # `systemd.mounts."/var/cache/restic-backups-storagebox".what` instead <2026-07-07> # what = "/srv/var/cache/restic-backups-storagebox"; # }; age.secrets = { resticStorageboxSshKey = lib.mkIf cfg.storagebox.enable { file = cfg.storagebox.sshKey; mode = "0700"; owner = "root"; group = "root"; }; resticStorageboxRepositoryPassword = lib.mkIf cfg.storagebox.enable { file = cfg.storagebox.repositoryPassword; mode = "0700"; owner = "root"; group = "root"; }; resticLocalRepositoryPassword = lib.mkIf cfg.local.enable { file = cfg.local.repositoryPassword; mode = "0700"; owner = "root"; group = "root"; }; }; systemd.tmpfiles.settings = lib.mkIf cfg.storagebox.enable { "10-backup-module" = { "/root/.ssh/config".f = let knownHostsFile = pkgs.writeTextFile { name = "known-hosts-file"; text = '' [${cfg.storagebox.user}.your-storagebox.de]:23 ${cfg.storagebox.sshHostKey} ''; }; in { group = "root"; user = "root"; mode = "0755"; # Don't try to authenticate via password for these, because we need to use the # forced commands, and they are only provided via the ssh key login. # So password login is a footgun (it is, however, valid to use it for non-root # use-cases) argument = '' Host *.your-storagebox.de PasswordAuthentication no Port 23 UserKnownHostsFile ${knownHostsFile} ''; }; }; }; programs.fuse.enable = cfg.storagebox.enable || cfg.local.enable; systemd.services = { prepare-backup = { requires = []; after = []; description = "Prepare a backup by snapshotting the system."; serviceConfig = { ExecStart = lib.getExe (pkgs.writeShellApplication { name = "prepareBackup"; text = '' set -x '' + builtins.concatStringsSep "\n\n" (builtins.attrValues cfg.backupPrepare) + '' [ -d "${snapshotDir}" ] && btrfs subvolume delete "${snapshotDir}" # -r := Make the snapshot read-only btrfs subvolume snapshot -r /srv "${snapshotDir}"; ''; inheritPath = false; runtimeInputs = [ pkgs.btrfs-progs ]; }); Type = "oneshot"; User = "root"; Group = "root"; # TODO: Hardening <2025-05-04> }; }; restic-backups-storagebox = lib.mkIf cfg.storagebox.enable { requires = ["prepare-backup.service"]; after = ["prepare-backup.service"]; }; restic-backups-local = lib.mkIf cfg.local.enable { requires = ["prepare-backup.service"]; after = ["prepare-backup.service"]; serviceConfig = { ConditionPathIsDirectory = "${cfg.local.backupMountPoint}"; }; }; }; services.restic.backups = let paths = [ snapshotDir ]; exclude = lib.map (path: "${snapshotDir}/${path}") cfg.exclude; extraBackupArgs = [ # Don't scan the filesystem to determine an estimate. "--no-scan" "--verbose=2" ]; environmentFile = "${./env.vars}"; in { local = lib.mkIf cfg.local.enable { inhibitsSleep = true; initialize = true; inherit paths exclude extraBackupArgs environmentFile; passwordFile = config.age.secrets.resticLocalRepositoryPassword.path; repository = "${cfg.local.backupMountPoint}/restic-backup-data/"; # Start on demand. timerConfig = null; }; storagebox = lib.mkIf cfg.storagebox.enable { inhibitsSleep = true; initialize = true; inherit paths exclude extraBackupArgs environmentFile; passwordFile = config.age.secrets.resticStorageboxRepositoryPassword.path; extraOptions = [ "rclone.program='ssh -p 23 ${cfg.storagebox.user}@${cfg.storagebox.user}.your-storagebox.de -i ${config.age.secrets.resticStorageboxSshKey.path} command_forced_on_remote'" ]; # This setting is normally passed to rclone, but we force # the command on the remote. # As such, the value does not matter and must only be parse-able by restic. repository = "rclone: "; timerConfig = { Requires = "network-online.target"; OnActiveSec = "30m"; OnUnitInactiveSec = "2h"; Persistent = true; }; }; # This is only for listing, pruning and such stuff. storagebox-admin = lib.mkIf cfg.storagebox.enable { inhibitsSleep = true; # Don't create the repository if it doesn't exist yet. initialize = false; inherit paths exclude extraBackupArgs environmentFile; passwordFile = config.age.secrets.resticStorageboxRepositoryPassword.path; extraOptions = [ "rclone.program='ssh -p 23 ${cfg.storagebox.user}@${cfg.storagebox.user}.your-storagebox.de command_forced_on_remote'" ]; # This setting is normally passed to rclone, but we force # the command on the remote. # As such, the value does not matter and must only be parse-able by restic. repository = "rclone: "; timerConfig = null; }; }; }; }