From 66b57496f80cd24641d188c6dbf1c75ffef214ac Mon Sep 17 00:00:00 2001 From: mhx Date: Fri, 5 Jun 2026 18:18:16 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20tests:=20add=20local=20Docker=20end?= =?UTF-8?q?-to-end=20suite=20(Traefik=20+=20Crowdsec)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A per-scenario end-to-end suite that boots real Traefik + Crowdsec containers and exercises the plugin loaded from a local path. Scenarios: stream / live / none modes, trusted IPs, custom ban page, captcha, and appsec. This suite is local-only (run with `make e2e`): it boots a real Crowdsec and, for appsec, downloads the OWASP CRS collections — heavier and less deterministic than CI needs. CI runs a separate, lighter binary + mock-LAPI suite instead (see the companion PR). Co-Authored-By: Claude Opus 4.8 --- Makefile | 9 ++- tests/e2e/README.md | 55 +++++++++++++ tests/e2e/lib/common.sh | 78 +++++++++++++++++++ tests/e2e/scenarios/appsec/acquis.yaml | 6 ++ tests/e2e/scenarios/appsec/docker-compose.yml | 55 +++++++++++++ tests/e2e/scenarios/appsec/run.sh | 40 ++++++++++ tests/e2e/scenarios/captcha/captcha.html | 9 +++ .../e2e/scenarios/captcha/docker-compose.yml | 59 ++++++++++++++ tests/e2e/scenarios/captcha/run.sh | 53 +++++++++++++ tests/e2e/scenarios/custom-ban-page/ban.html | 8 ++ .../custom-ban-page/docker-compose.yml | 53 +++++++++++++ tests/e2e/scenarios/custom-ban-page/run.sh | 53 +++++++++++++ .../scenarios/live-mode/docker-compose.yml | 51 ++++++++++++ tests/e2e/scenarios/live-mode/run.sh | 48 ++++++++++++ .../scenarios/none-mode/docker-compose.yml | 50 ++++++++++++ tests/e2e/scenarios/none-mode/run.sh | 48 ++++++++++++ .../scenarios/stream-mode/docker-compose.yml | 51 ++++++++++++ tests/e2e/scenarios/stream-mode/run.sh | 57 ++++++++++++++ .../scenarios/trusted-ips/docker-compose.yml | 52 +++++++++++++ tests/e2e/scenarios/trusted-ips/run.sh | 46 +++++++++++ 20 files changed, 880 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/README.md create mode 100755 tests/e2e/lib/common.sh create mode 100644 tests/e2e/scenarios/appsec/acquis.yaml create mode 100644 tests/e2e/scenarios/appsec/docker-compose.yml create mode 100755 tests/e2e/scenarios/appsec/run.sh create mode 100644 tests/e2e/scenarios/captcha/captcha.html create mode 100644 tests/e2e/scenarios/captcha/docker-compose.yml create mode 100755 tests/e2e/scenarios/captcha/run.sh create mode 100644 tests/e2e/scenarios/custom-ban-page/ban.html create mode 100644 tests/e2e/scenarios/custom-ban-page/docker-compose.yml create mode 100755 tests/e2e/scenarios/custom-ban-page/run.sh create mode 100644 tests/e2e/scenarios/live-mode/docker-compose.yml create mode 100755 tests/e2e/scenarios/live-mode/run.sh create mode 100644 tests/e2e/scenarios/none-mode/docker-compose.yml create mode 100755 tests/e2e/scenarios/none-mode/run.sh create mode 100644 tests/e2e/scenarios/stream-mode/docker-compose.yml create mode 100755 tests/e2e/scenarios/stream-mode/run.sh create mode 100644 tests/e2e/scenarios/trusted-ips/docker-compose.yml create mode 100755 tests/e2e/scenarios/trusted-ips/run.sh diff --git a/Makefile b/Makefile index 4785399..e02f58b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ -.PHONY: lint test vendor clean +.PHONY: lint test vendor clean e2e export GO111MODULE=on +E2E_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec + default: lint test lint: @@ -13,6 +15,11 @@ test: yaegi_test: yaegi test -v . +e2e: $(addprefix e2e_,$(E2E_SCENARIOS)) + +e2e_%: + ./tests/e2e/scenarios/$*/run.sh + vendor: go mod vendor diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..db4f8fb --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,55 @@ +# End-to-end test suite + +These tests spin up real Traefik + Crowdsec containers and exercise the +plugin in the same conditions Traefik uses in production: loaded from a +local path, no module download, no mocking. + +Each scenario lives in its own directory under `scenarios/` and owns: + +- `docker-compose.yml` — the stack to spin up +- `run.sh` — orchestration + assertions +- optional fixtures (`acquis.yaml`, `ban.html`, ...) + +## Running locally + +Prerequisites: `docker`, `docker compose`, `curl`, `bash`. + +Run a single scenario: + +```bash +./tests/e2e/scenarios/stream-mode/run.sh +# or +make e2e_stream-mode +``` + +Run everything: + +```bash +make e2e +``` + +Scenarios run **sequentially** on a single host: they share the canonical +`crowdsec` container name (so `cscli` commands work uniformly) and the same +`8000:80` port. Each scenario uses its own Docker Compose project +(`-p e2e-`) and tears its stack down on exit, so the next one +starts clean. `make e2e` runs them one after another; Docker reuses the +images pulled by the first scenario, so the Traefik / Crowdsec / whoami +images are downloaded only once for the whole suite. + +## Writing a new scenario + +1. Copy `scenarios/stream-mode/` as a template. +2. Rename `container_name`s (keep `crowdsec` for the LAPI container). +3. Edit `run.sh` to express the behavior under test. +4. Add the scenario name to `E2E_SCENARIOS` in the `Makefile`. + +## CI + +This Docker suite is **local-only** and intentionally not run in CI: it boots a +real Crowdsec (and downloads the AppSec collections for that scenario), which is +heavier and less deterministic than CI needs. CI instead runs a lighter +binary + mock-LAPI suite that exercises the plugin without Docker or a real +Crowdsec. + +Run this suite locally with `make e2e` (all scenarios) or +`make e2e_` (one scenario). diff --git a/tests/e2e/lib/common.sh b/tests/e2e/lib/common.sh new file mode 100755 index 0000000..64e4cfd --- /dev/null +++ b/tests/e2e/lib/common.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Shared helpers for end-to-end scenarios. +# Dependencies: bash, curl, docker. + +# Wait until the Crowdsec LAPI reports ready, or fail after timeout. +# Usage: wait_crowdsec_ready [container_name] [timeout_seconds] +wait_crowdsec_ready() { + local container="${1:-crowdsec}" + local timeout="${2:-90}" + local elapsed=0 + while (( elapsed < timeout )); do + if docker exec "$container" cscli lapi status >/dev/null 2>&1; then + return 0 + fi + sleep 1 + ((elapsed++)) + done + echo "wait_crowdsec_ready: timed out after ${timeout}s waiting for $container" >&2 + return 1 +} + +# Poll URL until it returns the expected status code, or fail. +# Usage: wait_for_status URL CODE [TIMEOUT_SECONDS] [curl args...] +wait_for_status() { + local url="$1" expected="$2" timeout="${3:-30}" + shift 3 || true + local elapsed=0 + local got="" + while (( elapsed < timeout )); do + got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url" || true) + if [[ "$got" == "$expected" ]]; then + return 0 + fi + sleep 1 + ((elapsed++)) + done + echo "wait_for_status: $url expected $expected, last seen ${got:-}" >&2 + return 1 +} + +# Assert a single curl returns the expected status code. +# Usage: assert_status URL CODE [curl args...] +assert_status() { + local url="$1" expected="$2" + shift 2 || true + local got + got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url") + if [[ "$got" != "$expected" ]]; then + echo "assert_status: $url expected $expected, got $got" >&2 + return 1 + fi +} + +# Assert a response header matches a value (case-insensitive name). +# Usage: assert_header URL HEADER VALUE [curl args...] +assert_header() { + local url="$1" header="$2" expected="$3" + shift 3 || true + local got + got=$(curl -s -D - -o /dev/null "$@" "$url" | tr -d '\r' \ + | awk -v h="${header,,}" -F': ' 'tolower($1) == h { print $2; exit }') + if [[ "$got" != "$expected" ]]; then + echo "assert_header: $url header $header expected \"$expected\", got \"$got\"" >&2 + return 1 + fi +} + +# Dump diagnostic info for a compose project; called on failure. +# Usage: dump_diagnostics PROJECT COMPOSE_FILE +dump_diagnostics() { + local project="$1" compose_file="$2" + echo "=== docker compose ps ===" + docker compose -p "$project" -f "$compose_file" ps || true + echo "=== docker compose logs ===" + docker compose -p "$project" -f "$compose_file" logs --no-color || true + echo "=== cscli decisions list ===" + docker exec crowdsec cscli decisions list 2>/dev/null || true +} diff --git a/tests/e2e/scenarios/appsec/acquis.yaml b/tests/e2e/scenarios/appsec/acquis.yaml new file mode 100644 index 0000000..97d9bc5 --- /dev/null +++ b/tests/e2e/scenarios/appsec/acquis.yaml @@ -0,0 +1,6 @@ +listen_addr: 0.0.0.0:7422 +appsec_config: crowdsecurity/crs-inband +name: e2eAppSec +source: appsec +labels: + type: appsec diff --git a/tests/e2e/scenarios/appsec/docker-compose.yml b/tests/e2e/scenarios/appsec/docker-compose.yml new file mode 100644 index 0000000..b3d2a87 --- /dev/null +++ b/tests/e2e/scenarios/appsec/docker-compose.yml @@ -0,0 +1,55 @@ +services: + traefik: + image: traefik:v3.7.1 + container_name: e2e-appsec-traefik + command: + - "--log.level=INFO" + - "--accesslog" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entryPoints.web.address=:80" + - "--entryPoints.web.forwardedHeaders.insecure=true" + - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + ports: + - "8000:80" + depends_on: + crowdsec: + condition: service_healthy + + whoami: + image: traefik/whoami + container_name: e2e-appsec-whoami + labels: + - "traefik.enable=true" + - "traefik.http.routers.r.rule=PathPrefix(`/foo`)" + - "traefik.http.routers.r.entrypoints=web" + - "traefik.http.routers.r.middlewares=bouncer@docker" + - "traefik.http.services.s.loadbalancer.server.port=80" + - "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true" + # IP bouncing disabled — we only test AppSec body inspection here. + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=none" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecappsecenabled=true" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecappsechost=crowdsec:7422" + - "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12" + + crowdsec: + image: crowdsecurity/crowdsec:v1.7.8 + container_name: crowdsec + environment: + COLLECTIONS: "crowdsecurity/appsec-crs-inband" + BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5 + CUSTOM_HOSTNAME: crowdsec + CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true" + volumes: + - ./acquis.yaml:/etc/crowdsec/acquis.yaml:ro + healthcheck: + test: ["CMD", "cscli", "lapi", "status"] + interval: 2s + timeout: 3s + retries: 60 + start_period: 15s diff --git a/tests/e2e/scenarios/appsec/run.sh b/tests/e2e/scenarios/appsec/run.sh new file mode 100755 index 0000000..c0c6b80 --- /dev/null +++ b/tests/e2e/scenarios/appsec/run.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../../lib/common.sh +source "$HERE/../../lib/common.sh" + +SCENARIO=appsec +PROJECT="e2e-${SCENARIO}" +COMPOSE_FILE="$HERE/docker-compose.yml" +LOG_FILE="/tmp/e2e-${SCENARIO}.log" + +cleanup() { + local rc=$? + if (( rc != 0 )); then + dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true + echo "Scenario failed. Logs written to $LOG_FILE" + fi + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true + exit $rc +} +trap cleanup EXIT + +echo "[$SCENARIO] starting stack (AppSec collections download — may take ~30s on first boot)..." +docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait + +echo "[$SCENARIO] waiting for crowdsec readiness..." +wait_crowdsec_ready crowdsec 180 + +echo "[$SCENARIO] waiting for traefik readiness..." +wait_for_status http://localhost:8000/foo 200 30 + +echo "[$SCENARIO] benign request must pass" +assert_status http://localhost:8000/foo 200 + +echo "[$SCENARIO] SQL-injection-like query string must be blocked by OWASP CRS (inband)" +# Classic SQLi probe: ?id=1' OR '1'='1 — caught by CRS rule 942100/942130 (paranoia 1). +assert_status "http://localhost:8000/foo?id=1%27%20OR%20%271%27%3D%271" 403 + +echo "[$SCENARIO] OK" diff --git a/tests/e2e/scenarios/captcha/captcha.html b/tests/e2e/scenarios/captcha/captcha.html new file mode 100644 index 0000000..94736cc --- /dev/null +++ b/tests/e2e/scenarios/captcha/captcha.html @@ -0,0 +1,9 @@ + + +E2E captcha marker + +

