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

This commit is contained in:
openhands committed 2026-09-24 18:08:18 +02:00
1 parent 3e1a3f92c8
commit 84d53139a9
15 files changed
+1002 -2

No files matched your search

+15
View File
@@ -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
+82
View File
@@ -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
+3 -2
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
filenames:
- /var/log/nginx/access.log
labels:
type: nginx
+41
View File
@@ -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:
+16
View File
@@ -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
File diff suppressed because it is too large. Load diff
+26
View File
@@ -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);
});
+16
View File
@@ -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(", ")}`);
}
+91
View File
@@ -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
View File
@@ -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>;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+28
View File
@@ -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);
});
});
+9
View File
@@ -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,