Commit 26df56fc3e9f

Vincent Demeester <vincent@sbr.pm>
2025-12-22 16:55:46
feat(backup): add off-site restic backups to aix for aion and rhea
- Enable disaster recovery with encrypted weekly backups to aix - Protect critical app data: arr services, Jellyfin, Immich, Audiobookshelf - Implement authenticated ntfy.sh notifications for backup monitoring - Optimize strategy: rsync for media, restic for versioned databases Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 29ff157
secrets/aion/restic-aix-password.age
@@ -0,0 +1,9 @@
+age-encryption.org/v1
+-> piv-p256 ItIHHA A358ugWZLfOwRtt7zNALZHMybJas7HvoQqzh+z47lpzs
+kw+adVWVvGyta9u8yyngYXzyGgqVYBTN0xYCcwhv66k
+-> piv-p256 ViCCtQ At82oGi0N1eaUniWXoEA0sJ4y8VSaE97TS+rmFjLa6ew
+y7MVJnP6YCJyk9Jmg5Ocf/piXTjW8JZZEjsLB/Pltio
+-> ssh-ed25519 5bXRbA R1Kw1VQZ6T8qWN7yf+77CD+i8IM64jD2btvOzZwpIy0
+cFjHFXPFAdPnnHCXMQ+TMh4TQMpu3DIZ2AHfTVVnmh0
+--- aDfkx2Pz1h9kzdXft3oPpldYz+GacXwrWAlxjNTPhcU
+SP��9qu<�NK��oFu�b��x m��)Z�� �&n\'P"m��h���:�=�����lS�D�b�
\ No newline at end of file
secrets/rhea/restic-aix-password.age
@@ -0,0 +1,11 @@
+age-encryption.org/v1
+-> piv-p256 ItIHHA AqG+sM7Nht5jBimmNVQp2aIYGAIr+HpJD6XnZDRezIaf
+VXmDKQWzDQfIE7PKorSDwyUtTfqO9PU7RqV2Md2hqn4
+-> piv-p256 ViCCtQ Aiu8xAmC1nfcfUKtKIw2haYdfmKsGPnEVsEfGPlHV4qH
+9GH6ZrO1ByuDDAgobzBL3M7ICMFHfSlF9rVKG61cbtM
+-> ssh-ed25519 EboMJg 4q9dnqs1ACqF9+64gwy9b2izU5JGLDadwvcOMQLy7DI
+HVVZ0nWKBN1TkF3HQ2KqB6FokwNygg3kkm71dRXu2+s
+--- 058fqEgNV+RnfBWo/mRWdoWeZdd0+2C7v3ROwo2Ztfo
+��@yz3A$>�>
+��'8!�8s�W�/N����
+y���2@���C�ٟ*��o(�"}'�.�������K
\ No newline at end of file
systems/aion/extra.nix
@@ -3,6 +3,7 @@
   globals,
   lib,
   pkgs,
+  config,
   ...
 }:
 let
@@ -64,6 +65,18 @@ in
       mode = "440";
       group = "homepage";
     };
+    "restic-aix-password" = {
+      file = ../../secrets/aion/restic-aix-password.age;
+      mode = "400";
+      owner = "vincent";
+      group = "users";
+    };
+    "ntfy-token" = {
+      file = ../../secrets/sakhalin/ntfy-token.age;
+      mode = "400";
+      owner = "vincent";
+      group = "users";
+    };
   };
 
   services = {
@@ -124,6 +137,85 @@ in
       };
     };
 
