flake-update-20260505
  1{
  2  config,
  3  globals,
  4  lib,
  5  libx,
  6  pkgs,
  7  ...
  8}:
  9let
 10  # Common security headers for Caddy
 11  securityHeaders = ''
 12    header {
 13      Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
 14      X-Content-Type-Options "nosniff"
 15      X-Frame-Options "SAMEORIGIN"
 16      Referrer-Policy "strict-origin-when-cross-origin"
 17      Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()"
 18      Content-Security-Policy "default-src 'self' *.sbr.pm *.demeester.fr; style-src 'self' 'unsafe-inline'; script-src 'self'"
 19      X-XSS-Protection "1; mode=block"
 20      Cache-Control "public, max-age=604800, immutable"
 21      -Server
 22    }
 23  '';
 24
 25  # Security headers for media services (more permissive CSP for multimedia)
 26  mediaSecurityHeaders = ''
 27    header {
 28      Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
 29      X-Content-Type-Options "nosniff"
 30      X-Frame-Options "SAMEORIGIN"
 31      Referrer-Policy "strict-origin-when-cross-origin"
 32      Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"
 33      -Server
 34    }
 35  '';
 36
 37  # Security headers for git repository viewer (allow inline scripts/styles for gitmal)
 38  gitSecurityHeaders = ''
 39    header {
 40      Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
 41      X-Content-Type-Options "nosniff"
 42      X-Frame-Options "SAMEORIGIN"
 43      Referrer-Policy "strict-origin-when-cross-origin"
 44      Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"
 45      Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'"
 46      X-XSS-Protection "1; mode=block"
 47      -Server
 48    }
 49  '';
 50
 51  # Robots.txt snippet - polite request to AI scrapers
 52  robotsTxtSnippet = ''
 53        @robots path /robots.txt
 54        handle @robots {
 55          respond 200 {
 56            body `User-agent: CCBot
 57    Disallow: /
 58
 59    User-agent: ChatGPT-User
 60    Disallow: /
 61
 62    User-agent: GPTBot
 63    Disallow: /
 64
 65    User-agent: Google-Extended
 66    Disallow: /
 67
 68    User-agent: anthropic-ai
 69    Disallow: /
 70
 71    User-agent: Omgilibot
 72    Disallow: /
 73
 74    User-agent: Omgili
 75    Disallow: /
 76
 77    User-agent: FacebookBot
 78    Disallow: /`
 79            close
 80          }
 81        }
 82  '';
 83
 84  # AI bot blocking snippet - enforcement via HTTP 403
 85  blockAIBotsSnippet = ''
 86    @aibots {
 87      header User-Agent *CCBot*
 88      header User-Agent *ChatGPT-User*
 89      header User-Agent *GPTBot*
 90      header User-Agent *Google-Extended*
 91      header User-Agent *anthropic-ai*
 92      header User-Agent *Omgilibot*
 93      header User-Agent *Omgili*
 94      header User-Agent *FacebookBot*
 95    }
 96    handle @aibots {
 97      respond "AI scraping not permitted" 403
 98    }
 99  '';
