main
  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 and loopback
122    ignoreIP = [
123      "127.0.0.0/8"
124      "::1"
125      "10.100.0.0/24" # WireGuard VPN
126      "176.172.78.0/24" # Home (Bouygues FTTH)
127      "196.176.0.0/16" # Tunisia (Ooredoo)
128    ];
129
130    jails = {
131      # Caddy auth failures (401/403 responses)
132      caddy-auth = ''
133        enabled = true
134        backend = auto
135        filter = caddy-auth
136        logpath = /var/log/caddy/access*.log
137        maxretry = 10
138        findtime = 600
139        bantime = 3600
140      '';
141
142      # Caddy aggressive scanning (404 floods)
143      caddy-scan = ''
144        enabled = true
145        backend = auto
146        filter = caddy-scan
147        logpath = /var/log/caddy/access*.log
148        maxretry = 30
149        findtime = 60
150        bantime = 3600
151      '';
152
153      # Caddy rate abuse (too many requests)
154      caddy-flood = ''
155        enabled = true
156        backend = auto
157        filter = caddy-flood
158        logpath = /var/log/caddy/access*.log
159        maxretry = 200
160        findtime = 60
161        bantime = 7200
162      '';
163    };
164  };
165
166  # Caddy fail2ban filters for JSON access logs
167  environment.etc = {
168    # Ban IPs that get too many 401/403 responses (brute force / unauthorized access)
169    "fail2ban/filter.d/caddy-auth.conf".text = ''
170      [Definition]
171      failregex = ^.*"remote_ip":"<HOST>".*"status":(401|403),.*$
172      ignoreregex = ^.*"host":"ntfy\.sbr\.pm".*$
173      datepattern = "ts":{EPOCH}
174    '';
175
176    # Ban IPs that trigger excessive 404s (scanning for vulnerabilities)
177    "fail2ban/filter.d/caddy-scan.conf".text = ''
178      [Definition]
179      failregex = ^.*"remote_ip":"<HOST>".*"status":404,.*$
180      ignoreregex =
181      datepattern = "ts":{EPOCH}
182    '';
183
184    # Ban IPs with excessive request volume (flood / DDoS)
185    "fail2ban/filter.d/caddy-flood.conf".text = ''
186      [Definition]
187      failregex = ^.*"remote_ip":"<HOST>".*"status":\d+,.*$
188      ignoreregex = ^.*"remote_ip":"10\.100\.0\..*$
189                    ^.*"host":"ntfy\.sbr\.pm".*$
190                    ^.*"host":"git\.sbr\.pm".*$
191      datepattern = "ts":{EPOCH}
192    '';
193  };
194
195  # Age secrets
196  age.secrets."ntfy-token" = {
197    file = ../../secrets/sakhalin/ntfy-token.age;
198    mode = "400";
199    owner = "root";
200    group = "root";
201  };
202
203  # Allow Caddy to access git repositories in vincent's home
204  users.users.caddy.extraGroups = [ "users" ];
205
206  # Allow vincent to run systemd-run without password (for git hooks)
207  # Use /run/current-system/sw/bin path to avoid hardcoded Nix store paths
208  security.sudo.extraRules = [
209    {
210      users = [ "vincent" ];
211      commands = [
212        {
213          command = "/run/current-system/sw/bin/systemd-run";
214          options = [ "NOPASSWD" ];
215        }
216      ];
217    }
218  ];
219
220  # Install gitmal for self-hosted git web view
221  environment.systemPackages = with pkgs; [
222    gitmal
223  ];
224
225  # Git hook background task execution with notifications
226  systemd.services."git-notify@" = {
227    description = "Git build notification for %i";
228    serviceConfig = {
229      Type = "oneshot";
230      ExecStart = "${pkgs.writeShellScript "git-notify" ''
231        #!/usr/bin/env bash
232        set -euo pipefail
233
234        UNIT_NAME="$1"
235        RESULT=$(${pkgs.systemd}/bin/systemctl show -p Result --value "$UNIT_NAME")
236        EXIT_CODE=$(${pkgs.systemd}/bin/systemctl show -p ExecMainStatus --value "$UNIT_NAME")
237
238        # Get execution timestamps (in microseconds since epoch)
239        START_TIME=$(${pkgs.systemd}/bin/systemctl show -p ExecMainStartTimestamp --value "$UNIT_NAME")
240        EXIT_TIME=$(${pkgs.systemd}/bin/systemctl show -p ExecMainExitTimestamp --value "$UNIT_NAME")
241
242        # Calculate duration in seconds
243        START_EPOCH=$(${pkgs.coreutils}/bin/date -d "$START_TIME" +%s 2>/dev/null || echo "0")
244        EXIT_EPOCH=$(${pkgs.coreutils}/bin/date -d "$EXIT_TIME" +%s 2>/dev/null || echo "0")
245        DURATION=$((EXIT_EPOCH - START_EPOCH))
246
247        # Format duration as human-readable
248        if [ "$DURATION" -ge 60 ]; then
249          MINUTES=$((DURATION / 60))
250          SECONDS=$((DURATION % 60))
251          DURATION_STR="''${MINUTES}m ''${SECONDS}s"
252        else
253          DURATION_STR="''${DURATION}s"
254        fi
255
256        # Parse unit name to extract job type and repo
257        # Format: git-<job>-<repo>-<timestamp>
258        JOB_TYPE=$(echo "$UNIT_NAME" | cut -d'-' -f2)
259        REPO=$(echo "$UNIT_NAME" | cut -d'-' -f3)
260
261        # Only notify on failure
262        if [ "$RESULT" != "success" ]; then
263          ${pkgs.curl}/bin/curl -s \
264            -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
265              config.age.secrets."ntfy-token".path
266            })" \
267            -H "Title:  Git $JOB_TYPE Failed: $REPO (after $DURATION_STR)" \
268            -H "Priority: high" \
269            -H "Tags: x,git,$JOB_TYPE,warning" \
270            -d "Job $UNIT_NAME failed after $DURATION_STR (exit code: $EXIT_CODE). Check logs: journalctl -u $UNIT_NAME" \
271            "https://ntfy.sbr.pm/git-builds" || true
272        fi
273      ''} %i";
274    };
275  };
276
277  # Helper script for gitmal generation (called from post-receive hooks)
278  environment.etc."git-hooks/generate-gitmal.sh" = {
279    text = ''
280      #!${pkgs.bash}/bin/bash
281      set -euo pipefail
282
283      # Set PATH to include git and coreutils (gitmal needs git, script needs basename)
284      export PATH="${pkgs.git}/bin:${pkgs.coreutils}/bin:$PATH"
285
286      REPO_PATH="$1"
287      THEME="''${2:-github-dark}"  # Default to 'github-dark' theme if not specified
288      REPO_NAME=$(basename "$REPO_PATH" .git)
289      OUTPUT_DIR="/home/vincent/git/public/$REPO_NAME"
290
291      echo "Generating gitmal for repository: $REPO_NAME"
292      echo "Repository path: $REPO_PATH"
293      echo "Output directory: $OUTPUT_DIR"
294      echo "Theme: $THEME"
295
296      # Generate static site with gitmal
297      cd "$REPO_PATH"
298      ${pkgs.gitmal}/bin/gitmal --output "$OUTPUT_DIR" --theme "$THEME"
299
300      echo "Gitmal generation complete: $OUTPUT_DIR"
301    '';
302    mode = "0755";
303  };
304
305  # Remote build forwarding script
306  environment.etc."git-hooks/forward-build.sh" = {
307    text = ''
308      #!${pkgs.bash}/bin/bash
309      set -euo pipefail
310
311      REPO_NAME="$1"
312      BUILD_TYPE="''${2:-auto}"
313      TIMESTAMP=$(${pkgs.coreutils}/bin/date +%Y%m%d-%H%M%S)
314      UNIT_NAME="build-remote-''${REPO_NAME}-''${TIMESTAMP}"
315
316      echo "Forwarding build to aomi: $REPO_NAME ($BUILD_TYPE)..."
317
318      # SSH to aomi and trigger build with systemd-run
319      ${pkgs.openssh}/bin/ssh -o BatchMode=yes builder@10.100.0.17 \
320        "sudo /run/current-system/sw/bin/systemd-run \
321          --unit=\"$UNIT_NAME\" \
322          --description=\"Remote build: $REPO_NAME ($BUILD_TYPE)\" \
323          --property=\"OnSuccess=job-notify@\''${UNIT_NAME}.service\" \
324          --property=\"OnFailure=job-notify@\''${UNIT_NAME}.service\" \
325          --property=\"User=builder\" \
326          --property=\"Group=users\" \
327          --property=\"WorkingDirectory=/var/lib/git-builds\" \
328          /etc/git-builds/execute-build.sh \"$REPO_NAME\" \"$BUILD_TYPE\""
329
330      echo " Build queued on aomi: $UNIT_NAME"
331      echo "  View status: ssh aomi 'systemctl status $UNIT_NAME'"
332      echo "  View logs:   ssh aomi 'journalctl -u $UNIT_NAME'"
333    '';
334    mode = "0755";
335  };
336
337  # Example post-receive hook template
338  environment.etc."git-hooks/post-receive.example" = {
339    text = ''
340      #!${pkgs.bash}/bin/bash
341      # Example post-receive hook for git repositories
342      # Copy this to your repository's hooks/post-receive and make it executable
343      #
344      # This hook uses systemd-run to execute gitmal generation in the background
345      # with automatic notifications via ntfy when the job completes.
346      #
347      # Optionally, it can also trigger remote builds on aomi.
348
349      set -euo pipefail
350
351      # Configuration
352      GITMAL_ENABLED="true"
353      GITMAL_THEME="github-dark"  # Options: github-dark, github-light, dark, light, auto
354      REMOTE_BUILD_ENABLED="false"  # Set to "true" to enable remote builds on aomi
355      BUILD_TYPE="nixos"             # Options: nixos, make, docker, go, custom, auto
356
357      REPO_PATH="$(pwd)"
358      REPO_NAME=$(basename "$REPO_PATH" .git)
359      TIMESTAMP=$(date +%Y%m%d-%H%M%S)
360
361      # 1. Generate gitmal (local static site on kerkouane)
362      if [ "$GITMAL_ENABLED" = "true" ]; then
363        UNIT_NAME="git-gitmal-''${REPO_NAME}-''${TIMESTAMP}"
364        echo "Queuing gitmal generation for $REPO_NAME with theme: $GITMAL_THEME..."
365
366        sudo /run/current-system/sw/bin/systemd-run \
367          --unit="$UNIT_NAME" \
368          --description="Gitmal generation for $REPO_NAME" \
369          --property="OnSuccess=git-notify@''${UNIT_NAME}.service" \
370          --property="OnFailure=git-notify@''${UNIT_NAME}.service" \
371          --property="User=vincent" \
372          --property="Group=users" \
373          --working-directory="$REPO_PATH" \
374          /etc/git-hooks/generate-gitmal.sh "$REPO_PATH" "$GITMAL_THEME"
375
376        echo " Gitmal generation queued as: $UNIT_NAME"
377        echo "  View status: systemctl status $UNIT_NAME"
378        echo "  View logs:   journalctl -u $UNIT_NAME"
379      fi
380
381      # 2. Trigger remote build (on aomi)
382      if [ "$REMOTE_BUILD_ENABLED" = "true" ]; then
383        /etc/git-hooks/forward-build.sh "$REPO_NAME" "$BUILD_TYPE"
384      fi
385    '';
386    mode = "0755";
387  };
388
389  # Setup permissions for git directories (via systemd tmpfiles)
390  systemd.tmpfiles.rules = [
391    "d /home/vincent 0711 vincent users -" # Allow traversal to git directory
392    "d /home/vincent/git 0700 vincent users -" # Private git directory
393    "d /home/vincent/git/public 0755 vincent users -" # Public repositories only
394    "d /var/log/git-builds 0755 vincent users -" # Git build logs
395  ];
396
397  # Disable TPM2 (VPS has no TPM hardware)
398  security.tpm2.enable = lib.mkForce false;
399
400  # Override common SSH config to restrict to VPN network only
401  services.openssh = {
402    listenAddresses = [
403      {
404        addr = builtins.head globals.machines.kerkouane.net.vpn.ips;
405        port = 22;
406      }
407    ];
408    openFirewall = lib.mkForce false;
409  };
410
411  services.wireguard.server = {
412    enable = true;
413    ips = libx.wg-ips globals.machines.kerkouane.net.vpn.ips;
414    peers = libx.generateWireguardPeers globals.machines;
415  };
416
417  # SOCKS5 proxy for SearXNG outgoing requests (VPN-only)
418  # Allows searx on sakhalin to route queries through kerkouane's IP
419  # for round-robin proxy rotation to avoid search engine rate limiting
420  services.microsocks = {
421    enable = true;
422    ip = builtins.head globals.machines.kerkouane.net.vpn.ips;
423    port = 1080;
424  };
425
426  services.gosmee = {
427    enable = true;
428    public-url = "https://webhook.sbr.pm";
429  };
430
431  services.ntfy-sh = {
432    enable = true;
433    settings = {
434      base-url = "https://ntfy.sbr.pm";
435      upstream-base-url = "https://ntfy.sh";
436      listen-http = "localhost:8111";
437      behind-proxy = true;
438      enable-login = true;
439      auth-default-access = "deny-all";
440    };
441  };
442
443  # Firewall configuration
444  # TODO: Migrate to nftables once wireguard server module supports it
445  networking.firewall = {
446    allowPing = true;
447    # Public ports
448    allowedTCPPorts = [
449      80 # HTTP
450      443 # HTTPS
451    ];
452
453    # Additional iptables rules
454    extraCommands = ''
455      # Allow node exporter (9000) only from VPN network
456      iptables -A nixos-fw -p tcp -s 10.100.0.0/24 --dport 9000 -j nixos-fw-accept
457
458      # Allow microsocks SOCKS5 proxy (1080) only from VPN network
459      iptables -A nixos-fw -p tcp -s 10.100.0.0/24 --dport 1080 -j nixos-fw-accept
460
461      # Block known SYN flood source (USBINF INFORMATICA LTDA, Brazil)
462      iptables -I nixos-fw 1 -s 45.233.176.0/22 -j DROP
463      ip6tables -I nixos-fw 1 -s ::ffff:45.233.176.0/118 -j DROP
464
465      # SYN flood protection: limit new connections per /24 subnet
466      iptables -A nixos-fw -p tcp --syn -m connlimit --connlimit-above 30 --connlimit-mask 24 -j DROP
467      ip6tables -A nixos-fw -p tcp --syn -m connlimit --connlimit-above 30 --connlimit-mask 24 -j DROP
468    '';
469  };
470  # Allow Caddy to access public git repositories only (override ProtectHome)
471  systemd.services.caddy.serviceConfig = {
472    ProtectHome = lib.mkForce "tmpfs"; # Allow read access to /home with bind mounts
473    BindReadOnlyPaths = [ "/home/vincent/git/public" ];
474  };
475
476  services.caddy = {
477    enable = true;
478    email = "vincent@sbr.pm";
479
480    # Use Caddy with rate-limit plugin
481    package = pkgs.caddy.withPlugins {
482      plugins = [ "github.com/mholt/caddy-ratelimit@v0.1.1-0.20250915152450-04ea34edc0c4" ];
483      hash = "sha256-x5jeKfjXeUj4t5t6+gRRjpmjF3n5P25T0lVED4EEu54=";
484    };
485
486    # Enable Prometheus metrics on VPN interface only
487    globalConfig = ''
488      admin ${builtins.head globals.machines.kerkouane.net.vpn.ips}:2019
489      metrics
490    '';
491
492    # Enable JSON access logging (NixOS option)
493    logFormat = ''
494      output file /var/log/caddy/access.log {
495        roll_size 100MiB
496        roll_keep 10
497        roll_keep_for 720h
498      }
499      format json
500    '';
501
502    virtualHosts = {
503      # File server with directory browsing (replaces fancyindex)
504      "dl.sbr.pm".extraConfig = ''
505        ${blockAIBotsSnippet}
506        ${robotsTxtSnippet}
507
508        root * /var/www/dl.sbr.pm
509        file_server browse {
510          hide .fancyindex README.md HEADER.md
511        }
512
513        ${securityHeaders}
514      '';
515
516      # Alias for dl.sbr.pm
517      "files.sbr.pm".extraConfig = ''
518        redir https://dl.sbr.pm{uri} permanent
519      '';
520
521      # ntfy - reverse proxy with websockets
522      "ntfy.sbr.pm".extraConfig = ''
523        # Rate limiting for notification service
524        rate_limit {
525          zone ntfy_publish {
526            key {remote_host}
527            events 50
528            window 1m
529          }
530        }
531
532        reverse_proxy localhost:8111
533      '';
534
535      # Static sites
536      "paste.sbr.pm".extraConfig = ''
537        ${blockAIBotsSnippet}
538        ${robotsTxtSnippet}
539
540        root * /var/www/paste.sbr.pm
541        file_server
542        ${securityHeaders}
543      '';
544
545      "sbr.pm".extraConfig = ''
546        ${blockAIBotsSnippet}
547        ${robotsTxtSnippet}
548
549        root * /var/www/sbr.pm
550        file_server
551        ${securityHeaders}
552      '';
553
554      # Go vanity URL service
555      "go.sbr.pm".extraConfig = ''
556        reverse_proxy localhost:8080
557        ${securityHeaders}
558      '';
559
560      # Whoami service (remote)
561      "whoami.sbr.pm".extraConfig = ''
562        reverse_proxy 10.100.0.8:80 {
563          header_up Host {host}
564        }
565      '';
566
567      # Immich photo management (proxied to rhea)
568      "immich.sbr.pm".extraConfig = ''
569        ${blockAIBotsSnippet}
570        ${robotsTxtSnippet}
571
572        # Allow large photo/video uploads (50GB limit)
573        request_body {
574          max_size 50GB
575        }
576
577        # Strict rate limiting for authentication endpoints
578        @auth {
579          path /auth/* /api/auth/*
580        }
581        route @auth {
582          rate_limit {
583            zone immich_auth {
584              key {remote_host}
585              events 10
586              window 1m
587            }
588          }
589          reverse_proxy 10.100.0.50:2283 {
590            header_up Host {host}
591            header_up X-Real-IP {remote_host}
592          }
593        }
594
595        # Moderate rate limiting for API endpoints
596        @api {
597          path /api/*
598        }
599        route @api {
600          rate_limit {
601            zone immich_api {
602              key {remote_host}
603              events 100
604              window 1m
605            }
606          }
607          reverse_proxy 10.100.0.50:2283 {
608            header_up Host {host}
609            header_up X-Real-IP {remote_host}
610          }
611        }
612
613        # Permissive rate limiting for media/general requests
614        rate_limit {
615          zone immich_media {
616            key {remote_host}
617            events 1000
618            window 1m
619          }
620        }
621
622        reverse_proxy 10.100.0.50:2283 {
623          header_up Host {host}
624          header_up X-Real-IP {remote_host}
625        }
626
627        ${mediaSecurityHeaders}
628      '';
629
630      # Navidrome music streaming (proxied to aion)
631      "navidrome.sbr.pm".extraConfig = ''
632        ${blockAIBotsSnippet}
633        ${robotsTxtSnippet}
634
635        # Rate limiting for music streaming
636        rate_limit {
637          zone navidrome_general {
638            key {remote_host}
639            events 500
640            window 1m
641          }
642        }
643
644        reverse_proxy 10.100.0.49:4533 {
645          header_up Host {host}
646          header_up X-Real-IP {remote_host}
647        }
648
649        ${mediaSecurityHeaders}
650      '';
651
652      # Jellyfin media server (proxied to rhea)
653      "jellyfin.sbr.pm".extraConfig = ''
654        ${blockAIBotsSnippet}
655        ${robotsTxtSnippet}
656
657        # Rate limiting for media server
658        rate_limit {
659          zone jellyfin_general {
660            key {remote_host}
661            events 500
662            window 1m
663          }
664        }
665
666        reverse_proxy 10.100.0.50:8096 {
667          header_up Host {host}
668          header_up X-Real-IP {remote_host}
669        }
670
671        ${mediaSecurityHeaders}
672      '';
673
674      # Audiobookshelf audiobook server (proxied to aion)
675      "audiobookshelf.sbr.pm".extraConfig = ''
676        ${blockAIBotsSnippet}
677        ${robotsTxtSnippet}
678
679        # Rate limiting for audiobook streaming
680        rate_limit {
681          zone audiobookshelf_general {
682            key {remote_host}
683            events 500
684            window 1m
685          }
686        }
687
688        reverse_proxy 10.100.0.49:13378 {
689          header_up Host {host}
690          header_up X-Real-IP {remote_host}
691        }
692
693        ${mediaSecurityHeaders}
694      '';
695
696      # Service aliases (user-friendly URLs - transparent proxy)
697      "music.sbr.pm".extraConfig = ''
698        ${blockAIBotsSnippet}
699        ${robotsTxtSnippet}
700
701        # Rate limiting for music streaming
702        rate_limit {
703          zone music_general {
704            key {remote_host}
705            events 500
706            window 1m
707          }
708        }
709
710        reverse_proxy 10.100.0.49:4533 {
711          header_up Host {host}
712          header_up X-Real-IP {remote_host}
713        }
714
715        ${mediaSecurityHeaders}
716      '';
717
718      "photos.sbr.pm".extraConfig = ''
719        ${blockAIBotsSnippet}
720        ${robotsTxtSnippet}
721
722        # Allow large photo/video uploads (50GB limit)
723        request_body {
724          max_size 50GB
725        }
726
727        # Strict rate limiting for authentication endpoints
728        @auth {
729          path /auth/* /api/auth/*
730        }
731        route @auth {
732          rate_limit {
733            zone photos_auth {
734              key {remote_host}
735              events 10
736              window 1m
737            }
738          }
739          reverse_proxy 10.100.0.50:2283 {
740            header_up Host {host}
741            header_up X-Real-IP {remote_host}
742          }
743        }
744
745        # Moderate rate limiting for API endpoints
746        @api {
747          path /api/*
748        }
749        route @api {
750          rate_limit {
751            zone photos_api {
752              key {remote_host}
753              events 100
754              window 1m
755            }
756          }
757          reverse_proxy 10.100.0.50:2283 {
758            header_up Host {host}
759            header_up X-Real-IP {remote_host}
760          }
761        }
762
763        # Permissive rate limiting for media/general requests
764        rate_limit {
765          zone photos_media {
766            key {remote_host}
767            events 1000
768            window 1m
769          }
770        }
771
772        reverse_proxy 10.100.0.50:2283 {
773          header_up Host {host}
774          header_up X-Real-IP {remote_host}
775        }
776
777        ${mediaSecurityHeaders}
778      '';
779
780      "podcasts.sbr.pm".extraConfig = ''
781        ${blockAIBotsSnippet}
782        ${robotsTxtSnippet}
783
784        # Rate limiting for audiobook streaming
785        rate_limit {
786          zone podcasts_general {
787            key {remote_host}
788            events 500
789            window 1m
790          }
791        }
792
793        reverse_proxy 10.100.0.49:13378 {
794          header_up Host {host}
795          header_up X-Real-IP {remote_host}
796        }
797
798        ${mediaSecurityHeaders}
799      '';
800
801      # Webhook/gosmee service with SSE support
802      "webhook.sbr.pm".extraConfig = ''
803        reverse_proxy localhost:3333 {
804          flush_interval -1
805        }
806      '';
807
808      # Personal website with directory browsing
809      "vincent.demeester.fr".extraConfig = ''
810        ${blockAIBotsSnippet}
811        ${robotsTxtSnippet}
812
813        root * /var/www/vincent.demeester.fr
814
815        # Try files with .html extension
816        try_files {path} {path}.html {path}/ /index.html
817
818        file_server browse {
819          hide .fancyindex README.md HEADER.md
820        }
821
822        ${securityHeaders}
823      '';
824
825      # Self-hosted git repositories (public only)
826      "git.sbr.pm".extraConfig = ''
827        ${blockAIBotsSnippet}
828        ${robotsTxtSnippet}
829
830        root * /home/vincent/git/public
831        file_server browse {
832          hide .fancyindex README.md HEADER.md
833        }
834
835        ${gitSecurityHeaders}
836      '';
837    };
838  };
839
840  services.govanityurl = {
841    enable = true;
842    user = "caddy";
843    host = "go.sbr.pm";
844    config = ''
845      paths:
846        /x:
847          repo: https://github.com/vdemeester/x
848        /lord:
849          repo: https://github.com/vdemeester/lord
850        /ape:
851          repo: https://git.sr.ht/~vdemeester/ape
852        /nr:
853          repo: https://git.sr.ht/~vdemeester/nr
854        /ram:
855          repo: https://git.sr.ht/~vdemeester/ram
856        /sec:
857          repo: https://git.sr.ht/~vdemeester/sec
858    '';
859  };
860  security.acme = {
861    acceptTerms = true;
862    defaults.email = "vincent@sbr.pm";
863  };
864}