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
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$DIR"
|
||||
ENV_FILE="$DIR/.env"
|
||||
COMPOSE_FILE="deployment/crowdsec/compose.crowdsec.yml"
|
||||
PROJECT_NAME="epicnext-crowdsec"
|
||||
CONTAINER_NAME="epicnext-crowdsec"
|
||||
DEFAULT_PORT="18080"
|
||||
|
||||
mode="${1:-enable}"
|
||||
case "$mode" in
|
||||
enable|--enable) ;;
|
||||
status|--status) ;;
|
||||
disable|--disable) ;;
|
||||
*) echo "Usage: bash cms security [enable|status|disable]" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
umask 077
|
||||
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
for command in docker flock; do command -v "$command" >/dev/null || fail "Required command: $command"; done
|
||||
docker info >/dev/null 2>&1 || fail "Docker is not reachable."
|
||||
docker compose version >/dev/null 2>&1 || fail "Docker Compose plugin required."
|
||||
exec 9>"$DIR/.deploy.lock"
|
||||
flock -w 30 9 || fail "Another installation or update is running."
|
||||
[[ -f "$ENV_FILE" ]] || fail "Create .env first (bash cms install)."
|
||||
|
||||
env_get() {
|
||||
local key="$1" line
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
case "$line" in
|
||||
"$key="*) line="${line#*=}"; line="${line%\"}"; line="${line#\"}"; printf '%s' "$line"; return 0 ;;
|
||||
esac
|
||||
done < "$ENV_FILE"
|
||||
return 1
|
||||
}
|
||||
|
||||
env_set() {
|
||||
local key="$1" value="$2" tmp
|
||||
tmp="$(mktemp "$DIR/.env.crowdsec.XXXXXX")"
|
||||
if awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="=";done=0} { if ($1==k) { print k "=" v; done=1 } else print } END { if (!done) print k "=" v }' "$ENV_FILE" > "$tmp"; then
|
||||
chmod 600 "$tmp"
|
||||
mv -f -- "$tmp" "$ENV_FILE"
|
||||
else
|
||||
rm -f -- "$tmp"
|
||||
fail "Could not update .env"
|
||||
fi
|
||||
}
|
||||
|
||||
compose_cmd() {
|
||||
docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" --profile security "$@"
|
||||
}
|
||||
|
||||
health_probe() {
|
||||
local url="$1"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsS --max-time 3 "$url" >/dev/null 2>&1
|
||||
else
|
||||
compose_cmd exec -T crowdsec wget -q -O - "$url" >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
container_running() {
|
||||
[[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" = true ]]
|
||||
}
|
||||
|
||||
if [[ "$mode" = disable || "$mode" = --disable ]]; then
|
||||
set +e
|
||||
compose_cmd stop crowdsec
|
||||
rc=$?
|
||||
set -e
|
||||
[[ $rc -eq 0 ]] || printf 'CrowdSec engine was not running or could not be stopped.\n'
|
||||
env_set CROWDSEC_LOCAL_ENABLED false
|
||||
printf 'CrowdSec local stack disabled. The engine container is stopped; volumes and .env key were kept.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$mode" = status || "$mode" = --status ]]; then
|
||||
enabled=no
|
||||
[[ "$(env_get CROWDSEC_LOCAL_ENABLED 2>/dev/null || true)" = true ]] && enabled=yes
|
||||
port="$(env_get CROWDSEC_LAPI_PORT 2>/dev/null || true)"
|
||||
[[ -z "$port" ]] && port="$DEFAULT_PORT"
|
||||
printf 'CROWDSEC_LOCAL_ENABLED=%s\n' "$enabled"
|
||||
if container_running; then
|
||||
printf 'Engine: running\n'
|
||||
if health_probe "http://127.0.0.1:$port/health"; then
|
||||
printf 'LAPI health: OK (127.0.0.1:%s)\n' "$port"
|
||||
else
|
||||
printf 'LAPI health: UNREACHABLE (127.0.0.1:%s)\n' "$port"
|
||||
fi
|
||||
else
|
||||
printf 'Engine: not running\n'
|
||||
printf 'Start with: bash cms security\n'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
port="$(env_get CROWDSEC_LAPI_PORT 2>/dev/null || true)"
|
||||
[[ -n "$port" ]] || port="$DEFAULT_PORT"
|
||||
[[ "$port" =~ ^[0-9]{1,5}$ ]] || fail "CROWDSEC_LAPI_PORT must be a port number."
|
||||
if (( port < 1024 || port > 65535 )); then
|
||||
fail "CROWDSEC_LAPI_PORT must be within 1024-65535."
|
||||
fi
|
||||
key="$(env_get CROWDSEC_LAPI_API_KEY 2>/dev/null || true)"
|
||||
[[ -n "$key" ]] || key="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')"
|
||||
url="$(env_get CROWDSEC_LAPI_URL 2>/dev/null || true)"
|
||||
[[ -n "$url" ]] || url="http://127.0.0.1:$port"
|
||||
log_dir="${CROWDSEC_NGINX_LOG_DIR:-$(env_get CROWDSEC_NGINX_LOG_DIR 2>/dev/null || true)}"
|
||||
[[ -n "$log_dir" ]] || log_dir="/var/log/nginx"
|
||||
|
||||
if ! container_running && command -v ss >/dev/null 2>&1; then
|
||||
if ss -ltn "( sport = :$port )" 2>/dev/null | grep -q LISTEN; then
|
||||
fail "Port $port is already in use. Set CROWDSEC_LAPI_PORT (and CROWDSEC_LAPI_URL) in .env to a free port and re-run."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -r "$log_dir/access.log" ]]; then
|
||||
printf 'Warning: %s/access.log is not readable. The engine will run but has no detections until an access log is available.\n' "$log_dir"
|
||||
fi
|
||||
|
||||
env_set CROWDSEC_LOCAL_ENABLED true
|
||||
env_set CROWDSEC_LAPI_URL "$url"
|
||||
env_set CROWDSEC_LAPI_PORT "$port"
|
||||
env_set CROWDSEC_LAPI_API_KEY "$key"
|
||||
env_set CROWDSEC_NGINX_LOG_DIR "$log_dir"
|
||||
|
||||
compose_cmd config --quiet || fail "CrowdSec Compose configuration is invalid; fix CROWDSEC_* settings in .env."
|
||||
set +e
|
||||
compose_cmd up -d --wait crowdsec
|
||||
rc=$?
|
||||
set -e
|
||||
if [[ $rc -ne 0 ]]; then
|
||||
compose_cmd up -d crowdsec
|
||||
fi
|
||||
|
||||
attempt=0
|
||||
while ! health_probe "http://127.0.0.1:$port/health"; do
|
||||
attempt=$((attempt + 1))
|
||||
[[ $attempt -lt 30 ]] || fail "CrowdSec LAPI did not become healthy on port $port."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
printf 'CrowdSec engine running on 127.0.0.1:%s (container %s), reading %s/access.log.\n' "$port" "$CONTAINER_NAME" "$log_dir"
|
||||
compose_cmd exec -T crowdsec cscli bouncers list >/dev/null 2>&1 \
|
||||
&& printf 'Bouncer "cms" was registered against the local LAPI.\n' \
|
||||
|| printf 'Warning: could not list bouncers. Diagnose with: docker compose exec -T %s cscli bouncers list\n' "$CONTAINER_NAME"
|
||||
printf 'Restart the CMS container (or run your next deployment) so it loads the new bouncer env. For a clone: bash cms update --skip-pull\n'
|
||||
@@ -15,3 +15,29 @@ it("imports runtime validation without starting the CMS", () => {
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
it("rejects the local CrowdSec bouncer without a key", () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
"import {validateRuntime} from './scripts/docker-start.mjs';try{validateRuntime({HOTEL_NAME:'x',AUTH_SECRET:'01234567890123456789012345678901',DATABASE_URL:'mysql://u:p@h/db',APP_URL:'http://h',CROWDSEC_LOCAL_ENABLED:'true'});process.exit(1)}catch(error){if(!String(error.message).includes('CROWDSEC_LAPI_API_KEY'))throw error}",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
it("accepts a complete local CrowdSec configuration", () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
"import {validateRuntime} from './scripts/docker-start.mjs';validateRuntime({HOTEL_NAME:'x',AUTH_SECRET:'01234567890123456789012345678901',DATABASE_URL:'mysql://u:p@h/db',APP_URL:'http://h',CROWDSEC_LOCAL_ENABLED:'true',CROWDSEC_LAPI_API_KEY:'fixture-key',CROWDSEC_LAPI_URL:'http://127.0.0.1:18080'})",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
@@ -23,6 +23,22 @@ export function validateRuntime(settings) {
|
||||
invalid.push(key);
|
||||
}
|
||||
}
|
||||
const localEnabled = ["true", "1"].includes(
|
||||
String(settings.CROWDSEC_LOCAL_ENABLED ?? "")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
);
|
||||
if (localEnabled) {
|
||||
if (!settings.CROWDSEC_LAPI_API_KEY?.trim())
|
||||
invalid.push("CROWDSEC_LAPI_API_KEY");
|
||||
if (settings.CROWDSEC_LAPI_URL) {
|
||||
try {
|
||||
new URL(settings.CROWDSEC_LAPI_URL);
|
||||
} catch {
|
||||
invalid.push("CROWDSEC_LAPI_URL");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (invalid.length)
|
||||
throw new Error(`Invalid runtime configuration: ${invalid.join(", ")}`);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
copyFileSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
@@ -84,3 +85,93 @@ it.skipIf(!hasCompose)(
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
it("documents the local CrowdSec switches in .env.example", () => {
|
||||
const examples = readFileSync(path.join(root, ".env.example"), "utf8");
|
||||
for (const key of [
|
||||
"CROWDSEC_LOCAL_ENABLED",
|
||||
"CROWDSEC_LAPI_URL",
|
||||
"CROWDSEC_LAPI_PORT",
|
||||
"CROWDSEC_LAPI_API_KEY",
|
||||
"CROWDSEC_NGINX_LOG_DIR",
|
||||
]) {
|
||||
expect(examples).toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(!hasCompose)(
|
||||
"renders the standalone CrowdSec stack with a loopback-only LAPI",
|
||||
() => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "cms-crowdsec-"));
|
||||
try {
|
||||
mkdirSync(path.join(directory, "deployment/crowdsec/acquis.d"), {
|
||||
recursive: true,
|
||||
});
|
||||
copyFileSync(
|
||||
path.join(root, "deployment/crowdsec/compose.crowdsec.yml"),
|
||||
path.join(directory, "deployment/crowdsec/compose.crowdsec.yml"),
|
||||
);
|
||||
copyFileSync(
|
||||
path.join(root, "deployment/crowdsec/acquis.d/nginx.yaml"),
|
||||
path.join(directory, "deployment/crowdsec/acquis.d/nginx.yaml"),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(directory, ".env"),
|
||||
[
|
||||
"CROWDSEC_LAPI_API_KEY=fixture-key",
|
||||
"CROWDSEC_LAPI_PORT=18080",
|
||||
"CROWDSEC_LAPI_URL=http://127.0.0.1:18080",
|
||||
"CROWDSEC_NGINX_LOG_DIR=/var/log/nginx",
|
||||
].join("\n"),
|
||||
);
|
||||
const environment = { ...process.env };
|
||||
for (const key of Object.keys(environment))
|
||||
if (
|
||||
key.startsWith("COMPOSE_") ||
|
||||
key.startsWith("CROWDSEC_") ||
|
||||
key.startsWith("TZ")
|
||||
)
|
||||
delete environment[key];
|
||||
const result = spawnSync(
|
||||
"docker",
|
||||
[
|
||||
"compose",
|
||||
"--project-name",
|
||||
"crowdsec-fixture",
|
||||
"--env-file",
|
||||
".env",
|
||||
"-f",
|
||||
"deployment/crowdsec/compose.crowdsec.yml",
|
||||
"--profile",
|
||||
"security",
|
||||
"config",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
{ cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 },
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const config = JSON.parse(result.stdout);
|
||||
const service = config.services.crowdsec;
|
||||
expect(service).toBeDefined();
|
||||
expect(service.image).toContain("crowdsecurity/crowdsec:");
|
||||
expect(service.environment.BOUNCER_KEY_cms).toBe("fixture-key");
|
||||
expect(service.environment.DISABLE_ONLINE_API).toBe("true");
|
||||
expect(
|
||||
service.ports.some(
|
||||
(published) =>
|
||||
published.host_ip === "127.0.0.1" &&
|
||||
published.published === "18080" &&
|
||||
published.target === 8080,
|
||||
),
|
||||
).toBe(true);
|
||||
const targets = service.volumes.map((volume) => volume.target);
|
||||
expect(targets).toContain("/var/log/nginx");
|
||||
expect(targets).toContain("/etc/crowdsec/acquis.d");
|
||||
expect(service.healthcheck.test.join(" ")).toContain("wget");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
Reference in new issue
Block a user