100in
101{
102  imports = [
103
104    ../common/services/openssh.nix
105  ];
106
107  # ── Fail2ban ────────────────────────────────────────────────────────
108  services.fail2ban = {
109    enable = true;
110
111    # Ban for 1 hour, increase on repeat offenders via recidive jail
112    bantime = "1h";
113    bantime-increment = {
114      enable = true;
115      maxtime = "168h"; # Max 1 week ban
116      factor = "4"; # Aggressive escalation
117    };
118
119    maxretry = 5;
120
121    # Ignore VPN, loopback, and dynamic home IP (managed by athena)
122    ignoreIP = [
123      "127.0.0.0/8"
124      "::1"
125      "10.100.0.0/24" # WireGuard VPN
126    ];
127
128    jails = {
129      # Caddy auth failures (401/403 responses)
130      caddy-auth = ''
131        enabled = true
132        backend = auto
133        filter = caddy-auth
134        logpath = /var/log/caddy/access*.log
135        maxretry = 10
136        findtime = 600
137        bantime = 3600
138      '';
139
140      # Caddy aggressive scanning (404 floods)
141      caddy-scan = ''
142        enabled = true
143        backend = auto
144        filter = caddy-scan
145        logpath = /var/log/caddy/access*.log
146        maxretry = 30
147        findtime = 60
148        bantime = 3600
149      '';
150
151      # Caddy rate abuse (too many requests)
152      caddy-flood = ''
153        enabled = true
154        backend = auto
155        filter = caddy-flood
156        logpath = /var/log/caddy/access*.log
157        maxretry = 200
158        findtime = 60
159        bantime = 7200
160      '';
161    };
162  };
163
164  # Caddy fail2ban filters for JSON access logs
165  environment.etc = {
166    # Ban IPs that get too many 401/403 responses (brute force / unauthorized access)
167    # Excludes services with their own auth that naturally return 401 for token refreshes
168    "fail2ban/filter.d/caddy-auth.conf".text = ''
169      [Definition]
170      failregex = ^.*"remote_ip":"<HOST>".*"status":(401|403),.*$
171      ignoreregex = ^.*"host":"immich\.sbr\.pm".*$
172                    ^.*"host":"photos\.sbr\.pm".*$
173                    ^.*"host":"navidrome\.sbr\.pm".*$
174                    ^.*"host":"music\.sbr\.pm".*$
175                    ^.*"host":"jellyfin\.sbr\.pm".*$
176                    ^.*"host":"audiobookshelf\.sbr\.pm".*$
177                    ^.*"host":"podcasts\.sbr\.pm".*$
178                    ^.*"host":"ntfy\.sbr\.pm".*$
179      datepattern = "ts":{EPOCH}
180    '';
181
182    # Ban IPs that trigger excessive 404s (scanning for vulnerabilities)
183    "fail2ban/filter.d/caddy-scan.conf".text = ''
184      [Definition]
185      failregex = ^.*"remote_ip":"<HOST>".*"status":404,.*$
186      ignoreregex =
187      datepattern = "ts":{EPOCH}
188    '';
189
190    # Ban IPs with excessive request volume (flood / DDoS)
191    "fail2ban/filter.d/caddy-flood.conf".text = ''
192      [Definition]
193      failregex = ^.*"remote_ip":"<HOST>".*"status":\d+,.*$
194      ignoreregex = ^.*"remote_ip":"10\.100\.0\..*$
195                    ^.*"host":"ntfy\.sbr\.pm".*$
196                    ^.*"host":"git\.sbr\.pm".*$
197      datepattern = "ts":{EPOCH}
198    '';
199  };
200
201  # Dynamic home IP whitelist for fail2ban
202  # athena pushes the home public IP to /var/lib/fail2ban/home-ip.txt via SSH
203  # A path unit watches the file and reloads fail2ban ignoreip accordingly
204  systemd.tmpfiles.settings.fail2ban = {
205    "/var/lib/fail2ban".d = {
206      mode = "0755";
207      user = "root";
208      group = "root";
209    };
210  };
211
212  systemd.services.fail2ban-home-ip = {
213    description = "Update fail2ban with dynamic home IP";
214    serviceConfig = {
215      Type = "oneshot";
216      ExecStart = pkgs.writeShellScript "fail2ban-home-ip" ''
217        #!/usr/bin/env bash
218        set -euo pipefail
219        IP_FILE="/var/lib/fail2ban/home-ip.txt"
220        if [ ! -f "$IP_FILE" ]; then
221          echo "No home IP file found, skipping"
222          exit 0
223        fi
224        NEW_IP=$(${pkgs.coreutils}/bin/cat "$IP_FILE" | ${pkgs.coreutils}/bin/tr -d '[:space:]')
225        if [ -z "$NEW_IP" ]; then
226          echo "Empty IP file, skipping"
227          exit 0
228        fi
229        # Validate IP format
230        if ! echo "$NEW_IP" | ${pkgs.gnugrep}/bin/grep -qP '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'; then
231          echo "Invalid IP format: $NEW_IP"
232          exit 1
233        fi
234        OLD_IP_FILE="/var/lib/fail2ban/home-ip-current.txt"
235        OLD_IP=""
236        if [ -f "$OLD_IP_FILE" ]; then
237          OLD_IP=$(${pkgs.coreutils}/bin/cat "$OLD_IP_FILE" | ${pkgs.coreutils}/bin/tr -d '[:space:]')
238        fi
239        if [ "$NEW_IP" = "$OLD_IP" ]; then
240          echo "Home IP unchanged: $NEW_IP"
241          exit 0
242        fi
243        echo "Updating home IP: $OLD_IP -> $NEW_IP"
244        # Remove old IP from all jails
245        if [ -n "$OLD_IP" ]; then
246          for jail in caddy-auth caddy-flood caddy-scan sshd; do
247            ${pkgs.fail2ban}/bin/fail2ban-client set "$jail" delignoreip "$OLD_IP" 2>/dev/null || true
248            # Also unban in case it was already banned
249            ${pkgs.fail2ban}/bin/fail2ban-client set "$jail" unbanip "$OLD_IP" 2>/dev/null || true
250          done
251        fi
252        # Add new IP to all jails
253        for jail in caddy-auth caddy-flood caddy-scan sshd; do
254          ${pkgs.fail2ban}/bin/fail2ban-client set "$jail" addignoreip "$NEW_IP" 2>/dev/null || true
255          # Unban new IP too in case it was banned before being whitelisted
256          ${pkgs.fail2ban}/bin/fail2ban-client set "$jail" unbanip "$NEW_IP" 2>/dev/null || true
257        done
258        echo "$NEW_IP" > "$OLD_IP_FILE"
259        echo "Home IP updated successfully: $NEW_IP"
260      '';
261    };
262    after = [ "fail2ban.service" ];
263    requires = [ "fail2ban.service" ];
264  };
265
266  systemd.paths.fail2ban-home-ip = {
267    description = "Watch for home IP changes";
268    wantedBy = [ "multi-user.target" ];
269    pathConfig = {
270      PathModified = "/var/lib/fail2ban/home-ip.txt";
271    };
272  };
273
274  # Allow athena to write the home IP file via SSH
275  # athena will: curl -s ifconfig.me | ssh carthage.vpn 'cat > /var/lib/fail2ban/home-ip.txt'
276
277  # Age secrets
278  age.secrets."ntfy-token" = {
279    file = ../../secrets/sakhalin/ntfy-token.age;
280    mode = "400";
281    owner = "root";
282    group = "root";
283  };
284
285  age.secrets."flux-github-token" = {
286    file = ../../secrets/carthage/flux-github-token.age;
287    mode = "400";
288    owner = "vincent";
289    group = "users";
290  };
291
292  # Flux — website generator (hourly)
293  systemd.tmpfiles.rules = [
294    "d /var/lib/flux 0755 vincent users -"
295    # Git directory permissions (for Caddy access to public repos)
296    "d /home/vincent 0711 vincent users -" # Allow traversal to git directory
297    "d /home/vincent/git 0700 vincent users -" # Private git directory
298    "d /home/vincent/git/public 0755 vincent users -" # Public repositories only
299    "d /var/log/git-builds 0755 vincent users -" # Git build logs
300  ];
301
302  systemd.services.flux-generate = {
303    description = "Generate and deploy vincent.demeester.fr";
304    serviceConfig = {
305      Type = "oneshot";
306      User = "vincent";
307      Group = "users";
308      WorkingDirectory = "/var/lib/flux";
309      ExecStart = "/var/lib/flux/www/scripts/flux-generate.sh";
310      Environment = "HOME=/home/vincent";
311    };
312    path = with pkgs; [
313      nix
314      bash
315      git
316      openssh
317      rsync
318      coreutils
319      findutils
320      gnused
321      gnugrep
322      gnutar
323      gzip
324    ];
325  };
326
327  systemd.timers.flux-generate = {
328    description = "Generate website hourly";
329    wantedBy = [ "timers.target" ];
330    timerConfig = {
331      OnCalendar = "hourly";
332      Persistent = true;
333      RandomizedDelaySec = "5min";
334    };
335  };
336
337  # Allow Caddy to access git repositories in vincent's home
338  users.users.caddy.extraGroups = [ "users" ];
339
340  # Allow vincent to run systemd-run without password (for git hooks)
341  # Use /run/current-system/sw/bin path to avoid hardcoded Nix store paths
342  security.sudo.extraRules = [
343    {
344      users = [ "vincent" ];
345      commands = [
346        {
347          command = "/run/current-system/sw/bin/systemd-run";
348          options = [ "NOPASSWD" ];
349        }
350      ];
351    }
352  ];
353
354  # Install gitmal for self-hosted git web view
355  environment.systemPackages = with pkgs; [
356    gitmal
357  ];
358
359  # Git hook background task execution with notifications
360  systemd.services."git-notify@" = {
361    description = "Git build notification for %i";
362    serviceConfig = {
363      Type = "oneshot";
364      ExecStart = "${pkgs.writeShellScript "git-notify" ''
365        #!/usr/bin/env bash
366        set -euo pipefail
367
368        UNIT_NAME="$1"
369        RESULT=$(${pkgs.systemd}/bin/systemctl show -p Result --value "$UNIT_NAME")
370        EXIT_CODE=$(${pkgs.systemd}/bin/systemctl show -p ExecMainStatus --value "$UNIT_NAME")
371
372        # Get execution timestamps (in microseconds since epoch)
373        START_TIME=$(${pkgs.systemd}/bin/systemctl show -p ExecMainStartTimestamp --value "$UNIT_NAME")
374        EXIT_TIME=$(${pkgs.systemd}/bin/systemctl show -p ExecMainExitTimestamp --value "$UNIT_NAME")
375
376        # Calculate duration in seconds
377        START_EPOCH=$(${pkgs.coreutils}/bin/date -d "$START_TIME" +%s 2>/dev/null || echo "0")
378        EXIT_EPOCH=$(${pkgs.coreutils}/bin/date -d "$EXIT_TIME" +%s 2>/dev/null || echo "0")
379        DURATION=$((EXIT_EPOCH - START_EPOCH))
380
381        # Format duration as human-readable
382        if [ "$DURATION" -ge 60 ]; then
383          MINUTES=$((DURATION / 60))
384          SECONDS=$((DURATION % 60))
385          DURATION_STR="''${MINUTES}m ''${SECONDS}s"
386        else
387          DURATION_STR="''${DURATION}s"
388        fi
389
390        # Parse unit name to extract job type and repo
391        # Format: git-<job>-<repo>-<timestamp>
392        JOB_TYPE=$(echo "$UNIT_NAME" | cut -d'-' -f2)
393        REPO=$(echo "$UNIT_NAME" | cut -d'-' -f3)
394
395        # Only notify on failure
396        if [ "$RESULT" != "success" ]; then
397          ${pkgs.curl}/bin/curl -s \
398            -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
399              config.age.secrets."ntfy-token".path
400            })" \
401            -H "Title:  Git $JOB_TYPE Failed: $REPO (after $DURATION_STR)" \
402            -H "Priority: high" \
403            -H "Tags: x,git,$JOB_TYPE,warning" \
404            -d "Job $UNIT_NAME failed after $DURATION_STR (exit code: $EXIT_CODE). Check logs: journalctl -u $UNIT_NAME" \
405            "https://ntfy.sbr.pm/git-builds" || true
406        fi
407      ''} %i";
408    };
409  };
410
411  # Helper script for gitmal generation (called from post-receive hooks)
412  environment.etc."git-hooks/generate-gitmal.sh" = {
413    text = ''
414      #!${pkgs.bash}/bin/bash
415      set -euo pipefail
416
417      # Set PATH to include git and coreutils (gitmal needs git, script needs basename)
418      export PATH="${pkgs.git}/bin:${pkgs.coreutils}/bin:$PATH"
419
420      REPO_PATH="$1"
421      THEME="''${2:-github-dark}"  # Default to 'github-dark' theme if not specified
422      REPO_NAME=$(basename "$REPO_PATH" .git)
423      OUTPUT_DIR="/home/vincent/git/public/$REPO_NAME"
424
425      echo "Generating gitmal for repository: $REPO_NAME"
426      echo "Repository path: $REPO_PATH"
427      echo "Output directory: $OUTPUT_DIR"
428      echo "Theme: $THEME"
429
430      # Generate static site with gitmal
431      cd "$REPO_PATH"
432      ${pkgs.gitmal}/bin/gitmal --output "$OUTPUT_DIR" --theme "$THEME"
433
434      echo "Gitmal generation complete: $OUTPUT_DIR"
435    '';
436    mode = "0755";
437  };
438
439  # Remote build forwarding script
440  environment.etc."git-hooks/forward-build.sh" = {
441    text = ''
442      #!${pkgs.bash}/bin/bash
443      set -euo pipefail
444
445      REPO_NAME="$1"
446      BUILD_TYPE="''${2:-auto}"
447      TIMESTAMP=$(${pkgs.coreutils}/bin/date +%Y%m%d-%H%M%S)
448      UNIT_NAME="build-remote-''${REPO_NAME}-''${TIMESTAMP}"
449
450      echo "Forwarding build to aomi: $REPO_NAME ($BUILD_TYPE)..."
451
452      # SSH to aomi and trigger build with systemd-run
453      ${pkgs.openssh}/bin/ssh -o BatchMode=yes builder@10.100.0.17 \
454        "sudo /run/current-system/sw/bin/systemd-run \
455          --unit=\"$UNIT_NAME\" \
456          --description=\"Remote build: $REPO_NAME ($BUILD_TYPE)\" \
457          --property=\"OnSuccess=job-notify@\''${UNIT_NAME}.service\" \
458          --property=\"OnFailure=job-notify@\''${UNIT_NAME}.service\" \
459          --property=\"User=builder\" \
460          --property=\"Group=users\" \
461          --property=\"WorkingDirectory=/var/lib/git-builds\" \
462          /etc/git-builds/execute-build.sh \"$REPO_NAME\" \"$BUILD_TYPE\""
463
464      echo " Build queued on aomi: $UNIT_NAME"
465      echo "  View status: ssh aomi 'systemctl status $UNIT_NAME'"
466      echo "  View logs:   ssh aomi 'journalctl -u $UNIT_NAME'"
467    '';
468    mode = "0755";
469  };
470
471  # Example post-receive hook template
472  environment.etc."git-hooks/post-receive.example" = {
473    text = ''
474      #!${pkgs.bash}/bin/bash
475      # Example post-receive hook for git repositories
476      # Copy this to your repository's hooks/post-receive and make it executable
477      #
478      # This hook uses systemd-run to execute gitmal generation in the background
479      # with automatic notifications via ntfy when the job completes.
480      #
481      # Optionally, it can also trigger remote builds on aomi.
482
483      set -euo pipefail
484
485      # Configuration
486      GITMAL_ENABLED="true"
487      GITMAL_THEME="github-dark"  # Options: github-dark, github-light, dark, light, auto
488      REMOTE_BUILD_ENABLED="false"  # Set to "true" to enable remote builds on aomi
489      BUILD_TYPE="nixos"             # Options: nixos, make, docker, go, custom, auto
490
491      REPO_PATH="$(pwd)"
492      REPO_NAME=$(basename "$REPO_PATH" .git)
493      TIMESTAMP=$(date +%Y%m%d-%H%M%S)
494
495      # 1. Generate gitmal (local static site on carthage)
496      if [ "$GITMAL_ENABLED" = "true" ]; then
497        UNIT_NAME="git-gitmal-''${REPO_NAME}-''${TIMESTAMP}"
498        echo "Queuing gitmal generation for $REPO_NAME with theme: $GITMAL_THEME..."
499
500        sudo /run/current-system/sw/bin/systemd-run \
501          --unit="$UNIT_NAME" \
502          --description="Gitmal generation for $REPO_NAME" \
503          --property="OnSuccess=git-notify@''${UNIT_NAME}.service" \
504          --property="OnFailure=git-notify@''${UNIT_NAME}.service" \
505          --property="User=vincent" \
506          --property="Group=users" \
507          --working-directory="$REPO_PATH" \
508          /etc/git-hooks/generate-gitmal.sh "$REPO_PATH" "$GITMAL_THEME"
509
510        echo " Gitmal generation queued as: $UNIT_NAME"
511        echo "  View status: systemctl status $UNIT_NAME"
512        echo "  View logs:   journalctl -u $UNIT_NAME"
513      fi
514
515      # 2. Trigger remote build (on aomi)
516      if [ "$REMOTE_BUILD_ENABLED" = "true" ]; then
517        /etc/git-hooks/forward-build.sh "$REPO_NAME" "$BUILD_TYPE"
518      fi
519    '';
520    mode = "0755";
521  };
522
523  # Git directory permissions are set in the systemd.tmpfiles.rules above (with flux)
524
525  # Disable TPM2 (VPS has no TPM hardware)
526  security.tpm2.enable = lib.mkForce false;
527
528  # Override common SSH config to restrict to VPN network only
529  # SSH only on VPN interface — no public access
530  services.openssh = {
531    listenAddresses = [
532      {
533        addr = builtins.head globals.machines.carthage.net.vpn.ips;
534        port = 22;
535      }
536    ];
537    openFirewall = lib.mkForce false;
538  };
539
540  services.wireguard.server = {
541    enable = true;
542    ips = libx.wg-ips globals.machines.carthage.net.vpn.ips;
543    peers = libx.generateWireguardPeers globals.machines;
544  };
545
546  # SOCKS5 proxy for SearXNG outgoing requests (VPN-only)
547  # Allows searx on sakhalin to route queries through carthage's IP
548  # for round-robin proxy rotation to avoid search engine rate limiting
549  services.microsocks = {
550    enable = true;
551    ip = builtins.head globals.machines.carthage.net.vpn.ips;
552    port = 1080;
553  };
554
555  services.gosmee = {
556    enable = true;
557    public-url = "https://webhook.sbr.pm";
558  };
559
560  services.ntfy-sh = {
561    enable = true;
562    settings = {
563      base-url = "https://ntfy.sbr.pm";
564      upstream-base-url = "https://ntfy.sh";
565      listen-http = "localhost:8111";
566      behind-proxy = true;
567      enable-login = true;
568      auth-default-access = "deny-all";
569    };
570  };
571
572  # Firewall configuration
573  # TODO: Migrate to nftables once wireguard server module supports it
574  networking.firewall = {
575    allowPing = true;
576    # Public ports
577    allowedTCPPorts = [
578      80 # HTTP
579      443 # HTTPS
580    ];
581
582    # Additional iptables rules
583    extraCommands = ''
584      # Allow node exporter (9000) only from VPN network
585      iptables -A nixos-fw -p tcp -s 10.100.0.0/24 --dport 9000 -j nixos-fw-accept
586
587      # Allow microsocks SOCKS5 proxy (1080) only from VPN network
588      iptables -A nixos-fw -p tcp -s 10.100.0.0/24 --dport 1080 -j nixos-fw-accept
589
590      # Block known SYN flood source (USBINF INFORMATICA LTDA, Brazil)
591      iptables -I nixos-fw 1 -s 45.233.176.0/22 -j DROP
592      ip6tables -I nixos-fw 1 -s ::ffff:45.233.176.0/118 -j DROP
593
594      # SYN flood protection: limit new connections per /24 subnet
595      iptables -A nixos-fw -p tcp --syn -m connlimit --connlimit-above 30 --connlimit-mask 24 -j DROP
596      ip6tables -A nixos-fw -p tcp --syn -m connlimit --connlimit-above 30 --connlimit-mask 24 -j DROP
597    '';
598  };
599  # Allow Caddy to access public git repositories only (override ProtectHome)
600  systemd.services.caddy.serviceConfig = {
601    ProtectHome = lib.mkForce "tmpfs"; # Allow read access to /home with bind mounts
602    BindReadOnlyPaths = [ "/home/vincent/git/public" ];
603  };
604
605  services.caddy = {
606    enable = true;
607    email = "vincent@sbr.pm";
608
609    # Use Caddy with rate-limit plugin
610    package = pkgs.caddy.withPlugins {
611      plugins = [ "github.com/mholt/caddy-ratelimit@v0.1.1-0.20250915152450-04ea34edc0c4" ];
612      hash = "sha256-HsastuExreRLPw+d2Z1WP0p6IHY2GLNhjpPTRpxPiB0=";
613    };
614
615    # Enable Prometheus metrics on VPN interface only
616    globalConfig = ''
617      admin ${builtins.head globals.machines.carthage.net.vpn.ips}:2019
618      metrics
619    '';
620
621    # Enable JSON access logging (NixOS option)
622    logFormat = ''
623      output file /var/log/caddy/access.log {
624        roll_size 100MiB
625        roll_keep 10
626        roll_keep_for 720h
627      }
628      format json
629    '';
630
631    virtualHosts = {
632      # File server with directory browsing (replaces fancyindex)
633      "dl.sbr.pm".extraConfig = ''
634        ${blockAIBotsSnippet}
635        ${robotsTxtSnippet}
636
637        root * /var/www/dl.sbr.pm
638        file_server browse {
639          hide .fancyindex README.md HEADER.md
640        }
641
642        ${securityHeaders}
643      '';
644
645      # Alias for dl.sbr.pm
646      "files.sbr.pm".extraConfig = ''
647        redir https://dl.sbr.pm{uri} permanent
648      '';
649
650      # ntfy - reverse proxy with websockets
651      "ntfy.sbr.pm".extraConfig = ''
652        # Rate limiting for notification service
653        rate_limit {
654          zone ntfy_publish {
655            key {remote_host}
656            events 50
657            window 1m
658          }
659        }
660
661        reverse_proxy localhost:8111
662      '';
663
664      # Static sites
665      "paste.sbr.pm".extraConfig = ''
666        ${blockAIBotsSnippet}
667        ${robotsTxtSnippet}
668
669        root * /var/www/paste.sbr.pm
670        file_server
671        ${securityHeaders}
672      '';
673
674      "sbr.pm".extraConfig = ''
675        ${blockAIBotsSnippet}
676        ${robotsTxtSnippet}
677
678        root * /var/www/sbr.pm
679        file_server
680        ${securityHeaders}
681      '';
682
683      # Go vanity URL service
684      "go.sbr.pm".extraConfig = ''
685        reverse_proxy localhost:8080
686        ${securityHeaders}
687      '';
688
689      # Whoami service (remote)
690      "whoami.sbr.pm".extraConfig = ''
691        reverse_proxy 10.100.0.8:80 {
692          header_up Host {host}
693        }
694      '';
695
696      # Immich photo management (proxied to rhea)
697      "immich.sbr.pm".extraConfig = ''
698        ${blockAIBotsSnippet}
699        ${robotsTxtSnippet}
700
701        # Allow large photo/video uploads (50GB limit)
702        request_body {
703          max_size 50GB
704        }
705
706        # Strict rate limiting for authentication endpoints
707        @auth {
708          path /auth/* /api/auth/*
709        }
710        route @auth {
711          rate_limit {
712            zone immich_auth {
713              key {remote_host}
714              events 10
715              window 1m
716            }
717          }
718          reverse_proxy 10.100.0.50:2283 {
719            header_up Host {host}
720            header_up X-Real-IP {remote_host}
721          }
722        }
723
724        # Moderate rate limiting for API endpoints
725        @api {
726          path /api/*
727        }
728        route @api {
729          rate_limit {
730            zone immich_api {
731              key {remote_host}
732              events 100
733              window 1m
734            }
735          }
736          reverse_proxy 10.100.0.50:2283 {
737            header_up Host {host}
738            header_up X-Real-IP {remote_host}
739          }
740        }
741
742        # Permissive rate limiting for media/general requests
743        rate_limit {
744          zone immich_media {
745            key {remote_host}
746            events 1000
747            window 1m
748          }
749        }
750
751        reverse_proxy 10.100.0.50:2283 {
752          header_up Host {host}
753          header_up X-Real-IP {remote_host}
754        }
755
756        ${mediaSecurityHeaders}
757      '';
758
759      # Navidrome music streaming (proxied to aion)
760      "navidrome.sbr.pm".extraConfig = ''
761        ${blockAIBotsSnippet}
762        ${robotsTxtSnippet}
763
764        # Rate limiting for music streaming
765        rate_limit {
766          zone navidrome_general {
767            key {remote_host}
768            events 500
769            window 1m
770          }
771        }
772
773        reverse_proxy 10.100.0.49:4533 {
774          header_up Host {host}
775          header_up X-Real-IP {remote_host}
776        }
777
778        ${mediaSecurityHeaders}
779      '';
780
781      # Jellyfin media server (proxied to rhea)
782      "jellyfin.sbr.pm".extraConfig = ''
783        ${blockAIBotsSnippet}
784        ${robotsTxtSnippet}
785
786        # Rate limiting for media server
787        rate_limit {
788          zone jellyfin_general {
789            key {remote_host}
790            events 500
791            window 1m
792          }
793        }
794
795        reverse_proxy 10.100.0.50:8096 {
796          header_up Host {host}
797          header_up X-Real-IP {remote_host}
798        }
799
800        ${mediaSecurityHeaders}
801      '';
802
803      # Audiobookshelf audiobook server (proxied to aion)
804      "audiobookshelf.sbr.pm".extraConfig = ''
805        ${blockAIBotsSnippet}
806        ${robotsTxtSnippet}
807
808        # Rate limiting for audiobook streaming
809        rate_limit {
810          zone audiobookshelf_general {
811            key {remote_host}
812            events 500
813            window 1m
814          }
815        }
816
817        reverse_proxy 10.100.0.49:13378 {
818          header_up Host {host}
819          header_up X-Real-IP {remote_host}
820        }
821
822        ${mediaSecurityHeaders}
823      '';
824
825      # Service aliases (user-friendly URLs - transparent proxy)
826      "music.sbr.pm".extraConfig = ''
827        ${blockAIBotsSnippet}
828        ${robotsTxtSnippet}
829
830        # Rate limiting for music streaming
831        rate_limit {
832          zone music_general {
833            key {remote_host}
834            events 500
835            window 1m
836          }
837        }
838
839        reverse_proxy 10.100.0.49:4533 {
840          header_up Host {host}
841          header_up X-Real-IP {remote_host}
842        }
843
844        ${mediaSecurityHeaders}
845      '';
846
847      "photos.sbr.pm".extraConfig = ''
848        ${blockAIBotsSnippet}
849        ${robotsTxtSnippet}
850
851        # Allow large photo/video uploads (50GB limit)
852        request_body {
853          max_size 50GB
854        }
855
856        # Strict rate limiting for authentication endpoints
857        @auth {
858          path /auth/* /api/auth/*
859        }
860        route @auth {
861          rate_limit {
862            zone photos_auth {
863              key {remote_host}
864              events 10
865              window 1m
866            }
867          }
868          reverse_proxy 10.100.0.50:2283 {
869            header_up Host {host}
870            header_up X-Real-IP {remote_host}
871          }
872        }
873
874        # Moderate rate limiting for API endpoints
875        @api {
876          path /api/*
877        }
878        route @api {
879          rate_limit {
880            zone photos_api {
881              key {remote_host}
882              events 100
883              window 1m
884            }
885          }
886          reverse_proxy 10.100.0.50:2283 {
887            header_up Host {host}
888            header_up X-Real-IP {remote_host}
889          }
890        }
891
892        # Permissive rate limiting for media/general requests
893        rate_limit {
894          zone photos_media {
895            key {remote_host}
896            events 1000
897            window 1m
898          }
899        }
900
901        reverse_proxy 10.100.0.50:2283 {
902          header_up Host {host}
903          header_up X-Real-IP {remote_host}
904        }
905
906        ${mediaSecurityHeaders}
907      '';
908
909      "podcasts.sbr.pm".extraConfig = ''
910        ${blockAIBotsSnippet}
911        ${robotsTxtSnippet}
912
913        # Rate limiting for audiobook streaming
914        rate_limit {
915          zone podcasts_general {
916            key {remote_host}
917            events 500
918            window 1m
919          }
920        }
921
922        reverse_proxy 10.100.0.49:13378 {
923          header_up Host {host}
924          header_up X-Real-IP {remote_host}
925        }
926
927        ${mediaSecurityHeaders}
928      '';
929
930      # Webhook/gosmee service with SSE support
931      "webhook.sbr.pm".extraConfig = ''
932        reverse_proxy localhost:3333 {
933          flush_interval -1
934        }
935      '';
936
937      # Personal website with directory browsing
938      "vincent.demeester.fr".extraConfig = ''
939        ${blockAIBotsSnippet}
940        ${robotsTxtSnippet}
941
942        root * /var/www/vincent.demeester.fr
943
944        # Try files with .html extension
945        try_files {path} {path}.html {path}/ /index.html
946
947        file_server browse {
948          hide .fancyindex README.md HEADER.md
949        }
950
951        ${securityHeaders}
952      '';
953
954      # Self-hosted git repositories (public only)
955      "git.sbr.pm".extraConfig = ''
956        ${blockAIBotsSnippet}
957        ${robotsTxtSnippet}
958
959        root * /home/vincent/git/public
960        file_server browse {
961          hide .fancyindex README.md HEADER.md
962        }
963
964        ${gitSecurityHeaders}
965      '';
966    };
967  };
968
969  services.govanityurl = {
970    enable = true;
971    user = "caddy";
972    host = "go.sbr.pm";
973    config = ''
974      paths:
975        /x:
976          repo: https://github.com/vdemeester/x
977        /lord:
978          repo: https://github.com/vdemeester/lord
979        /ape:
980          repo: https://git.sr.ht/~vdemeester/ape
981        /nr:
982          repo: https://git.sr.ht/~vdemeester/nr
983        /ram:
984          repo: https://git.sr.ht/~vdemeester/ram
985        /sec:
986          repo: https://git.sr.ht/~vdemeester/sec
987    '';
988  };
989  security.acme = {
990    acceptTerms = true;
991    defaults.email = "vincent@sbr.pm";
992  };
993}