+    # Restic backup to aix (off-site backup)
+    # Note: Photos are already rsync'd to aix daily via aix's pull job
+    # This backup focuses on critical versioned data only
+    restic.backups.aix-critical = {
+      user = "vincent";
+      repository = "sftp:vincent@aix.sbr.pm:/data/backup/restic/aion";
+
+      # Use password-based encryption
+      passwordFile = config.age.secrets."restic-aix-password".path;
+
+      paths = [
+        "/neo/pictures/photos/backups" # Immich database dumps only (~100MB, versioned)
+        "/home/vincent/desktop/org" # Org files (<1GB)
+        "/home/vincent/desktop/documents" # Personal docs (~113GB)
+        "/var/lib/lidarr" # Lidarr database and config (~4.6GB)
+        "/var/lib/audiobookshelf" # Audiobookshelf database and config (~30MB)
+      ];
+
+      # Backup schedule - weekly for large dataset
+      timerConfig = {
+        OnCalendar = "weekly";
+        Persistent = true;
+        RandomizedDelaySec = "1h"; # Avoid VPN congestion
+      };
+
+      # Retention policy
+      pruneOpts = [
+        "--keep-daily 7" # Last 7 days
+        "--keep-weekly 4" # Last 4 weeks
+        "--keep-monthly 12" # Last 12 months
+        "--keep-yearly 3" # Last 3 years
+      ];
+
+      # Backup options
+      extraBackupArgs = [
+        "--exclude-caches"
+        "--exclude='*.Trash-*'"
+        "--exclude='lost+found'"
+        "--exclude='.sync-conflict-*'" # Syncthing conflicts
+        "--verbose"
+      ];
+
+      # Check repository integrity after backup
+      checkOpts = [
+        "--read-data-subset=5%" # Verify 5% of data each run
+      ];
+
+      # Backup monitoring with ntfy.sh
+      backupPrepareCommand = ''
+        ${pkgs.curl}/bin/curl \
+          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
+            config.age.secrets."ntfy-token".path
+          })" \
+          -H "Title: Restic Backup Starting (aion)" \
+          -d "Starting backup to aix (critical data only)" \
+          https://ntfy.sbr.pm/backups
+      '';
+
+      backupCleanupCommand = ''
+        ${pkgs.curl}/bin/curl \
+          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
+            config.age.secrets."ntfy-token".path
+          })" \
+          -H "Title: Restic Backup Complete (aion)" \
+          -H "Tags: white_check_mark" \
+          -d "Backup to aix completed successfully" \
+          https://ntfy.sbr.pm/backups || \
+        ${pkgs.curl}/bin/curl \
+          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
+            config.age.secrets."ntfy-token".path
+          })" \
+          -H "Title: Restic Backup Failed (aion)" \
+          -H "Tags: x,warning" \
+          -H "Priority: high" \
+          -d "Backup to aix failed! Check logs: journalctl -u restic-backups-aix-critical.service" \
+          https://ntfy.sbr.pm/backups
+      '';
+    };
+
     music-playlist-dl = {
       enable = true; # Enable on music migration day
       user = "vincent";
@@ -158,7 +250,7 @@ in
       };
     };
 
-    transmission = {
+    transmission = serviceDefaults // {
       enable = true; # Enable on music migration day
       package = pkgs.transmission_4;
       openRPCPort = true; # Open firewall for RPC (port 9091)
systems/rhea/extra.nix
@@ -100,6 +100,18 @@ in
       mode = "400";
       owner = "jellyfin-auto-collections";
     };
+    "restic-aix-password" = {
+      file = ../../secrets/rhea/restic-aix-password.age;
+      mode = "400";
+      owner = "vincent";
+      group = "users";
+    };
+    "ntfy-token" = {
+      file = ../../secrets/sakhalin/ntfy-token.age;
+      mode = "400";
+      owner = "vincent";
+      group = "users";
+    };
   }
   // lib.mapAttrs' (
     name: _cfg:
@@ -652,6 +664,88 @@ in
         apiKeyFile = config.age.secrets."exportarr-${name}-apikey".path;
       }
     ) exportarrServices;
