feat(security): opt-in local CrowdSec LAPI bouncer on the Docker engine
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-unit (push) Successful in 1m37s
CI / tests-integration (push) Successful in 1m55s
CI / tests-ui (push) Successful in 2m23s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m38s
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-unit (push) Successful in 1m37s
CI / tests-integration (push) Successful in 1m55s
CI / tests-ui (push) Successful in 2m23s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m38s
This commit is contained in:
1 parent
3e1a3f92c8
commit
84d53139a9
15 files changed
+1002
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
filenames:
|
||||
- /var/log/nginx/access.log
|
||||
labels:
|
||||
type: nginx
|
||||
@@ -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:
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(", ")}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
+25
@@ -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<typeof schema>;
|
||||
|
||||
@@ -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<string, string>(),
|
||||
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<typeof vi.fn>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, CacheEntry>();
|
||||
|
||||
let backoffUntil = 0;
|
||||
let authBackoffWarned = false;
|
||||
|
||||
async function rememberCache(
|
||||
ip: string,
|
||||
entry: CacheEntry,
|
||||
ttlMs: number,
|
||||
): Promise<void> {
|
||||
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<CacheEntry | null> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<CrowdsecLocalDecision | null> {
|
||||
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<CrowdsecLocalBlockResult> {
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user