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                paperless = mkRouter "paperless" [ "paperless.sbr.pm" ];
335                grafana = mkRouter "grafana" [ "grafana.sbr.pm" ];
336                navidrome = mkRouter "navidrome" [
337                  "navidrome.sbr.pm"
338                  "music.sbr.pm"
339                ];
340                transmission-music = mkRouter "transmission-music" [
341                  "transmission-music.sbr.pm"
342                  "tm.sbr.pm"
343                ];
344                audiobookshelf = mkRouter "audiobookshelf" [
345                  "audiobookshelf.sbr.pm"
346                  "podcasts.sbr.pm"
347                ];
348                lidarr = mkRouter "lidarr" [ "lidarr.sbr.pm" ];
349                wallabag = mkRouter "wallabag" [
350                  "wallabag.sbr.pm"
351                  "read.sbr.pm"
352                ];
353                miniflux = mkRouter "miniflux" [
354                  "miniflux.sbr.pm"
355                  "rss.sbr.pm"
356                ];
357                homepage = mkRouter "homepage" [ "homepage.sbr.pm" ];
358                # OpenCode web interface on okinawa (VPN-only)
359                opencode = mkRouter "opencode" [ "opencode.sbr.pm" ];
360                reading = mkRouter "reading" [ "reading.sbr.pm" ];
361                # Traefik dashboard
362                traefik-dashboard = {
363                  rule = "Host(`traefik.sbr.pm`)";
364                  service = "api@internal";
365                  entryPoints = [ "websecure" ];
366                  tls.certResolver = "letsencrypt";
367                };
368              };
369            services =
370              syncthingServices
371              // localHttpServices
372              // {
373                home = mkService "http://${builtins.head globals.machines.hass.net.ips}:8123";
374                paperless = mkService "http://${builtins.head globals.machines.aion.net.ips}:8000";
375                grafana = mkService "http://${builtins.head globals.machines.sakhalin.net.ips}:3000";
376                navidrome = mkService "http://${builtins.head globals.machines.aion.net.ips}:4533";
377                transmission-music = mkService "http://${builtins.head globals.machines.aion.net.ips}:9091";
378                homepage = mkService "http://${builtins.head globals.machines.aion.net.ips}:3001";
379                audiobookshelf = mkService "http://${builtins.head globals.machines.aion.net.ips}:13378";
380                lidarr = mkService "http://${builtins.head globals.machines.aion.net.ips}:8686";
381                wallabag = mkService "http://${builtins.head globals.machines.aion.net.ips}:8084";
382                miniflux = mkService "http://${builtins.head globals.machines.aion.net.ips}:8085";
383                opencode = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:5555";
384                reading = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:8880";
385              };
386            middlewares =
387              syncthingMiddlewares
388              // syncthingAddSlashMiddlewares
389              // {
390                # Middleware for handling large file uploads (Immich)
391                immich-buffering = {
392                  buffering = {
393                    maxRequestBodyBytes = 0; # No limit
394                    memRequestBodyBytes = 104857600; # 100MB in memory
395                    maxResponseBodyBytes = 0; # No limit
396                    memResponseBodyBytes = 104857600; # 100MB in memory
397                    retryExpression = "IsNetworkError() && Attempts() < 2";
398                  };
399                };
400                # Middleware for Home Assistant reverse proxy headers
401                home-headers = {
402                  headers = {
403                    customRequestHeaders = {
404                      X-Forwarded-Proto = "https";
405                    };
406                  };
407                };
408              };
409          };
410          tcp = {
411            routers = {
412              mqtt = {
413                rule = "HostSNI(`*`)";
414                service = "mqtt";
415                entryPoints = [ "mqtt" ];
416              };
417              mqtts = {
418                rule = "HostSNI(`mqtt.sbr.pm`)";
419                service = "mqtt";
420                entryPoints = [ "mqtts" ];
421                tls = {
422                  certResolver = "letsencrypt";
423                };
424              };
425            };
426            services = {
427              mqtt = {
428                loadBalancer = {
429                  servers = [
430                    { address = "${builtins.head globals.machines.demeter.net.ips}:1883"; }
431                  ];
432                };
433              };
434            };
435          };
436        };
437    };
438
439    # smartd = {
440    #   enable = true;
441    #   devices = [ { device = "/dev/nvme0n1"; } ];
442    # };
443    samba.settings = {
444      global."server string" = "Rhea";
445    }
446    // builtins.mapAttrs (
447      name: cfg:
448      libx.mkSambaShare (
449        {
450          inherit name;
451          path = "/neo/${name}";
452        }
453        // cfg
454      )
455    ) neoSambaShares
456    // builtins.mapAttrs (
457      name: cfg:
458      libx.mkSambaShare (
459        {
460          inherit name;
461          path = "/zion/${name}";
462        }
463        // cfg
464      )
465    ) zionSambaShares;
466    nfs.server = {
467      enable = true;
468      # Fixed ports for firewall configuration
469      lockdPort = 4001;
470      mountdPort = 4002;
471      statdPort = 4000;
472      exports = ''
473                /neo                      192.168.1.0/24(rw,fsid=0,no_subtree_check) 10.100.0.0/24(rw,fsid=0,no_subtree_check)
474                /neo/backup               192.168.1.0/24(rw,fsid=2,no_subtree_check) 10.100.0.0/24(rw,fsid=2,no_subtree_check)
475                /neo/downloads            192.168.1.0/24(rw,fsid=4,no_subtree_check) 10.100.0.0/24(rw,fsid=4,no_subtree_check)
476                /neo/pictures             192.168.1.0/24(rw,fsid=7,no_subtree_check) 10.100.0.0/24(rw,fsid=7,no_subtree_check)
477                /neo/videos               192.168.1.0/24(rw,fsid=8,no_subtree_check) 10.100.0.0/24(rw,fsid=8,no_subtree_check)
478                /zion                     192.168.1.0/24(rw,fsid=10,no_subtree_check) 10.100.0.0/24(rw,fsid=10,no_subtree_check)
479                /zion/audiobooks          192.168.1.0/24(ro,fsid=11,no_subtree_check) 10.100.0.0/24(ro,fsid=11,no_subtree_check)
480                /zion/documents           192.168.1.0/24(rw,fsid=12,no_subtree_check) 10.100.0.0/24(rw,fsid=12,no_subtree_check)
481                /zion/ebooks              192.168.1.0/24(rw,fsid=13,no_subtree_check) 10.100.0.0/24(rw,fsid=13,no_subtree_check)
482                /zion/music               192.168.1.0/24(ro,fsid=14,no_subtree_check) 10.100.0.0/24(ro,fsid=14,no_subtree_check)
483        			'';
484    };
485    immich = serviceDefaults // {
486      enable = true;
487      host = "0.0.0.0"; # Listen on all interfaces for VPN access
488      mediaLocation = "/neo/pictures/photos";
489    };
490    postgresql = {
491      package = pkgs.postgresql_16;
492      ensureDatabases = [
493        "immich"
494      ];
495      ensureUsers = [
496        {
497          name = "vincent";
498        }
499      ];
500    };
501    jellyfin = serviceDefaults // {
502      enable = true;
503    };
504    jellyseerr = {
505      enable = true;
506      openFirewall = true;
507    };
508    webdav = {
509      enable = true;
510      user = "vincent";
511      group = "users";
512      environmentFile = config.age.secrets."webdav-password".path;
513      settings = {
514        address = "127.0.0.1";
515        port = 6065;
516        scope = "/zion/documents/boox";
517        modify = true;
518        users = [
519          {
520            username = "vincent";
521            password = "{env}WEBDAV_PASSWORD_HASH";
522          }
523        ];
524        rules = [
525          {
526            regex = "(\\..*|.*\\.tmp)$"; # Block hidden files and .tmp files
527            allow = false;
528          }
529        ];
530      };
531    };
532    jellyfin-auto-collections = {
533      enable = true;
534      jellyfinUrl = "http://localhost:8096";
535      userId = "400fef4e0ab2448cb8a2bc8ca2facc4f";
536      apiKeyFile = config.age.secrets."jellyfin-auto-collections-api-key".path;
537      schedule = "daily"; # Run daily at midnight
538
539      jellyseerr = {
540        enable = false; # Enable when password secret is created
541        serverUrl = "http://localhost:5055";
542        email = "vincent@sbr.pm";
543        # Uncomment when jellyseerr password secret is created
544        # passwordFile = config.age.secrets."jellyfin-auto-collections-jellyseerr-password".path;
545        userType = "local";
546      };
547
548      settings = {
549        plugins = {
550          imdb_chart = {
551            enabled = true;
552            list_ids = [
553              "top"
554              "moviemeter"
555            ];
556            clear_collection = true;
557          };
558          imdb_list = {
559            enabled = true;
560            list_ids = [
561              "ls055592025" # IMDb Top 250
562            ];
563          };
564          jellyfin_api = {
565            enabled = true;
566            list_ids = [
567              # Marvel Cinematic Universe
568              {
569                studios = [
570                  "Marvel Studios"
571                  "Marvel Entertainment"
572                ];
573                list_name = "Marvel Cinematic Universe";
574                includeItemTypes = [ "Movie" ];
575              }
576              # Pixar Animation
577              {
578                studios = [ "Pixar" ];
579                list_name = "Pixar Collection";
580                includeItemTypes = [ "Movie" ];
581              }
582              # Studio Ghibli
583              {
584                studios = [ "Studio Ghibli" ];
585                list_name = "Studio Ghibli Collection";
586                includeItemTypes = [ "Movie" ];
587              }
588              # Sing Movies (Illumination)
589              {
590                searchTerm = "Sing";
591                studios = [ "Illumination Entertainment" ];
592                list_name = "Sing Movies";
593                includeItemTypes = [ "Movie" ];
594              }
595              # Christopher Nolan Films
596              {
597                person = [ "Christopher Nolan" ];
598                list_name = "Christopher Nolan Collection";
599                includeItemTypes = [ "Movie" ];
600              }
601              # Highly Rated Sci-Fi
602              {
603                genres = [ "Science Fiction" ];
604                minCriticRating = [ "8" ];
605                list_name = "Top Sci-Fi Movies";
606                includeItemTypes = [ "Movie" ];
607              }
608              # Recent Movies (2024-2025)
609              {
610                years = [
611                  2024
612                  2025
613                ];
614                list_name = "Recent Releases";
615                includeItemTypes = [ "Movie" ];
616              }
617              # Award Winners
618              {
619                tags = [ "Oscar Winner" ];
620                list_name = "Oscar Winners";
621                includeItemTypes = [ "Movie" ];
622              }
623            ];
624          };
625        };
626      };
627    };
628    jellyfin-favorites-sync = {
629      enable = true;
630      schedule = "daily"; # Run daily at midnight
631
632      jellyfinUrl = "http://localhost:8096";
633      apiKeyFile = config.age.secrets."jellyfin-favorites-sync-api-key".path;
634      userId = "400fef4e0ab2448cb8a2bc8ca2facc4f"; # vincent user ID
635
636      # Use "Keep" playlist instead of favorites
637      playlistName = "Keep";
638
639      sourceRoot = "/neo/videos";
640
641      destination = {
642        host = "aix.sbr.pm";
643        user = "vincent";
644        root = "/data/videos";
645      };
646
647      # SSH key for authentication
648      sshKeyFile = config.age.secrets."jellyfin-favorites-sync-ssh-key".path;
649
650      sshArgs = [
651        "-o StrictHostKeyChecking=no"
652        "-o UserKnownHostsFile=/dev/null"
653      ];
654
655      # Dry-run verified, now syncing for real
656      dryRun = false;
657    };
658    transmission = serviceDefaults // {
659      enable = true;
660      package = pkgs.transmission_4;
661      openRPCPort = true; # Open firewall for RPC
662      home = "/neo/torrents";
663      settings = {
664        # Override default settings
665        incomplete-dir-enabled = true;
666        rpc-bind-address = "0.0.0.0"; # Bind to own IP
667        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";
668        rpc-host-whitelist-enabled = true;
669        rpc-whitelist-enabled = true;
670        rpc-whitelist = "127.0.0.1,192.168.1.*,10.100.0.*"; # Whitelist your remote machine (10.0.0.1 in this example)
671        rpc-username = "transmission";
672        rpc-password = "transmission";
673        download-queue-enabled = true;
674        download-queue-size = 15;
675        queue-stalled-enabled = true;
676        queue-stalled-minutes = 30;
677        ratio-limit = 0.1;
678        ratio-limit-enabled = true;
679      };
680    };
681    # *arr services - ports configured via exportarrServices
682    sonarr = serviceDefaults // {
683      enable = true;
684      settings.server.port = exportarrServices.sonarr.servicePort;
685    };
686    radarr = serviceDefaults // {
687      enable = true;
688      settings.server.port = exportarrServices.radarr.servicePort;
689    };
690    bazarr = serviceDefaults // {
691      enable = true;
692      listenPort = exportarrServices.bazarr.servicePort;
693    };
694    prowlarr = {
695      enable = true;
696      openFirewall = true;
697      settings.server.port = exportarrServices.prowlarr.servicePort;
698    };
699
700    # Rsync replica jobs to backup FROM aion (disabled until migration)
701    rsync-replica = {
702      enable = true; # Enable after audio services migration to aion
703      jobs = {
704        aion-music-hourly = aionBackupDefaults // {
705          source = aionBackupDefaults.source // {
706            paths = [ "/zion/music" ];
707          };
708          schedule = "hourly";
709        };
710        aion-audiobooks-daily = aionBackupDefaults // {
711          source = aionBackupDefaults.source // {
712            paths = [ "/zion/audiobooks" ];
713          };
714          schedule = "daily";
715        };
716      };
717    };
718
719    # Generate prometheus exporters for all exportarr services
720    prometheus.exporters = lib.mapAttrs' (
721      name: cfg:
722      lib.nameValuePair "exportarr-${name}" {
723        enable = true;
724        inherit (cfg) port;
725        url = "http://localhost:${toString cfg.servicePort}";
726        apiKeyFile = config.age.secrets."exportarr-${name}-apikey".path;
727      }
728    ) exportarrServices;
729
730    # Restic backup to aix (off-site backup)
731    # Note: Media files are rsync'd (rhea → aion → aix)
732    # This backup focuses on arr service databases and configs
733    restic.backups.aix-critical = {
734      user = "vincent";
735      repository = "sftp:vincent@aix.sbr.pm:/data/backup/restic/rhea";
736
737      # Use password-based encryption
738      passwordFile = config.age.secrets."restic-aix-password".path;
739
740      paths = [
741        "/var/lib/sonarr" # Sonarr database and config (~501MB)
742        "/var/lib/radarr" # Radarr database and config (~729MB)
743        "/var/lib/bazarr" # Bazarr database and config (~25MB)
744        "/var/lib/readarr" # Readarr database and config (~6MB)
745        "/var/lib/prowlarr" # Prowlarr database and config
746        "/var/lib/jellyfin" # Jellyfin database and config
747        # "/var/lib/immich" # Immich app data # Already handled in aion
748        # "/var/lib/traefik" # Traefik acme.json (Let's Encrypt certs)
749      ];
750
751      # Backup schedule - weekly for moderate dataset
752      timerConfig = {
753        OnCalendar = "weekly";
754        Persistent = true;
755        RandomizedDelaySec = "2h"; # Avoid conflict with aion backup
756      };
757
758      # Retention policy
759      pruneOpts = [
760        "--keep-daily 7" # Last 7 days
761        "--keep-weekly 4" # Last 4 weeks
762        "--keep-monthly 12" # Last 12 months
763        "--keep-yearly 3" # Last 3 years
764      ];
765
766      # Backup options
767      extraBackupArgs = [
768        "--exclude-caches"
769        "--exclude='*.Trash-*'"
770        "--exclude='lost+found'"
771        "--exclude='logs.db'" # Exclude log databases (large, not critical)
772        "--verbose"
773      ];
774
775      # Check repository integrity after backup
776      checkOpts = [
777        "--read-data-subset=5%" # Verify 5% of data each run
778      ];
779
780      # Backup monitoring with ntfy.sh
781      backupPrepareCommand = ''
782        ${pkgs.curl}/bin/curl \
783          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
784            config.age.secrets."ntfy-token".path
785          })" \
786          -H "Title: Restic Backup Starting (rhea)" \
787          -d "Starting backup to aix (arr services + configs)" \
788          https://ntfy.sbr.pm/backups
789      '';
790
791      backupCleanupCommand = ''
792        ${pkgs.curl}/bin/curl \
793          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
794            config.age.secrets."ntfy-token".path
795          })" \
796          -H "Title: Restic Backup Complete (rhea)" \
797          -H "Tags: white_check_mark" \
798          -d "Backup to aix completed successfully" \
799          https://ntfy.sbr.pm/backups || \
800        ${pkgs.curl}/bin/curl \
801          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
802            config.age.secrets."ntfy-token".path
803          })" \
804          -H "Title: Restic Backup Failed (rhea)" \
805          -H "Tags: x,warning" \
806          -H "Priority: high" \
807          -d "Backup to aix failed! Check logs: journalctl -u restic-backups-aix-critical.service" \
808          https://ntfy.sbr.pm/backups
809      '';
810    };
811  };
812
813  security.acme = {
814    acceptTerms = true;
815    defaults.email = "vincent@sbr.pm";
816  };
817
818  # Grant vincent ownership and superuser privileges for the immich database
819  # Grant healthchecks user permissions for the healthchecks database
820  systemd.services.postgresql.postStart = lib.mkAfter ''
821    PSQL="${config.services.postgresql.package}/bin/psql --port=${toString config.services.postgresql.settings.port}"
822    $PSQL -tAc "SELECT 1 FROM pg_roles WHERE rolname = 'vincent'" | grep -q 1 || $PSQL -tAc "CREATE ROLE vincent WITH LOGIN SUPERUSER"
823    $PSQL -tAc "ALTER ROLE vincent WITH SUPERUSER"
824    $PSQL -tAc "ALTER DATABASE immich OWNER TO vincent"
825    $PSQL immich -tAc "ALTER SCHEMA public OWNER TO vincent"
826    $PSQL immich -tAc "GRANT ALL PRIVILEGES ON SCHEMA public TO vincent"
827    $PSQL immich -tAc "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO vincent"
828    $PSQL immich -tAc "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO vincent"
829    $PSQL immich -tAc "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO vincent"
830  '';
831
832  # Calibre Content Server for ebook library
833  systemd.services.calibre-server = {
834    description = "Calibre Content Server";
835    after = [ "network.target" ];
836    wantedBy = [ "multi-user.target" ];
837
838    serviceConfig = {
839      Type = "simple";
840      ExecStart = "${pkgs.calibre}/bin/calibre-server --port=8083 /zion/ebooks";
841      Restart = "on-failure";
842      User = "vincent";
843      Group = "users";
844    };
845  };
846
847  networking.useDHCP = lib.mkDefault true;
848
849  # Open firewall for Traefik and NFS
850  networking.firewall = {
851    allowedTCPPorts = [
852      80
853      443
854      1883 # MQTT
855      8883 # MQTTS
856      8080 # Traefik metrics
857      9000 # Node exporter
858      9187 # PostgreSQL exporter
859      # Exportarr exporters
860      9707 # Sonarr
861      9708 # Radarr
862      9710 # Prowlarr
863      9712 # Bazarr
864      # NFS ports
865      111 # rpcbind
866      2049 # NFS daemon
867      4000 # statd
868      4001 # lockd
869      4002 # mountd
870      20048 # mountd (NFSv4)
871    ];
872    allowedUDPPorts = [
873      # NFS ports
874      111 # rpcbind
875      2049 # NFS daemon
876      4000 # statd
877      4001 # lockd
878      4002 # mountd
879      20048 # mountd (NFSv4)
880    ];
881  };
882
883  # Add ffsubsync and ffmpeg to bazarr's PATH for subtitle synchronization
884  systemd.services.bazarr.path = with pkgs; [
885    ffsubsync
886    ffmpeg-full
887  ];
888
889  # Environment file for Gandi API key (managed by agenix)
890  systemd.services.traefik.serviceConfig = {
891    EnvironmentFile = config.age.secrets."gandi.env".path;
892  };
893
894  environment.systemPackages = with pkgs; [
895    lm_sensors
896    gnumake
897    ffmpeg-full
898  ];
899
900}