E2E_CAPTCHA_PAGE_MARKER

+ +
+ + diff --git a/tests/e2e/scenarios/captcha/docker-compose.yml b/tests/e2e/scenarios/captcha/docker-compose.yml new file mode 100644 index 0000000..9b0306c --- /dev/null +++ b/tests/e2e/scenarios/captcha/docker-compose.yml @@ -0,0 +1,59 @@ +services: + traefik: + image: traefik:v3.7.1 + container_name: e2e-captcha-traefik + command: + - "--log.level=INFO" + - "--accesslog" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entryPoints.web.address=:80" + - "--entryPoints.web.forwardedHeaders.insecure=true" + - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + - ./captcha.html:/captcha.html:ro + ports: + - "8000:80" + depends_on: + crowdsec: + condition: service_healthy + + whoami: + image: traefik/whoami + container_name: e2e-captcha-whoami + labels: + - "traefik.enable=true" + - "traefik.http.routers.r.rule=PathPrefix(`/foo`)" + - "traefik.http.routers.r.entrypoints=web" + - "traefik.http.routers.r.middlewares=bouncer@docker" + - "traefik.http.services.s.loadbalancer.server.port=80" + - "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream" + - "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" + - "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12" + - "traefik.http.middlewares.bouncer.plugin.bouncer.captchaprovider=turnstile" + # Cloudflare Turnstile public test keys (always pass / always fail variants exist). + # These render a valid widget without contacting any real API key. + - "traefik.http.middlewares.bouncer.plugin.bouncer.captchasitekey=1x00000000000000000000AA" + - "traefik.http.middlewares.bouncer.plugin.bouncer.captchasecretkey=1x0000000000000000000000000000000AA" + - "traefik.http.middlewares.bouncer.plugin.bouncer.captchahtmlfilepath=/captcha.html" + - "traefik.http.middlewares.bouncer.plugin.bouncer.captchagraceperiodseconds=10" + + crowdsec: + image: crowdsecurity/crowdsec:v1.7.8 + container_name: crowdsec + environment: + DISABLE_ONLINE_API: "true" + BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5 + CUSTOM_HOSTNAME: crowdsec + CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true" + healthcheck: + test: ["CMD", "cscli", "lapi", "status"] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/tests/e2e/scenarios/captcha/run.sh b/tests/e2e/scenarios/captcha/run.sh new file mode 100755 index 0000000..581aa65 --- /dev/null +++ b/tests/e2e/scenarios/captcha/run.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../../lib/common.sh +source "$HERE/../../lib/common.sh" + +SCENARIO=captcha +PROJECT="e2e-${SCENARIO}" +COMPOSE_FILE="$HERE/docker-compose.yml" +LOG_FILE="/tmp/e2e-${SCENARIO}.log" + +cleanup() { + local rc=$? + if (( rc != 0 )); then + dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true + echo "Scenario failed. Logs written to $LOG_FILE" + fi + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true + exit $rc +} +trap cleanup EXIT + +echo "[$SCENARIO] starting stack..." +docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait + +echo "[$SCENARIO] waiting for crowdsec readiness..." +wait_crowdsec_ready + +echo "[$SCENARIO] waiting for traefik readiness..." +wait_for_status http://localhost:8000/foo 200 30 + +echo "[$SCENARIO] adding captcha decision for 1.2.3.4" +docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type captcha --duration 5m + +echo "[$SCENARIO] waiting one stream tick + buffer..." +sleep 6 + +echo "[$SCENARIO] captcha response must be HTTP 200 (the captcha page itself, not a 403)" +assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] captcha response body must contain the captcha template marker" +body=$(curl -s http://localhost:8000/foo -H "X-Forwarded-For: 1.2.3.4") +if ! grep -q "E2E_CAPTCHA_PAGE_MARKER" <<<"$body"; then + echo "Expected response body to contain E2E_CAPTCHA_PAGE_MARKER, got:" >&2 + echo "$body" >&2 + exit 1 +fi + +echo "[$SCENARIO] non-flagged IP must still pass through to the backend" +assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8" + +echo "[$SCENARIO] OK" diff --git a/tests/e2e/scenarios/custom-ban-page/ban.html b/tests/e2e/scenarios/custom-ban-page/ban.html new file mode 100644 index 0000000..62c0fe3 --- /dev/null +++ b/tests/e2e/scenarios/custom-ban-page/ban.html @@ -0,0 +1,8 @@ + + +E2E ban marker + +

