From b2011438447af0476709c57cd9c3a9673992790b Mon Sep 17 00:00:00 2001 From: mhx Date: Fri, 5 Jun 2026 12:48:58 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20tests:=20add=20binary=20e2e=20suite?= =?UTF-8?q?=20(Traefik=20binary=20+=20Go=20mock=20LAPI),=20run=20it=20in?= =?UTF-8?q?=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a second e2e suite that runs Traefik as a downloaded binary with the plugin loaded from source, and replaces Crowdsec with a small stdlib-only Go LAPI mock driven via /admin endpoints. No Docker, no real Crowdsec. The mock lives in its own nested Go module (tests/e2e/mock/mocklapi) so it stays out of the plugin module's build, lint, test and vendor. This suite validates the plugin's own behaviour (live/none/stream modes, caching, trusted-IP bypass, ban/captcha rendering). Crowdsec and AppSec correctness are out of scope on purpose — they are validated upstream by the maintainer — so the AppSec scenario is intentionally absent and the README says so to avoid misfiled issues. CI now runs this suite only (`make e2e_mock`), since it needs neither Docker nor a real Crowdsec. The Docker suite (tests/e2e/scenarios) is kept for local debugging (`make e2e`). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/e2e.yml | 24 +- Makefile | 10 +- tests/e2e/README.md | 20 +- tests/e2e/mock/.gitignore | 2 + tests/e2e/mock/README.md | 82 +++++++ tests/e2e/mock/lib/common.sh | 227 +++++++++++++++++ tests/e2e/mock/lib/traefik.yml | 28 +++ tests/e2e/mock/mocklapi/go.mod | 6 + tests/e2e/mock/mocklapi/main.go | 232 ++++++++++++++++++ tests/e2e/mock/scenarios/captcha/captcha.html | 9 + tests/e2e/mock/scenarios/captcha/dynamic.yml | 33 +++ tests/e2e/mock/scenarios/captcha/run.sh | 27 ++ .../mock/scenarios/custom-ban-page/ban.html | 8 + .../scenarios/custom-ban-page/dynamic.yml | 27 ++ .../e2e/mock/scenarios/custom-ban-page/run.sh | 27 ++ .../e2e/mock/scenarios/live-mode/dynamic.yml | 26 ++ tests/e2e/mock/scenarios/live-mode/run.sh | 27 ++ .../e2e/mock/scenarios/none-mode/dynamic.yml | 25 ++ tests/e2e/mock/scenarios/none-mode/run.sh | 27 ++ .../mock/scenarios/stream-mode/dynamic.yml | 26 ++ tests/e2e/mock/scenarios/stream-mode/run.sh | 36 +++ .../mock/scenarios/trusted-ips/dynamic.yml | 28 +++ tests/e2e/mock/scenarios/trusted-ips/run.sh | 25 ++ 23 files changed, 967 insertions(+), 15 deletions(-) create mode 100644 tests/e2e/mock/.gitignore create mode 100644 tests/e2e/mock/README.md create mode 100644 tests/e2e/mock/lib/common.sh create mode 100644 tests/e2e/mock/lib/traefik.yml create mode 100644 tests/e2e/mock/mocklapi/go.mod create mode 100644 tests/e2e/mock/mocklapi/main.go create mode 100644 tests/e2e/mock/scenarios/captcha/captcha.html create mode 100644 tests/e2e/mock/scenarios/captcha/dynamic.yml create mode 100755 tests/e2e/mock/scenarios/captcha/run.sh create mode 100644 tests/e2e/mock/scenarios/custom-ban-page/ban.html create mode 100644 tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml create mode 100755 tests/e2e/mock/scenarios/custom-ban-page/run.sh create mode 100644 tests/e2e/mock/scenarios/live-mode/dynamic.yml create mode 100755 tests/e2e/mock/scenarios/live-mode/run.sh create mode 100644 tests/e2e/mock/scenarios/none-mode/dynamic.yml create mode 100755 tests/e2e/mock/scenarios/none-mode/run.sh create mode 100644 tests/e2e/mock/scenarios/stream-mode/dynamic.yml create mode 100755 tests/e2e/mock/scenarios/stream-mode/run.sh create mode 100644 tests/e2e/mock/scenarios/trusted-ips/dynamic.yml create mode 100755 tests/e2e/mock/scenarios/trusted-ips/run.sh diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0d2f459..23e0968 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -14,21 +14,27 @@ permissions: jobs: e2e: - name: e2e + name: e2e (binary + mock LAPI) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - # A single runner runs every scenario sequentially. Docker caches the - # Traefik/Crowdsec/whoami images locally, so they are pulled only once - # for the whole suite instead of once per scenario. `-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 scenarios - run: make -k e2e + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.23" + # 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-*.log + path: /tmp/e2e-mock-*.log if-no-files-found: ignore diff --git a/Makefile b/Makefile index e02f58b..c9265b7 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,11 @@ -.PHONY: lint test vendor clean e2e +.PHONY: lint test vendor clean e2e e2e_mock export GO111MODULE=on +# Docker suite (real Traefik + Crowdsec). Kept for local debugging. E2E_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec +# Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs. +E2E_MOCK_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha default: lint test @@ -20,6 +23,11 @@ e2e: $(addprefix e2e_,$(E2E_SCENARIOS)) e2e_%: ./tests/e2e/scenarios/$*/run.sh +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/README.md b/tests/e2e/README.md index a707288..258a1ef 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1,5 +1,16 @@ # End-to-end test suite +There are two suites: + +- **`scenarios/` (this one)** — real Traefik + Crowdsec **Docker** containers. + High fidelity, kept for **local debugging**. Run with `make e2e`. +- **[`mock/`](mock/README.md)** — Traefik **binary** + a **mock LAPI**, no + Docker. This is what **CI runs** (`make e2e_mock`). It validates the + plugin's own behaviour; Crowdsec / AppSec correctness is out of scope there + (that is the upstream maintainer's responsibility). + +The rest of this document covers the Docker 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. @@ -45,8 +56,7 @@ images are downloaded only once for the whole suite. ## CI -`.github/workflows/e2e.yml` runs the whole suite in a single job -(`make -k e2e`) on every PR and push to `main`. `-k` lets the remaining -scenarios run after a failure so the logs cover all of them, while make -still exits non-zero if any scenario failed. On failure, the per-scenario -logs (`/tmp/e2e-*.log`) are uploaded as an artifact named `e2e-logs`. +This Docker suite is **not** run in CI — it is meant for local debugging. +`.github/workflows/e2e.yml` runs the [`mock/`](mock/README.md) suite +(`make -k e2e_mock`) instead, which needs neither Docker nor a real Crowdsec. +Run this suite locally with `make e2e` (or `make e2e_`). diff --git a/tests/e2e/mock/.gitignore b/tests/e2e/mock/.gitignore new file mode 100644 index 0000000..c87aec2 --- /dev/null +++ b/tests/e2e/mock/.gitignore @@ -0,0 +1,2 @@ +# Cached Traefik binary and compiled mock, produced on first run. +.cache/ diff --git a/tests/e2e/mock/README.md b/tests/e2e/mock/README.md new file mode 100644 index 0000000..ca626a5 --- /dev/null +++ b/tests/e2e/mock/README.md @@ -0,0 +1,82 @@ +# 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`). The Docker suite in +[`../scenarios`](../scenarios) is kept for local debugging against a real +Crowdsec, but is not exercised in CI. + +## Scope — what this suite does and does NOT test + +These tests validate the **plugin's own behaviour**: the request flow through +the Traefik middleware, the live / none / stream modes, caching, trusted-IP +bypass, and ban / captcha page rendering. + +They deliberately **do not** test that Crowdsec or its AppSec engine work +correctly — that is validated and owned by the upstream maintainer +([@maxlerebourg](https://github.com/maxlerebourg)), not by this plugin. The +mock only emulates the slice of the LAPI HTTP contract the plugin consumes. + +So please **don't open issues here about Crowdsec/AppSec detection accuracy** +based on this suite: the AppSec scenario is intentionally absent, and the mock +returns whatever decisions the test tells it to. + +## What runs + +| Component | How | +|-----------|-----| +| Traefik | Binary `v3.7.1`, downloaded once and cached under `.cache/` | +| 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` | +| Backend | A plain HTTP responder built into the mock | + +Fixed ports (override with env vars if needed): Traefik `8000`, LAPI `8090`, +backend `8091`. + +## Running locally + +Prerequisites: `bash`, `curl`, `go`, `tar`. The Traefik binary is fetched and +the mock is compiled automatically on first run (both cached under `.cache/`). + +```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 + 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]`, + `lapi_delete_decision `, `lapi_reset`. +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..fdf4cb1 --- /dev/null +++ b/tests/e2e/mock/lib/common.sh @@ -0,0 +1,227 @@ +#!/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), NOT Crowdsec or AppSec correctness. +# +# Dependencies: bash, curl, go, tar. The Traefik binary is downloaded and the +# mock is compiled on first run (both cached), so local and CI behave the same. + +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}" +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 +} + +# 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 +} + +lapi_reset() { + curl -sS -X POST "http://127.0.0.1:${LAPI_PORT}/admin/reset" >/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|@@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}" \ + --api-key "${LAPI_KEY}" >"$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 + # /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..ce26619 --- /dev/null +++ b/tests/e2e/mock/mocklapi/main.go @@ -0,0 +1,232 @@ +// Command mocklapi is a minimal Crowdsec LAPI mock for the binary e2e suite. +// +// It speaks just enough of the LAPI HTTP contract for the plugin to exercise +// its own logic — live/none queries, the stream delta protocol and the +// usage-metrics push — without running a real Crowdsec. Decisions are driven +// from the test via the /admin endpoints instead of `cscli`. +// +// This is deliberately NOT a Crowdsec/AppSec conformance harness: validating +// that Crowdsec or its AppSec engine behave correctly is the upstream +// maintainer's responsibility, not this plugin's. See the suite README. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "sync" +) + +const lapiKeyHeader = "X-Api-Key" + +// Decision mirrors the subset of the LAPI decision object the plugin reads. +type Decision struct { + ID int `json:"id"` + Origin string `json:"origin"` + Type string `json:"type"` + Scope string `json:"scope"` + Value string `json:"value"` + Duration string `json:"duration"` + Scenario string `json:"scenario"` +} + +// store holds the active decisions and the stream delta bookkeeping. +type store struct { + mu sync.Mutex + decisions map[string]Decision + streamed map[string]struct{} // values already advertised to the stream consumer + nextID int +} + +func newStore() *store { + return &store{ + decisions: map[string]Decision{}, + streamed: map[string]struct{}{}, + nextID: 1, + } +} + +func (s *store) add(ip, dtype, duration string) { + s.mu.Lock() + defer s.mu.Unlock() + s.decisions[ip] = Decision{ + ID: s.nextID, + Origin: "cscli", + Type: dtype, + Scope: "Ip", + Value: ip, + Duration: duration, + Scenario: "e2e", + } + s.nextID++ +} + +func (s *store) delete(ip string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.decisions, ip) +} + +func (s *store) reset() { + s.mu.Lock() + defer s.mu.Unlock() + s.decisions = map[string]Decision{} + s.streamed = map[string]struct{}{} +} + +func (s *store) get(ip string) (Decision, bool) { + s.mu.Lock() + defer s.mu.Unlock() + d, ok := s.decisions[ip] + return d, ok +} + +// stream computes the new/deleted delta since the last poll. On startup the +// consumer expects the full current set as "new". +func (s *store) stream(startup bool) map[string][]Decision { + s.mu.Lock() + defer s.mu.Unlock() + newd := []Decision{} + deleted := []Decision{} + for ip, d := range s.decisions { + if startup { + newd = append(newd, d) + continue + } + if _, seen := s.streamed[ip]; !seen { + newd = append(newd, d) + } + } + if !startup { + for ip := range s.streamed { + if _, active := s.decisions[ip]; !active { + deleted = append(deleted, Decision{Value: ip, Scope: "Ip", Type: "ban"}) + } + } + } + s.streamed = map[string]struct{}{} + for ip := range s.decisions { + s.streamed[ip] = struct{}{} + } + return map[string][]Decision{"new": newd, "deleted": deleted} +} + +func writeJSON(w http.ResponseWriter, code int, payload any) { + body, _ := json.Marshal(payload) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _, _ = w.Write(body) +} + +func lapiHandler(s *store, apiKey string) http.Handler { + mux := http.NewServeMux() + + authorized := func(r *http.Request) bool { + return r.Header.Get(lapiKeyHeader) == apiKey + } + + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + + mux.HandleFunc("/v1/decisions/stream", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + writeJSON(w, http.StatusForbidden, map[string]string{"message": "access forbidden"}) + return + } + startup := r.URL.Query().Get("startup") == "true" + writeJSON(w, http.StatusOK, s.stream(startup)) + }) + + mux.HandleFunc("/v1/decisions", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + writeJSON(w, http.StatusForbidden, map[string]string{"message": "access forbidden"}) + return + } + ip := r.URL.Query().Get("ip") + if d, ok := s.get(ip); ok { + writeJSON(w, http.StatusOK, []Decision{d}) + return + } + // LAPI returns the literal `null` when no decision matches. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("null")) + }) + + mux.HandleFunc("/v1/usage-metrics", func(w http.ResponseWriter, r *http.Request) { + // Accept and ignore — we only assert the plugin can push without erroring. + if !authorized(r) { + writeJSON(w, http.StatusForbidden, map[string]string{"message": "access forbidden"}) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"message": "ok"}) + }) + + mux.HandleFunc("/admin/decisions", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + ip := q.Get("ip") + switch r.Method { + case http.MethodPost: + if ip == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"message": "missing ip"}) + return + } + dtype := q.Get("type") + if dtype == "" { + dtype = "ban" + } + duration := q.Get("duration") + if duration == "" { + duration = "4h" + } + s.add(ip, dtype, duration) + writeJSON(w, http.StatusOK, map[string]string{"message": "added", "ip": ip, "type": dtype}) + case http.MethodDelete: + s.delete(ip) + writeJSON(w, http.StatusOK, map[string]string{"message": "deleted", "ip": ip}) + default: + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"message": "method not allowed"}) + } + }) + + mux.HandleFunc("/admin/reset", func(w http.ResponseWriter, _ *http.Request) { + s.reset() + writeJSON(w, http.StatusOK, map[string]string{"message": "reset"}) + }) + + return mux +} + +func backendHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("X-E2E-Backend", "ok") + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + _, _ = w.Write([]byte("E2E_BACKEND_OK\n")) + } + }) +} + +func main() { + lapiAddr := flag.String("lapi-addr", "127.0.0.1:8090", "address for the LAPI mock") + backendAddr := flag.String("backend-addr", "127.0.0.1:8091", "address for the backend responder") + apiKey := flag.String("api-key", "e2e-mock-key", "expected X-Api-Key value") + flag.Parse() + + s := newStore() + + go func() { + if err := http.ListenAndServe(*backendAddr, backendHandler()); err != nil { + log.Fatalf("backend: %v", err) + } + }() + + fmt.Printf("mocklapi: LAPI on %s, backend on %s\n", *lapiAddr, *backendAddr) + if err := http.ListenAndServe(*lapiAddr, lapiHandler(s, *apiKey)); err != nil { + log.Fatalf("lapi: %v", err) + } +} 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..4f6c39d --- /dev/null +++ b/tests/e2e/mock/scenarios/captcha/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=captcha + +body() { + echo "[$SCENARIO] adding captcha decision for 1.2.3.4" + lapi_add_decision 1.2.3.4 captcha 5m + + echo "[$SCENARIO] waiting one stream tick + buffer..." + sleep 4 + + echo "[$SCENARIO] captcha response must be 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] captcha response body must contain the captcha template marker" + assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CAPTCHA_PAGE_MARKER" -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..6766b9f --- /dev/null +++ b/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml @@ -0,0 +1,27 @@ +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" 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..91f17ea --- /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] waiting one stream tick + buffer..." + sleep 4 + + echo "[$SCENARIO] banned response status is 403" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -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" +} + +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..7f73a02 --- /dev/null +++ b/tests/e2e/mock/scenarios/live-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=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 + + 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://127.0.0.1:${WEB_PORT}/foo" 403 -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..9c9cb50 --- /dev/null +++ b/tests/e2e/mock/scenarios/stream-mode/run.sh @@ -0,0 +1,36 @@ +#!/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] waiting one stream tick + buffer..." + sleep 4 + + echo "[$SCENARIO] banned IP must be blocked (HTTP 403)" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -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] waiting one stream tick + buffer..." + sleep 4 + + echo "[$SCENARIO] previously banned IP must pass again" + 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/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..714cc24 --- /dev/null +++ b/tests/e2e/mock/scenarios/trusted-ips/run.sh @@ -0,0 +1,25 @@ +#!/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 + + echo "[$SCENARIO] waiting one stream tick + buffer..." + sleep 4 + + 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" + + echo "[$SCENARIO] untrusted banned IP must be blocked (control: proves the bouncer is active)" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 5.6.7.8" +} + +run_scenario "$SCENARIO" "$HERE" body