{ config, lib, pkgs, namespace, ... }: with lib; let inherit (lib.${namespace}) mkOpt mkBoolOpt ; cfg = config.${namespace}.services.restic; # Only process jobs that have enable = true. enabledBackups = filterAttrs (_name: jobCfg: jobCfg.enable) cfg.backups; enabledJobNames = attrNames enabledBackups; # --------------------------------------------------------------------------- # ntfy notify script # Reads NTFY_USER and NTFY_PASSWORD from the environment (injected via the # restic-ntfy.env SOPS template at runtime). # # On success, queries `restic snapshots --json` to retrieve stats from the # most recent snapshot and builds a rich summary message. # --------------------------------------------------------------------------- mkNotifyScript = jobName: pkgs.writeShellScript "restic-notify-${jobName}" '' STATUS="$1" # "success" or "failure" HOST="$(${pkgs.hostname}/bin/hostname)" TOPIC="${cfg.ntfy.topic}" SERVER="${cfg.ntfy.server}" if [ "$STATUS" = "success" ]; then TITLE="Backup succeeded: ${jobName} on $HOST" PRIORITY="low" TAGS="white_check_mark,floppy_disk" # Query the latest snapshot for metadata. RESTIC_* env vars are # already set by the restic systemd service so we can call restic # directly. Fall back gracefully if the query fails. SNAP_JSON="$(${pkgs.restic}/bin/restic snapshots --json --last 2>/dev/null || echo '[]')" SNAP_ID="$( echo "$SNAP_JSON" | ${pkgs.jq}/bin/jq -r 'last | .short_id // "unknown"')" SNAP_TIME="$( echo "$SNAP_JSON" | ${pkgs.jq}/bin/jq -r 'last | .time[:19] // "unknown"' | ${pkgs.gnused}/bin/sed 's/T/ /')" SNAP_HOST="$( echo "$SNAP_JSON" | ${pkgs.jq}/bin/jq -r 'last | .hostname // "unknown"')" SNAP_PATHS="$(echo "$SNAP_JSON" | ${pkgs.jq}/bin/jq -r 'last | .paths[] // "unknown"' | ${pkgs.coreutils}/bin/tr '\n' ' ')" MESSAGE="$(printf 'Job: %s\nHost: %s\nPaths: %s\nSnapshot: %s\nTime: %s' \ '${jobName}' "$SNAP_HOST" "$SNAP_PATHS" "$SNAP_ID" "$SNAP_TIME")" else TITLE="Backup FAILED: ${jobName} on $HOST" PRIORITY="high" TAGS="rotating_light,floppy_disk" MESSAGE="Restic backup '${jobName}' failed. Check: journalctl -u restic-backups-${jobName}.service" fi ${pkgs.curl}/bin/curl -sf \ --user "$NTFY_USER:$NTFY_PASSWORD" \ -H "Title: $TITLE" \ -H "Priority: $PRIORITY" \ -H "Tags: $TAGS" \ -d "$MESSAGE" \ "$SERVER/$TOPIC" || true ''; # --------------------------------------------------------------------------- # Failure-notify companion unit (triggered via OnFailure=) # --------------------------------------------------------------------------- mkFailureNotifyUnit = jobName: let notifyScript = mkNotifyScript jobName; in { description = "Notify ntfy on restic backup failure: ${jobName}"; serviceConfig = { Type = "oneshot"; ExecStart = "${notifyScript} failure"; EnvironmentFile = [ config.sops.templates."restic-ntfy.env".path ]; }; }; in { options.${namespace}.services.restic = { enable = mkBoolOpt false "Enable restic backup jobs"; # ----------------------------------------------------------------------- # ntfy notification options # ----------------------------------------------------------------------- ntfy = { enable = mkBoolOpt false "Send ntfy notifications on backup success/failure"; server = mkOpt types.str "https://ntfy.mjallen.dev" "ntfy server base URL"; topic = mkOpt types.str "backups" "ntfy topic to publish notifications to"; userSecret = mkOpt types.str "" "SOPS secret key path for the ntfy username (must be declared in sops.secrets by the caller)"; passwordSecret = mkOpt types.str "" "SOPS secret key path for the ntfy password (must be declared in sops.secrets by the caller)"; }; # ----------------------------------------------------------------------- # Default excludes applied to every job (merged with per-job excludes) # ----------------------------------------------------------------------- defaultExcludes = mkOpt (types.listOf types.str) [ ] "Exclude patterns applied to every backup job"; # ----------------------------------------------------------------------- # Backup job definitions # ----------------------------------------------------------------------- backups = mkOpt (types.attrsOf ( types.submodule { options = { enable = mkBoolOpt true "Enable this backup job"; paths = mkOpt (types.listOf types.str) [ ] "Paths to back up"; exclude = mkOpt (types.listOf types.str) [ ] "Additional exclude patterns for this job"; # Repository — exactly one of repository / repositoryFile must be set. repository = mkOpt (types.nullOr types.str) null "Restic repository URL (plain string)"; repositoryFile = mkOpt (types.nullOr types.str) null "Path to a file containing the repository URL (e.g. from SOPS)"; passwordFile = mkOpt (types.nullOr types.str) null "Path to the restic repository password file"; environmentFile = mkOpt (types.nullOr types.str) null "Path to an environment file (e.g. REST credentials from a SOPS template)"; rcloneConfigFile = mkOpt (types.nullOr types.str) null "Path to an rclone config file (for rclone: repositories)"; # Timer timerConfig = mkOpt (types.attrsOf types.anything) { OnCalendar = "02:00"; RandomizedDelaySec = "1h"; Persistent = true; } "systemd timer configuration for this job"; # Pruning / retention pruneOpts = mkOpt (types.listOf types.str) [ "--keep-daily 7" "--keep-weekly 4" "--keep-monthly 12" "--keep-yearly 2" ] "restic forget flags that control snapshot retention"; # Misc initialize = mkBoolOpt true "Initialise the repository if it does not exist"; createWrapper = mkBoolOpt true "Create a restic wrapper script for this job"; inhibitsSleep = mkBoolOpt true "Inhibit system sleep while this backup is running"; extraBackupArgs = mkOpt (types.listOf types.str) [ ] "Extra arguments passed to restic backup"; }; } )) { } "Restic backup job definitions"; }; config = mkIf cfg.enable { # ----------------------------------------------------------------------- # CLI tools # ----------------------------------------------------------------------- environment.systemPackages = with pkgs; [ restic restic-browser restic-integrity ]; # ----------------------------------------------------------------------- # SOPS: ntfy credentials env-file template # # The secrets themselves (desktop/ntfy/user, desktop/ntfy/password) are # declared in the per-system sops.nix so they can carry the correct # sopsFile path. This module only renders the template that combines them. # ----------------------------------------------------------------------- sops.templates."restic-ntfy.env" = mkIf (cfg.ntfy.enable && cfg.ntfy.userSecret != "" && cfg.ntfy.passwordSecret != "") { mode = "0600"; content = '' NTFY_USER=${config.sops.placeholder.${cfg.ntfy.userSecret}} NTFY_PASSWORD=${config.sops.placeholder.${cfg.ntfy.passwordSecret}} ''; restartUnits = map (name: "restic-backups-${name}.service") enabledJobNames; }; # ----------------------------------------------------------------------- # Restic backup jobs # ----------------------------------------------------------------------- services.restic.backups = mapAttrs ( _name: jobCfg: { inherit (jobCfg) initialize createWrapper inhibitsSleep paths timerConfig pruneOpts extraBackupArgs ; exclude = jobCfg.exclude ++ cfg.defaultExcludes; } // optionalAttrs (jobCfg.passwordFile != null) { inherit (jobCfg) passwordFile; } // optionalAttrs (jobCfg.repository != null) { inherit (jobCfg) repository; } // optionalAttrs (jobCfg.repositoryFile != null) { inherit (jobCfg) repositoryFile; } // optionalAttrs (jobCfg.environmentFile != null) { inherit (jobCfg) environmentFile; } // optionalAttrs (jobCfg.rcloneConfigFile != null) { inherit (jobCfg) rcloneConfigFile; } ) enabledBackups; # ----------------------------------------------------------------------- # Systemd service overrides: ntfy env-file injection + notifications # ----------------------------------------------------------------------- systemd.services = mkIf cfg.ntfy.enable ( mkMerge ( map (jobName: { # Inject the ntfy credentials env-file into the NixOS-generated # restic service and wire up the failure-notify companion unit. "restic-backups-${jobName}" = { serviceConfig = { EnvironmentFile = [ config.sops.templates."restic-ntfy.env".path ]; # Send a success notification after the backup (and prune) completes. ExecStartPost = "${mkNotifyScript jobName} success"; }; unitConfig.OnFailure = "restic-notify-failure-${jobName}.service"; }; # One-shot unit that fires on failure via OnFailure=. "restic-notify-failure-${jobName}" = mkFailureNotifyUnit jobName; }) enabledJobNames ) ); }; }