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            liseur = {
248              port = 8585;
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/liseur-sync" # Liseur Sync database, accounts, and reading state
748        # "/var/lib/immich" # Immich app data # Already handled in aion
749        # "/var/lib/traefik" # Traefik acme.json (Let's Encrypt certs)
750      ];
751
752      # Backup schedule - weekly for moderate dataset
753      timerConfig = {
754        OnCalendar = "weekly";
755        Persistent = true;
756        RandomizedDelaySec = "2h"; # Avoid conflict with aion backup
757      };
758
759      # Retention policy
760      pruneOpts = [
761        "--keep-daily 7" # Last 7 days
762        "--keep-weekly 4" # Last 4 weeks
763        "--keep-monthly 12" # Last 12 months
764        "--keep-yearly 3" # Last 3 years
765      ];
766
767      # Backup options
768      extraBackupArgs = [
769        "--exclude-caches"
770        "--exclude='*.Trash-*'"
771        "--exclude='lost+found'"
772        "--exclude='logs.db'" # Exclude log databases (large, not critical)
773        "--verbose"
774      ];
775
776      # Check repository integrity after backup
777      checkOpts = [
778        "--read-data-subset=5%" # Verify 5% of data each run
779      ];
780
781      # Backup monitoring with ntfy.sh
782      backupPrepareCommand = ''
783        ${pkgs.curl}/bin/curl \
784          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
785            config.age.secrets."ntfy-token".path
786          })" \
787          -H "Title: Restic Backup Starting (rhea)" \
788          -d "Starting backup to aix (arr services + configs)" \
789          https://ntfy.sbr.pm/backups
790      '';
791
792      backupCleanupCommand = ''
793        ${pkgs.curl}/bin/curl \
794          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
795            config.age.secrets."ntfy-token".path
796          })" \
797          -H "Title: Restic Backup Complete (rhea)" \
798          -H "Tags: white_check_mark" \
799          -d "Backup to aix completed successfully" \
800          https://ntfy.sbr.pm/backups || \
801        ${pkgs.curl}/bin/curl \
802          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
803            config.age.secrets."ntfy-token".path
804          })" \
805          -H "Title: Restic Backup Failed (rhea)" \
806          -H "Tags: x,warning" \
807          -H "Priority: high" \
808          -d "Backup to aix failed! Check logs: journalctl -u restic-backups-aix-critical.service" \
809          https://ntfy.sbr.pm/backups
810      '';
811    };
812  };
813
814  security.acme = {
815    acceptTerms = true;
816    defaults.email = "vincent@sbr.pm";
817  };
818
819  # Grant vincent ownership and superuser privileges for the immich database
820  # Grant healthchecks user permissions for the healthchecks database
821  systemd.services.postgresql.postStart = lib.mkAfter ''
822    PSQL="${config.services.postgresql.package}/bin/psql --port=${toString config.services.postgresql.settings.port}"
823    $PSQL -tAc "SELECT 1 FROM pg_roles WHERE rolname = 'vincent'" | grep -q 1 || $PSQL -tAc "CREATE ROLE vincent WITH LOGIN SUPERUSER"
824    $PSQL -tAc "ALTER ROLE vincent WITH SUPERUSER"
825    $PSQL -tAc "ALTER DATABASE immich OWNER TO vincent"
826    $PSQL immich -tAc "ALTER SCHEMA public OWNER TO vincent"
827    $PSQL immich -tAc "GRANT ALL PRIVILEGES ON SCHEMA public TO vincent"
828    $PSQL immich -tAc "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO vincent"
829    $PSQL immich -tAc "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO vincent"
830    $PSQL immich -tAc "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO vincent"
831  '';
832
833  # Liseur Sync indexes the Calibre library and synchronizes reader state.
834  virtualisation.podman.enable = true;
835  virtualisation.oci-containers = {
836    backend = "podman";
837    containers.liseur-sync = {
838      image = "ghcr.io/chmouel/liseur-sync@sha256:5015b61a6ac32eea9fdaa641b15c9a6234a4700b9d2a8a07ffcba2485efdaf24";
839      pull = "newer";
840      cmd = [ "serve" ];
841      environment = {
842        LISEUR_LISTEN_ADDR = "127.0.0.1:8585";
843        LISEUR_DATABASE_DRIVER = "sqlite";
844        LISEUR_DATABASE_URL = "/data/liseur-sync.db";
845        LISEUR_CACHE_DIR = "/data/cache";
846        LISEUR_FOLDER_ROOTS = "/books";
847        LISEUR_TRUSTED_PROXIES = "127.0.0.1/32,::1/128";
848      };
849      extraOptions = [ "--network=host" ];
850      volumes = [
851        "/var/lib/liseur-sync:/data"
852        "/zion/ebooks:/books:ro"
853      ];
854    };
855  };
856
857  systemd.tmpfiles.rules = [
858    "d /var/lib/liseur-sync 0750 65532 65532 -"
859  ];
860
861  networking.useDHCP = lib.mkDefault true;
862
863  # Open firewall for Traefik and NFS
864  networking.firewall = {
865    allowedTCPPorts = [
866      80
867      443
868      1883 # MQTT
869      8883 # MQTTS
870      8080 # Traefik metrics
871      9000 # Node exporter
872      9187 # PostgreSQL exporter
873      # Exportarr exporters
874      9707 # Sonarr
875      9708 # Radarr
876      9710 # Prowlarr
877      9712 # Bazarr
878      # NFS ports
879      111 # rpcbind
880      2049 # NFS daemon
881      4000 # statd
882      4001 # lockd
883      4002 # mountd
884      20048 # mountd (NFSv4)
885    ];
886    allowedUDPPorts = [
887      # NFS ports
888      111 # rpcbind
889      2049 # NFS daemon
890      4000 # statd
891      4001 # lockd
892      4002 # mountd
893      20048 # mountd (NFSv4)
894    ];
895  };
896
897  # Add ffsubsync and ffmpeg to bazarr's PATH for subtitle synchronization
898  systemd.services.bazarr.path = with pkgs; [
899    ffsubsync
900    ffmpeg-full
901  ];
902
903  # Environment file for Gandi API key (managed by agenix)
904  systemd.services.traefik.serviceConfig = {
905    EnvironmentFile = config.age.secrets."gandi.env".path;
906  };
907
908  environment.systemPackages = with pkgs; [
909    lm_sensors
910    gnumake
911    ffmpeg-full
912  ];
913
914}