This commit is contained in:
mjallen18
2026-03-24 13:23:38 -05:00
parent 540dabcb5d
commit 35ac45f5ce
4 changed files with 296 additions and 74 deletions

View File

@@ -1,32 +1,243 @@
{
config,
lib,
pkgs,
namespace,
...
}:
with lib;
let
name = "restic";
cfg = config.${namespace}.services.${name};
inherit (lib.${namespace})
mkOpt
mkBoolOpt
;
resticConfig = lib.${namespace}.mkModule {
inherit config name;
serviceName = "${name}-rest-server";
description = "restic";
options = { };
moduleConfig = {
# Configure the standard NixOS restic server service
services.restic.server = {
enable = true;
dataDir = "${cfg.dataDir}/backup/restic";
prometheus = true;
listenAddress = "${cfg.listenAddress}:${toString cfg.port}";
htpasswd-file = "${cfg.dataDir}/backup/restic/.htpasswd";
extraFlags = [ "--no-auth" ];
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
{
imports = [ resticConfig ];
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-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:
{
initialize = jobCfg.initialize;
createWrapper = jobCfg.createWrapper;
inhibitsSleep = jobCfg.inhibitsSleep;
paths = jobCfg.paths;
exclude = jobCfg.exclude ++ cfg.defaultExcludes;
timerConfig = jobCfg.timerConfig;
pruneOpts = jobCfg.pruneOpts;
extraBackupArgs = jobCfg.extraBackupArgs;
}
// 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
)
);
};
}

View File

