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}