main
1{
2 libx,
3 lib,
4 pkgs,
5 config,
6 ...
7}:
8let
9 # Service defaults for media/homelab services
10 serviceDefaults = libx.mkServiceDefaults { };
11
12 # Common rsync configuration for rhea backups
13 rheaBackupDefaults = {
14 source = {
15 host = "rhea.sbr.pm";
16 user = "vincent";
17 };
18 destination = "/neo";
19 delete = true; # Mirror mode: delete files in destination that don't exist in source
20 user = "vincent";
21 group = "users";
22 rsyncArgs = [
23 "--exclude=.Trash-*"
24 "--exclude=lost+found"
25 ];
26 sshArgs = [
27 "-o StrictHostKeyChecking=accept-new"
28 ];
29 };
30
31 # Exportarr services configuration (data-driven approach)
32 exportarrServices = {
33 lidarr = {
34 port = 9709;
35 servicePort = 8686;
36 };
37 };
38in
39{
40 nixpkgs.config.permittedInsecurePackages = [
41 "python3.13-beets-2.5.1"
42 ];
43
44 imports = [
45 ../common/services/samba.nix
46 ../common/services/homepage.nix
47
48 ../../modules/audible-sync
49 ../../modules/beets-auto-import
50 ../../modules/music-playlist-dl
51 ../../modules/harmonia
52 ../../modules/xmpp-research-bot
53 ../../modules/wallabag
54 ./xmpp.nix
55 ];
56
57 users.users.vincent.linger = true;
58
59 # Allow navidrome to read music files owned by users group
60 users.users.navidrome.extraGroups = [ "users" ];
61
62 # Age secrets for homepage widgets (API keys for *arr services on rhea)
63 age.secrets = {
64 "exportarr-sonarr-apikey" = {
65 file = ../../secrets/rhea/exportarr-sonarr-apikey.age;
66 mode = "440";
67 group = "homepage";
68 };
69 "exportarr-radarr-apikey" = {
70 file = ../../secrets/rhea/exportarr-radarr-apikey.age;
71 mode = "440";
72 group = "homepage";
73 };
74 "exportarr-lidarr-apikey" = {
75 file = ../../secrets/rhea/exportarr-lidarr-apikey.age;
76 mode = "440";
77 group = "homepage";
78 };
79 "restic-aix-password" = {
80 file = ../../secrets/aion/restic-aix-password.age;
81 mode = "400";
82 owner = "vincent";
83 group = "users";
84 };
85 "ntfy-token" = {
86 file = ../../secrets/sakhalin/ntfy-token.age;
87 mode = "400";
88 owner = "vincent";
89 group = "users";
90 };
91 "harmonia-aion-signing-key" = {
92 file = ../../secrets/harmonia/aion-signing-key.age;
93 mode = "440";
94 owner = "root";
95 group = "root";
96 };
97 "miniflux-admin-credentials" = {
98 file = ../../secrets/aion/miniflux-admin-credentials.age;
99 mode = "400";
100 };
101 "wallabag-secret-key" = {
102 file = ../../secrets/aion/wallabag-secret-key.age;
103 mode = "400";
104 owner = "wallabag";
105 group = "wallabag";
106 };
107 # TODO: Uncomment after creating secrets with agenix
108 # "xmpp-research-bot-password" = {
109 # file = ../../secrets/aion/xmpp-research-bot-password.age;
110 # mode = "400";
111 # owner = "vincent";
112 # group = "users";
113 # };
114 # "anthropic-api-key" = {
115 # file = ../../secrets/aion/anthropic-api-key.age;
116 # mode = "400";
117 # owner = "vincent";
118 # group = "users";
119 # };
120 };
121
122 services = {
123 # Paperless document management
124 paperless = {
125 enable = true;
126 address = "0.0.0.0";
127 port = 8000;
128
129 dataDir = "/neo/paperless/data";
130 mediaDir = "/neo/paperless/media";
131 consumptionDir = "/neo/paperless/consume";
132
133 settings = {
134 PAPERLESS_URL = "https://paperless.sbr.pm";
135 PAPERLESS_EMPTY_TRASH_DIR = "/neo/paperless/trash";
136 PAPERLESS_FILENAME_FORMAT = "{{ created_year }}/{{ created | datetime('%Y%m%dT%H%M%S') }}{% if correspondent != 'none' %}=={{ correspondent | slugify | replace('-', '=') }}{% endif %}--{{ title | slugify }}{% if document_type != 'none' or tag_name_list %}__{% if document_type != 'none' %}{{ document_type | slugify | replace('-', '') }}{% endif %}{% if document_type != 'none' and tag_name_list %}_{% endif %}{% if tag_name_list %}{{ tag_name_list | join('_') }}{% endif %}{% endif %}";
137 PAPERLESS_FILENAME_FORMAT_REMOVE_NONE = "true";
138 };
139 };
140
141 # Binary cache server (aarch64-linux)
142 harmonia-cache = {
143 enable = true;
144 signKeyPath = config.age.secrets."harmonia-aion-signing-key".path;
145 port = 5000;
146 workers = 4;
147 priority = 30;
148
149 # Nightly cache pre-population
150 builder = {
151 enable = true;
152 systems = [
153 "aion" # Self
154 "athena" # RPi4
155 "demeter" # RPi4
156 "aix" # RPi4
157 "rhea" # Media server
158 ];
159 schedule = "02:30"; # 2:30 AM daily (offset from aomi)
160 notification = {
161 enable = true;
162 tokenFile = config.age.secrets."ntfy-token".path;
163 };
164 };
165 };
166
167 audible-sync = {
168 enable = true; # enable one migration dayrs
169 user = "vincent";
170 outputDir = "/neo/audiobooks";
171 tempDir = "/neo/audiobooks/zz_import"; # Keep AAX files for reuse
172 quality = "best";
173 format = "m4b";
174 schedule = "daily"; # Run daily at 3 AM
175 notification = {
176 enable = true;
177 ntfyUrl = "https://ntfy.sbr.pm";
178 topic = "homelab";
179 tokenFile = config.age.secrets."ntfy-token".path;
180 };
181 };
182
183 beets-auto-import = {
184 enable = true;
185 package = pkgs.beetsWithPlugins; # Use same package as home-manager (includes lidarrfields and filetote plugins)
186 user = "vincent";
187 musicDir = "/neo/music";
188 importDirs = [
189 "library"
190 "soundtrack"
191 "compilation"
192 ];
193 updatePlaylists = true;
194 podcastDir = "/neo/music/podcasts";
195 playlistDir = "/neo/music/playlists";
196 schedule = "daily"; # Run daily
197 notification = {
198 enable = true;
199 ntfyUrl = "https://ntfy.sbr.pm";
200 topic = "homelab";
201 tokenFile = config.age.secrets."ntfy-token".path;
202 };
203 };
204
205 audiobookshelf = serviceDefaults // {
206 enable = true;
207 port = 13378;
208 host = "0.0.0.0";
209 };
210
211 lidarr = serviceDefaults // {
212 enable = true;
213 settings.server.port = exportarrServices.lidarr.servicePort;
214 };
215
216 rsync-replica = {
217 enable = true;
218 jobs = {
219 rhea-daily = rheaBackupDefaults // {
220 source = rheaBackupDefaults.source // {
221 paths = [
222 "/zion/documents"
223 "/zion/ebooks"
224 ];
225 };
226 destination = "/zion";
227 schedule = "daily";
228 };
229 rhea-pictures = rheaBackupDefaults // {
230 source = rheaBackupDefaults.source // {
231 paths = [
232 "/neo/pictures"
233 ];
234 };
235 destination = "/zion";
236 schedule = "hourly";
237 };
238 rhea-videos-neo = rheaBackupDefaults // {
239 source = rheaBackupDefaults.source // {
240 paths = [
241 "/neo/videos/movies"
242 "/neo/videos/series"
243 ];
244 };
245 destination = "/neo/videos";
246 schedule = "hourly";
247 };
248 rhea-videos-zion = rheaBackupDefaults // {
249 source = rheaBackupDefaults.source // {
250 paths = [
251 "/neo/videos/animes"
252 "/neo/videos/family"
253 "/neo/videos/tounsi"
254 ];
255 };
256 destination = "/zion/videos";
257 schedule = "hourly";
258 };
259 };
260 };
261
262 # Restic backup to aix (off-site backup) - daily for org and documents
263 restic.backups.aix-daily = {
264 user = "vincent";
265 repository = "sftp:vincent@aix.sbr.pm:/data/backup/restic/aion";
266 passwordFile = config.age.secrets."restic-aix-password".path;
267
268 paths = [
269 "/home/vincent/desktop/org" # Org files (<1GB)
270 "/zion/documents" # Personal docs rsynced from rhea (~113GB)
271 "/neo/paperless/data" # Paperless database (~164MB)
272 "/neo/paperless/media" # Paperless PDFs (~8MB → 3GB)
273 ];
274
275 # Daily backup for frequently changing org files and paperless
276 timerConfig = {
277 OnCalendar = "daily";
278 Persistent = true;
279 RandomizedDelaySec = "30m";
280 };
281
282 pruneOpts = [
283 "--keep-daily 14" # Last 2 weeks
284 "--keep-weekly 8" # Last 2 months
285 "--keep-monthly 12" # Last year
286 ];
287
288 extraBackupArgs = [
289 "--exclude-caches"
290 "--exclude='.sync-conflict-*'"
291 "--exclude='/neo/paperless/trash'" # Exclude trash
292 "--exclude='/neo/paperless/consume'" # Exclude inbox
293 "--verbose"
294 ];
295 };
296
297 # Restic backup to aix (off-site backup) - weekly for large datasets
298 # Note: Photos are already rsync'd to aix daily via aix's pull job
299 restic.backups.aix-critical = {
300 user = "vincent";
301 repository = "sftp:vincent@aix.sbr.pm:/data/backup/restic/aion";
302
303 # Use password-based encryption
304 passwordFile = config.age.secrets."restic-aix-password".path;
305
306 paths = [
307 "/zion/pictures/photos/backups" # Immich database dumps only (~100MB, versioned)
308 "/var/lib/lidarr" # Lidarr database and config (~4.6GB)
309 "/var/lib/audiobookshelf" # Audiobookshelf database and config (~30MB)
310 ];
311
312 # Backup schedule - weekly for large dataset
313 timerConfig = {
314 OnCalendar = "weekly";
315 Persistent = true;
316 RandomizedDelaySec = "1h"; # Avoid VPN congestion
317 };
318
319 # Retention policy
320 pruneOpts = [
321 "--keep-daily 7" # Last 7 days
322 "--keep-weekly 4" # Last 4 weeks
323 "--keep-monthly 12" # Last 12 months
324 "--keep-yearly 3" # Last 3 years
325 ];
326
327 # Backup options
328 extraBackupArgs = [
329 "--exclude-caches"
330 "--exclude='*.Trash-*'"
331 "--exclude='lost+found'"
332 "--exclude='.sync-conflict-*'" # Syncthing conflicts
333 "--verbose"
334 ];
335
336 # Check repository integrity after backup
337 checkOpts = [
338 "--read-data-subset=5%" # Verify 5% of data each run
339 ];
340
341 # Backup monitoring with ntfy.sh
342 backupPrepareCommand = ''
343 ${pkgs.curl}/bin/curl \
344 -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
345 config.age.secrets."ntfy-token".path
346 })" \
347 -H "Title: Restic Backup Starting (aion)" \
348 -d "Starting backup to aix (critical data only)" \
349 https://ntfy.sbr.pm/backups
350 '';
351
352 backupCleanupCommand = ''
353 ${pkgs.curl}/bin/curl \
354 -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
355 config.age.secrets."ntfy-token".path
356 })" \
357 -H "Title: Restic Backup Complete (aion)" \
358 -H "Tags: white_check_mark" \
359 -d "Backup to aix completed successfully" \
360 https://ntfy.sbr.pm/backups || \
361 ${pkgs.curl}/bin/curl \
362 -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
363 config.age.secrets."ntfy-token".path
364 })" \
365 -H "Title: Restic Backup Failed (aion)" \
366 -H "Tags: x,warning" \
367 -H "Priority: high" \
368 -d "Backup to aix failed! Check logs: journalctl -u restic-backups-aix-critical.service" \
369 https://ntfy.sbr.pm/backups
370 '';
371 };
372
373 # Prometheus exporter for restic backup monitoring
374 # DISABLED: Causes excessive load (restic check every 60s over SFTP)
375 # TODO: Re-enable with local repository or periodic timer-based checks
376 prometheus.exporters.restic = {
377 enable = false;
378 port = 9753;
379 user = "vincent"; # Must run as vincent to access SSH keys for aix
380 group = "users";
381 repository = "sftp:vincent@aix.sbr.pm:/data/backup/restic/aion";
382 passwordFile = config.age.secrets."restic-aix-password".path;
383 };
384
385 music-playlist-dl = {
386 enable = true; # Enable on music migration day
387 user = "vincent";
388 configFile = "/neo/music/music-playlist-dl.yaml";
389 baseDir = "/neo/music/mixes"; # Downloads to /neo/music/mixes/{show}, playlists to /neo/music/playlists
390 schedule = "weekly"; # Run weekly on Sundays at 2 AM
391 notification = {
392 enable = true;
393 ntfyUrl = "https://ntfy.sbr.pm";
394 topic = "homelab";
395 tokenFile = config.age.secrets."ntfy-token".path;
396 };
397 };
398
399 # XMPP Research Bot (disabled until secrets are created)
400 xmpp-research-bot = {
401 enable = false; # TODO: Enable after creating secrets with agenix
402 # jid = "researchbot@xmpp.sbr.pm";
403 # ownerJid = "vincent@xmpp.sbr.pm";
404 # passwordFile = config.age.secrets."xmpp-research-bot-password".path;
405 # apiKeyFile = config.age.secrets."anthropic-api-key".path;
406 # inboxPath = "/home/vincent/desktop/org/inbox.org";
407 # user = "vincent";
408 # group = "users";
409 };
410
411 navidrome = {
412 enable = true;
413 settings = {
414 MusicFolder = "/neo/music";
415 Address = "0.0.0.0";
416 Port = 4533;
417 BaseURL = "https://music.sbr.pm";
418
419 # Paths
420 DataFolder = "/var/lib/navidrome";
421 CacheFolder = "/var/cache/navidrome";
422
423 # Features
424 EnableTranscodingConfig = false; # Disabled for security - transcoding still works, UI editing disabled
425 EnableSubsonic = true;
426
427 # Scanner settings
428 Scanner.Schedule = "@every 6h"; # Rescan library periodically (beets imports daily at midnight)
429 Scanner.PurgeMissing = "full"; # Auto-cleanup missing files after full scans
430
431 # Optional: Scrobbling (can enable later)
432 # LastFM.Enabled = true;
433 };
434 };
435
436 transmission = serviceDefaults // {
437 enable = true; # Enable on music migration day
438 package = pkgs.transmission_4;
439 openRPCPort = true; # Open firewall for RPC (port 9091)
440 home = "/neo/torrents";
441 settings = {
442 # Override default settings
443 incomplete-dir-enabled = true;
444 rpc-bind-address = "0.0.0.0"; # Bind to all interfaces
445 rpc-host-whitelist = "localhost,tm.sbr.pm,transmission-music.sbr.pm,aion.home,aion.vpn,aion.sbr.pm,192.168.1.51,10.100.0.51";
446 rpc-host-whitelist-enabled = true;
447 rpc-whitelist-enabled = true;
448 rpc-whitelist = "127.0.0.1,192.168.1.*,10.100.0.*"; # Allow local network access
449 rpc-username = "transmission";
450 rpc-password = "transmission";
451 download-queue-enabled = true;
452 download-queue-size = 15;
453 queue-stalled-enabled = true;
454 queue-stalled-minutes = 30;
455 ratio-limit = 0.1;
456 ratio-limit-enabled = true;
457 };
458 };
459
460 # Samba shares for music and audiobooks
461 samba.settings = {
462 global."server string" = "Aion";
463 music = libx.mkSambaShare {
464 name = "music";
465 path = "/neo/music";
466 };
467 audiobooks = libx.mkSambaShare {
468 name = "audiobooks";
469 path = "/neo/audiobooks";
470 };
471 };
472
473 # NFS server for music and audiobooks
474 nfs.server = {
475 enable = true;
476 # Fixed ports for firewall configuration
477 lockdPort = 4001;
478 mountdPort = 4002;
479 statdPort = 4000;
480 exports = ''
481 /neo/music 192.168.1.0/24(rw,fsid=0,no_subtree_check) 10.100.0.0/24(rw,fsid=0,no_subtree_check)
482 /neo/audiobooks 192.168.1.0/24(rw,fsid=1,no_subtree_check) 10.100.0.0/24(rw,fsid=1,no_subtree_check)
483 '';
484 };
485 };
486
487 # Override paperless services to run as vincent
488 systemd.services.paperless-scheduler.serviceConfig = {
489 User = lib.mkForce "vincent";
490 Group = lib.mkForce "users";
491 ReadWritePaths = [ "/neo/paperless/trash" ];
492 };
493
494 systemd.services.paperless-task-queue.serviceConfig = {
495 User = lib.mkForce "vincent";
496 Group = lib.mkForce "users";
497 ReadWritePaths = [
498 "/neo/paperless/trash"
499 "/neo/paperless"
500 ];
501 };
502
503 systemd.services.paperless-consumer.serviceConfig = {
504 User = lib.mkForce "vincent";
505 Group = lib.mkForce "users";
506 ReadWritePaths = [ "/neo/paperless/trash" ];
507 };
508
509 systemd.services.paperless-web.serviceConfig = {
510 User = lib.mkForce "vincent";
511 Group = lib.mkForce "users";
512 ReadWritePaths = [ "/neo/paperless/trash" ];
513 };
514
515 # Create paperless directory structure
516 systemd.tmpfiles.rules = [
517 "d /neo/paperless 0755 vincent users -"
518 "d /neo/paperless/data 0755 vincent users -"
519 "d /neo/paperless/media 0755 vincent users -"
520 "d /neo/paperless/consume 0755 vincent users -"
521 "d /neo/paperless/trash 0755 vincent users -"
522 ];
523
524 # Wallabag - read-it-later service
525 services.wallabag = {
526 enable = true;
527 port = 8084;
528 domainName = "https://wallabag.sbr.pm";
529 serverName = "wallabag";
530 secretKeyFile = config.age.secrets."wallabag-secret-key".path;
531 database.createLocally = true;
532 };
533
534 # Miniflux - RSS feed reader
535 services.miniflux = {
536 enable = true;
537 config = {
538 LISTEN_ADDR = "0.0.0.0:8085";
539 BASE_URL = "https://miniflux.sbr.pm";
540 INTEGRATION_ALLOW_PRIVATE_NETWORKS = "true";
541 };
542 createDatabaseLocally = true;
543 adminCredentialsFile = config.age.secrets."miniflux-admin-credentials".path;
544 };
545
546 # Override prometheus-restic-exporter service to disable DynamicUser
547 # This is needed so the service runs as vincent and can access SSH keys
548 # DISABLED: Service is currently disabled due to excessive load
549 # systemd.services.prometheus-restic-exporter.serviceConfig = {
550 # DynamicUser = lib.mkForce false;
551 # User = lib.mkForce "vincent";
552 # Group = lib.mkForce "users";
553 # ProtectHome = lib.mkForce false; # Disable home protection to allow SSH control sockets
554 # RestrictAddressFamilies = lib.mkForce [
555 # "AF_UNIX"
556 # "AF_INET"
557 # "AF_INET6"
558 # ]; # Allow all network families for SSH
559 # };
560
561 networking = {
562 useDHCP = lib.mkDefault true;
563 firewall = {
564 allowedTCPPorts = [
565 3001 # Homepage dashboard
566 4533 # Navidrome
567 8000 # Paperless
568 8384 # Syncthing web UI
569 13378 # Audiobookshelf
570 8084 # Wallabag (via Caddy)
571 8085 # Miniflux
572 8686 # Lidarr
573 9000 # Node exporter
574 9709 # Lidarr exportarr (prometheus)
575 # 9753 # Restic exporter (prometheus) - DISABLED
576 9091 # Transmission (music torrents)
577 # NFS ports
578 111 # rpcbind
579 2049 # NFS daemon
580 4000 # statd
581 4001 # lockd
582 4002 # mountd
583 20048 # mountd (NFSv4)
584 ];
585 allowedUDPPorts = [
586 # NFS ports
587 111 # rpcbind
588 2049 # NFS daemon
589 4000 # statd
590 4001 # lockd
591 4002 # mountd
592 20048 # mountd (NFSv4)
593 ];
594 };
595 };
596
597 environment.systemPackages = with pkgs; [
598 lm_sensors
599 gnumake
600 audible-converter
601 audible-cli
602 ffmpeg-full
603 ];
604
605}