diff --git a/.env.example b/.env.example index f367c168..83ce1f69 100644 --- a/.env.example +++ b/.env.example @@ -112,6 +112,21 @@ CROWDSEC_REPORT_ENROLL_KEY= # Central API base — override only for tests/staging. CROWDSEC_CAPI_BASE_URL=https://api.crowdsec.net/v3 +# --- CROWDSEC LOCAL (opt-in engine on this Docker host, no proxy changes) --- +# App-layer LAPI bouncer: the anti-DDoS gate asks the local engine per client +# IP (short-cached) and blocks ban/captcha decisions before its own buckets. +# Start everything with `bash cms security`; it writes the key below into .env +# and starts the CrowdSec engine bound to 127.0.0.1. Set to "true" to load the +# bouncer without the local engine (not recommended). +CROWDSEC_LOCAL_ENABLED=false +# Host access-log directory mounted into the engine for detection (Nginx only). +CROWDSEC_NGINX_LOG_DIR=/var/log/nginx +# Change LAPI port AND LAPI URL together when 18080 is already taken. +CROWDSEC_LAPI_PORT=18080 +CROWDSEC_LAPI_URL=http://127.0.0.1:18080 +# Generated by `bash cms security`; keep in .env, never commit a value. +CROWDSEC_LAPI_API_KEY= + # --- PATHS --- BADGE_UPLOAD_DIR=./public/assets/images/badges EMULATOR_JAR_PATH=./emulator/Arcturus.jar diff --git a/README.md b/README.md index c2ab3e6d..e0419ba2 100644 --- a/README.md +++ b/README.md @@ -775,6 +775,88 @@ Open **DevOps → Anti-DDoS protection** --- +## Local CrowdSec Engine (opt-in) + +The repository ships a self-contained CrowdSec engine that runs on the same +Docker host. It reads the host Nginx access log, runs +`crowdsecurity/crowdsec:1.8.1` in its own Compose project and exposes LAPI only +on `127.0.0.1:18080`. When enabled, the app-layer anti-DDoS gate +(`src/lib/crowdsec-local.ts`) asks the local LAPI per client IP (short-cached) +and blocks `ban` / `captcha` decisions before its own rate buckets run. +No reverse-proxy, Traefik, Cloudflare or firewall configuration is changed. + +### Step 1 — Enable the engine and register the bouncer + +```bash +bash cms security +``` + +This generates `CROWDSEC_LAPI_API_KEY` (random 64 hex chars), writes the +CrowdSec flags into `.env`, starts the engine and registers the `cms` bouncer +against the local LAPI. The engine does **not** enroll into the CrowdSec +Central API (`DISABLE_ONLINE_API=true`): detection stays local. + +### Step 2 — Restart the CMS so it loads the bouncer credentials + +A CI-managed `epicnext-cms-app` picks the new `.env` values up on its next +deployment. For a clone running via the updater: + +```bash +bash cms update --skip-pull +``` + +or restart the container directly (`docker compose restart cms`). Without the +restart the gate has not loaded the LAPI URL/key yet. + +### Step 3 — Verify + +```bash +bash cms security status +``` + +Expect `CROWDSEC_LOCAL_ENABLED=yes` and `LAPI health: OK (127.0.0.1:18080)`. +In the admin panel, **DevOps → Anti-DDoS protection** shows live block +statistics split per origin (`community` vs `local`). + +### Step 4 — Stop the engine again (optional) + +```bash +bash cms security disable +``` + +Stops the container and sets `CROWDSEC_LOCAL_ENABLED=false`. Volumes and the +`.env` key are kept. + +### Environment variables + +| Variable | Default | Purpose | +| ---------------------------- | ----------------------------- | ------------------------------------ | +| `CROWDSEC_LOCAL_ENABLED` | `false` | Master switch for the local stack | +| `CROWDSEC_LAPI_URL` | `http://127.0.0.1:18080` | LAPI endpoint (loopback only) | +| `CROWDSEC_LAPI_PORT` | `18080` | Host port the engine maps to LAPI | +| `CROWDSEC_LAPI_API_KEY` | — | Bouncer key; required when enabled | +| `CROWDSEC_LAPI_TIMEOUT_MS` | `500` | Per-decision request timeout | +| `CROWDSEC_LAPI_RETRY_MS` | `500` | Backoff before retrying LAPI | +| `CROWDSEC_NGINX_LOG_DIR` | `/var/log/nginx` | Access-log directory for the engine | + +### Notes and limitations + +- Changing the port means updating `CROWDSEC_LAPI_PORT` **and** + `CROWDSEC_LAPI_URL` together, then re-running `bash cms security`. +- Rotate the key by editing `CROWDSEC_LAPI_API_KEY` in `.env`, running + `bash cms security` again (re-registers the bouncer) and restarting the CMS. +- The gate is **fail-closed at startup** when the feature is enabled without a + key (startup aborts with a clear message). At runtime a LAPI network error + **fails open** (traffic is allowed, decisions paused); a 403 from LAPI + pauses local decisions for 5 minutes. +- This bouncer is **application-layer**: it sheds known-bad IPs at the CMS + process only. It does not drop traffic before the origin, does not protect + other host ports/services, and depends on the client IP being trustworthy at + the ingress. Keep the upstream protections (Cloudflare IP rules, proxy rate + limits) for defense before the origin. + +--- + ## Production Deployment (PM2) ```bash diff --git a/cms b/cms index 88328982..a2a510ea 100644 --- a/cms +++ b/cms @@ -4,6 +4,7 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" case "${1:-help}" in install) shift; exec bash "$DIR/scripts/docker-install.sh" "$@" ;; update) shift; exec bash "$DIR/scripts/docker-update.sh" "$@" ;; - help|--help|-h) printf '%s\n' 'bash cms install Configure and install on a Linux Docker host' 'bash cms update Update using saved settings; --skip-pull uses checked-out release' ;; - *) echo "Unknown command. Use: bash cms install | update" >&2; exit 1 ;; + security) shift; exec bash "$DIR/scripts/crowdsec-setup.sh" "$@" ;; + help|--help|-h) printf '%s\n' 'bash cms install Configure and install on a Linux Docker host' 'bash cms update Update using saved settings; --skip-pull uses checked-out release' 'bash cms security Configure the opt-in local CrowdSec stack (enable|status|disable)' ;; + *) echo "Unknown command. Use: bash cms install | update | security" >&2; exit 1 ;; esac diff --git a/deployment/crowdsec/acquis.d/nginx.yaml b/deployment/crowdsec/acquis.d/nginx.yaml new file mode 100644 index 00000000..151bacba --- /dev/null +++ b/deployment/crowdsec/acquis.d/nginx.yaml @@ -0,0 +1,4 @@ +filenames: + - /var/log/nginx/access.log +labels: + type: nginx \ No newline at end of file diff --git a/deployment/crowdsec/compose.crowdsec.yml b/deployment/crowdsec/compose.crowdsec.yml new file mode 100644 index 00000000..2b2bb67e --- /dev/null +++ b/deployment/crowdsec/compose.crowdsec.yml @@ -0,0 +1,41 @@ +services: + crowdsec: + image: crowdsecurity/crowdsec:${CROWDSEC_VERSION:-1.8.1} + container_name: epicnext-crowdsec + restart: unless-stopped + profiles: ["security"] + environment: + COLLECTIONS: ${CROWDSEC_COLLECTIONS:-crowdsecurity/nginx} + BOUNCER_KEY_cms: ${CROWDSEC_LAPI_API_KEY:?CROWDSEC_LAPI_API_KEY must be set} + DISABLE_ONLINE_API: "true" + GID: "${CROWDSEC_GID:-0}" + TZ: "${TZ:-UTC}" + ports: + - "${CROWDSEC_LAPI_BIND_HOST:-127.0.0.1}:${CROWDSEC_LAPI_PORT:-18080}:8080" + volumes: + - ./acquis.d:/etc/crowdsec/acquis.d:ro + - ${CROWDSEC_NGINX_LOG_DIR:-/var/log/nginx}:/var/log/nginx:ro + - crowdsec-config:/etc/crowdsec + - crowdsec-data:/var/lib/crowdsec/data + security_opt: + - no-new-privileges:true + pids_limit: 256 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + healthcheck: + test: + [ + "CMD-SHELL", + "wget -q -O - http://127.0.0.1:8080/health >/dev/null 2>&1", + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + +volumes: + crowdsec-config: + crowdsec-data: \ No newline at end of file diff --git a/docs/operations/docker-installation.md b/docs/operations/docker-installation.md index 9a57763d..ee83a773 100644 --- a/docs/operations/docker-installation.md +++ b/docs/operations/docker-installation.md @@ -85,6 +85,22 @@ The direct template intentionally records the CDN/edge socket address when place `pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall. +## Opt-in: CrowdSec on the same Docker host + +A self-contained CrowdSec engine ships in `deployment/crowdsec`. It reads the host Nginx access log, runs `crowdsecurity/crowdsec:1.8.1` in its own Compose project and exposes LAPI only on `127.0.0.1:18080`. No reverse-proxy, Traefik, Cloudflare or firewall configuration is changed. + +```sh +bash cms security +``` + +The command generates `CROWDSEC_LAPI_API_KEY`, writes the CrowdSec flags into `.env`, starts the engine and registers the `cms` bouncer. The anti-DDoS gate then consults the local LAPI per client IP (short-cached) and blocks `ban`/`captcha` decisions before its own rate buckets. `bash cms security status` reports engine state and `bash cms security disable` stops the engine and flips the toggle off. + +The engine does not enroll into the CrowdSec Central API (`DISABLE_ONLINE_API=true`): detection stays local. The app still has its separate opt-in traffic-sharing channel via `CROWDSEC_REPORT_ENABLED`. Change `CROWDSEC_LAPI_PORT` and `CROWDSEC_LAPI_URL` together when `18080` is already in use. `CROWDSEC_NGINX_LOG_DIR` overrides the log directory the engine acquires. + +This bouncer is application-layer: it sheds known-bad IPs at the CMS process and only for traffic that reaches the Next.js proxy. It does not drop traffic before the origin, does not protect other host ports/services, and depends on the client IP being trustworthy at the ingress. Keep the upstream protections (Cloudflare IP rules, proxy rate limits) for defense before the origin. + +The running CMS loads the new env values on its next restart or deployment. For a CI-managed `epicnext-cms-app`, the next deploy (which sources `.env`) applies them; for a clone, `bash cms update --skip-pull` restarts it. `.env` now holds the LAPI key — keep its permissions restrictive. + ## Routine and selected-release updates ```sh diff --git a/scripts/crowdsec-setup.sh b/scripts/crowdsec-setup.sh new file mode 100644 index 00000000..8c6f31ab --- /dev/null +++ b/scripts/crowdsec-setup.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$DIR" +ENV_FILE="$DIR/.env" +COMPOSE_FILE="deployment/crowdsec/compose.crowdsec.yml" +PROJECT_NAME="epicnext-crowdsec" +CONTAINER_NAME="epicnext-crowdsec" +DEFAULT_PORT="18080" + +mode="${1:-enable}" +case "$mode" in + enable|--enable) ;; + status|--status) ;; + disable|--disable) ;; + *) echo "Usage: bash cms security [enable|status|disable]" >&2; exit 1 ;; +esac + +umask 077 +fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } +for command in docker flock; do command -v "$command" >/dev/null || fail "Required command: $command"; done +docker info >/dev/null 2>&1 || fail "Docker is not reachable." +docker compose version >/dev/null 2>&1 || fail "Docker Compose plugin required." +exec 9>"$DIR/.deploy.lock" +flock -w 30 9 || fail "Another installation or update is running." +[[ -f "$ENV_FILE" ]] || fail "Create .env first (bash cms install)." + +env_get() { + local key="$1" line + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + "$key="*) line="${line#*=}"; line="${line%\"}"; line="${line#\"}"; printf '%s' "$line"; return 0 ;; + esac + done < "$ENV_FILE" + return 1 +} + +env_set() { + local key="$1" value="$2" tmp + tmp="$(mktemp "$DIR/.env.crowdsec.XXXXXX")" + if awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="=";done=0} { if ($1==k) { print k "=" v; done=1 } else print } END { if (!done) print k "=" v }' "$ENV_FILE" > "$tmp"; then + chmod 600 "$tmp" + mv -f -- "$tmp" "$ENV_FILE" + else + rm -f -- "$tmp" + fail "Could not update .env" + fi +} + +compose_cmd() { + docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" --profile security "$@" +} + +health_probe() { + local url="$1" + if command -v curl >/dev/null 2>&1; then + curl -fsS --max-time 3 "$url" >/dev/null 2>&1 + else + compose_cmd exec -T crowdsec wget -q -O - "$url" >/dev/null 2>&1 + fi +} + +container_running() { + [[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" = true ]] +} + +if [[ "$mode" = disable || "$mode" = --disable ]]; then + set +e + compose_cmd stop crowdsec + rc=$? + set -e + [[ $rc -eq 0 ]] || printf 'CrowdSec engine was not running or could not be stopped.\n' + env_set CROWDSEC_LOCAL_ENABLED false + printf 'CrowdSec local stack disabled. The engine container is stopped; volumes and .env key were kept.\n' + exit 0 +fi + +if [[ "$mode" = status || "$mode" = --status ]]; then + enabled=no + [[ "$(env_get CROWDSEC_LOCAL_ENABLED 2>/dev/null || true)" = true ]] && enabled=yes + port="$(env_get CROWDSEC_LAPI_PORT 2>/dev/null || true)" + [[ -z "$port" ]] && port="$DEFAULT_PORT" + printf 'CROWDSEC_LOCAL_ENABLED=%s\n' "$enabled" + if container_running; then + printf 'Engine: running\n' + if health_probe "http://127.0.0.1:$port/health"; then + printf 'LAPI health: OK (127.0.0.1:%s)\n' "$port" + else + printf 'LAPI health: UNREACHABLE (127.0.0.1:%s)\n' "$port" + fi + else + printf 'Engine: not running\n' + printf 'Start with: bash cms security\n' + fi + exit 0 +fi + +port="$(env_get CROWDSEC_LAPI_PORT 2>/dev/null || true)" +[[ -n "$port" ]] || port="$DEFAULT_PORT" +[[ "$port" =~ ^[0-9]{1,5}$ ]] || fail "CROWDSEC_LAPI_PORT must be a port number." +if (( port < 1024 || port > 65535 )); then + fail "CROWDSEC_LAPI_PORT must be within 1024-65535." +fi +key="$(env_get CROWDSEC_LAPI_API_KEY 2>/dev/null || true)" +[[ -n "$key" ]] || key="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')" +url="$(env_get CROWDSEC_LAPI_URL 2>/dev/null || true)" +[[ -n "$url" ]] || url="http://127.0.0.1:$port" +log_dir="${CROWDSEC_NGINX_LOG_DIR:-$(env_get CROWDSEC_NGINX_LOG_DIR 2>/dev/null || true)}" +[[ -n "$log_dir" ]] || log_dir="/var/log/nginx" + +if ! container_running && command -v ss >/dev/null 2>&1; then + if ss -ltn "( sport = :$port )" 2>/dev/null | grep -q LISTEN; then + fail "Port $port is already in use. Set CROWDSEC_LAPI_PORT (and CROWDSEC_LAPI_URL) in .env to a free port and re-run." + fi +fi + +if [[ ! -r "$log_dir/access.log" ]]; then + printf 'Warning: %s/access.log is not readable. The engine will run but has no detections until an access log is available.\n' "$log_dir" +fi + +env_set CROWDSEC_LOCAL_ENABLED true +env_set CROWDSEC_LAPI_URL "$url" +env_set CROWDSEC_LAPI_PORT "$port" +env_set CROWDSEC_LAPI_API_KEY "$key" +env_set CROWDSEC_NGINX_LOG_DIR "$log_dir" + +compose_cmd config --quiet || fail "CrowdSec Compose configuration is invalid; fix CROWDSEC_* settings in .env." +set +e +compose_cmd up -d --wait crowdsec +rc=$? +set -e +if [[ $rc -ne 0 ]]; then + compose_cmd up -d crowdsec +fi + +attempt=0 +while ! health_probe "http://127.0.0.1:$port/health"; do + attempt=$((attempt + 1)) + [[ $attempt -lt 30 ]] || fail "CrowdSec LAPI did not become healthy on port $port." + sleep 2 +done + +printf 'CrowdSec engine running on 127.0.0.1:%s (container %s), reading %s/access.log.\n' "$port" "$CONTAINER_NAME" "$log_dir" +compose_cmd exec -T crowdsec cscli bouncers list >/dev/null 2>&1 \ + && printf 'Bouncer "cms" was registered against the local LAPI.\n' \ + || printf 'Warning: could not list bouncers. Diagnose with: docker compose exec -T %s cscli bouncers list\n' "$CONTAINER_NAME" +printf 'Restart the CMS container (or run your next deployment) so it loads the new bouncer env. For a clone: bash cms update --skip-pull\n' \ No newline at end of file diff --git a/scripts/docker-start.import.test.mjs b/scripts/docker-start.import.test.mjs index b242bf8d..4c312a84 100644 --- a/scripts/docker-start.import.test.mjs +++ b/scripts/docker-start.import.test.mjs @@ -15,3 +15,29 @@ it("imports runtime validation without starting the CMS", () => { ); assert.equal(result.status, 0, result.stderr); }); + +it("rejects the local CrowdSec bouncer without a key", () => { + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "-e", + "import {validateRuntime} from './scripts/docker-start.mjs';try{validateRuntime({HOTEL_NAME:'x',AUTH_SECRET:'01234567890123456789012345678901',DATABASE_URL:'mysql://u:p@h/db',APP_URL:'http://h',CROWDSEC_LOCAL_ENABLED:'true'});process.exit(1)}catch(error){if(!String(error.message).includes('CROWDSEC_LAPI_API_KEY'))throw error}", + ], + { encoding: "utf8" }, + ); + assert.equal(result.status, 0, result.stderr); +}); + +it("accepts a complete local CrowdSec configuration", () => { + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "-e", + "import {validateRuntime} from './scripts/docker-start.mjs';validateRuntime({HOTEL_NAME:'x',AUTH_SECRET:'01234567890123456789012345678901',DATABASE_URL:'mysql://u:p@h/db',APP_URL:'http://h',CROWDSEC_LOCAL_ENABLED:'true',CROWDSEC_LAPI_API_KEY:'fixture-key',CROWDSEC_LAPI_URL:'http://127.0.0.1:18080'})", + ], + { encoding: "utf8" }, + ); + assert.equal(result.status, 0, result.stderr); +}); diff --git a/scripts/docker-start.mjs b/scripts/docker-start.mjs index 5c17161f..dc4ac8b6 100644 --- a/scripts/docker-start.mjs +++ b/scripts/docker-start.mjs @@ -23,6 +23,22 @@ export function validateRuntime(settings) { invalid.push(key); } } + const localEnabled = ["true", "1"].includes( + String(settings.CROWDSEC_LOCAL_ENABLED ?? "") + .trim() + .toLowerCase(), + ); + if (localEnabled) { + if (!settings.CROWDSEC_LAPI_API_KEY?.trim()) + invalid.push("CROWDSEC_LAPI_API_KEY"); + if (settings.CROWDSEC_LAPI_URL) { + try { + new URL(settings.CROWDSEC_LAPI_URL); + } catch { + invalid.push("CROWDSEC_LAPI_URL"); + } + } + } if (invalid.length) throw new Error(`Invalid runtime configuration: ${invalid.join(", ")}`); } diff --git a/scripts/proxy-config.test.mjs b/scripts/proxy-config.test.mjs index 99493778..5c60b422 100644 --- a/scripts/proxy-config.test.mjs +++ b/scripts/proxy-config.test.mjs @@ -3,6 +3,7 @@ import { copyFileSync, mkdirSync, mkdtempSync, + readFileSync, rmSync, writeFileSync, } from "node:fs"; @@ -84,3 +85,93 @@ it.skipIf(!hasCompose)( }, 30_000, ); + +it("documents the local CrowdSec switches in .env.example", () => { + const examples = readFileSync(path.join(root, ".env.example"), "utf8"); + for (const key of [ + "CROWDSEC_LOCAL_ENABLED", + "CROWDSEC_LAPI_URL", + "CROWDSEC_LAPI_PORT", + "CROWDSEC_LAPI_API_KEY", + "CROWDSEC_NGINX_LOG_DIR", + ]) { + expect(examples).toContain(key); + } +}); + +it.skipIf(!hasCompose)( + "renders the standalone CrowdSec stack with a loopback-only LAPI", + () => { + const directory = mkdtempSync(path.join(tmpdir(), "cms-crowdsec-")); + try { + mkdirSync(path.join(directory, "deployment/crowdsec/acquis.d"), { + recursive: true, + }); + copyFileSync( + path.join(root, "deployment/crowdsec/compose.crowdsec.yml"), + path.join(directory, "deployment/crowdsec/compose.crowdsec.yml"), + ); + copyFileSync( + path.join(root, "deployment/crowdsec/acquis.d/nginx.yaml"), + path.join(directory, "deployment/crowdsec/acquis.d/nginx.yaml"), + ); + writeFileSync( + path.join(directory, ".env"), + [ + "CROWDSEC_LAPI_API_KEY=fixture-key", + "CROWDSEC_LAPI_PORT=18080", + "CROWDSEC_LAPI_URL=http://127.0.0.1:18080", + "CROWDSEC_NGINX_LOG_DIR=/var/log/nginx", + ].join("\n"), + ); + const environment = { ...process.env }; + for (const key of Object.keys(environment)) + if ( + key.startsWith("COMPOSE_") || + key.startsWith("CROWDSEC_") || + key.startsWith("TZ") + ) + delete environment[key]; + const result = spawnSync( + "docker", + [ + "compose", + "--project-name", + "crowdsec-fixture", + "--env-file", + ".env", + "-f", + "deployment/crowdsec/compose.crowdsec.yml", + "--profile", + "security", + "config", + "--format", + "json", + ], + { cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 }, + ); + expect(result.status, result.stderr).toBe(0); + const config = JSON.parse(result.stdout); + const service = config.services.crowdsec; + expect(service).toBeDefined(); + expect(service.image).toContain("crowdsecurity/crowdsec:"); + expect(service.environment.BOUNCER_KEY_cms).toBe("fixture-key"); + expect(service.environment.DISABLE_ONLINE_API).toBe("true"); + expect( + service.ports.some( + (published) => + published.host_ip === "127.0.0.1" && + published.published === "18080" && + published.target === 8080, + ), + ).toBe(true); + const targets = service.volumes.map((volume) => volume.target); + expect(targets).toContain("/var/log/nginx"); + expect(targets).toContain("/etc/crowdsec/acquis.d"); + expect(service.healthcheck.test.join(" ")).toContain("wget"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, + 30_000, +); diff --git a/src/env.ts b/src/env.ts index 94c3baa2..557cc5bc 100644 --- a/src/env.ts +++ b/src/env.ts @@ -197,6 +197,23 @@ const schema = z .string() .url() .default("https://api.crowdsec.net/v3"), + // Local CrowdSec engine shipped as an opt-in Docker stack in + // deployment/crowdsec. When enabled, the anti-DDoS gate asks the local + // LAPI (bouncer) for each client IP before its own buckets and blocks + // ban/captcha decisions immediately. The key lives in env only. + CROWDSEC_LOCAL_ENABLED: z + .string() + .optional() + .transform((value) => value === "true" || value === "1"), + CROWDSEC_LAPI_URL: z + .string() + .optional() + .transform((value) => + value?.trim() ? value.trim() : "http://127.0.0.1:18080", + ) + .pipe(z.string().url()), + CROWDSEC_LAPI_API_KEY: z.string().optional(), + CROWDSEC_LAPI_TIMEOUT_MS: z.coerce.number().int().positive().default(500), // Watcher credentials for signal push. When omitted, a stable pair is // generated once and persisted in Redis (48-char alnum machine id, // per the CAPI schema). @@ -232,6 +249,14 @@ const schema = z path: ["PAYPAL_CLIENT_ID"], }); } + if (data.CROWDSEC_LOCAL_ENABLED && !data.CROWDSEC_LAPI_API_KEY) { + ctx.addIssue({ + code: "custom", + message: + "CROWDSEC_LAPI_API_KEY is required when CROWDSEC_LOCAL_ENABLED=true", + path: ["CROWDSEC_LAPI_API_KEY"], + }); + } }); type Env = z.infer; diff --git a/src/lib/crowdsec-local.test.ts b/src/lib/crowdsec-local.test.ts new file mode 100644 index 00000000..36cdb947 --- /dev/null +++ b/src/lib/crowdsec-local.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + checkCrowdsecLocalBlock, + isBlockingCrowdsecDecision, + parseCrowdsecDecisionDuration, + resetCrowdsecLocalCache, +} from "@/lib/crowdsec-local"; + +const state = vi.hoisted(() => ({ + map: new Map(), + sendAlert: vi.fn(), +})); + +vi.mock("@/lib/services/alert", () => ({ + sendAlert: state.sendAlert, + ddosDetected: vi.fn(), +})); + +vi.mock("@/lib/redis", () => ({ + redis: { + get: async (key: string) => state.map.get(key) ?? null, + set: async ( + key: string, + value: string, + _mode?: string, + _seconds?: number, + nx?: string, + ) => { + if (nx === "NX" && state.map.has(key)) return null; + state.map.set(key, value); + return "OK"; + }, + del: async (...keys: string[]) => { + for (const key of keys) state.map.delete(key); + return keys.length; + }, + incr: async (key: string) => { + const next = (Number(state.map.get(key)) || 0) + 1; + state.map.set(key, String(next)); + return next; + }, + expire: async () => 1, + pexpire: async () => 1, + pttl: async () => 60_000, + }, + __esModule: true, +})); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +const IP = "198.51.100.11"; +const LAPI_URL = "http://127.0.0.1:18080"; + +describe("crowdsec-local app-layer bouncer", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + state.map.clear(); + resetCrowdsecLocalCache(); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "true"); + vi.stubEnv("CROWDSEC_LAPI_URL", LAPI_URL); + vi.stubEnv("CROWDSEC_LAPI_API_KEY", "test-local-key"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + state.map.clear(); + resetCrowdsecLocalCache(); + }); + + it("does nothing when the local stack is not enabled", async () => { + vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "false"); + const result = await checkCrowdsecLocalBlock(IP); + expect(result.blocked).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does nothing without a bouncer key", async () => { + vi.stubEnv("CROWDSEC_LAPI_API_KEY", ""); + const result = await checkCrowdsecLocalBlock(IP); + expect(result.blocked).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("blocks an IP with a local ban decision and caches it", async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + { + origin: "crowdsec", + type: "ban", + scope: "ip", + value: IP, + duration: "4h", + }, + ]), + ); + + const first = await checkCrowdsecLocalBlock(IP); + expect(first.blocked).toBe(true); + expect(first.retryAfterSeconds).toBeGreaterThan(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0][0])).toContain( + `/v1/decisions?ip=${IP}`, + ); + + const second = await checkCrowdsecLocalBlock(IP); + expect(second.blocked).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("treats captcha decisions as blocks", async () => { + fetchMock.mockResolvedValue( + jsonResponse([{ type: "captcha", scope: "ip", value: IP }]), + ); + const result = await checkCrowdsecLocalBlock(IP); + expect(result.blocked).toBe(true); + }); + + it("passes non-blocking decisions and caches the negative", async () => { + fetchMock.mockResolvedValue( + jsonResponse([{ type: "probation", scope: "ip", value: IP }]), + ); + const first = await checkCrowdsecLocalBlock(IP); + expect(first.blocked).toBe(false); + const second = await checkCrowdsecLocalBlock(IP); + expect(second.blocked).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("fails open when LAPI errors and backs off", async () => { + fetchMock.mockRejectedValueOnce(new Error("connection refused")); + const first = await checkCrowdsecLocalBlock(IP); + expect(first.blocked).toBe(false); + await new Promise((resolve) => setTimeout(resolve, 5)); + const second = await checkCrowdsecLocalBlock(IP); + expect(second.blocked).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("backs off for five minutes when the bouncer key is rejected", async () => { + fetchMock.mockResolvedValue(jsonResponse({ message: "forbidden" }, 403)); + const first = await checkCrowdsecLocalBlock(IP); + expect(first.blocked).toBe(false); + const second = await checkCrowdsecLocalBlock(IP); + expect(second.blocked).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not query the LAPI for the unknown-IP sentinel", async () => { + const result = await checkCrowdsecLocalBlock("0.0.0.0"); + expect(result.blocked).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("crowdsec-local decision parsing", () => { + it("parses Go-style durations into seconds", () => { + expect(parseCrowdsecDecisionDuration("3h51m57s")).toBe( + 3 * 3_600 + 51 * 60 + 57, + ); + expect(parseCrowdsecDecisionDuration("500ms")).toBeCloseTo(0.5); + expect(parseCrowdsecDecisionDuration("")).toBe(0); + expect(parseCrowdsecDecisionDuration(null)).toBe(0); + }); + + it("recognises only ban/captcha ip/range decisions", () => { + expect( + isBlockingCrowdsecDecision({ type: "ban", scope: "ip", value: IP }), + ).toBe(true); + expect( + isBlockingCrowdsecDecision({ type: "ban", scope: "range", value: IP }), + ).toBe(true); + expect( + isBlockingCrowdsecDecision({ type: "captcha", scope: "ip", value: IP }), + ).toBe(true); + expect( + isBlockingCrowdsecDecision({ type: "probation", scope: "ip", value: IP }), + ).toBe(false); + expect( + isBlockingCrowdsecDecision({ type: "ban", scope: "as", value: IP }), + ).toBe(false); + expect(isBlockingCrowdsecDecision(null)).toBe(false); + }); +}); diff --git a/src/lib/crowdsec-local.ts b/src/lib/crowdsec-local.ts new file mode 100644 index 00000000..09bb2767 --- /dev/null +++ b/src/lib/crowdsec-local.ts @@ -0,0 +1,304 @@ +import "server-only"; + +import { env } from "@/env"; +import { + bumpCrowdsecBreakdownStat, + bumpCrowdsecStat, +} from "@/lib/crowdsec-stats"; +import { logger } from "@/lib/logger"; +import { redis } from "@/lib/redis"; +import { UNKNOWN_CLIENT_IP } from "./client-ip"; + +export interface CrowdsecLocalDecision { + origin?: string; + scope?: string; + type?: string; + value?: string; + duration?: string | null; +} + +export interface CrowdsecLocalBlockResult { + blocked: boolean; + retryAfterSeconds: number; +} + +const DEFAULT_LAPI_URL = "http://127.0.0.1:18080"; +const REQUEST_TIMEOUT_MS = 500; +const NEGATIVE_CACHE_TTL_MS = 2_000; +const DECISION_CACHE_TTL_MS = 300_000; +const DECISION_RETRY_MAX_SECONDS = 300; +const FALLBACK_RETRY_SECONDS = 60; +const BACKOFF_MS = 5_000; +const AUTH_BACKOFF_MS = 300_000; +const MEMORY_CACHE_MAX = 5_000; +const CACHE_PREFIX = "crowdsec:local:"; +const BACKOFF_KEY = "crowdsec:local:backoff-until"; + +const DURATION_TOKEN = /(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g; + +export function parseCrowdsecDecisionDuration( + value: string | null | undefined, +): number { + if (!value) return 0; + let total = 0; + for (const match of value.matchAll(DURATION_TOKEN)) { + const amount = Number(match[1]); + if (!Number.isFinite(amount)) continue; + const unit = match[2]; + if (unit === "h") total += amount * 3_600; + else if (unit === "m") total += amount * 60; + else if (unit === "s") total += amount; + else if (unit === "ms") total += amount / 1_000; + else if (unit === "us" || unit === "µs") total += amount / 1_000_000; + else if (unit === "ns") total += amount / 1_000_000_000; + } + return total; +} + +export function isBlockingCrowdsecDecision( + decision: CrowdsecLocalDecision | null | undefined, +): boolean { + if (!decision) return false; + const type = decision.type?.toLowerCase(); + const scope = decision.scope?.toLowerCase(); + if ((type !== "ban" && type !== "captcha") || !decision.value) return false; + return scope === "ip" || scope === "range"; +} + +function localEnabled(): boolean { + const flag: unknown = env.CROWDSEC_LOCAL_ENABLED; + return flag === true || flag === "true" || flag === "1"; +} + +function localConfig(): { + url: string; + apiKey: string; + timeoutMs: number; +} { + return { + url: (env.CROWDSEC_LAPI_URL || DEFAULT_LAPI_URL).replace(/\/+$/, ""), + apiKey: String(env.CROWDSEC_LAPI_API_KEY ?? "").trim(), + timeoutMs: + Number(env.CROWDSEC_LAPI_TIMEOUT_MS) > 0 + ? Number(env.CROWDSEC_LAPI_TIMEOUT_MS) + : REQUEST_TIMEOUT_MS, + }; +} + +interface CacheEntry { + blocked: boolean; + retryAfterSeconds: number; + until: number; +} + +const memoryCache = new Map(); + +let backoffUntil = 0; +let authBackoffWarned = false; + +async function rememberCache( + ip: string, + entry: CacheEntry, + ttlMs: number, +): Promise { + memoryCache.delete(ip); + memoryCache.set(ip, entry); + while (memoryCache.size > MEMORY_CACHE_MAX) { + const oldest = memoryCache.keys().next(); + if (oldest.done) break; + memoryCache.delete(oldest.value); + } + if (!redis) return; + try { + await redis.set( + `${CACHE_PREFIX}block:${ip}`, + JSON.stringify(entry), + "EX", + Math.max(1, Math.ceil(ttlMs / 1_000)), + ); + } catch { + // Cache is best-effort — a miss only costs one extra LAPI call. + } +} + +async function readCache(ip: string): Promise { + const memory = memoryCache.get(ip); + if (memory && memory.until > Date.now()) return memory; + if (redis) { + try { + const raw = await redis.get(`${CACHE_PREFIX}block:${ip}`); + if (raw) { + const parsed = JSON.parse(raw) as CacheEntry; + if (parsed.until > Date.now()) return parsed; + } + } catch { + // Redis hiccup — an extra local LAPI call is the only cost. + } + } + return null; +} + +async function getBackoffUntil(): Promise { + if (Date.now() < backoffUntil) return backoffUntil; + if (redis) { + try { + const raw = await redis.get(BACKOFF_KEY); + const shared = Number(raw ?? 0); + if (Number.isFinite(shared) && shared > backoffUntil) { + backoffUntil = shared; + } + } catch { + // Redis hiccup — the local view is enough. + } + } + return backoffUntil; +} + +async function setBackoff(ms: number): Promise { + const until = Date.now() + ms; + backoffUntil = until; + if (redis) { + try { + await redis.set( + BACKOFF_KEY, + String(until), + "EX", + Math.ceil(ms / 1_000) + 1, + ); + } catch { + // Local view still protects this instance. + } + } +} + +function decisionRetrySeconds(decision: CrowdsecLocalDecision | null): number { + const parsed = decision + ? parseCrowdsecDecisionDuration(decision.duration) + : 0; + if (parsed <= 0) return FALLBACK_RETRY_SECONDS; + return Math.min(Math.max(1, Math.floor(parsed)), DECISION_RETRY_MAX_SECONDS); +} + +async function queryLocalDecision( + baseUrl: string, + apiKey: string, + timeoutMs: number, + ip: string, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch( + `${baseUrl}/v1/decisions?ip=${encodeURIComponent(ip)}`, + { + headers: { + "X-Api-Key": apiKey, + Accept: "application/json", + }, + signal: controller.signal, + cache: "no-store", + }, + ); + if (response.status === 401 || response.status === 403) { + await setBackoff(AUTH_BACKOFF_MS); + if (!authBackoffWarned) { + authBackoffWarned = true; + logger.warn( + "[crowdsec-local] LAPI rejected the bouncer key — local decisions paused for 5 minutes", + { status: response.status }, + ); + } + return null; + } + if (!response.ok) { + await setBackoff(BACKOFF_MS); + logger.warn( + "[crowdsec-local] LAPI decision request failed — failing open", + { ip, status: response.status }, + ); + return null; + } + const body = (await response.json()) as unknown; + if (!Array.isArray(body)) return null; + return body.find(isBlockingCrowdsecDecision) ?? null; + } catch (error) { + await setBackoff(BACKOFF_MS); + logger.warn( + "[crowdsec-local] LAPI decision request errored — failing open", + { + ip, + error: error instanceof Error ? error.message : String(error), + }, + ); + return null; + } finally { + clearTimeout(timer); + } +} + +export async function checkCrowdsecLocalBlock( + ip: string, +): Promise { + if (!localEnabled()) return { blocked: false, retryAfterSeconds: 0 }; + const config = localConfig(); + if (!config.apiKey) return { blocked: false, retryAfterSeconds: 0 }; + if (!ip || ip === UNKNOWN_CLIENT_IP) { + return { blocked: false, retryAfterSeconds: 0 }; + } + + if (Date.now() < (await getBackoffUntil())) { + return { blocked: false, retryAfterSeconds: 0 }; + } + + const cached = await readCache(ip); + if (cached && cached.until > Date.now()) { + return { + blocked: cached.blocked, + retryAfterSeconds: cached.blocked ? cached.retryAfterSeconds : 0, + }; + } + + const decision = await queryLocalDecision( + config.url, + config.apiKey, + config.timeoutMs, + ip, + ); + if (decision) { + const retryAfterSeconds = decisionRetrySeconds(decision); + await rememberCache( + ip, + { + blocked: true, + retryAfterSeconds, + until: Date.now() + DECISION_CACHE_TTL_MS, + }, + DECISION_CACHE_TTL_MS, + ); + void bumpCrowdsecStat("blocks"); + void bumpCrowdsecBreakdownStat("category", "local"); + logger.info("[crowdsec-local] IP blocked by a local CrowdSec decision", { + ip, + type: decision.type, + retryAfterSeconds, + }); + return { blocked: true, retryAfterSeconds }; + } + + await rememberCache( + ip, + { + blocked: false, + retryAfterSeconds: 0, + until: Date.now() + NEGATIVE_CACHE_TTL_MS, + }, + NEGATIVE_CACHE_TTL_MS, + ); + return { blocked: false, retryAfterSeconds: 0 }; +} + +export function resetCrowdsecLocalCache(): void { + memoryCache.clear(); + backoffUntil = 0; + authBackoffWarned = false; +} diff --git a/src/lib/ddos-guard-crowdsec.test.ts b/src/lib/ddos-guard-crowdsec.test.ts index 1cee8c13..dd8d814a 100644 --- a/src/lib/ddos-guard-crowdsec.test.ts +++ b/src/lib/ddos-guard-crowdsec.test.ts @@ -2,6 +2,7 @@ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { invalidateAntiddosConfig } from "@/lib/antiddos-config"; import { resetCrowdsecCache } from "@/lib/crowdsec-api"; +import { resetCrowdsecLocalCache } from "@/lib/crowdsec-local"; import { enforceDdosRateLimit } from "@/lib/ddos-guard"; // The gate's block escalation (and thus the CrowdSec hook) only runs when @@ -123,6 +124,7 @@ describe("anti-DDoS automatic CrowdSec blocks", () => { state.z.clear(); state.sendAlert.mockReset(); resetCrowdsecCache(); + resetCrowdsecLocalCache(); invalidateAntiddosConfig(); fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); @@ -141,6 +143,7 @@ describe("anti-DDoS automatic CrowdSec blocks", () => { vi.unstubAllEnvs(); state.map.clear(); resetCrowdsecCache(); + resetCrowdsecLocalCache(); invalidateAntiddosConfig(); }); @@ -244,4 +247,29 @@ describe("anti-DDoS automatic CrowdSec blocks", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + + it("blocks immediately on a local LAPI ban decision (app-layer bouncer)", async () => { + vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "true"); + vi.stubEnv("CROWDSEC_LAPI_URL", "http://127.0.0.1:18080"); + vi.stubEnv("CROWDSEC_LAPI_API_KEY", "local-bouncer-key"); + fetchMock.mockImplementation(async (input) => { + if (String(input).startsWith("http://127.0.0.1:18080/")) { + return jsonResponse([ + { + origin: "crowdsec", + type: "ban", + scope: "ip", + value: "198.51.100.88", + duration: "1h", + }, + ]); + } + return jsonResponse({ message: "unexpected upstream" }, 500); + }); + + const ip = "198.51.100.88"; + const blocks = await pump(proxiedRequest(ip), 1); + expect(blocks).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/lib/ddos-guard.ts b/src/lib/ddos-guard.ts index fe91776a..68796c8c 100644 --- a/src/lib/ddos-guard.ts +++ b/src/lib/ddos-guard.ts @@ -8,6 +8,7 @@ import { resolveClientIp } from "@/lib/client-ip"; import { isCloudflareProxied } from "@/lib/cloudflare"; import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api"; import { maybeAutoBlockCrowdsec } from "@/lib/crowdsec-api"; +import { checkCrowdsecLocalBlock } from "@/lib/crowdsec-local"; import { reportCrowdsecSignal } from "@/lib/crowdsec-report"; import { bumpCrowdsecBreakdownStat, @@ -84,6 +85,14 @@ export async function enforceDdosRateLimit( } } + const localBlock = await checkCrowdsecLocalBlock(ip); + if (localBlock.blocked) { + return { + outcome: "block", + retryAfterSeconds: Math.max(localBlock.retryAfterSeconds, 1), + }; + } + const global = await rateLimit( "antiddos:global:all", config.global.limit,