fedora-csb-system-manager
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}