@@ -5,6 +5,9 @@ desktop:
user: ENC[AES256_GCM,data:IoDWBPg=,iv:Am5YWSr6qhQZumY/BUUgtL131q/gsk3OpSLFjPpYu8c=,tag:3fhkAZdukXbppH9BLUVSfA==,type:str]
password: ENC[AES256_GCM,data:D0u9Wq67jDetyzI=,iv:yjL3Ywfa5VlKbMhQFduujReElGWTJFT2ppUEtYxsLwk=,tag:YlnW17CTmADN8p9rzwGhlQ==,type:str]
repo: ENC[AES256_GCM,data:iWw+aBd1S1WVyP5QinxZBuw5JPvpgLi2uAeAf3AWpKccRtQfE2D8nOUE5ynIek1pkfBn,iv:ltqNrRXeDkiesc2Q6ScNcMGYTyQAYUonAqOfA9KRQTI=,tag:n7Z9iLgUCGUs7uJLLilLEg==,type:str]
ntfy:
user: ENC[AES256_GCM,data:cM7KklfgUQ==,iv:cmrJDVLlw5se/p2LqpYCSajtq+3qFKxZkSLKmfju0QI=,tag:rWVjjWUEdHyhKYamszaLPQ==,type:str]
password: ENC[AES256_GCM,data:CYmfWQOzvmlTKsk=,iv:DiSHqpL8C6N1uTkYcttzXyawvq/psDJe3WkjhZ2hj/k=,tag:zaATIj3oaqPBlMzDxD+jdA==,type:str]
system-ed25519-pub: ENC[AES256_GCM,data:yM0Q6bf5qoY02jxayk0F3U1d4mR5ZTAmGEscmYMIQncUthefbfhaBgZn2uaDwEdagSN6ihZ9IzZ8i+KEiCW8X1iWt+N2zd3dCyQCM9D/5VUDulvjoNE2IN+T/bIsbCfh,iv:iLuNOQV0M40wao1LBjwy0opqxZZRmPsAxTbg18CHn/Y=,tag:PmxE9ldU/hWEqKL4juLdBw==,type:str]
system-ed25519-priv: ENC[AES256_GCM,data:neR7rxQic+JxfkupQh9hIFOSF+QEahIWhFaP4Vk7bDBims9nimy1WRF4jwwoY8+rco+mfrQZT+/F5URpC0uCf0UeL+RCnGcVPMS+NS1+T9/Wygh1ZfsdQNv3G9+H2r59n4kGmaPfaLxfeftNf2M2YkTD5VEt4oZuHg8gcivoeQ8evtezjicIZGLrMxJLXn+SxDq6+glKKk4xOel9AdYsXTw6fCZ/y8uXCjhpMp0FvV6DZbeBawm8O+R/m0KUIEBpfDLXWXnpjZ71XhYCNYRyIwiDBhW5S0o0X+0iX4vSQfxBcwd1oLPYWJjYlMcUSTmaiKH2x5JaQJRiXCd6t3vQchRNQ81wNbL1Q/xERO8RsUMu7u0EW5qeBiGAuW7z/sx3yV8c8SNBwr1GfT+d3g77EShXn48XaGIYvNKW1ANdxkgSc6h66acWeI1kY6/Dk9IpVxvuAQgV3Ukwv8HDCnSHsKJVfm7KYw/jqVZGKTE/msVkCm7KGig1OfinZCs5VvBO7YSWrSyeb580kiiKf32xxBJtmELWueVVov1c,iv:m2Eqw9OAWf1UO38r5i4DVdh9zqLdrbggUOcxqu2339w=,tag:+yq0MDE3dofu8XbDdcTbPQ==,type:str]
system-rsa-pub: ENC[AES256_GCM,data:uqiKfnAPDXEo0UF0kITdSaGeYaG5Je8jLx2S0tYEyz2jGlTYGvervjQvpbSogHot0A2qgJUc+E2o161fJFNDH599MnI7n3WGJpNQekfUZh/kGM3tBOigzMl6d0UdDjWnJh7IQo06dD35rCE79ZLg5HQEuJz602HG36Be/XfJ2nWAVDqd+/F8+06jrc7RKvecp4ubbrrz9u/wj9BZRLN3GcG8QV4MCRY5neX7MrLWNtB9wcuRS/kt7jLtM2aIDd7a+DZvcUM+4fbWLqpwMQq4N/pf6AYLs/GuemjaZ4iE8evBvHoP82TaCLUhd4jgWp0g8KamK+DHwZAn2nkNGVNOFTSy4p3hA2tkugDIubDhY4ahKAbf9Z0IgY+2iW/ICtvSGxcjT7zIgynnOHTZn0wdVUJEWomTwWdmjCdWo5WSQnCvSEXngNDvEhDVkyA51nBaKVKnshHwOEFiV/0RvO+WnjRjR1jnPz4sksp58OzChd5J4YrA8Y2RbhHFxFV/W3Sn9HFUbGUWo2xHWqhWTBhVmmJSHlwkuG1hzZi1q18aHLgaJBbJzlaDcWLrn8XMm7CPMQzf4+iMJliaANHwGKv52+NEyRMcsgAHUyfsW2qU2uZtCkziPMzxQpJZrNibZrAdwII50hT2B4BQBPVzK1VBC+BLrYmRl+Qvie9C5yzzllQMhLEcA0ChI5+MJ6Rul8UjVpBxsWteLWkc4KHkOabjDLGbF6UAjpnj8xpGCZteLfno6UxJFavXaqm2IsX1IgW80ptPKuJmxiwnkQg7yue19/lsB9/5pWfgHfpThj7Rt25TUIAdl0VtC0yer7UuIKdyl6t58TZHZ78ncTWWRqgnEPABjsN3oUpuhCUbtaq0u1jbJt6CUfIXhaO9Y/YlMPwMyfacAZKQBlg2BxBbtycsWG/ktMUKAbNIdxr+g2kKCEGBgpuMJXmhsE4QmLzZJ1J+y9TOkBrimpJPY+awYkAL0q86HVQ=,iv:tD8LK0+Ksb+3Ahhx2td//ktIgeyFykdrFjN1HURZwno=,tag:XeRgt9DQhWUynhBRZF+rcA==,type:str]
@@ -161,8 +164,8 @@ sops:
STU1bkRXNVRsYkJac0RPOVpZTmJCaW8KS9zUt1QpP0k38LQ6OMCkL7Ee3r/fZsWp
hfISSv9uO1uEmgRHtXSRaElQmOmGgcZB7oqSJvY3SJHxENPiCK4cDw==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-03-23T21:04:05Z"
mac: ENC[AES256_GCM,data:RzOE3TI/cz2OD/cfyuR4aUTm2idclRiBEMzex8HAdg9MiHbxrBST7UF1D0hkG8tRxJZccKerwjJLj+cY+zfMGI59299AC8PEdHQFnyK8JJH3Nk93Bl5Ctsd0eSRV5UHo+XerZbEh0/5o2YlXLQcar++08GhDf0bRjiacjBt6TRc=,iv:79JFaUW4ro35VS9NFlSdG57y6NJ10KglRQOLfhiiQHY=,tag:7CpnuBT5kdcMqEsKcGtimA==,type:str]
lastmodified: "2026-03-24T18:20:10Z"
mac: ENC[AES256_GCM,data:X+n7XWuSM7fKBwimjhavlb4ZBHqS2C5U01HchnQ2WNJDCqg4rebCP08Fvv2WZOCUexS+Xp1W3C43vHmC51SMPB/8R/mABPDviC5B4HjGjp+WEET1jcoZtO7EZ5zCJwxAuIC4U5JrZQA89Uq64urJ1ggKhlt4Vv6cYpq3WYBNQ6s=,iv:GM4uRui5PjYH30zwani5uA5EYQbI5frUVXwsYk5Acoc=,tag:myVURZ7H7328/UrDnV/naA==,type:str]
pgp:
- created_at: "2026-02-06T15:34:29Z"
enc: |-
@@ -185,4 +188,4 @@ sops:
-----END PGP MESSAGE-----
fp: CBCB9B18A6B8930B0B6ABFD1CCB8CBEB30633684
unencrypted_suffix: _unencrypted
version: 3.11.0
version: 3.12.1

View File

@@ -1,59 +1,59 @@
{ config, pkgs, ... }:
{
environment.systemPackages = with pkgs; [
restic
# restic-browser
restic-integrity
];
config,
namespace,
...
}:
{
${namespace}.services.restic = {
enable = true;
services.restic.backups = {
jallen-nas = {
initialize = true;
createWrapper = true;
inhibitsSleep = true;
environmentFile = config.sops.templates."restic.env".path;
passwordFile = config.sops.secrets."desktop/restic/password".path;
repositoryFile = config.sops.secrets."desktop/restic/repo".path;
paths = [
"/home/matt"
];
exclude = [
"/home/matt/Steam"
"/home/matt/Heroic"
"/home/matt/1TB"
"/home/matt/Downloads"
"/home/matt/Nextcloud"
"/home/matt/.cache"
"/home/matt/.local/share/Steam"
"/home/matt/.var/app/com.valvesoftware.Steam"
"/home/matt/.tmp"
"/home/matt/.thumbnails"
"/home/matt/.compose-cache"
];
# -------------------------------------------------------------------------
# ntfy notifications
# -------------------------------------------------------------------------
ntfy = {
enable = true;
server = "https://ntfy.mjallen.dev";
topic = "backups";
# SOPS secret keys — these must be declared in sops.nix with the correct
# sopsFile so that sops-nix knows how to decrypt them.
userSecret = "desktop/ntfy/user";
passwordSecret = "desktop/ntfy/password";
};
proton-drive = {
initialize = true;
createWrapper = true;
inhibitsSleep = true;
passwordFile = config.sops.secrets."desktop/restic/password".path;
rcloneConfigFile = "/home/matt/.config/rclone/rclone.conf";
repository = "rclone:proton-drive:backup-nix";
paths = [
"/home/matt"
];
exclude = [
"/home/matt/Steam"
"/home/matt/Heroic"
"/home/matt/1TB"
"/home/matt/Downloads"
"/home/matt/Nextcloud"
"/home/matt/.cache"
"/home/matt/.local/share/Steam"
"/home/matt/.var/app/com.valvesoftware.Steam"
"/home/matt/.tmp"
"/home/matt/.thumbnails"
"/home/matt/.compose-cache"
];
# -------------------------------------------------------------------------
# Excludes shared by every job on this host
# -------------------------------------------------------------------------
defaultExcludes = [
"/home/matt/Steam"
"/home/matt/Heroic"
"/home/matt/1TB"
"/home/matt/Downloads"
"/home/matt/Nextcloud"
"/home/matt/.cache"
"/home/matt/.local/share/Steam"
"/home/matt/.var/app/com.valvesoftware.Steam"
"/home/matt/.tmp"
"/home/matt/.thumbnails"
"/home/matt/.compose-cache"
];
# -------------------------------------------------------------------------
# Backup jobs
# -------------------------------------------------------------------------
backups = {
jallen-nas = {
paths = [ "/home/matt" ];
environmentFile = config.sops.templates."restic.env".path;
passwordFile = config.sops.secrets."desktop/restic/password".path;
repositoryFile = config.sops.secrets."desktop/restic/repo".path;
};
proton-drive = {
paths = [ "/home/matt" ];
passwordFile = config.sops.secrets."desktop/restic/password".path;
rcloneConfigFile = "/home/matt/.config/rclone/rclone.conf";
repository = "rclone:proton-drive:backup-nix";
};
};
};
}

View File

@@ -51,6 +51,14 @@ in
sopsFile = desktopSopsFile;
mode = "0600";
};
"desktop/ntfy/user" = {
sopsFile = desktopSopsFile;
mode = "0600";
};
"desktop/ntfy/password" = {
sopsFile = desktopSopsFile;
mode = "0600";
};
# ------------------------------
# SSH keys
# ------------------------------