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

+147
View File
@@ -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'
+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,
);