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