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
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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>;
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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