{ config, pkgs, lib, ... }: let inherit (lib) types optional singleton; mkShutdownScript = service: pkgs.writeShellScript "backup-${service}-shutdown" '' if systemctl is-active --quiet '${service}'; then touch '/tmp/${service}-was-active' systemctl stop '${service}' fi ''; mkRestartScript = service: pkgs.writeShellScript "backup-${service}-restart" '' if [ -f '/tmp/${service}-was-active' ]; then rm '/tmp/${service}-was-active' systemctl start '${service}' fi ''; writeScript = name: packages: text: lib.getExe (pkgs.writeShellApplication { inherit name text; runtimeInputs = packages; }); in { options = { services.backups = lib.mkOption { description = lib.mdDoc '' Configure restic backups with a specific tag. ''; type = types.attrsOf (types.submodule ({ config, name, ... }: { options = { user = lib.mkOption { type = types.str; description = '' The user as which to run the backup. ''; }; paths = lib.mkOption { type = types.listOf types.str; description = '' The paths to back up. ''; }; tag = lib.mkOption { type = types.str; description = '' The restic tag to mark the backup with. ''; default = name; }; preparation = { packages = lib.mkOption { type = types.listOf types.package; default = []; description = '' The list of packages to make available in the preparation script. ''; }; text = lib.mkOption { type = types.nullOr types.str; default = null; description = '' The preparation script to run before the backup. This should include things like database dumps and enabling maintenance modes. If a service needs to be shut down for backups, use `pauseServices` instead. ''; }; }; cleanup = { packages = lib.mkOption { type = types.listOf types.package; default = []; description = '' The list of packages to make available in the cleanup script. ''; }; text = lib.mkOption { type = types.nullOr types.str; default = null; description = '' The cleanup script to run after the backup. This should do things like cleaning up database dumps and disabling maintenance modes. ''; }; }; pauseServices = lib.mkOption { type = types.listOf types.str; default = []; description = '' The systemd services that need to be shut down before the backup can run. Services will be restarted after the backup is complete. This is intended to be used for services that do not support hot backups. ''; }; }; })); }; }; config = lib.mkIf (config.services.backups != {}) { systemd.services = { restic-prune = { # Doesn't hurt to finish the ongoing prune restartIfChanged = false; environment = { RESTIC_PASSWORD_FILE = config.sops.secrets."restic/local-backups".path; RESTIC_REPOSITORY = "/var/lib/backups/"; }; path = with pkgs; [ restic ]; script = '' # TODO(tlater): In an append-only setup, we should be # careful with this; an attacker could delete backups by # simply appending ad infinitum: # https://restic.readthedocs.io/en/stable/060_forget.html#security-considerations-in-append-only-mode restic forget --keep-last 3 --prune restic check ''; serviceConfig = { DynamicUser = true; Group = "backup"; CacheDirectory = "restic-prune"; CacheDirectoryMode = "0700"; ReadWritePaths = "/var/lib/backups/"; # Ensure we don't leave behind any files with the # temporary UID of this service. ExecStopPost = "+${pkgs.coreutils}/bin/chown -R root:backup /var/lib/backups/"; }; }; } // lib.mapAttrs' (name: backup: lib.nameValuePair "backup-${name}" { # Don't want to restart mid-backup restartIfChanged = false; environment = { RESTIC_CACHE_DIR = "%C/backup-${name}"; RESTIC_PASSWORD_FILE = config.sops.secrets."restic/local-backups".path; # TODO(tlater): If I ever add more than one repo, service # shutdown/restarting will potentially break if multiple # backups for the same service overlap. A more clever # sentinel file with reference counts would probably solve # this. RESTIC_REPOSITORY = "/var/lib/backups/"; }; serviceConfig = { User = backup.user; Group = "backup"; RuntimeDirectory = "backup-${name}"; CacheDirectory = "backup-${name}"; CacheDirectoryMode = "0700"; PrivateTmp = true; ExecStart = [ (lib.concatStringsSep " " (["${pkgs.restic}/bin/restic" "backup" "--tag" name] ++ backup.paths)) ]; ExecStartPre = map (service: "+${mkShutdownScript service}") backup.pauseServices ++ singleton (writeScript "backup-${name}-repo-init" [pkgs.restic pkgs.coreutils] '' restic snapshots || (restic init && chmod -R g+rwx "$RESTIC_REPOSITORY"/*) '') ++ optional (backup.preparation.text != null) (writeScript "backup-${name}-prepare" backup.preparation.packages backup.preparation.text); # TODO(tlater): Add repo pruning/checking ExecStopPost = map (service: "+${mkRestartScript service}") backup.pauseServices ++ optional (backup.cleanup.text != null) (writeScript "backup-${name}-cleanup" backup.cleanup.packages backup.cleanup.text); }; }) config.services.backups; systemd.timers = { restic-prune = { wantedBy = ["timers.target"]; timerConfig.OnCalendar = "Thursday 03:00:00 UTC"; # Don't make this persistent, in case the server was offline # for a while. This job cannot run at the same time as any # of the backup jobs. }; } // lib.mapAttrs' (name: backup: lib.nameValuePair "backup-${name}" { wantedBy = ["timers.target"]; timerConfig = { OnCalendar = "Wednesday 02:30:00 UTC"; RandomizedDelaySec = "1h"; FixedRandomDelay = true; Persistent = true; }; }) config.services.backups; users.groups.backup = {}; systemd.tmpfiles.rules = [ "d /var/lib/backups/ 0770 root backup" ]; }; }