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