E2E_CUSTOM_BAN_PAGE_MARKER

+

IP: {{ .ClientIP }} reason: {{ .RemediationReason }}

+ + diff --git a/tests/e2e/scenarios/custom-ban-page/docker-compose.yml b/tests/e2e/scenarios/custom-ban-page/docker-compose.yml new file mode 100644 index 0000000..d07397d --- /dev/null +++ b/tests/e2e/scenarios/custom-ban-page/docker-compose.yml @@ -0,0 +1,53 @@ +services: + traefik: + image: traefik:v3.7.1 + container_name: e2e-banpage-traefik + command: + - "--log.level=INFO" + - "--accesslog" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entryPoints.web.address=:80" + - "--entryPoints.web.forwardedHeaders.insecure=true" + - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + - ./ban.html:/ban.html:ro + ports: + - "8000:80" + depends_on: + crowdsec: + condition: service_healthy + + whoami: + image: traefik/whoami + container_name: e2e-banpage-whoami + labels: + - "traefik.enable=true" + - "traefik.http.routers.r.rule=PathPrefix(`/foo`)" + - "traefik.http.routers.r.entrypoints=web" + - "traefik.http.routers.r.middlewares=bouncer@docker" + - "traefik.http.services.s.loadbalancer.server.port=80" + - "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream" + - "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" + - "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12" + - "traefik.http.middlewares.bouncer.plugin.bouncer.banhtmlfilepath=/ban.html" + + crowdsec: + image: crowdsecurity/crowdsec:v1.7.8 + container_name: crowdsec + environment: + DISABLE_ONLINE_API: "true" + BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5 + CUSTOM_HOSTNAME: crowdsec + CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true" + healthcheck: + test: ["CMD", "cscli", "lapi", "status"] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/tests/e2e/scenarios/custom-ban-page/run.sh b/tests/e2e/scenarios/custom-ban-page/run.sh new file mode 100755 index 0000000..02ca143 --- /dev/null +++ b/tests/e2e/scenarios/custom-ban-page/run.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../../lib/common.sh +source "$HERE/../../lib/common.sh" + +SCENARIO=custom-ban-page +PROJECT="e2e-${SCENARIO}" +COMPOSE_FILE="$HERE/docker-compose.yml" +LOG_FILE="/tmp/e2e-${SCENARIO}.log" + +cleanup() { + local rc=$? + if (( rc != 0 )); then + dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true + echo "Scenario failed. Logs written to $LOG_FILE" + fi + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true + exit $rc +} +trap cleanup EXIT + +echo "[$SCENARIO] starting stack..." +docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait + +echo "[$SCENARIO] waiting for crowdsec readiness..." +wait_crowdsec_ready + +echo "[$SCENARIO] waiting for traefik readiness..." +wait_for_status http://localhost:8000/foo 200 30 + +echo "[$SCENARIO] adding ban decision" +docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m + +echo "[$SCENARIO] waiting one stream tick + buffer..." +sleep 6 + +echo "[$SCENARIO] banned response status is 403" +assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] banned response Content-Type is HTML" +assert_header http://localhost:8000/foo Content-Type "text/html; charset=utf-8" -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] banned response body contains the custom marker" +body=$(curl -s http://localhost:8000/foo -H "X-Forwarded-For: 1.2.3.4") +if ! grep -q "E2E_CUSTOM_BAN_PAGE_MARKER" <<<"$body"; then + echo "Expected response body to contain E2E_CUSTOM_BAN_PAGE_MARKER, got:" >&2 + echo "$body" >&2 + exit 1 +fi + +echo "[$SCENARIO] OK" diff --git a/tests/e2e/scenarios/live-mode/docker-compose.yml b/tests/e2e/scenarios/live-mode/docker-compose.yml new file mode 100644 index 0000000..e6fe8a0 --- /dev/null +++ b/tests/e2e/scenarios/live-mode/docker-compose.yml @@ -0,0 +1,51 @@ +services: + traefik: + image: traefik:v3.7.1 + container_name: e2e-live-traefik + command: + - "--log.level=INFO" + - "--accesslog" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entryPoints.web.address=:80" + - "--entryPoints.web.forwardedHeaders.insecure=true" + - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + ports: + - "8000:80" + depends_on: + crowdsec: + condition: service_healthy + + whoami: + image: traefik/whoami + container_name: e2e-live-whoami + labels: + - "traefik.enable=true" + - "traefik.http.routers.r.rule=PathPrefix(`/foo`)" + - "traefik.http.routers.r.entrypoints=web" + - "traefik.http.routers.r.middlewares=bouncer@docker" + - "traefik.http.services.s.loadbalancer.server.port=80" + - "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=live" + - "traefik.http.middlewares.bouncer.plugin.bouncer.defaultdecisionseconds=2" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" + - "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12" + + crowdsec: + image: crowdsecurity/crowdsec:v1.7.8 + container_name: crowdsec + environment: + DISABLE_ONLINE_API: "true" + BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5 + CUSTOM_HOSTNAME: crowdsec + CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true" + healthcheck: + test: ["CMD", "cscli", "lapi", "status"] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/tests/e2e/scenarios/live-mode/run.sh b/tests/e2e/scenarios/live-mode/run.sh new file mode 100755 index 0000000..2324105 --- /dev/null +++ b/tests/e2e/scenarios/live-mode/run.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../../lib/common.sh +source "$HERE/../../lib/common.sh" + +SCENARIO=live-mode +PROJECT="e2e-${SCENARIO}" +COMPOSE_FILE="$HERE/docker-compose.yml" +LOG_FILE="/tmp/e2e-${SCENARIO}.log" + +cleanup() { + local rc=$? + if (( rc != 0 )); then + dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true + echo "Scenario failed. Logs written to $LOG_FILE" + fi + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true + exit $rc +} +trap cleanup EXIT + +echo "[$SCENARIO] starting stack..." +docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait + +echo "[$SCENARIO] waiting for crowdsec readiness..." +wait_crowdsec_ready + +echo "[$SCENARIO] waiting for traefik readiness..." +wait_for_status http://localhost:8000/foo 200 30 + +echo "[$SCENARIO] no decision -> first hit queries LAPI, returns 200, caches 'allowed' for 2s" +assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] adding ban decision for 1.2.3.4" +docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m + +echo "[$SCENARIO] waiting for defaultDecisionSeconds cache to expire..." +sleep 3 + +echo "[$SCENARIO] next hit must re-query LAPI and now see the ban" +assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] another non-banned IP must still pass" +assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8" + +echo "[$SCENARIO] OK" diff --git a/tests/e2e/scenarios/none-mode/docker-compose.yml b/tests/e2e/scenarios/none-mode/docker-compose.yml new file mode 100644 index 0000000..af14153 --- /dev/null +++ b/tests/e2e/scenarios/none-mode/docker-compose.yml @@ -0,0 +1,50 @@ +services: + traefik: + image: traefik:v3.7.1 + container_name: e2e-none-traefik + command: + - "--log.level=INFO" + - "--accesslog" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entryPoints.web.address=:80" + - "--entryPoints.web.forwardedHeaders.insecure=true" + - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + ports: + - "8000:80" + depends_on: + crowdsec: + condition: service_healthy + + whoami: + image: traefik/whoami + container_name: e2e-none-whoami + labels: + - "traefik.enable=true" + - "traefik.http.routers.r.rule=PathPrefix(`/foo`)" + - "traefik.http.routers.r.entrypoints=web" + - "traefik.http.routers.r.middlewares=bouncer@docker" + - "traefik.http.services.s.loadbalancer.server.port=80" + - "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=none" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" + - "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12" + + crowdsec: + image: crowdsecurity/crowdsec:v1.7.8 + container_name: crowdsec + environment: + DISABLE_ONLINE_API: "true" + BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5 + CUSTOM_HOSTNAME: crowdsec + CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true" + healthcheck: + test: ["CMD", "cscli", "lapi", "status"] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/tests/e2e/scenarios/none-mode/run.sh b/tests/e2e/scenarios/none-mode/run.sh new file mode 100755 index 0000000..328ce71 --- /dev/null +++ b/tests/e2e/scenarios/none-mode/run.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../../lib/common.sh +source "$HERE/../../lib/common.sh" + +SCENARIO=none-mode +PROJECT="e2e-${SCENARIO}" +COMPOSE_FILE="$HERE/docker-compose.yml" +LOG_FILE="/tmp/e2e-${SCENARIO}.log" + +cleanup() { + local rc=$? + if (( rc != 0 )); then + dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true + echo "Scenario failed. Logs written to $LOG_FILE" + fi + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true + exit $rc +} +trap cleanup EXIT + +echo "[$SCENARIO] starting stack..." +docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait + +echo "[$SCENARIO] waiting for crowdsec readiness..." +wait_crowdsec_ready + +echo "[$SCENARIO] waiting for traefik readiness..." +wait_for_status http://localhost:8000/foo 200 30 + +echo "[$SCENARIO] no decision -> request passes (LAPI queried per request)" +assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] adding ban decision" +docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m + +echo "[$SCENARIO] none mode has no cache -> next request must be blocked immediately" +assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] deleting decision" +docker exec crowdsec cscli decisions delete --ip 1.2.3.4 + +echo "[$SCENARIO] previously banned IP must pass again immediately" +assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] OK" diff --git a/tests/e2e/scenarios/stream-mode/docker-compose.yml b/tests/e2e/scenarios/stream-mode/docker-compose.yml new file mode 100644 index 0000000..718650e --- /dev/null +++ b/tests/e2e/scenarios/stream-mode/docker-compose.yml @@ -0,0 +1,51 @@ +services: + traefik: + image: traefik:v3.7.1 + container_name: e2e-stream-traefik + command: + - "--log.level=INFO" + - "--accesslog" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entryPoints.web.address=:80" + - "--entryPoints.web.forwardedHeaders.insecure=true" + - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + ports: + - "8000:80" + depends_on: + crowdsec: + condition: service_healthy + + whoami: + image: traefik/whoami + container_name: e2e-stream-whoami + labels: + - "traefik.enable=true" + - "traefik.http.routers.r.rule=PathPrefix(`/foo`)" + - "traefik.http.routers.r.entrypoints=web" + - "traefik.http.routers.r.middlewares=bouncer@docker" + - "traefik.http.services.s.loadbalancer.server.port=80" + - "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream" + - "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" + - "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12" + + crowdsec: + image: crowdsecurity/crowdsec:v1.7.8 + container_name: crowdsec + environment: + DISABLE_ONLINE_API: "true" + BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5 + CUSTOM_HOSTNAME: crowdsec + CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true" + healthcheck: + test: ["CMD", "cscli", "lapi", "status"] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/tests/e2e/scenarios/stream-mode/run.sh b/tests/e2e/scenarios/stream-mode/run.sh new file mode 100755 index 0000000..1988c23 --- /dev/null +++ b/tests/e2e/scenarios/stream-mode/run.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../../lib/common.sh +source "$HERE/../../lib/common.sh" + +SCENARIO=stream-mode +PROJECT="e2e-${SCENARIO}" +COMPOSE_FILE="$HERE/docker-compose.yml" +LOG_FILE="/tmp/e2e-${SCENARIO}.log" + +cleanup() { + local rc=$? + if (( rc != 0 )); then + dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true + echo "Scenario failed. Logs written to $LOG_FILE" + fi + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true + exit $rc +} +trap cleanup EXIT + +echo "[$SCENARIO] starting stack..." +docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait + +echo "[$SCENARIO] waiting for crowdsec readiness..." +wait_crowdsec_ready + +echo "[$SCENARIO] waiting for traefik readiness..." +wait_for_status http://localhost:8000/foo 200 30 + +echo "[$SCENARIO] no decision yet -> request allowed" +assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] adding ban decision for 1.2.3.4" +docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m + +echo "[$SCENARIO] waiting one stream tick + buffer..." +sleep 6 + +echo "[$SCENARIO] banned IP must be blocked (HTTP 403)" +assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] non-banned IP must still pass (HTTP 200)" +assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8" + +echo "[$SCENARIO] deleting ban decision" +docker exec crowdsec cscli decisions delete --ip 1.2.3.4 + +echo "[$SCENARIO] waiting one stream tick + buffer..." +sleep 6 + +echo "[$SCENARIO] previously banned IP must pass again" +assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] OK" diff --git a/tests/e2e/scenarios/trusted-ips/docker-compose.yml b/tests/e2e/scenarios/trusted-ips/docker-compose.yml new file mode 100644 index 0000000..39d6042 --- /dev/null +++ b/tests/e2e/scenarios/trusted-ips/docker-compose.yml @@ -0,0 +1,52 @@ +services: + traefik: + image: traefik:v3.7.1 + container_name: e2e-trusted-traefik + command: + - "--log.level=INFO" + - "--accesslog" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entryPoints.web.address=:80" + - "--entryPoints.web.forwardedHeaders.insecure=true" + - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + ports: + - "8000:80" + depends_on: + crowdsec: + condition: service_healthy + + whoami: + image: traefik/whoami + container_name: e2e-trusted-whoami + labels: + - "traefik.enable=true" + - "traefik.http.routers.r.rule=PathPrefix(`/foo`)" + - "traefik.http.routers.r.entrypoints=web" + - "traefik.http.routers.r.middlewares=bouncer@docker" + - "traefik.http.services.s.loadbalancer.server.port=80" + - "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream" + - "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3" + - "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" + - "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12" + - "traefik.http.middlewares.bouncer.plugin.bouncer.clienttrustedips=1.2.3.4/32" + + crowdsec: + image: crowdsecurity/crowdsec:v1.7.8 + container_name: crowdsec + environment: + DISABLE_ONLINE_API: "true" + BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5 + CUSTOM_HOSTNAME: crowdsec + CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true" + healthcheck: + test: ["CMD", "cscli", "lapi", "status"] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/tests/e2e/scenarios/trusted-ips/run.sh b/tests/e2e/scenarios/trusted-ips/run.sh new file mode 100755 index 0000000..479bc5e --- /dev/null +++ b/tests/e2e/scenarios/trusted-ips/run.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../../lib/common.sh +source "$HERE/../../lib/common.sh" + +SCENARIO=trusted-ips +PROJECT="e2e-${SCENARIO}" +COMPOSE_FILE="$HERE/docker-compose.yml" +LOG_FILE="/tmp/e2e-${SCENARIO}.log" + +cleanup() { + local rc=$? + if (( rc != 0 )); then + dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true + echo "Scenario failed. Logs written to $LOG_FILE" + fi + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true + exit $rc +} +trap cleanup EXIT + +echo "[$SCENARIO] starting stack..." +docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait + +echo "[$SCENARIO] waiting for crowdsec readiness..." +wait_crowdsec_ready + +echo "[$SCENARIO] waiting for traefik readiness..." +wait_for_status http://localhost:8000/foo 200 30 + +echo "[$SCENARIO] banning the trusted IP 1.2.3.4 and an untrusted IP 5.6.7.8" +docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m +docker exec crowdsec cscli decisions add --ip 5.6.7.8 --type ban --duration 5m + +echo "[$SCENARIO] waiting one stream tick + buffer..." +sleep 6 + +echo "[$SCENARIO] trusted IP must bypass the bouncer even though it is banned" +assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] untrusted banned IP must be blocked (control: proves the bouncer is active)" +assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 5.6.7.8" + +echo "[$SCENARIO] OK"