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