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