Files
nix-config/modules/nixos/services/restic/default.nix
2026-04-05 19:10:23 -05:00

247 lines
9.8 KiB
Nix

{
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
)
);
};
}