+
+    # Restic backup to aix (off-site backup)
+    # Note: Media files are rsync'd (rhea → aion → aix)
+    # This backup focuses on arr service databases and configs
+    restic.backups.aix-critical = {
+      user = "vincent";
+      repository = "sftp:vincent@aix.sbr.pm:/data/backup/restic/rhea";
+
+      # Use password-based encryption
+      passwordFile = config.age.secrets."restic-aix-password".path;
+
+      paths = [
+        "/var/lib/sonarr" # Sonarr database and config (~501MB)
+        "/var/lib/radarr" # Radarr database and config (~729MB)
+        "/var/lib/bazarr" # Bazarr database and config (~25MB)
+        "/var/lib/readarr" # Readarr database and config (~6MB)
+        "/var/lib/prowlarr" # Prowlarr database and config
+        "/var/lib/jellyfin" # Jellyfin database and config
+        # "/var/lib/immich" # Immich app data # Already handled in aion
+        # "/var/lib/traefik" # Traefik acme.json (Let's Encrypt certs)
+      ];
+
+      # Backup schedule - weekly for moderate dataset
+      timerConfig = {
+        OnCalendar = "weekly";
+        Persistent = true;
+        RandomizedDelaySec = "2h"; # Avoid conflict with aion backup
+      };
+
+      # Retention policy
+      pruneOpts = [
+        "--keep-daily 7" # Last 7 days
+        "--keep-weekly 4" # Last 4 weeks
+        "--keep-monthly 12" # Last 12 months
+        "--keep-yearly 3" # Last 3 years
+      ];
+
+      # Backup options
+      extraBackupArgs = [
+        "--exclude-caches"
+        "--exclude='*.Trash-*'"
+        "--exclude='lost+found'"
+        "--exclude='logs.db'" # Exclude log databases (large, not critical)
+        "--verbose"
+      ];
+
+      # Check repository integrity after backup
+      checkOpts = [
+        "--read-data-subset=5%" # Verify 5% of data each run
+      ];
+
+      # Backup monitoring with ntfy.sh
+      backupPrepareCommand = ''
+        ${pkgs.curl}/bin/curl \
+          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
+            config.age.secrets."ntfy-token".path
+          })" \
+          -H "Title: Restic Backup Starting (rhea)" \
+          -d "Starting backup to aix (arr services + configs)" \
+          https://ntfy.sbr.pm/backups
+      '';
+
+      backupCleanupCommand = ''
+        ${pkgs.curl}/bin/curl \
+          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
+            config.age.secrets."ntfy-token".path
+          })" \
+          -H "Title: Restic Backup Complete (rhea)" \
+          -H "Tags: white_check_mark" \
+          -d "Backup to aix completed successfully" \
+          https://ntfy.sbr.pm/backups || \
+        ${pkgs.curl}/bin/curl \
+          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
+            config.age.secrets."ntfy-token".path
+          })" \
+          -H "Title: Restic Backup Failed (rhea)" \
+          -H "Tags: x,warning" \
+          -H "Priority: high" \
+          -d "Backup to aix failed! Check logs: journalctl -u restic-backups-aix-critical.service" \
+          https://ntfy.sbr.pm/backups
+      '';
+    };
   };
 
   security.acme = {
secrets.nix
@@ -18,13 +18,14 @@ let
   # wakasu = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrAh07USjRnAdS3mMNGdKee1KumjYDLzgXaiZ5LYi2D"; # ssh-keyscan -q -t ed25519 wakasu.sbr.pm
   kyushu = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINd795m+P54GlGJdMaGci9pQ9N942VUz8ri2F14+LWxg"; # ssh-keyscan -q -t ed25519 kyushu.sbr.pm
   aion = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAXDNi2KtoRU83y/V5OWnMbFWmxwBknPmrNWV4RChE7R"; # ssh-keyscan -q -t ed25519 aion.sbr.pm
+  aix = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEoUicDySCGETPAgmI0P3UrgZEXXw3zNsyCIylUP0bML"; # ssh-keyscan -q -t ed25519 aix.sbr.pm
   # TODO: kobe
-  # TODO: aix
   desktops = [
     kyushu
   ];
   servers = [
     aion
+    aix
     aomi
     athena
     demeter
@@ -122,7 +123,13 @@ in
   "secrets/rhea/jellyfin-auto-collections-jellyseerr-password.age".publicKeys = users ++ [ rhea ];
   "secrets/rhea/webdav-password.age".publicKeys = users ++ [ rhea ];
   "secrets/sakhalin/grafana-admin-password.age".publicKeys = users ++ [ sakhalin ];
-  "secrets/sakhalin/ntfy-token.age".publicKeys = users ++ [ sakhalin ];
+  "secrets/sakhalin/ntfy-token.age".publicKeys = users ++ [
+    sakhalin
+    aion
+    rhea
+  ];
   "secrets/sakhalin/homeassistant-prometheus-token.age".publicKeys = users ++ [ sakhalin ];
   "secrets/demeter/mosquitto-homeassistant-password.age".publicKeys = users ++ [ demeter ];
+  "secrets/aion/restic-aix-password.age".publicKeys = users ++ [ aion ];
+  "secrets/rhea/restic-aix-password.age".publicKeys = users ++ [ rhea ];
 }