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