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