diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..cea8880 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,42 @@ +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 diff --git a/.gitignore b/.gitignore index f635f65..2bad5b6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ conf db logs 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/ diff --git a/Makefile b/Makefile index 4785399..2581487 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,11 @@ -.PHONY: lint test vendor clean +.PHONY: lint test vendor clean e2e_mock export GO111MODULE=on +# Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs. +# 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 lint: @@ -13,6 +17,11 @@ test: yaegi_test: yaegi test -v . +e2e_mock: $(addprefix e2e_mock_,$(E2E_MOCK_SCENARIOS)) + +e2e_mock_%: + ./tests/e2e/mock/scenarios/$*/run.sh + vendor: go mod vendor diff --git a/tests/e2e/mock/README.md b/tests/e2e/mock/README.md new file mode 100644 index 0000000..86999e1 --- /dev/null +++ b/tests/e2e/mock/README.md @@ -0,0 +1,84 @@ +# 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/ + / + 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//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 "" "$HERE" body`. +3. Drive decisions with `lapi_add_decision [type] [duration]` and + `lapi_delete_decision `. +4. Add `` to `E2E_MOCK_SCENARIOS` in the `Makefile`. diff --git a/tests/e2e/mock/lib/common.sh b/tests/e2e/mock/lib/common.sh new file mode 100644 index 0000000..1431bd1 --- /dev/null +++ b/tests/e2e/mock/lib/common.sh @@ -0,0 +1,248 @@ +#!/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:-}" >&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" +} diff --git a/tests/e2e/mock/lib/traefik.yml b/tests/e2e/mock/lib/traefik.yml new file mode 100644 index 0000000..3116458 --- /dev/null +++ b/tests/e2e/mock/lib/traefik.yml @@ -0,0 +1,28 @@ +# 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 diff --git a/tests/e2e/mock/mocklapi/go.mod b/tests/e2e/mock/mocklapi/go.mod new file mode 100644 index 0000000..bd05338 --- /dev/null +++ b/tests/e2e/mock/mocklapi/go.mod @@ -0,0 +1,6 @@ +// 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 diff --git a/tests/e2e/mock/mocklapi/main.go b/tests/e2e/mock/mocklapi/main.go new file mode 100644 index 0000000..32a3631 --- /dev/null +++ b/tests/e2e/mock/mocklapi/main.go @@ -0,0 +1,135 @@ +// 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)) +} diff --git a/tests/e2e/mock/scenarios/appsec/dynamic.yml b/tests/e2e/mock/scenarios/appsec/dynamic.yml new file mode 100644 index 0000000..05fa8d9 --- /dev/null +++ b/tests/e2e/mock/scenarios/appsec/dynamic.yml @@ -0,0 +1,29 @@ +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" diff --git a/tests/e2e/mock/scenarios/appsec/run.sh b/tests/e2e/mock/scenarios/appsec/run.sh new file mode 100755 index 0000000..1e1d2bd --- /dev/null +++ b/tests/e2e/mock/scenarios/appsec/run.sh @@ -0,0 +1,23 @@ +#!/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 diff --git a/tests/e2e/mock/scenarios/captcha/captcha.html b/tests/e2e/mock/scenarios/captcha/captcha.html new file mode 100644 index 0000000..94736cc --- /dev/null +++ b/tests/e2e/mock/scenarios/captcha/captcha.html @@ -0,0 +1,9 @@ + + +E2E captcha marker + +

E2E_CAPTCHA_PAGE_MARKER

