tests: add binary e2e suite (Traefik binary + Go mock LAPI), run it in CI

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 <noreply@anthropic.com>
This commit is contained in:
mhx
2026-06-06 12:03:19 +02:00
co-authored by Claude Opus 4.8
parent 33def87c30
commit b201143844
23 changed files with 967 additions and 15 deletions
+15 -9
View File
@@ -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
+9 -1
View File
@@ -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
+15 -5
View File
@@ -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_<scenario>`).
+2
View File
@@ -0,0 +1,2 @@
# Cached Traefik binary and compiled mock, produced on first run.
.cache/
+82
View File
@@ -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/
<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]`,
`lapi_delete_decision <ip>`, `lapi_reset`.
4. Add `<name>` to `E2E_MOCK_SCENARIOS` in the `Makefile`.
+227
View File
@@ -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:-<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
}
# 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"
}
+28
View File
@@ -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
+6
View File
@@ -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
+232
View File
@@ -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)
}
}
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>E2E captcha marker</title></head>
<body>
<h1 id="e2e-captcha-marker">E2E_CAPTCHA_PAGE_MARKER</h1>
<script src="{{ .FrontendJS }}"></script>
<div class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}"></div>
</body>
</html>
@@ -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"
+27
View File
@@ -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
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>E2E ban marker</title></head>
<body>
<h1 id="e2e-ban-marker">E2E_CUSTOM_BAN_PAGE_MARKER</h1>
<p>IP: {{ .ClientIP }} reason: {{ .RemediationReason }}</p>
</body>
</html>
@@ -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"
+27
View File
@@ -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
@@ -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"
+27
View File
@@ -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
@@ -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"
+27
View File
@@ -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
@@ -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"
+36
View File
@@ -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
@@ -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"
+25
View File
@@ -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