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.
|
# Central API base — override only for tests/staging.
|
||||||
CROWDSEC_CAPI_BASE_URL=https://api.crowdsec.net/v3
|
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 ---
|
# --- PATHS ---
|
||||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
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)
|
## Production Deployment (PM2)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
case "${1:-help}" in
|
case "${1:-help}" in
|
||||||
install) shift; exec bash "$DIR/scripts/docker-install.sh" "$@" ;;
|
install) shift; exec bash "$DIR/scripts/docker-install.sh" "$@" ;;
|
||||||
update) shift; exec bash "$DIR/scripts/docker-update.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' ;;
|
security) shift; exec bash "$DIR/scripts/crowdsec-setup.sh" "$@" ;;
|
||||||
*) echo "Unknown command. Use: bash cms install | update" >&2; exit 1 ;;
|
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
|
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.
|
`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
|
## Routine and selected-release updates
|
||||||
|
|
||||||
```sh
|
```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);
|
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);
|
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)
|
if (invalid.length)
|
||||||
throw new Error(`Invalid runtime configuration: ${invalid.join(", ")}`);
|
throw new Error(`Invalid runtime configuration: ${invalid.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
copyFileSync,
|
copyFileSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
@@ -84,3 +85,93 @@ it.skipIf(!hasCompose)(
|
|||||||
},
|
},
|
||||||
30_000,
|
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()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
.default("https://api.crowdsec.net/v3"),
|
.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
|
// Watcher credentials for signal push. When omitted, a stable pair is
|
||||||
// generated once and persisted in Redis (48-char alnum machine id,
|
// generated once and persisted in Redis (48-char alnum machine id,
|
||||||
// per the CAPI schema).
|
// per the CAPI schema).
|
||||||
@@ -232,6 +249,14 @@ const schema = z
|
|||||||
path: ["PAYPAL_CLIENT_ID"],
|
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>;
|
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { invalidateAntiddosConfig } from "@/lib/antiddos-config";
|
import { invalidateAntiddosConfig } from "@/lib/antiddos-config";
|
||||||
import { resetCrowdsecCache } from "@/lib/crowdsec-api";
|
import { resetCrowdsecCache } from "@/lib/crowdsec-api";
|
||||||
|
import { resetCrowdsecLocalCache } from "@/lib/crowdsec-local";
|
||||||
import { enforceDdosRateLimit } from "@/lib/ddos-guard";
|
import { enforceDdosRateLimit } from "@/lib/ddos-guard";
|
||||||
|
|
||||||
// The gate's block escalation (and thus the CrowdSec hook) only runs when
|
// 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.z.clear();
|
||||||
state.sendAlert.mockReset();
|
state.sendAlert.mockReset();
|
||||||
resetCrowdsecCache();
|
resetCrowdsecCache();
|
||||||
|
resetCrowdsecLocalCache();
|
||||||
invalidateAntiddosConfig();
|
invalidateAntiddosConfig();
|
||||||
fetchMock = vi.fn();
|
fetchMock = vi.fn();
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
@@ -141,6 +143,7 @@ describe("anti-DDoS automatic CrowdSec blocks", () => {
|
|||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
state.map.clear();
|
state.map.clear();
|
||||||
resetCrowdsecCache();
|
resetCrowdsecCache();
|
||||||
|
resetCrowdsecLocalCache();
|
||||||
invalidateAntiddosConfig();
|
invalidateAntiddosConfig();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -244,4 +247,29 @@ describe("anti-DDoS automatic CrowdSec blocks", () => {
|
|||||||
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
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 { isCloudflareProxied } from "@/lib/cloudflare";
|
||||||
import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api";
|
import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api";
|
||||||
import { maybeAutoBlockCrowdsec } from "@/lib/crowdsec-api";
|
import { maybeAutoBlockCrowdsec } from "@/lib/crowdsec-api";
|
||||||
|
import { checkCrowdsecLocalBlock } from "@/lib/crowdsec-local";
|
||||||
import { reportCrowdsecSignal } from "@/lib/crowdsec-report";
|
import { reportCrowdsecSignal } from "@/lib/crowdsec-report";
|
||||||
import {
|
import {
|
||||||
bumpCrowdsecBreakdownStat,
|
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(
|
const global = await rateLimit(
|
||||||
"antiddos:global:all",
|
"antiddos:global:all",
|
||||||
config.global.limit,
|
config.global.limit,
|
||||||
|
|||||||
Reference in new issue
Block a user