+ +
+ + diff --git a/tests/e2e/mock/scenarios/captcha/dynamic.yml b/tests/e2e/mock/scenarios/captcha/dynamic.yml new file mode 100644 index 0000000..b204df4 --- /dev/null +++ b/tests/e2e/mock/scenarios/captcha/dynamic.yml @@ -0,0 +1,33 @@ +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" diff --git a/tests/e2e/mock/scenarios/captcha/run.sh b/tests/e2e/mock/scenarios/captcha/run.sh new file mode 100755 index 0000000..86ed301 --- /dev/null +++ b/tests/e2e/mock/scenarios/captcha/run.sh @@ -0,0 +1,26 @@ +#!/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 diff --git a/tests/e2e/mock/scenarios/custom-ban-page/ban.html b/tests/e2e/mock/scenarios/custom-ban-page/ban.html new file mode 100644 index 0000000..62c0fe3 --- /dev/null +++ b/tests/e2e/mock/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/mock/scenarios/custom-ban-page/dynamic.yml b/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml new file mode 100644 index 0000000..2bfbdf6 --- /dev/null +++ b/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml @@ -0,0 +1,28 @@ +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" diff --git a/tests/e2e/mock/scenarios/custom-ban-page/run.sh b/tests/e2e/mock/scenarios/custom-ban-page/run.sh new file mode 100755 index 0000000..c12b217 --- /dev/null +++ b/tests/e2e/mock/scenarios/custom-ban-page/run.sh @@ -0,0 +1,27 @@ +#!/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 diff --git a/tests/e2e/mock/scenarios/live-mode/dynamic.yml b/tests/e2e/mock/scenarios/live-mode/dynamic.yml new file mode 100644 index 0000000..ac84c99 --- /dev/null +++ b/tests/e2e/mock/scenarios/live-mode/dynamic.yml @@ -0,0 +1,26 @@ +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" diff --git a/tests/e2e/mock/scenarios/live-mode/run.sh b/tests/e2e/mock/scenarios/live-mode/run.sh new file mode 100755 index 0000000..2f90dd4 --- /dev/null +++ b/tests/e2e/mock/scenarios/live-mode/run.sh @@ -0,0 +1,26 @@ +#!/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 diff --git a/tests/e2e/mock/scenarios/none-mode/dynamic.yml b/tests/e2e/mock/scenarios/none-mode/dynamic.yml new file mode 100644 index 0000000..2ec7fe5 --- /dev/null +++ b/tests/e2e/mock/scenarios/none-mode/dynamic.yml @@ -0,0 +1,25 @@ +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" diff --git a/tests/e2e/mock/scenarios/none-mode/run.sh b/tests/e2e/mock/scenarios/none-mode/run.sh new file mode 100755 index 0000000..2afc06a --- /dev/null +++ b/tests/e2e/mock/scenarios/none-mode/run.sh @@ -0,0 +1,27 @@ +#!/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 diff --git a/tests/e2e/mock/scenarios/stream-mode/dynamic.yml b/tests/e2e/mock/scenarios/stream-mode/dynamic.yml new file mode 100644 index 0000000..302ea1b --- /dev/null +++ b/tests/e2e/mock/scenarios/stream-mode/dynamic.yml @@ -0,0 +1,26 @@ +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" diff --git a/tests/e2e/mock/scenarios/stream-mode/run.sh b/tests/e2e/mock/scenarios/stream-mode/run.sh new file mode 100755 index 0000000..c584502 --- /dev/null +++ b/tests/e2e/mock/scenarios/stream-mode/run.sh @@ -0,0 +1,30 @@ +#!/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 diff --git a/tests/e2e/mock/scenarios/trusted-ips/dynamic.yml b/tests/e2e/mock/scenarios/trusted-ips/dynamic.yml new file mode 100644 index 0000000..ea51d62 --- /dev/null +++ b/tests/e2e/mock/scenarios/trusted-ips/dynamic.yml @@ -0,0 +1,28 @@ +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" diff --git a/tests/e2e/mock/scenarios/trusted-ips/run.sh b/tests/e2e/mock/scenarios/trusted-ips/run.sh new file mode 100755 index 0000000..4031c42 --- /dev/null +++ b/tests/e2e/mock/scenarios/trusted-ips/run.sh @@ -0,0 +1,24 @@ +#!/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