fedora-csb-system-manager
  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/music-playlist-dl
 47    ../../modules/harmonia
 48    ../../modules/xmpp-research-bot
 49    ./xmpp.nix
 50  ];
 51
 52  users.users.vincent.linger = true;
 53
 54  # Age secrets for homepage widgets (API keys for *arr services on rhea)
 55  age.secrets = {
 56    "exportarr-sonarr-apikey" = {
 57      file = ../../secrets/rhea/exportarr-sonarr-apikey.age;
 58      mode = "440";
 59      group = "homepage";
 60    };
 61    "exportarr-radarr-apikey" = {
 62      file = ../../secrets/rhea/exportarr-radarr-apikey.age;
 63      mode = "440";
 64      group = "homepage";
 65    };
 66    "exportarr-lidarr-apikey" = {
 67      file = ../../secrets/rhea/exportarr-lidarr-apikey.age;
 68      mode = "440";
 69      group = "homepage";
 70    };
 71    "restic-aix-password" = {
 72      file = ../../secrets/aion/restic-aix-password.age;
 73      mode = "400";
 74      owner = "vincent";
 75      group = "users";
 76    };
 77    "ntfy-token" = {
 78      file = ../../secrets/sakhalin/ntfy-token.age;
 79      mode = "400";
 80      owner = "vincent";
 81      group = "users";
 82    };
 83    "harmonia-aion-signing-key" = {
 84      file = ../../secrets/harmonia/aion-signing-key.age;
 85      mode = "440";
 86      owner = "root";
 87      group = "root";
 88    };
 89    # TODO: Uncomment after creating secrets with agenix
 90    # "xmpp-research-bot-password" = {
 91    #   file = ../../secrets/aion/xmpp-research-bot-password.age;
 92    #   mode = "400";
 93    #   owner = "vincent";
 94    #   group = "users";
 95    # };
 96    # "anthropic-api-key" = {
 97    #   file = ../../secrets/aion/anthropic-api-key.age;
 98    #   mode = "400";
 99    #   owner = "vincent";
100    #   group = "users";
101    # };
102  };
103
104  services = {
105    # Binary cache server (aarch64-linux)
106    harmonia-cache = {
107      enable = true;
108      signKeyPath = config.age.secrets."harmonia-aion-signing-key".path;
109      port = 5000;
110      workers = 4;
111      priority = 30;
112
113      # Nightly cache pre-population
114      builder = {
115        enable = true;
116        systems = [
117          "aion" # Self
118          "athena" # RPi4
119          "demeter" # RPi4
120          "aix" # RPi4
121          "rhea" # Media server
122        ];
123        schedule = "02:30"; # 2:30 AM daily (offset from aomi)
124        notification = {
125          enable = true;
126          tokenFile = config.age.secrets."ntfy-token".path;
127        };
128      };
129    };
130
131    wireguard = {
132      enable = true;
133      ips = libx.wg-ips globals.machines.aion.net.vpn.ips;
134      endpoint = "${globals.net.vpn.endpoint}";
135      endpointPublicKey = "${globals.machines.kerkouane.net.vpn.pubkey}";
136    };
137
138    audible-sync = {
139      enable = true; # enable one migration dayrs
140      user = "vincent";
141      outputDir = "/neo/audiobooks";
142      tempDir = "/neo/audiobooks/zz_import"; # Keep AAX files for reuse
143      quality = "best";
144      format = "m4b";
145      schedule = "daily"; # Run daily at 3 AM
146      notification = {
147        enable = true;
148        ntfyUrl = "https://ntfy.sbr.pm";
149        topic = "homelab";
150        tokenFile = config.age.secrets."ntfy-token".path;
151      };
152    };
153
154    audiobookshelf = serviceDefaults // {
155      enable = true;
156      port = 13378;
157      host = "0.0.0.0";
158    };
159
160    lidarr = serviceDefaults // {
161      enable = true;
162      settings.server.port = exportarrServices.lidarr.servicePort;
163    };
164
165    rsync-replica = {
166      enable = true;
167      jobs = {
168        rhea-daily = rheaBackupDefaults // {
169          source = rheaBackupDefaults.source // {
170            paths = [
171              "/neo/documents"
172              "/neo/ebooks"
173            ];
174          };
175          schedule = "daily";
176        };
177        rhea-hourly = rheaBackupDefaults // {
178          source = rheaBackupDefaults.source // {
179            paths = [
180              "/neo/pictures"
181              "/neo/videos"
182            ];
183          };
184          schedule = "hourly";
185        };
186      };
187    };
188
189    # Restic backup to aix (off-site backup)
190    # Note: Photos are already rsync'd to aix daily via aix's pull job
191    # This backup focuses on critical versioned data only
192    restic.backups.aix-critical = {
193      user = "vincent";
194      repository = "sftp:vincent@aix.sbr.pm:/data/backup/restic/aion";
195
196      # Use password-based encryption
197      passwordFile = config.age.secrets."restic-aix-password".path;
198
199      paths = [
200        "/neo/pictures/photos/backups" # Immich database dumps only (~100MB, versioned)
201        "/home/vincent/desktop/org" # Org files (<1GB)
202        "/neo/documents" # Personal docs rsynced from rhea (~113GB)
203        "/var/lib/lidarr" # Lidarr database and config (~4.6GB)
204        "/var/lib/audiobookshelf" # Audiobookshelf database and config (~30MB)
205      ];
206
207      # Backup schedule - weekly for large dataset
208      timerConfig = {
209        OnCalendar = "weekly";
210        Persistent = true;
211        RandomizedDelaySec = "1h"; # Avoid VPN congestion
212      };
213
214      # Retention policy
215      pruneOpts = [
216        "--keep-daily 7" # Last 7 days
217        "--keep-weekly 4" # Last 4 weeks
218        "--keep-monthly 12" # Last 12 months
219        "--keep-yearly 3" # Last 3 years
220      ];
221
222      # Backup options
223      extraBackupArgs = [
224        "--exclude-caches"
225        "--exclude='*.Trash-*'"
226        "--exclude='lost+found'"
227        "--exclude='.sync-conflict-*'" # Syncthing conflicts
228        "--verbose"
229      ];
230
231      # Check repository integrity after backup
232      checkOpts = [
233        "--read-data-subset=5%" # Verify 5% of data each run
234      ];
235
236      # Backup monitoring with ntfy.sh
237      backupPrepareCommand = ''
238        ${pkgs.curl}/bin/curl \
239          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
240            config.age.secrets."ntfy-token".path
241          })" \
242          -H "Title: Restic Backup Starting (aion)" \
243          -d "Starting backup to aix (critical data only)" \
244          https://ntfy.sbr.pm/backups
245      '';
246
247      backupCleanupCommand = ''
248        ${pkgs.curl}/bin/curl \
249          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
250            config.age.secrets."ntfy-token".path
251          })" \
252          -H "Title: Restic Backup Complete (aion)" \
253          -H "Tags: white_check_mark" \
254          -d "Backup to aix completed successfully" \
255          https://ntfy.sbr.pm/backups || \
256        ${pkgs.curl}/bin/curl \
257          -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
258            config.age.secrets."ntfy-token".path
259          })" \
260          -H "Title: Restic Backup Failed (aion)" \
261          -H "Tags: x,warning" \
262          -H "Priority: high" \
263          -d "Backup to aix failed! Check logs: journalctl -u restic-backups-aix-critical.service" \
264          https://ntfy.sbr.pm/backups
265      '';
266    };
267
268    # Prometheus exporter for restic backup monitoring
269    # DISABLED: Causes excessive load (restic check every 60s over SFTP)
270    # TODO: Re-enable with local repository or periodic timer-based checks
271    prometheus.exporters.restic = {
272      enable = false;
273      port = 9753;
274      user = "vincent"; # Must run as vincent to access SSH keys for aix
275      group = "users";
276      repository = "sftp:vincent@aix.sbr.pm:/data/backup/restic/aion";
277      passwordFile = config.age.secrets."restic-aix-password".path;
278    };
279
280    music-playlist-dl = {
281      enable = true; # Enable on music migration day
282      user = "vincent";
283      configFile = "/neo/music/music-playlist-dl.yaml";
284      baseDir = "/neo/music/mixes"; # Downloads to /neo/music/mixes/{show}, playlists to /neo/music/playlists
285      schedule = "weekly"; # Run weekly on Sundays at 2 AM
286      notification = {
287        enable = true;
288        ntfyUrl = "https://ntfy.sbr.pm";
289        topic = "homelab";
290        tokenFile = config.age.secrets."ntfy-token".path;
291      };
292    };
293
294    # XMPP Research Bot (disabled until secrets are created)
295    xmpp-research-bot = {
296      enable = false; # TODO: Enable after creating secrets with agenix
297      # jid = "researchbot@xmpp.sbr.pm";
298      # ownerJid = "vincent@xmpp.sbr.pm";
299      # passwordFile = config.age.secrets."xmpp-research-bot-password".path;
300      # apiKeyFile = config.age.secrets."anthropic-api-key".path;
301      # inboxPath = "/home/vincent/desktop/org/inbox.org";
302      # user = "vincent";
303      # group = "users";
304    };
305
306    navidrome = {
307      enable = true;
308      settings = {
309        MusicFolder = "/neo/music";
310        Address = "0.0.0.0";
311        Port = 4533;
312        BaseURL = "https://music.sbr.pm";
313
314        # Paths
315        DataFolder = "/var/lib/navidrome";
316        CacheFolder = "/var/cache/navidrome";
317
318        # Features
319        EnableTranscodingConfig = false; # Disabled for security - transcoding still works, UI editing disabled
320        EnableSubsonic = true;
321
322        # Optional: Scrobbling (can enable later)
323        # LastFM.Enabled = true;
324      };
325    };
326
327    transmission = serviceDefaults // {
328      enable = true; # Enable on music migration day
329      package = pkgs.transmission_4;
330      openRPCPort = true; # Open firewall for RPC (port 9091)
331      home = "/neo/torrents";
332      settings = {
333        # Override default settings
334        incomplete-dir-enabled = true;
335        rpc-bind-address = "0.0.0.0"; # Bind to all interfaces
336        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";
337        rpc-host-whitelist-enabled = true;
338        rpc-whitelist-enabled = true;
339        rpc-whitelist = "127.0.0.1,192.168.1.*,10.100.0.*"; # Allow local network access
340        rpc-username = "transmission";
341        rpc-password = "transmission";
342        download-queue-enabled = true;
343        download-queue-size = 15;
344        queue-stalled-enabled = true;
345        queue-stalled-minutes = 30;
346        ratio-limit = 0.1;
347        ratio-limit-enabled = true;
348      };
349    };
350
351    # Samba shares for music and audiobooks
352    samba.settings = {
353      global."server string" = "Aion";
354      music = libx.mkSambaShare {
355        name = "music";
356        path = "/neo/music";
357      };
358      audiobooks = libx.mkSambaShare {
359        name = "audiobooks";
360        path = "/neo/audiobooks";
361      };
362    };
363
364    # NFS server for music and audiobooks
365    nfs.server = {
366      enable = true;
367      # Fixed ports for firewall configuration
368      lockdPort = 4001;
369      mountdPort = 4002;
370      statdPort = 4000;
371      exports = ''
372        /neo/music              192.168.1.0/24(rw,fsid=0,no_subtree_check) 10.100.0.0/24(rw,fsid=0,no_subtree_check)
373        /neo/audiobooks         192.168.1.0/24(rw,fsid=1,no_subtree_check) 10.100.0.0/24(rw,fsid=1,no_subtree_check)
374      '';
375    };
376  };
377
378  # Override prometheus-restic-exporter service to disable DynamicUser
379  # This is needed so the service runs as vincent and can access SSH keys
380  # DISABLED: Service is currently disabled due to excessive load
381  # systemd.services.prometheus-restic-exporter.serviceConfig = {
382  #   DynamicUser = lib.mkForce false;
383  #   User = lib.mkForce "vincent";
384  #   Group = lib.mkForce "users";
385  #   ProtectHome = lib.mkForce false; # Disable home protection to allow SSH control sockets
386  #   RestrictAddressFamilies = lib.mkForce [
387  #     "AF_UNIX"
388  #     "AF_INET"
389  #     "AF_INET6"
390  #   ]; # Allow all network families for SSH
391  # };
392
393  networking = {
394    useDHCP = lib.mkDefault true;
395    firewall = {
396      allowedTCPPorts = [
397        3001 # Homepage dashboard
398        4533 # Navidrome
399        13378 # Audiobookshelf
400        8686 # Lidarr
401        9000 # Node exporter
402        9709 # Lidarr exportarr (prometheus)
403        # 9753 # Restic exporter (prometheus) - DISABLED
404        9091 # Transmission (music torrents)
405        # NFS ports
406        111 # rpcbind
407        2049 # NFS daemon
408        4000 # statd
409        4001 # lockd
410        4002 # mountd
411        20048 # mountd (NFSv4)
412      ];
413      allowedUDPPorts = [
414        # NFS ports
415        111 # rpcbind
416        2049 # NFS daemon
417        4000 # statd
418        4001 # lockd
419        4002 # mountd
420        20048 # mountd (NFSv4)
421      ];
422    };
423  };
424
425  environment.systemPackages = with pkgs; [
426    lm_sensors
427    gnumake
428    audible-converter
429    audible-cli
430    ffmpeg-full
431  ];
432
433}