flake-update-20260505
  1{
  2  libx,
  3  globals,
  4  lib,
  5  pkgs,
  6  config,
  7  ...
  8}:
  9
 10let
 11  # Service defaults for media/homelab services
 12  serviceDefaults = libx.mkServiceDefaults { };
 13
 14  # Samba shares configuration (data-driven approach)
 15  # Samba shares on /neo
 16  neoSambaShares = {
 17    backup = { };
 18    downloads = { };
 19    pictures = { };
 20    videos = { };
 21  };
 22  # Samba shares on /zion
 23  zionSambaShares = {
 24    audiobooks = {
 25      readOnly = true;
 26    };
 27    ebooks = { };
 28    documents = { };
 29    music = {
 30      readOnly = true;
 31    };
 32  };
 33
 34  # Exportarr services configuration (data-driven approach)
 35  exportarrServices = {
 36    sonarr = {
 37      port = 9707;
 38      servicePort = 8989;
 39    };
 40    radarr = {
 41      port = 9708;
 42      servicePort = 7878;
 43    };
 44    lidarr = {
 45      port = 9709;
 46      servicePort = 8686;
 47    };
 48    prowlarr = {
 49      port = 9710;
 50      servicePort = 9696;
 51    };
 52    bazarr = {
 53      port = 9712;
 54      servicePort = 6767;
 55    };
 56  };
 57
 58  # Common rsync configuration for aion backups (reverse sync after migration)
 59  aionBackupDefaults = {
 60    source = {
 61      host = "aion.sbr.pm";
 62      user = "vincent";
 63    };
 64    destination = "/zion";
 65    delete = true; # Mirror mode: delete files in destination that don't exist in source
 66    user = "vincent";
 67    group = "users";
 68    rsyncArgs = [
 69      "--exclude=.Trash-*"
 70      "--exclude=lost+found"
 71    ];
 72    sshArgs = [
 73      "-o StrictHostKeyChecking=accept-new"
 74    ];
 75  };
 76in
 77{
 78  imports = [
 79    ../common/services/samba.nix
 80
 81    ../common/services/prometheus-exporters-postgres.nix
 82    ../../modules/jellyfin-auto-collections
 83    ../../modules/jellyfin-favorites-sync
 84  ];
 85
 86  # Age secrets: gandi.env + webdav + jellyfin + ollama + generated exportarr secrets
 87  age.secrets = {
 88    "gandi.env" = {
 89      file = ../../secrets/rhea/gandi.env.age;
 90      mode = "400";
 91      owner = "traefik";
 92      group = "traefik";
 93    };
 94    "webdav-password" = {
 95      file = ../../secrets/rhea/webdav-password.age;
 96      mode = "400";
 97    };
 98    "jellyfin-auto-collections-api-key" = {
 99      file = ../../secrets/rhea/jellyfin-auto-collections-api-key.age;
100      mode = "400";
101      owner = "jellyfin-auto-collections";
102    };
103    "jellyfin-auto-collections-jellyseerr-password" = {
104      file = ../../secrets/rhea/jellyfin-auto-collections-jellyseerr-password.age;
105      mode = "400";
106      owner = "jellyfin-auto-collections";
107    };
108    "jellyfin-favorites-sync-api-key" = {
109      file = ../../secrets/rhea/jellyfin-favorites-sync-api-key.age;
110      mode = "400";
111      owner = "jellyfin-favorites-sync";
112    };
113    "jellyfin-favorites-sync-ssh-key" = {
114      file = ../../secrets/rhea/jellyfin-favorites-sync-ssh-key.age;
115      mode = "400";
116      owner = "jellyfin-favorites-sync";
117    };
118    "restic-aix-password" = {
119      file = ../../secrets/rhea/restic-aix-password.age;
120      mode = "400";
121      owner = "vincent";
122      group = "users";
123    };
124    "ntfy-token" = {
125      file = ../../secrets/sakhalin/ntfy-token.age;
126      mode = "400";
127      owner = "vincent";
128      group = "users";
129    };
130  }
131  // lib.mapAttrs' (
132    name: _cfg:
133    lib.nameValuePair "exportarr-${name}-apikey" {
134      file = ../../secrets/rhea/exportarr-${name}-apikey.age;
135      mode = "400";
136      owner = "root";
137    }
138  ) exportarrServices;
139
140  users.users.vincent.linger = true;
141
142  services = {
143    traefik = {
144      enable = true;
145
146      staticConfigOptions = {
147        # API and Dashboard
148        api = {
149          dashboard = true;
150          insecure = false;
151        };
152
153        # Prometheus metrics
154        metrics.prometheus = {
155          addEntryPointsLabels = true;
156          addRoutersLabels = true;
157          addServicesLabels = true;
158        };
159
160        # Entry points
161        entryPoints = {
162          web = {
163            address = ":80";
164            http.redirections.entryPoint = {
165              to = "websecure";
166              scheme = "https";
167            };
168          };
169          websecure = {
170            address = ":443";
171            transport = {
172              respondingTimeouts = {
173                readTimeout = "600s"; # 10 minutes for large uploads
174                writeTimeout = "600s";
175                idleTimeout = "600s";
176              };
177            };
178          };
179          mqtt = {
180            address = ":1883";
181          };
182          mqtts = {
183            address = ":8883";
184          };
185        };
186
187        # Certificate resolver using Gandi DNS
188        certificatesResolvers.letsencrypt = {
189          acme = {
190            email = "vincent@sbr.pm";
191            storage = "/var/lib/traefik/acme.json";
192            dnsChallenge = {
193              provider = "gandiv5";
194              delayBeforeCheck = "0s";
195              resolvers = [
196                "1.1.1.1:53"
197                "8.8.8.8:53"
198              ];
199            };
200          };
201        };
202      };
203
204      # Dynamic configuration using module option
205      dynamicConfigOptions =
206        let
207          # Helper function to create a simple HTTP router
208          mkRouter = name: hosts: {
209            rule = lib.concatStringsSep " || " (map (host: "Host(`${host}`)") hosts);
210            service = name;
211            entryPoints = [ "websecure" ];
212            tls.certResolver = "letsencrypt";
213          };
214
215          # Helper function to create a router with middlewares
216          mkRouterWithMiddlewares = name: hosts: middlewares: {
217            rule = lib.concatStringsSep " || " (map (host: "Host(`${host}`)") hosts);
218            service = name;
219            entryPoints = [ "websecure" ];
220            tls.certResolver = "letsencrypt";
221            inherit middlewares;
222          };
223
224          # Helper function to create a simple HTTP service
225          mkService = url: {
226            loadBalancer.servers = [ { inherit url; } ];
227          };
228
229          # Define local services with their ports and optional alternate hosts
230          localServices = {
231            jellyfin.port = 8096;
232            jellyseerr.port = 5055;
233            # *arr services - ports from exportarrServices
234            sonarr.port = exportarrServices.sonarr.servicePort;
235            radarr.port = exportarrServices.radarr.servicePort;
236            bazarr.port = exportarrServices.bazarr.servicePort;
237            prowlarr.port = exportarrServices.prowlarr.servicePort;
238            transmission = {
239              port = 9091;
240              altHosts = [ "t.sbr.pm" ];
241            };
242            immich.port = 2283;
243            calibre = {
244              port = 8083;
245              altHosts = [ "books.sbr.pm" ];
246            };
247            dav.port = 6065;
248          };
249
250          # Generate routers for local services
251          localRouters = lib.mapAttrs' (
252            name: cfg:
253            let
254              hosts = [ "${name}.sbr.pm" ] ++ (cfg.altHosts or [ ]);
255            in
256            lib.nameValuePair name (mkRouter name hosts)
257          ) localServices;
258
259          # Generate services for local services
260          localHttpServices = lib.mapAttrs' (
261            name: cfg: lib.nameValuePair name (mkService "http://localhost:${toString cfg.port}")
262          ) localServices;
263
264          # Filter machines that have syncthing configured
265          syncthingMachines = lib.filterAttrs (
266            _name: machine: machine ? syncthing && machine.syncthing ? folders
267          ) globals.machines;
268
269          # Generate routers for syncthing hosts
270          syncthingRouters = lib.mapAttrs' (
271            name: _machine:
272            lib.nameValuePair "syncthing-${name}" {
273              rule = "Host(`syncthing.sbr.pm`) && PathPrefix(`/${name}`) || Host(`s.sbr.pm`) && PathPrefix(`/${name}`)";
274              service = "syncthing-${name}";
275              entryPoints = [ "websecure" ];
276              middlewares = [
277                "syncthing-${name}-addslash"
278                "syncthing-${name}-strip"
279              ];
280              tls = {
281                certResolver = "letsencrypt";
282              };
283            }
284          ) syncthingMachines;
285
286          # Generate services for syncthing hosts
287          syncthingServices = lib.mapAttrs' (
288            name: machine:
289            lib.nameValuePair "syncthing-${name}" {
290              loadBalancer = {
291                servers = [
292                  { url = "http://${builtins.head machine.net.vpn.ips}:8384"; }
293                ];
294              };
295            }
296          ) syncthingMachines;
297
298          # Generate middleware for path stripping
299          syncthingMiddlewares = lib.mapAttrs' (
300            name: _machine:
301            lib.nameValuePair "syncthing-${name}-strip" {
302              stripPrefix = {
303                prefixes = [ "/${name}" ];
304              };
305            }
306          ) syncthingMachines;
307
308          # Generate middleware for adding trailing slash
309          syncthingAddSlashMiddlewares = lib.mapAttrs' (
310            name: _machine:
311            lib.nameValuePair "syncthing-${name}-addslash" {
312              redirectRegex = {
313                regex = "^(https?://[^/]+/${name})$";
314                replacement = "$$1/";
315                permanent = true;
316              };
317            }
318          ) syncthingMachines;
319        in
320        {
321          http = {
322            routers =
323              syncthingRouters
324              // localRouters
325              // {
326                # Override immich router to add large file upload middleware
327                immich = mkRouterWithMiddlewares "immich" [ "immich.sbr.pm" ] [ "immich-buffering" ];
328                # Override home router to add Home Assistant headers
329                home = mkRouterWithMiddlewares "home" [ "home.sbr.pm" ] [ "home-headers" ];
330                kiwix = mkRouter "kiwix" [ "kiwix.sbr.pm" ];
331                n8n = mkRouter "n8n" [ "n8n.sbr.pm" ];
332                paperless = mkRouter "paperless" [ "paperless.sbr.pm" ];
333                grafana = mkRouter "grafana" [ "grafana.sbr.pm" ];
334                navidrome = mkRouter "navidrome" [
335                  "navidrome.sbr.pm"
336                  "music.sbr.pm"
337                ];
338                transmission-music = mkRouter "transmission-music" [
339                  "transmission-music.sbr.pm"
340                  "tm.sbr.pm"
341                ];
342                audiobookshelf = mkRouter "audiobookshelf" [
343                  "audiobookshelf.sbr.pm"
344                  "podcasts.sbr.pm"
345                ];
346                lidarr = mkRouter "lidarr" [ "lidarr.sbr.pm" ];
347                homepage = mkRouter "homepage" [ "homepage.sbr.pm" ];
348                # OpenCode web interface on okinawa (VPN-only)
349                opencode = mkRouter "opencode" [ "opencode.sbr.pm" ];
350                # llama-server on okinawa (VPN-only, OpenAI-compatible API)
351                llm = mkRouter "llm" [ "llm.sbr.pm" ];
352                reading = mkRouter "reading" [ "reading.sbr.pm" ];
353                # SearXNG metasearch engine on sakhalin
354                search = mkRouter "search" [
355                  "search.sbr.pm"
356                  "searxng.sbr.pm"
357                ];
358                # Traefik dashboard
359                traefik-dashboard = {
360                  rule = "Host(`traefik.sbr.pm`)";
361                  service = "api@internal";
362                  entryPoints = [ "websecure" ];
363                  tls.certResolver = "letsencrypt";
364                };
365              };
366            services =
367              syncthingServices
368              // localHttpServices
369              // {
370                home = mkService "http://${builtins.head globals.machines.hass.net.ips}:8123";
371                kiwix = mkService "http://${builtins.head globals.machines.sakhalin.net.ips}:8080";
372                n8n = mkService "http://${builtins.head globals.machines.sakhalin.net.ips}:5678";
373                paperless = mkService "http://${builtins.head globals.machines.aion.net.ips}:8000";
374                grafana = mkService "http://${builtins.head globals.machines.sakhalin.net.ips}:3000";
375                navidrome = mkService "http://${builtins.head globals.machines.aion.net.ips}:4533";
376                transmission-music = mkService "http://${builtins.head globals.machines.aion.net.ips}:9091";
377                homepage = mkService "http://${builtins.head globals.machines.aion.net.ips}:3001";
378                audiobookshelf = mkService "http://${builtins.head globals.machines.aion.net.ips}:13378";
379                lidarr = mkService "http://${builtins.head globals.machines.aion.net.ips}:8686";
380                opencode = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:5555";
381                llm = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:8090";
382                reading = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:8880";
383                search = mkService "http://${builtins.head globals.machines.sakhalin.net.vpn.ips}:8090";
384              };
385            middlewares =
386              syncthingMiddlewares
387              // syncthingAddSlashMiddlewares
388              // {
389                # Middleware for handling large file uploads (Immich)
390                immich-buffering = {
391                  buffering = {
392                    maxRequestBodyBytes = 0; # No limit
393                    memRequestBodyBytes = 104857600; # 100MB in memory
394                    maxResponseBodyBytes = 0; # No limit
395                    memResponseBodyBytes = 104857600; # 100MB in memory
396                    retryExpression = "IsNetworkError() && Attempts() < 2";
397                  };
398                };
399                # Middleware for Home Assistant reverse proxy headers
400                home-headers = {
401                  headers = {
402                    customRequestHeaders = {
403                      X-Forwarded-Proto = "https";
404                    };
405                  };
406                };
407              };
408          };
409          tcp = {
410            routers = {
411              mqtt = {
412                rule = "HostSNI(`*`)";
413                service = "mqtt";
414                entryPoints = [ "mqtt" ];
415              };
416              mqtts = {
417                rule = "HostSNI(`mqtt.sbr.pm`)";
418                service = "mqtt";
419                entryPoints = [ "mqtts" ];
420                tls = {
421                  certResolver = "letsencrypt";
422                };
423              };
424            };
425            services = {
426              mqtt = {
427                loadBalancer = {
428                  servers = [
429                    { address = "${builtins.head globals.machines.demeter.net.ips}:1883"; }
430                  ];
431                };
432              };
433            };
434          };
435        };
436    };
437
438    # smartd = {
439    #   enable = true;
440    #   devices = [ { device = "/dev/nvme0n1"; } ];
441    # };
442    samba.settings = {
443      global."server string" = "Rhea";
444    }
445    // builtins.mapAttrs (
446      name: cfg:
447      libx.mkSambaShare (
448        {
449          inherit name;
450          path = "/neo/${name}";
451        }
452        // cfg
453      )
454    ) neoSambaShares
455    // builtins.mapAttrs (
456      name: cfg:
457      libx.mkSambaShare (
458        {
459          inherit name;
460          path = "/zion/${name}";
461        }
462        // cfg
463      )
464    ) zionSambaShares;
465    nfs.server = {
466      enable = true;
467      # Fixed ports for firewall configuration
468      lockdPort = 4001;
469      mountdPort = 4002;
470      statdPort = 4000;
471      exports = ''
472                /neo                      192.168.1.0/24(rw,fsid=0,no_subtree_check) 10.100.0.0/24(rw,fsid=0,no_subtree_check)
473                /neo/backup               192.168.1.0/24(rw,fsid=2,no_subtree_check) 10.100.0.0/24(rw,fsid=2,no_subtree_check)
474                /neo/downloads            192.168.1.0/24(rw,fsid=4,no_subtree_check) 10.100.0.0/24(rw,fsid=4,no_subtree_check)
475                /neo/pictures             192.168.1.0/24(rw,fsid=7,no_subtree_check) 10.100.0.0/24(rw,fsid=7,no_subtree_check)
476                /neo/videos               192.168.1.0/24(rw,fsid=8,no_subtree_check) 10.100.0.0/24(rw,fsid=8,no_subtree_check)
477                /zion                     192.168.1.0/24(rw,fsid=10,no_subtree_check) 10.100.0.0/24(rw,fsid=10,no_subtree_check)
478                /zion/audiobooks          192.168.1.0/24(ro,fsid=11,no_subtree_check) 10.100.0.0/24(ro,fsid=11,no_subtree_check)
479                /zion/documents           192.168.1.0/24(rw,fsid=12,no_subtree_check) 10.100.0.0/24(rw,fsid=12,no_subtree_check)
480                /zion/ebooks              192.168.1.0/24(rw,fsid=13,no_subtree_check) 10.100.0.0/24(rw,fsid=13,no_subtree_check)
481                /zion/music               192.168.1.0/24(ro,fsid=14,no_subtree_check) 10.100.0.0/24(ro,fsid=14,no_subtree_check)
482        			'';
483    };
484    immich = serviceDefaults // {
485      enable = true;
486      host = "0.0.0.0"; # Listen on all interfaces for VPN access
487      mediaLocation = "/neo/pictures/photos";
488    };
489    postgresql = {
490      ensureDatabases = [
491        "immich"
492      ];
493      ensureUsers = [
494        {
495          name = "vincent";
496        }
497      ];
498    };
499    jellyfin = serviceDefaults // {
500      enable = true;
501    };
502    jellyseerr = {
503      enable = true;
504      openFirewall = true;
505    };
506    webdav = {
507      enable = true;
508      user = "vincent";
509      group = "users";
510      environmentFile = config.age.secrets."webdav-password".path;
511      settings = {
512        address = "127.0.0.1";
513        port = 6065;
514        scope = "/zion/documents/boox";
515        modify = true;
516        users = [
517          {
518            username = "vincent";
519            password = "{env}WEBDAV_PASSWORD_HASH";
520          }
521        ];
522        rules = [
523          {
524            regex = "(\\..*|.*\\.tmp)$"; # Block hidden files and .tmp files
525            allow = false;
526          }
527        ];
528      };
529    };
530    jellyfin-auto-collections = {
531      enable = true;
532      jellyfinUrl = "http://localhost:8096";
533      userId = "400fef4e0ab2448cb8a2bc8ca2facc4f";
534      apiKeyFile = config.age.secrets."jellyfin-auto-collections-api-key".path;
535      schedule = "daily"; # Run daily at midnight
536
537      jellyseerr = {
538        enable = false; # Enable when password secret is created
539        serverUrl = "http://localhost:5055";
540        email = "vincent@sbr.pm";
541        # Uncomment when jellyseerr password secret is created
542        # passwordFile = config.age.secrets."jellyfin-auto-collections-jellyseerr-password".path;
543        userType = "local";
544      };
545
546      settings = {
547        plugins = {
548          imdb_chart = {
549            enabled = true;
550            list_ids = [
551              "top"
552              "moviemeter"
553            ];
554            clear_collection = true;
555          };
556          imdb_list = {
557            enabled = true;
558            list_ids = [
559              "ls055592025" # IMDb Top 250
560            ];
561          };
562          jellyfin_api = {
563            enabled = true;
564            list_ids = [
565              # Marvel Cinematic Universe
566              {
567                studios = [
568                  "Marvel Studios"
569                  "Marvel Entertainment"
570                ];
571                list_name = "Marvel Cinematic Universe";
572                includeItemTypes = [ "Movie" ];
573              }
574              # Pixar Animation
575              {
576                studios = [ "Pixar" ];
577                list_name = "Pixar Collection";
578                includeItemTypes = [ "Movie" ];
579              }
580              # Studio Ghibli
581              {
582                studios = [ "Studio Ghibli" ];
583                list_name = "Studio Ghibli Collection";
584                includeItemTypes = [ "Movie" ];
585              }
586              # Sing Movies (Illumination)
587              {
588                searchTerm = "Sing";
589                studios = [ "Illumination Entertainment" ];
590                list_name = "Sing Movies";
591                includeItemTypes = [ "Movie" ];
592              }
593              # Christopher Nolan Films
594              {
595                person = [ "Christopher Nolan" ];
596                list_name = "Christopher Nolan Collection";
597                includeItemTypes = [ "Movie" ];
598              }
599              # Highly Rated Sci-Fi
600              {
601                genres = [ "Science Fiction" ];
602                minCriticRating = [ "8" ];
603                list_name = "Top Sci-Fi Movies";
604                includeItemTypes = [ "Movie" ];
605              }
606              # Recent Movies (2024-2025)
607              {
608                years = [
609                  2024
610                  2025
611                ];
612                list_name = "Recent Releases";
613                includeItemTypes = [ "Movie" ];
614              }
615              # Award Winners
616              {
617                tags = [ "Oscar Winner" ];
618                list_name = "Oscar Winners";
619                includeItemTypes = [ "Movie" ];
620              }
621            ];
622          };
623        };
624      };
625    };
626    jellyfin-favorites-sync = {
627      enable = true;
628      schedule = "daily"; # Run daily at midnight
629
630      jellyfinUrl = "http://localhost:8096";
631      apiKeyFile = config.age.secrets."jellyfin-favorites-sync-api-key".path;
632      userId = "400fef4e0ab2448cb8a2bc8ca2facc4f"; # vincent user ID
633
634      # Use "Keep" playlist instead of favorites
635      playlistName = "Keep";
636
637      sourceRoot = "/neo/videos";
638
639      destination = {
640        host = "aix.sbr.pm";
641        user = "vincent";
642        root = "/data/videos";
643      };
644
645      # SSH key for authentication
646      sshKeyFile = config.age.secrets."jellyfin-favorites-sync-ssh-key".path;
647
648      sshArgs = [
649        "-o StrictHostKeyChecking=no"
650        "-o UserKnownHostsFile=/dev/null"
651      ];
652
653      # Dry-run verified, now syncing for real
654      dryRun = false;
655    };
656    transmission = serviceDefaults // {
657      enable = true;
658      package = pkgs.transmission_4;
659      openRPCPort = true; # Open firewall for RPC
660      home = "/neo/torrents";
661      settings = {
662        # Override default settings
663        incomplete-dir-enabled = true;
664        rpc-bind-address = "0.0.0.0"; # Bind to own IP
665        rpc-host-whitelist = "localhost,t.sbr.pm,transmission.sbr.pm,rhea.home,rhea.vpn,rhea.sbr.pm,192.168.1.50,10.100.0.50";
666        rpc-host-whitelist-enabled = true;
667        rpc-whitelist-enabled = true;
668        rpc-whitelist = "127.0.0.1,192.168.1.*,10.100.0.*"; # Whitelist your remote machine (10.0.0.1 in this example)
669        rpc-username = "transmission";
670        rpc-password = "transmission";
671        download-queue-enabled = true;
672        download-queue-size = 15;
673        queue-stalled-enabled = true;
674        queue-stalled-minutes = 30;
675        ratio-limit = 0.1;
676        ratio-limit-enabled = true;
677      };
678    };
679    # *arr services - ports configured via exportarrServices
680    sonarr = serviceDefaults // {
681      enable = true;
682      settings.server.port = exportarrServices.sonarr.servicePort;
683    };
684    radarr = serviceDefaults // {
685      enable = true;
686      settings.server.port = exportarrServices.radarr.servicePort;
687    };
688    bazarr = serviceDefaults // {
689      enable = true;
690      listenPort = exportarrServices.bazarr.servicePort;
691    };
692    prowlarr = {
693      enable = true;
694      openFirewall = true;
695      settings.server.port = exportarrServices.prowlarr.servicePort;
696    };
697
698    # Rsync replica jobs to backup FROM aion (disabled until migration)
699    rsync-replica = {
700      enable = true; # Enable after audio services migration to aion
701      jobs = {
702        aion-music-hourly = aionBackupDefaults // {
703          source = aionBackupDefaults.source // {
704            paths = [ "/zion/music" ];
705          };
706          schedule = "hourly";
707        };
708        aion-audiobooks-daily = aionBackupDefaults // {
709          source = aionBackupDefaults.source // {
710            paths = [ "/zion/audiobooks" ];
711          };
712          schedule = "daily";
713        };
714      };
715    };
716
717    # Generate prometheus exporters for all exportarr services
718    prometheus.exporters = lib.mapAttrs' (
719      name: cfg:
720      lib.nameValuePair "exportarr-${name}" {
721        enable = true;
722        inherit (cfg) port;
723        url = "http://localhost:${toString cfg.servicePort}";
724        apiKeyFile = config.age.secrets."exportarr-${name}-apikey".path;
725      }
726    ) exportarrServices;
727
728    # Restic backup to aix (off-site backup)
729    # Note: Media files are rsync'd (rhea → aion → aix)
730    # This backup focuses on arr service databases and configs
731    restic.backups.aix-critical = {
732      user = "vincent";
733      repository = "sftp:vincent@aix.sbr.pm:/data/backup/restic/rhea";
734
735      # Use password-based encryption
736      passwordFile = config.age.secrets."restic-aix-password".path;
737
738      paths = [
739        "/var/lib/sonarr" # Sonarr database and config (~501MB)
740        "/var/lib/radarr" # Radarr database and config (~729MB)
741        "/var/lib/bazarr" # Bazarr database and config (~25MB)
742        "/var/lib/readarr" # Readarr database and config (~6MB)
743        "/var/lib/prowlarr" # Prowlarr database and config
744        "/var/lib/jellyfin" # Jellyfin database and config
745        # "/var/lib/immich" # Immich app data # Already handled in aion
746        # "/var/lib/traefik" # Traefik acme.json (Let's Encrypt certs)
747      ];
748
749      # Backup schedule - weekly for moderate dataset
750      timerConfig = {
751        OnCalendar = "weekly";
752        Persistent = true;
753        RandomizedDelaySec = "2h"; # Avoid conflict with aion backup
754      };
755
756      # Retention policy
757      pruneOpts = [
758        "--keep-daily 7" # Last 7 days
759        "--keep-weekly 4" # Last 4 weeks
760        "--keep-monthly 12" # Last 12 months
761        "--keep-yearly 3" # Last 3 years
762      ];
763
764      # Backup options
765      extraBackupArgs = [
766        "--exclude-caches"
767        "--exclude='*.Trash-*'"
768        "--exclude='lost+found'"
769        "--exclude='logs.db'" # Exclude log databases (large, not critical)
770        "--verbose"
771      ];
772
773      # Check repository integrity after backup
774      checkOpts = [
775        "--read-data-subset=5%" # Verify 5% of data each run
776      ];
777
778      # Backup monitoring with ntfy.sh
779      backupPrepareCommand = ''
780        ${pkgs.curl}/bin/curl \
781          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
782            config.age.secrets."ntfy-token".path
783          })" \
784          -H "Title: Restic Backup Starting (rhea)" \
785          -d "Starting backup to aix (arr services + configs)" \
786          https://ntfy.sbr.pm/backups
787      '';
788
789      backupCleanupCommand = ''
790        ${pkgs.curl}/bin/curl \
791          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
792            config.age.secrets."ntfy-token".path
793          })" \
794          -H "Title: Restic Backup Complete (rhea)" \
795          -H "Tags: white_check_mark" \
796          -d "Backup to aix completed successfully" \
797          https://ntfy.sbr.pm/backups || \
798        ${pkgs.curl}/bin/curl \
799          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
800            config.age.secrets."ntfy-token".path
801          })" \
802          -H "Title: Restic Backup Failed (rhea)" \
803          -H "Tags: x,warning" \
804          -H "Priority: high" \
805          -d "Backup to aix failed! Check logs: journalctl -u restic-backups-aix-critical.service" \
806          https://ntfy.sbr.pm/backups
807      '';
808    };
809  };
810
811  security.acme = {
812    acceptTerms = true;
813    defaults.email = "vincent@sbr.pm";
814  };
815
816  # Grant vincent ownership and superuser privileges for the immich database
817  # Grant healthchecks user permissions for the healthchecks database
818  systemd.services.postgresql.postStart = lib.mkAfter ''
819    PSQL="${config.services.postgresql.package}/bin/psql --port=${toString config.services.postgresql.settings.port}"
820    $PSQL -tAc "SELECT 1 FROM pg_roles WHERE rolname = 'vincent'" | grep -q 1 || $PSQL -tAc "CREATE ROLE vincent WITH LOGIN SUPERUSER"
821    $PSQL -tAc "ALTER ROLE vincent WITH SUPERUSER"
822    $PSQL -tAc "ALTER DATABASE immich OWNER TO vincent"
823    $PSQL immich -tAc "ALTER SCHEMA public OWNER TO vincent"
824    $PSQL immich -tAc "GRANT ALL PRIVILEGES ON SCHEMA public TO vincent"
825    $PSQL immich -tAc "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO vincent"
826    $PSQL immich -tAc "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO vincent"
827    $PSQL immich -tAc "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO vincent"
828  '';
829
830  # Calibre Content Server for ebook library
831  systemd.services.calibre-server = {
832    description = "Calibre Content Server";
833    after = [ "network.target" ];
834    wantedBy = [ "multi-user.target" ];
835
836    serviceConfig = {
837      Type = "simple";
838      ExecStart = "${pkgs.calibre}/bin/calibre-server --port=8083 /zion/ebooks";
839      Restart = "on-failure";
840      User = "vincent";
841      Group = "users";
842    };
843  };
844
845  networking.useDHCP = lib.mkDefault true;
846
847  # Open firewall for Traefik and NFS
848  networking.firewall = {
849    allowedTCPPorts = [
850      80
851      443
852      1883 # MQTT
853      8883 # MQTTS
854      8080 # Traefik metrics
855      9000 # Node exporter
856      9187 # PostgreSQL exporter
857      # Exportarr exporters
858      9707 # Sonarr
859      9708 # Radarr
860      9710 # Prowlarr
861      9712 # Bazarr
862      # NFS ports
863      111 # rpcbind
864      2049 # NFS daemon
865      4000 # statd
866      4001 # lockd
867      4002 # mountd
868      20048 # mountd (NFSv4)
869    ];
870    allowedUDPPorts = [
871      # NFS ports
872      111 # rpcbind
873      2049 # NFS daemon
874      4000 # statd
875      4001 # lockd
876      4002 # mountd
877      20048 # mountd (NFSv4)
878    ];
879  };
880
881  # Add ffsubsync and ffmpeg to bazarr's PATH for subtitle synchronization
882  systemd.services.bazarr.path = with pkgs; [
883    ffsubsync
884    ffmpeg-full
885  ];
886
887  # Environment file for Gandi API key (managed by agenix)
888  systemd.services.traefik.serviceConfig = {
889    EnvironmentFile = config.age.secrets."gandi.env".path;
890  };
891
892  environment.systemPackages = with pkgs; [
893    lm_sensors
894    gnumake
895    ffmpeg-full
896  ];
897
898}