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}