mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66b57496f8 |
@@ -1,42 +0,0 @@
|
|||||||
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: e2e (binary + mock LAPI)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
# Track go.mod (Go 1.22) — the plugin's yaegi-bound floor. Keeps the
|
|
||||||
# single source of truth and builds the mock on the supported version.
|
|
||||||
go-version-file: go.mod
|
|
||||||
# CI runs the binary/mock suite only: Traefik as a downloaded binary +
|
|
||||||
# a small LAPI mock (no Docker, no real Crowdsec). It validates the
|
|
||||||
# plugin's own behaviour. Crowdsec / AppSec correctness is upstream's
|
|
||||||
# responsibility, so those are intentionally out of scope here. The
|
|
||||||
# Docker suite (tests/e2e/scenarios) stays available for local debugging.
|
|
||||||
# `-k` keeps going after a failing scenario so the logs cover all of
|
|
||||||
# them, while make still exits non-zero if any scenario failed.
|
|
||||||
- name: Run mock scenarios
|
|
||||||
run: make -k e2e_mock
|
|
||||||
- name: Upload logs on failure
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: e2e-logs
|
|
||||||
path: /tmp/e2e-mock-*.log
|
|
||||||
if-no-files-found: ignore
|
|
||||||
@@ -5,7 +5,3 @@ conf
|
|||||||
db
|
db
|
||||||
logs
|
logs
|
||||||
docker-compose.dev.yml
|
docker-compose.dev.yml
|
||||||
|
|
||||||
# Binary e2e suite working cache: Traefik binary + compiled mock. Persisted
|
|
||||||
# across local runs; CI runs on fresh runners so it is recreated every time.
|
|
||||||
tests/e2e/mock/.cache/
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
.PHONY: lint test vendor clean e2e_mock
|
.PHONY: lint test vendor clean e2e
|
||||||
|
|
||||||
export GO111MODULE=on
|
export GO111MODULE=on
|
||||||
|
|
||||||
# Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs.
|
E2E_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec
|
||||||
# The local Docker suite (make e2e) lives in a separate PR/branch.
|
|
||||||
E2E_MOCK_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec
|
|
||||||
|
|
||||||
default: lint test
|
default: lint test
|
||||||
|
|
||||||
@@ -17,10 +15,10 @@ test:
|
|||||||
yaegi_test:
|
yaegi_test:
|
||||||
yaegi test -v .
|
yaegi test -v .
|
||||||
|
|
||||||
e2e_mock: $(addprefix e2e_mock_,$(E2E_MOCK_SCENARIOS))
|
e2e: $(addprefix e2e_,$(E2E_SCENARIOS))
|
||||||
|
|
||||||
e2e_mock_%:
|
e2e_%:
|
||||||
./tests/e2e/mock/scenarios/$*/run.sh
|
./tests/e2e/scenarios/$*/run.sh
|
||||||
|
|
||||||
vendor:
|
vendor:
|
||||||
go mod vendor
|
go mod vendor
|
||||||
|
|||||||
@@ -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-<scenario>`) 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_<scenario>` (one 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
|
||||||
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Binary e2e suite (Traefik binary + mock LAPI)
|
|
||||||
|
|
||||||
This suite runs **Traefik as a downloaded binary** with the plugin loaded from
|
|
||||||
the local source tree, and replaces Crowdsec with a small **HTTP mock**
|
|
||||||
([`mocklapi/`](mocklapi/main.go), a stdlib-only Go command). No Docker, no real
|
|
||||||
Crowdsec.
|
|
||||||
|
|
||||||
It is what **CI runs** (`make e2e_mock`). A separate, local-only **Docker
|
|
||||||
suite** (real Traefik + Crowdsec, under `tests/e2e/scenarios`) is kept for
|
|
||||||
high-fidelity debugging against a real Crowdsec but is not exercised in CI; it
|
|
||||||
ships in its own PR (#333).
|
|
||||||
|
|
||||||
## Scope — what this suite tests
|
|
||||||
|
|
||||||
These tests validate the **plugin's own behaviour**: the request flow through
|
|
||||||
the Traefik middleware, the live / none / stream modes, caching, trusted-IP
|
|
||||||
bypass, ban / captcha page rendering, and the AppSec request path (header
|
|
||||||
forwarding + enforcing the engine's allow/block verdict).
|
|
||||||
|
|
||||||
The mock stands in for Crowdsec, emulating the slice of the LAPI HTTP contract
|
|
||||||
the plugin consumes — including a single, deterministic AppSec rule (block any
|
|
||||||
URI containing `rpc2`, the probe from [`examples/appsec-enabled`](../../../examples/appsec-enabled)).
|
|
||||||
It is not the real WAF engine, so this suite exercises the plugin's AppSec
|
|
||||||
*wiring* rather than the detection accuracy of OWASP CRS / virtual patching —
|
|
||||||
that lives upstream in Crowdsec.
|
|
||||||
|
|
||||||
## What runs
|
|
||||||
|
|
||||||
| Component | How |
|
|
||||||
|-----------|-----|
|
|
||||||
| Traefik | Binary `v3.7.1`, downloaded into `.cache/` (reused across local runs; re-downloaded on fresh CI runners) |
|
|
||||||
| Plugin | Loaded via `experimental.localPlugins` from the repo root (symlinked into `plugins-local/`) |
|
|
||||||
| LAPI | `mocklapi` — a stdlib-only Go command (its own nested module), compiled and cached under `.cache/`, driven through `/admin` endpoints instead of `cscli` |
|
|
||||||
| AppSec | WAF stand-in built into the mock — blocks URIs containing `rpc2`, allows the rest |
|
|
||||||
| Backend | A plain HTTP responder built into the mock |
|
|
||||||
|
|
||||||
Fixed ports (override with env vars if needed): Traefik `8000`, LAPI `8090`,
|
|
||||||
backend `8091`, AppSec `8092`.
|
|
||||||
|
|
||||||
## Running locally
|
|
||||||
|
|
||||||
Prerequisites: `bash`, `curl`, `go`, `tar`. On first use the Traefik binary is
|
|
||||||
fetched and the mock is compiled into `.cache/`. That cache is reused across
|
|
||||||
local runs; CI runs on fresh runners, so both are recreated on every CI run.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# one scenario
|
|
||||||
make e2e_mock_stream-mode
|
|
||||||
# or directly
|
|
||||||
./tests/e2e/mock/scenarios/stream-mode/run.sh
|
|
||||||
|
|
||||||
# the whole suite
|
|
||||||
make e2e_mock
|
|
||||||
```
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
mock/
|
|
||||||
lib/
|
|
||||||
common.sh # stack lifecycle, Traefik download, mock build, assertions, admin client
|
|
||||||
traefik.yml # static Traefik config (shared by all scenarios)
|
|
||||||
mocklapi/
|
|
||||||
go.mod # nested module — kept out of the plugin's build/lint/vendor
|
|
||||||
main.go # mock LAPI + AppSec stand-in + backend
|
|
||||||
scenarios/
|
|
||||||
<name>/
|
|
||||||
dynamic.yml # Traefik dynamic config (router + bouncer middleware + backend)
|
|
||||||
run.sh # assertions for the scenario
|
|
||||||
*.html # optional fixtures (ban / captcha templates)
|
|
||||||
```
|
|
||||||
|
|
||||||
`dynamic.yml` uses placeholders (`@@APIKEY@@`, `@@LAPI_HOST@@`,
|
|
||||||
`@@BACKEND_URL@@`, `@@SCENARIO_DIR@@`) that `common.sh` substitutes at runtime.
|
|
||||||
|
|
||||||
## Adding a scenario
|
|
||||||
|
|
||||||
1. Create `scenarios/<name>/dynamic.yml` and `run.sh` (copy `stream-mode/` as a
|
|
||||||
template).
|
|
||||||
2. In `run.sh`, define a `body` function with the assertions and call
|
|
||||||
`run_scenario "<name>" "$HERE" body`.
|
|
||||||
3. Drive decisions with `lapi_add_decision <ip> [type] [duration]` and
|
|
||||||
`lapi_delete_decision <ip>`.
|
|
||||||
4. Add `<name>` to `E2E_MOCK_SCENARIOS` in the `Makefile`.
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Shared helpers for the binary (mock) e2e suite.
|
|
||||||
#
|
|
||||||
# Unlike the Docker suite under tests/e2e/scenarios, this one runs Traefik as a
|
|
||||||
# downloaded binary and replaces Crowdsec with a small HTTP mock (the mocklapi
|
|
||||||
# Go command). It validates the plugin's own behaviour (modes, cache, trusted
|
|
||||||
# IPs, ban / captcha rendering, AppSec wiring) — not the accuracy of Crowdsec's
|
|
||||||
# detection or its WAF engine, which the mock only stands in for.
|
|
||||||
#
|
|
||||||
# Dependencies: bash, curl, go, tar. The Traefik binary is downloaded and the
|
|
||||||
# mock is compiled into .cache/ on first use. That cache persists across local
|
|
||||||
# runs; CI runs on fresh runners, so both are recreated on every CI run.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Pinned to match the Docker suite (tests/e2e/scenarios/*/docker-compose.yml).
|
|
||||||
TRAEFIK_VERSION="${TRAEFIK_VERSION:-v3.7.1}"
|
|
||||||
|
|
||||||
WEB_PORT="${WEB_PORT:-8000}"
|
|
||||||
LAPI_PORT="${LAPI_PORT:-8090}"
|
|
||||||
BACKEND_PORT="${BACKEND_PORT:-8091}"
|
|
||||||
APPSEC_PORT="${APPSEC_PORT:-8092}"
|
|
||||||
LAPI_KEY="${LAPI_KEY:-e2e-mock-key}"
|
|
||||||
|
|
||||||
MOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "$MOCK_LIB_DIR/../../../.." && pwd)"
|
|
||||||
CACHE_DIR="$MOCK_LIB_DIR/../.cache"
|
|
||||||
|
|
||||||
# Populated by start_stack / run_scenario, consumed by the EXIT trap.
|
|
||||||
WORKDIR=""
|
|
||||||
TRAEFIK_PID=""
|
|
||||||
MOCK_PID=""
|
|
||||||
SCENARIO_NAME=""
|
|
||||||
SCENARIO_LOG=""
|
|
||||||
|
|
||||||
# Resolve (and cache) the Traefik binary for this host, echoing its path.
|
|
||||||
ensure_traefik() {
|
|
||||||
local bin="$CACHE_DIR/traefik-$TRAEFIK_VERSION"
|
|
||||||
if [[ -x "$bin" ]]; then
|
|
||||||
echo "$bin"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
mkdir -p "$CACHE_DIR"
|
|
||||||
local os arch
|
|
||||||
case "$(uname -s)" in
|
|
||||||
Linux) os=linux ;;
|
|
||||||
Darwin) os=darwin ;;
|
|
||||||
*) echo "ensure_traefik: unsupported OS $(uname -s)" >&2; return 1 ;;
|
|
||||||
esac
|
|
||||||
case "$(uname -m)" in
|
|
||||||
x86_64 | amd64) arch=amd64 ;;
|
|
||||||
aarch64 | arm64) arch=arm64 ;;
|
|
||||||
*) echo "ensure_traefik: unsupported arch $(uname -m)" >&2; return 1 ;;
|
|
||||||
esac
|
|
||||||
local url="https://github.com/traefik/traefik/releases/download/${TRAEFIK_VERSION}/traefik_${TRAEFIK_VERSION}_${os}_${arch}.tar.gz"
|
|
||||||
echo "ensure_traefik: downloading $url" >&2
|
|
||||||
local tmp
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
curl -sSfL "$url" -o "$tmp/traefik.tar.gz"
|
|
||||||
tar -xzf "$tmp/traefik.tar.gz" -C "$tmp" traefik
|
|
||||||
mv "$tmp/traefik" "$bin"
|
|
||||||
chmod +x "$bin"
|
|
||||||
rm -rf "$tmp"
|
|
||||||
echo "$bin"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build (and cache) the mock LAPI binary, echoing its path. Go's build cache
|
|
||||||
# makes the rebuild near-instant after the first run.
|
|
||||||
ensure_mock() {
|
|
||||||
local bin="$CACHE_DIR/mocklapi"
|
|
||||||
mkdir -p "$CACHE_DIR"
|
|
||||||
( cd "$MOCK_LIB_DIR/../mocklapi" && go build -o "$bin" . ) >&2
|
|
||||||
echo "$bin"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Poll a 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 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
|
|
||||||
# Note: `((elapsed++))` returns exit 1 when elapsed is 0, which trips set -e.
|
|
||||||
elapsed=$((elapsed + 1))
|
|
||||||
done
|
|
||||||
echo "wait_for_status: $url expected $expected, last seen ${got:-<none>}" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Poll a URL until its body contains a substring, or fail. Used when the status
|
|
||||||
# code alone can't tell the states apart (e.g. captcha page vs backend, both 200).
|
|
||||||
# Usage: wait_for_body_contains URL NEEDLE [TIMEOUT_SECONDS] [curl args...]
|
|
||||||
wait_for_body_contains() {
|
|
||||||
local url="$1" needle="$2" timeout="${3:-30}"
|
|
||||||
shift 3 || true
|
|
||||||
local elapsed=0 body=""
|
|
||||||
while (( elapsed < timeout )); do
|
|
||||||
body=$(curl -s "$@" "$url" || true)
|
|
||||||
if grep -q "$needle" <<<"$body"; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
elapsed=$((elapsed + 1))
|
|
||||||
done
|
|
||||||
echo "wait_for_body_contains: $url did not contain \"$needle\" within ${timeout}s" >&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
|
|
||||||
}
|
|
||||||
|
|
||||||
# Assert a response body contains a substring.
|
|
||||||
# Usage: assert_body_contains URL NEEDLE [curl args...]
|
|
||||||
assert_body_contains() {
|
|
||||||
local url="$1" needle="$2"
|
|
||||||
shift 2 || true
|
|
||||||
local body
|
|
||||||
body=$(curl -s "$@" "$url")
|
|
||||||
if ! grep -q "$needle" <<<"$body"; then
|
|
||||||
echo "assert_body_contains: $url expected to contain \"$needle\", got:" >&2
|
|
||||||
echo "$body" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- mock admin client -------------------------------------------------------
|
|
||||||
|
|
||||||
lapi_add_decision() {
|
|
||||||
local ip="$1" type="${2:-ban}" duration="${3:-4h}"
|
|
||||||
curl -sS -X POST "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}&type=${type}&duration=${duration}" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
lapi_delete_decision() {
|
|
||||||
local ip="$1"
|
|
||||||
curl -sS -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- stack lifecycle ---------------------------------------------------------
|
|
||||||
|
|
||||||
# start_stack SCENARIO_DIR
|
|
||||||
# Spins up the mock + Traefik (with the scenario's dynamic.yml) and waits ready.
|
|
||||||
start_stack() {
|
|
||||||
local scenario_dir="$1"
|
|
||||||
local traefik_bin mock_bin
|
|
||||||
traefik_bin="$(ensure_traefik)"
|
|
||||||
mock_bin="$(ensure_mock)"
|
|
||||||
|
|
||||||
WORKDIR="$(mktemp -d)"
|
|
||||||
# Expose the plugin source where Traefik's localPlugins loader expects it.
|
|
||||||
mkdir -p "$WORKDIR/plugins-local/src/github.com/maxlerebourg"
|
|
||||||
ln -s "$REPO_ROOT" "$WORKDIR/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
|
||||||
|
|
||||||
cp "$MOCK_LIB_DIR/traefik.yml" "$WORKDIR/traefik.yml"
|
|
||||||
|
|
||||||
# Render the scenario's dynamic config with the live ports / key / paths.
|
|
||||||
sed \
|
|
||||||
-e "s|@@APIKEY@@|${LAPI_KEY}|g" \
|
|
||||||
-e "s|@@LAPI_HOST@@|127.0.0.1:${LAPI_PORT}|g" \
|
|
||||||
-e "s|@@APPSEC_HOST@@|127.0.0.1:${APPSEC_PORT}|g" \
|
|
||||||
-e "s|@@BACKEND_URL@@|http://127.0.0.1:${BACKEND_PORT}|g" \
|
|
||||||
-e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \
|
|
||||||
"$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml"
|
|
||||||
|
|
||||||
"$mock_bin" \
|
|
||||||
--lapi-addr "127.0.0.1:${LAPI_PORT}" \
|
|
||||||
--backend-addr "127.0.0.1:${BACKEND_PORT}" \
|
|
||||||
--appsec-addr "127.0.0.1:${APPSEC_PORT}" >"$WORKDIR/mock.log" 2>&1 &
|
|
||||||
MOCK_PID=$!
|
|
||||||
|
|
||||||
( cd "$WORKDIR" && exec "$traefik_bin" --configfile=traefik.yml ) >"$WORKDIR/traefik.log" 2>&1 &
|
|
||||||
TRAEFIK_PID=$!
|
|
||||||
|
|
||||||
wait_for_status "http://127.0.0.1:${LAPI_PORT}/health" 200 30
|
|
||||||
# AppSec stand-in: a bare GET carries no "rpc2" URI, so it answers 200 (allow).
|
|
||||||
wait_for_status "http://127.0.0.1:${APPSEC_PORT}/" 200 30
|
|
||||||
# /ping is served by Traefik itself once it is up (plugin compilation included).
|
|
||||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/ping" 200 60
|
|
||||||
}
|
|
||||||
|
|
||||||
stop_stack() {
|
|
||||||
[[ -n "$TRAEFIK_PID" ]] && kill "$TRAEFIK_PID" 2>/dev/null || true
|
|
||||||
[[ -n "$MOCK_PID" ]] && kill "$MOCK_PID" 2>/dev/null || true
|
|
||||||
[[ -n "$TRAEFIK_PID" ]] && wait "$TRAEFIK_PID" 2>/dev/null || true
|
|
||||||
[[ -n "$MOCK_PID" ]] && wait "$MOCK_PID" 2>/dev/null || true
|
|
||||||
[[ -n "$WORKDIR" && -d "$WORKDIR" ]] && rm -rf "$WORKDIR" || true
|
|
||||||
}
|
|
||||||
|
|
||||||
dump_diagnostics() {
|
|
||||||
echo "=== traefik.log ==="
|
|
||||||
cat "$WORKDIR/traefik.log" 2>/dev/null || true
|
|
||||||
echo "=== mock.log ==="
|
|
||||||
cat "$WORKDIR/mock.log" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
# EXIT trap: runs after the scenario body (or after a failed assertion under
|
|
||||||
# `set -e`), so it relies only on globals, never on run_scenario's locals.
|
|
||||||
_scenario_cleanup() {
|
|
||||||
local rc=$?
|
|
||||||
if (( rc != 0 )); then
|
|
||||||
dump_diagnostics > "$SCENARIO_LOG" 2>&1 || true
|
|
||||||
echo "[$SCENARIO_NAME] failed. Logs written to $SCENARIO_LOG" >&2
|
|
||||||
fi
|
|
||||||
stop_stack
|
|
||||||
exit $rc
|
|
||||||
}
|
|
||||||
|
|
||||||
# run_scenario SCENARIO_NAME SCENARIO_DIR BODY_FN
|
|
||||||
# Wraps lifecycle + diagnostics so each run.sh stays declarative.
|
|
||||||
run_scenario() {
|
|
||||||
SCENARIO_NAME="$1"
|
|
||||||
local dir="$2" body="$3"
|
|
||||||
SCENARIO_LOG="/tmp/e2e-mock-${SCENARIO_NAME}.log"
|
|
||||||
trap _scenario_cleanup EXIT
|
|
||||||
|
|
||||||
echo "[$SCENARIO_NAME] starting binary stack (Traefik + mock LAPI)..."
|
|
||||||
start_stack "$dir"
|
|
||||||
"$body"
|
|
||||||
echo "[$SCENARIO_NAME] OK"
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Static Traefik configuration for the binary e2e suite.
|
|
||||||
# The dynamic part (router + bouncer middleware + backend service) lives in
|
|
||||||
# dynamic.yml, generated per scenario by common.sh.
|
|
||||||
entryPoints:
|
|
||||||
web:
|
|
||||||
address: ":8000"
|
|
||||||
forwardedHeaders:
|
|
||||||
# The test's curl sets X-Forwarded-For; preserve it through the proxy.
|
|
||||||
insecure: true
|
|
||||||
|
|
||||||
log:
|
|
||||||
level: INFO
|
|
||||||
|
|
||||||
accessLog: {}
|
|
||||||
|
|
||||||
# /ping on the web entrypoint is the readiness probe — no dashboard/API needed.
|
|
||||||
ping:
|
|
||||||
entryPoint: web
|
|
||||||
|
|
||||||
providers:
|
|
||||||
file:
|
|
||||||
filename: dynamic.yml
|
|
||||||
watch: false
|
|
||||||
|
|
||||||
experimental:
|
|
||||||
localPlugins:
|
|
||||||
bouncer:
|
|
||||||
moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// Standalone module so this test helper stays out of the plugin module:
|
|
||||||
// it is excluded from the plugin's `go build ./...`, `go test ./...`,
|
|
||||||
// golangci-lint and `go mod vendor`. Stdlib only — no dependencies.
|
|
||||||
module mocklapi
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
// Command mocklapi is a minimal Crowdsec LAPI stand-in for the binary e2e
|
|
||||||
// suite. It answers only the few LAPI routes the plugin calls — live/none
|
|
||||||
// decision lookups, the stream poll and the usage-metrics push — and lets the
|
|
||||||
// test drive decisions through /admin instead of `cscli`. It also serves the
|
|
||||||
// stub upstream that Traefik proxies allowed requests to.
|
|
||||||
//
|
|
||||||
// It is NOT a Crowdsec/AppSec conformance harness — the real WAF engine (OWASP
|
|
||||||
// CRS, virtual patching) is out of scope. The AppSec endpoint here emulates a
|
|
||||||
// single deterministic rule so the suite can exercise the plugin's AppSec
|
|
||||||
// wiring (header forwarding, allow/block handling) end to end. See the README.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Decision is the subset of a LAPI decision the plugin actually reads.
|
|
||||||
type Decision struct {
|
|
||||||
Value string `json:"value"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Duration string `json:"duration"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
active = map[string]Decision{} // ip -> decision currently in force
|
|
||||||
deleted = map[string]Decision{} // ip -> decision to report in the stream "deleted" list
|
|
||||||
)
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func list(m map[string]Decision) []Decision {
|
|
||||||
out := make([]Decision, 0, len(m))
|
|
||||||
for _, d := range m {
|
|
||||||
out = append(out, d)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
lapiAddr := flag.String("lapi-addr", "127.0.0.1:8090", "address for the LAPI mock")
|
|
||||||
// The stub upstream Traefik proxies allowed requests to — the binary-suite
|
|
||||||
// equivalent of the traefik/whoami container. Not AppSec.
|
|
||||||
backendAddr := flag.String("backend-addr", "127.0.0.1:8091", "address for the stub upstream service")
|
|
||||||
// AppSec WAF stand-in (the real engine listens on :7422). Not a CRS engine.
|
|
||||||
appsecAddr := flag.String("appsec-addr", "127.0.0.1:8092", "address for the AppSec mock")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
log.Fatal(http.ListenAndServe(*backendAddr, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, _ = w.Write([]byte("E2E_BACKEND_OK\n"))
|
|
||||||
})))
|
|
||||||
}()
|
|
||||||
|
|
||||||
// AppSec mock: the plugin forwards the request metadata in X-Crowdsec-Appsec-*
|
|
||||||
// headers and reads our status — 200 allows, 403 blocks. We emulate one
|
|
||||||
// deterministic virtual-patching rule (block any URI containing "rpc2", the
|
|
||||||
// exact probe from examples/appsec-enabled) so the plugin's AppSec path is
|
|
||||||
// exercised without standing up the real WAF.
|
|
||||||
go func() {
|
|
||||||
log.Fatal(http.ListenAndServe(*appsecAddr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "rpc2") {
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
}
|
|
||||||
})))
|
|
||||||
}()
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
|
|
||||||
// Readiness probe for the test harness (empty body, 200).
|
|
||||||
mux.HandleFunc("/health", func(http.ResponseWriter, *http.Request) {})
|
|
||||||
|
|
||||||
// live / none mode: the plugin asks about one IP and expects a decision
|
|
||||||
// array, or the literal `null` when there is none.
|
|
||||||
mux.HandleFunc("/v1/decisions", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
if d, ok := active[r.URL.Query().Get("ip")]; ok {
|
|
||||||
writeJSON(w, []Decision{d})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = w.Write([]byte("null"))
|
|
||||||
})
|
|
||||||
|
|
||||||
// stream mode: report the whole active set as "new" and anything removed as
|
|
||||||
// "deleted". Re-sending the same on every poll is harmless — the plugin just
|
|
||||||
// re-adds to / re-deletes from its cache.
|
|
||||||
mux.HandleFunc("/v1/decisions/stream", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
writeJSON(w, map[string][]Decision{"new": list(active), "deleted": list(deleted)})
|
|
||||||
})
|
|
||||||
|
|
||||||
// usage-metrics push: accept and ignore.
|
|
||||||
mux.HandleFunc("/v1/usage-metrics", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test control plane: add / remove decisions instead of cscli.
|
|
||||||
mux.HandleFunc("/admin/decisions", func(_ http.ResponseWriter, r *http.Request) {
|
|
||||||
q := r.URL.Query()
|
|
||||||
ip := q.Get("ip")
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodPost:
|
|
||||||
dtype := q.Get("type")
|
|
||||||
if dtype == "" {
|
|
||||||
dtype = "ban"
|
|
||||||
}
|
|
||||||
duration := q.Get("duration")
|
|
||||||
if duration == "" {
|
|
||||||
duration = "4h"
|
|
||||||
}
|
|
||||||
active[ip] = Decision{Value: ip, Type: dtype, Duration: duration}
|
|
||||||
delete(deleted, ip)
|
|
||||||
case http.MethodDelete:
|
|
||||||
if d, ok := active[ip]; ok {
|
|
||||||
deleted[ip] = d
|
|
||||||
delete(active, ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s", *lapiAddr, *backendAddr, *appsecAddr)
|
|
||||||
log.Fatal(http.ListenAndServe(*lapiAddr, mux))
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
http:
|
|
||||||
routers:
|
|
||||||
r:
|
|
||||||
rule: "PathPrefix(`/foo`)"
|
|
||||||
entryPoints:
|
|
||||||
- web
|
|
||||||
service: backend
|
|
||||||
middlewares:
|
|
||||||
- bouncer
|
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
loadBalancer:
|
|
||||||
servers:
|
|
||||||
- url: "@@BACKEND_URL@@"
|
|
||||||
middlewares:
|
|
||||||
bouncer:
|
|
||||||
plugin:
|
|
||||||
bouncer:
|
|
||||||
enabled: "true"
|
|
||||||
# IP bouncing disabled — this scenario exercises AppSec only.
|
|
||||||
crowdsecMode: none
|
|
||||||
crowdsecLapiScheme: http
|
|
||||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
|
||||||
crowdsecLapiKey: "@@APIKEY@@"
|
|
||||||
crowdsecAppsecEnabled: "true"
|
|
||||||
crowdsecAppsecScheme: http
|
|
||||||
crowdsecAppsecHost: "@@APPSEC_HOST@@"
|
|
||||||
forwardedHeadersTrustedIps:
|
|
||||||
- "127.0.0.1/32"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
# shellcheck source=../../lib/common.sh
|
|
||||||
source "$HERE/../../lib/common.sh"
|
|
||||||
|
|
||||||
SCENARIO=appsec
|
|
||||||
|
|
||||||
# AppSec wiring check: the plugin forwards each request to the AppSec engine and
|
|
||||||
# enforces its verdict. The mock emulates one virtual-patching rule (block any
|
|
||||||
# URI containing "rpc2"), mirroring examples/appsec-enabled. This proves the
|
|
||||||
# plugin's AppSec path end to end (header forwarding + allow/block handling); it
|
|
||||||
# does not test the real WAF's detection accuracy.
|
|
||||||
body() {
|
|
||||||
echo "[$SCENARIO] benign request must pass (AppSec allows)"
|
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
|
|
||||||
echo "[$SCENARIO] request whose URI contains 'rpc2' must be blocked (AppSec 403)"
|
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo/rpc2" 403 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_scenario "$SCENARIO" "$HERE" body
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
http:
|
|
||||||
routers:
|
|
||||||
r:
|
|
||||||
rule: "PathPrefix(`/foo`)"
|
|
||||||
entryPoints:
|
|
||||||
- web
|
|
||||||
service: backend
|
|
||||||
middlewares:
|
|
||||||
- bouncer
|
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
loadBalancer:
|
|
||||||
servers:
|
|
||||||
- url: "@@BACKEND_URL@@"
|
|
||||||
middlewares:
|
|
||||||
bouncer:
|
|
||||||
plugin:
|
|
||||||
bouncer:
|
|
||||||
enabled: "true"
|
|
||||||
crowdsecMode: stream
|
|
||||||
updateIntervalSeconds: "2"
|
|
||||||
crowdsecLapiScheme: http
|
|
||||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
|
||||||
crowdsecLapiKey: "@@APIKEY@@"
|
|
||||||
forwardedHeadersTrustedIps:
|
|
||||||
- "127.0.0.1/32"
|
|
||||||
captchaProvider: turnstile
|
|
||||||
# Cloudflare Turnstile public test keys: render a valid widget without
|
|
||||||
# contacting a real API key.
|
|
||||||
captchaSiteKey: "1x00000000000000000000AA"
|
|
||||||
captchaSecretKey: "1x0000000000000000000000000000000AA"
|
|
||||||
captchaHtmlFilePath: "@@SCENARIO_DIR@@/captcha.html"
|
|
||||||
captchaGracePeriodSeconds: "10"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
# shellcheck source=../../lib/common.sh
|
|
||||||
source "$HERE/../../lib/common.sh"
|
|
||||||
|
|
||||||
SCENARIO=captcha
|
|
||||||
|
|
||||||
body() {
|
|
||||||
echo "[$SCENARIO] adding captcha decision for 1.2.3.4"
|
|
||||||
lapi_add_decision 1.2.3.4 captcha 5m
|
|
||||||
|
|
||||||
# Status stays 200 before/after (captcha page vs backend), so gate on the body
|
|
||||||
# marker appearing once the captcha decision has been polled.
|
|
||||||
echo "[$SCENARIO] captcha page must be served once the decision is polled (200 + marker)"
|
|
||||||
wait_for_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CAPTCHA_PAGE_MARKER" 15 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
|
|
||||||
echo "[$SCENARIO] captcha response is HTTP 200 (the captcha page itself, not a 403)"
|
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
|
|
||||||
echo "[$SCENARIO] non-flagged IP must still pass through to the backend"
|
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_scenario "$SCENARIO" "$HERE" body
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
http:
|
|
||||||
routers:
|
|
||||||
r:
|
|
||||||
rule: "PathPrefix(`/foo`)"
|
|
||||||
entryPoints:
|
|
||||||
- web
|
|
||||||
service: backend
|
|
||||||
middlewares:
|
|
||||||
- bouncer
|
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
loadBalancer:
|
|
||||||
servers:
|
|
||||||
- url: "@@BACKEND_URL@@"
|
|
||||||
middlewares:
|
|
||||||
bouncer:
|
|
||||||
plugin:
|
|
||||||
bouncer:
|
|
||||||
enabled: "true"
|
|
||||||
crowdsecMode: stream
|
|
||||||
updateIntervalSeconds: "2"
|
|
||||||
crowdsecLapiScheme: http
|
|
||||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
|
||||||
crowdsecLapiKey: "@@APIKEY@@"
|
|
||||||
forwardedHeadersTrustedIps:
|
|
||||||
- "127.0.0.1/32"
|
|
||||||
banHtmlFilePath: "@@SCENARIO_DIR@@/ban.html"
|
|
||||||
remediationHeadersCustomName: "X-E2E-Remediation"
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/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
|
|
||||||
|
|
||||||
body() {
|
|
||||||
echo "[$SCENARIO] adding ban decision"
|
|
||||||
lapi_add_decision 1.2.3.4 ban 5m
|
|
||||||
|
|
||||||
echo "[$SCENARIO] banned response becomes 403 once the next stream poll lands"
|
|
||||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
|
|
||||||
echo "[$SCENARIO] banned response Content-Type is HTML"
|
|
||||||
assert_header "http://127.0.0.1:${WEB_PORT}/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"
|
|
||||||
assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CUSTOM_BAN_PAGE_MARKER" -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
|
|
||||||
echo "[$SCENARIO] banned response carries the custom remediation header (remediationHeadersCustomName)"
|
|
||||||
assert_header "http://127.0.0.1:${WEB_PORT}/foo" X-E2E-Remediation "ban" -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_scenario "$SCENARIO" "$HERE" body
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
http:
|
|
||||||
routers:
|
|
||||||
r:
|
|
||||||
rule: "PathPrefix(`/foo`)"
|
|
||||||
entryPoints:
|
|
||||||
- web
|
|
||||||
service: backend
|
|
||||||
middlewares:
|
|
||||||
- bouncer
|
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
loadBalancer:
|
|
||||||
servers:
|
|
||||||
- url: "@@BACKEND_URL@@"
|
|
||||||
middlewares:
|
|
||||||
bouncer:
|
|
||||||
plugin:
|
|
||||||
bouncer:
|
|
||||||
enabled: "true"
|
|
||||||
crowdsecMode: live
|
|
||||||
defaultDecisionSeconds: "2"
|
|
||||||
crowdsecLapiScheme: http
|
|
||||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
|
||||||
crowdsecLapiKey: "@@APIKEY@@"
|
|
||||||
forwardedHeadersTrustedIps:
|
|
||||||
- "127.0.0.1/32"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
#!/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
|
|
||||||
|
|
||||||
body() {
|
|
||||||
echo "[$SCENARIO] no decision -> first hit queries LAPI, returns 200, caches 'allowed' for 2s"
|
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
|
|
||||||
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
|
|
||||||
lapi_add_decision 1.2.3.4 ban 5m
|
|
||||||
|
|
||||||
# Stays 200 until the cached 'allowed' (defaultDecisionSeconds) expires, then
|
|
||||||
# the re-query sees the ban — poll instead of guessing the cache TTL.
|
|
||||||
echo "[$SCENARIO] hit must turn 403 once the cached 'allowed' expires and LAPI is re-queried"
|
|
||||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
|
|
||||||
echo "[$SCENARIO] another non-banned IP must still pass"
|
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_scenario "$SCENARIO" "$HERE" body
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
http:
|
|
||||||
routers:
|
|
||||||
r:
|
|
||||||
rule: "PathPrefix(`/foo`)"
|
|
||||||
entryPoints:
|
|
||||||
- web
|
|
||||||
service: backend
|
|
||||||
middlewares:
|
|
||||||
- bouncer
|
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
loadBalancer:
|
|
||||||
servers:
|
|
||||||
- url: "@@BACKEND_URL@@"
|
|
||||||
middlewares:
|
|
||||||
bouncer:
|
|
||||||
plugin:
|
|
||||||
bouncer:
|
|
||||||
enabled: "true"
|
|
||||||
crowdsecMode: none
|
|
||||||
crowdsecLapiScheme: http
|
|
||||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
|
||||||
crowdsecLapiKey: "@@APIKEY@@"
|
|
||||||
forwardedHeadersTrustedIps:
|
|
||||||
- "127.0.0.1/32"
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/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
|
|
||||||
|
|
||||||
body() {
|
|
||||||
echo "[$SCENARIO] no decision -> request passes (LAPI queried per request)"
|
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
|
|
||||||
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
|
|
||||||
lapi_add_decision 1.2.3.4 ban 5m
|
|
||||||
|
|
||||||
echo "[$SCENARIO] none mode has no cache -> next request must be blocked immediately"
|
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
|
|
||||||
echo "[$SCENARIO] deleting decision"
|
|
||||||
lapi_delete_decision 1.2.3.4
|
|
||||||
|
|
||||||
echo "[$SCENARIO] previously banned IP must pass again immediately"
|
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_scenario "$SCENARIO" "$HERE" body
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
http:
|
|
||||||
routers:
|
|
||||||
r:
|
|
||||||
rule: "PathPrefix(`/foo`)"
|
|
||||||
entryPoints:
|
|
||||||
- web
|
|
||||||
service: backend
|
|
||||||
middlewares:
|
|
||||||
- bouncer
|
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
loadBalancer:
|
|
||||||
servers:
|
|
||||||
- url: "@@BACKEND_URL@@"
|
|
||||||
middlewares:
|
|
||||||
bouncer:
|
|
||||||
plugin:
|
|
||||||
bouncer:
|
|
||||||
enabled: "true"
|
|
||||||
crowdsecMode: stream
|
|
||||||
updateIntervalSeconds: "2"
|
|
||||||
crowdsecLapiScheme: http
|
|
||||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
|
||||||
crowdsecLapiKey: "@@APIKEY@@"
|
|
||||||
forwardedHeadersTrustedIps:
|
|
||||||
- "127.0.0.1/32"
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
#!/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
|
|
||||||
|
|
||||||
body() {
|
|
||||||
echo "[$SCENARIO] no decision yet -> request allowed"
|
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
|
|
||||||
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
|
|
||||||
lapi_add_decision 1.2.3.4 ban 5m
|
|
||||||
|
|
||||||
echo "[$SCENARIO] banned IP must be blocked once the next stream poll lands (HTTP 403)"
|
|
||||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
|
|
||||||
echo "[$SCENARIO] non-banned IP must still pass (HTTP 200)"
|
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
|
|
||||||
|
|
||||||
echo "[$SCENARIO] deleting ban decision"
|
|
||||||
lapi_delete_decision 1.2.3.4
|
|
||||||
|
|
||||||
echo "[$SCENARIO] previously banned IP must pass again once the deletion is polled"
|
|
||||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_scenario "$SCENARIO" "$HERE" body
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
http:
|
|
||||||
routers:
|
|
||||||
r:
|
|
||||||
rule: "PathPrefix(`/foo`)"
|
|
||||||
entryPoints:
|
|
||||||
- web
|
|
||||||
service: backend
|
|
||||||
middlewares:
|
|
||||||
- bouncer
|
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
loadBalancer:
|
|
||||||
servers:
|
|
||||||
- url: "@@BACKEND_URL@@"
|
|
||||||
middlewares:
|
|
||||||
bouncer:
|
|
||||||
plugin:
|
|
||||||
bouncer:
|
|
||||||
enabled: "true"
|
|
||||||
crowdsecMode: stream
|
|
||||||
updateIntervalSeconds: "2"
|
|
||||||
crowdsecLapiScheme: http
|
|
||||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
|
||||||
crowdsecLapiKey: "@@APIKEY@@"
|
|
||||||
forwardedHeadersTrustedIps:
|
|
||||||
- "127.0.0.1/32"
|
|
||||||
clientTrustedIps:
|
|
||||||
- "1.2.3.4/32"
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/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
|
|
||||||
|
|
||||||
body() {
|
|
||||||
echo "[$SCENARIO] banning the trusted IP 1.2.3.4 and an untrusted IP 5.6.7.8"
|
|
||||||
lapi_add_decision 1.2.3.4 ban 5m
|
|
||||||
lapi_add_decision 5.6.7.8 ban 5m
|
|
||||||
|
|
||||||
# The untrusted IP turning 403 is our signal that the bans have been polled;
|
|
||||||
# it also doubles as the control proving the bouncer is active.
|
|
||||||
echo "[$SCENARIO] untrusted banned IP must be blocked once the bans are polled (HTTP 403)"
|
|
||||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 5.6.7.8"
|
|
||||||
|
|
||||||
echo "[$SCENARIO] trusted IP must bypass the bouncer even though it is banned"
|
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_scenario "$SCENARIO" "$HERE" body
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
listen_addr: 0.0.0.0:7422
|
||||||
|
appsec_config: crowdsecurity/crs-inband
|
||||||
|
name: e2eAppSec
|
||||||
|
source: appsec
|
||||||
|
labels:
|
||||||
|
type: appsec
|
||||||
@@ -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
|
||||||
Executable
+40
@@ -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"
|
||||||
@@ -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
|
||||||
Executable
+53
@@ -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"
|
||||||
@@ -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
|
||||||
Executable
+53
@@ -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"
|
||||||
@@ -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
|
||||||
Executable
+48
@@ -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"
|
||||||
@@ -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
|
||||||
Executable
+48
@@ -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"
|
||||||
@@ -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
|
||||||
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,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
|
||||||
Executable
+46
@@ -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"
|
||||||
Reference in New Issue
Block a user