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