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}