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