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