mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-07-21 11:38:59 +02:00
✨ tests: end-to-end suite scaffold + stream-mode scenario
Add tests/e2e/ structure with shared bash helpers and the first scenario (stream-mode): spin up real Traefik + Crowdsec via docker compose, mount the repo as a local plugin, add a ban via cscli, verify the bouncer blocks the matching X-Forwarded-For, then delete the decision and verify pass-through. Adds .github/workflows/e2e.yml with one matrix job per scenario (stream-mode for now), and Makefile targets `e2e` and `e2e_<scenario>` for local runs. Refs #328
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
name: E2E
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: e2e-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
name: ${{ matrix.scenario }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
scenario:
|
||||||
|
- stream-mode
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Run scenario
|
||||||
|
run: ./tests/e2e/scenarios/${{ matrix.scenario }}/run.sh
|
||||||
|
- name: Upload logs on failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: logs-${{ matrix.scenario }}
|
||||||
|
path: /tmp/e2e-${{ matrix.scenario }}.log
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
.PHONY: lint test vendor clean
|
.PHONY: lint test vendor clean e2e
|
||||||
|
|
||||||
export GO111MODULE=on
|
export GO111MODULE=on
|
||||||
|
|
||||||
|
E2E_SCENARIOS := stream-mode
|
||||||
|
|
||||||
default: lint test
|
default: lint test
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@@ -13,6 +15,11 @@ test:
|
|||||||
yaegi_test:
|
yaegi_test:
|
||||||
yaegi test -v .
|
yaegi test -v .
|
||||||
|
|
||||||
|
e2e: $(addprefix e2e_,$(E2E_SCENARIOS))
|
||||||
|
|
||||||
|
e2e_%:
|
||||||
|
./tests/e2e/scenarios/$*/run.sh
|
||||||
|
|
||||||
vendor:
|
vendor:
|
||||||
go mod vendor
|
go mod vendor
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Each scenario uses an isolated Docker Compose project (`-p e2e-<scenario>`),
|
||||||
|
so multiple scenarios can run in parallel without colliding on container
|
||||||
|
names — except for the `crowdsec` container, which keeps its canonical
|
||||||
|
name so `cscli` commands work uniformly across scenarios. Run scenarios
|
||||||
|
serially if you need to invoke them on the same host at the same time.
|
||||||
|
|
||||||
|
## 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 the `matrix` in `.github/workflows/e2e.yml`
|
||||||
|
and to `E2E_SCENARIOS` in the `Makefile`.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
`.github/workflows/e2e.yml` runs one parallel job per scenario on every
|
||||||
|
PR and push to `main`. On failure, container logs are uploaded as an
|
||||||
|
artifact named `logs-<scenario>`.
|
||||||
Executable
+78
@@ -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:-<none>}" >&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
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.5.0
|
||||||
|
container_name: e2e-stream-traefik
|
||||||
|
command:
|
||||||
|
- "--log.level=INFO"
|
||||||
|
- "--accesslog"
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--providers.file.filename=/etc/traefik/dynamic.yml"
|
||||||
|
- "--providers.file.watch=false"
|
||||||
|
- "--entrypoints.web.address=:80"
|
||||||
|
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||||
|
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
volumes:
|
||||||
|
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml: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
|
||||||
|
|
||||||
|
crowdsec:
|
||||||
|
image: crowdsecurity/crowdsec:v1.6.8
|
||||||
|
container_name: crowdsec
|
||||||
|
environment:
|
||||||
|
DISABLE_ONLINE_API: "true"
|
||||||
|
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||||
|
CUSTOM_HOSTNAME: crowdsec
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "cscli", "lapi", "status"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
start_period: 5s
|
||||||
Executable
+57
@@ -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"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
http:
|
||||||
|
routers:
|
||||||
|
whoami:
|
||||||
|
rule: "PathPrefix(`/foo`)"
|
||||||
|
entryPoints: [web]
|
||||||
|
service: whoami
|
||||||
|
middlewares: [bouncer]
|
||||||
|
services:
|
||||||
|
whoami:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://whoami:80"
|
||||||
|
middlewares:
|
||||||
|
bouncer:
|
||||||
|
plugin:
|
||||||
|
bouncer:
|
||||||
|
enabled: true
|
||||||
|
crowdsecMode: stream
|
||||||
|
updateIntervalSeconds: 3
|
||||||
|
crowdsecLapiKey: "40796d93c2958f9e58345514e67740e5"
|
||||||
|
forwardedHeadersTrustedIPs:
|
||||||
|
- "172.16.0.0/12"
|
||||||
Reference in New Issue
Block a user