From 35ac45f5ce9d8adfa1bddcb81669d9b7969fd03a Mon Sep 17 00:00:00 2001 From: mjallen18 Date: Tue, 24 Mar 2026 13:23:38 -0500 Subject: [PATCH] restic --- modules/nixos/services/restic/default.nix | 247 ++++++++++++++++-- secrets/desktop-secrets.yaml | 9 +- .../matt-nixos/services/restic/default.nix | 106 ++++---- systems/x86_64-linux/matt-nixos/sops.nix | 8 + 4 files changed, 296 insertions(+), 74 deletions(-) diff --git a/modules/nixos/services/restic/default.nix b/modules/nixos/services/restic/default.nix index 0a38cb1..8309d29 100644 --- a/modules/nixos/services/restic/default.nix +++ b/modules/nixos/services/restic/default.nix @@ -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 + ) + ); + }; } diff --git a/secrets/desktop-secrets.yaml b/secrets/desktop-secrets.yaml index e3982aa..8d7285f 100644 --- a/secrets/desktop-secrets.yaml +++ b/secrets/desktop-secrets.yaml @@ -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 diff --git a/systems/x86_64-linux/matt-nixos/services/restic/default.nix b/systems/x86_64-linux/matt-nixos/services/restic/default.nix index 0df1a06..5ad8d26 100644 --- a/systems/x86_64-linux/matt-nixos/services/restic/default.nix +++ b/systems/x86_64-linux/matt-nixos/services/restic/default.nix @@ -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"; + }; }; }; } diff --git a/systems/x86_64-linux/matt-nixos/sops.nix b/systems/x86_64-linux/matt-nixos/sops.nix index b070ba4..b8f8ce5 100755 --- a/systems/x86_64-linux/matt-nixos/sops.nix +++ b/systems/x86_64-linux/matt-nixos/sops.nix @@ -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 # ------------------------------