Compare commits

..
Author SHA1 Message Date
mhx 375a5f6bb1 🔊 update log level for handle cache fonction to debug only 2026-03-15 18:01:10 +01:00
28 changed files with 12 additions and 978 deletions
-42
View File
@@ -1,42 +0,0 @@
name: E2E
on:
push:
branches: [main]
pull_request:
concurrency:
group: e2e-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
e2e:
name: e2e (binary + mock LAPI)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
# Track go.mod (Go 1.22) — the plugin's yaegi-bound floor. Keeps the
# single source of truth and builds the mock on the supported version.
go-version-file: go.mod
# CI runs the binary/mock suite only: Traefik as a downloaded binary +
# a small LAPI mock (no Docker, no real Crowdsec). It validates the
# plugin's own behaviour. Crowdsec / AppSec correctness is upstream's
# responsibility, so those are intentionally out of scope here. The
# Docker suite (tests/e2e/scenarios) stays available for local debugging.
# `-k` keeps going after a failing scenario so the logs cover all of
# them, while make still exits non-zero if any scenario failed.
- name: Run mock scenarios
run: make -k e2e_mock
- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-logs
path: /tmp/e2e-mock-*.log
if-no-files-found: ignore
-4
View File
@@ -5,7 +5,3 @@ 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/
+1 -10
View File
@@ -1,11 +1,7 @@
.PHONY: lint test vendor clean e2e_mock
.PHONY: lint test vendor clean
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:
@@ -17,11 +13,6 @@ 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
-7
View File
@@ -465,12 +465,6 @@ make run
- int64
- default: 0
- Used only in `stream` and `alone` mode, the maximum number of time we can not reach Crowdsec before blocking traffic (set -1 to never block)
- StreamStartupBlock
- bool
- default: true
- Used only in `stream` and `alone` mode, controls whether the initial stream update runs synchronously or asynchronously during plugin initialization
- When `true`, plugin initialization waits for Crowdsec to be ready before serving traffic.
- **Warning**: When `false`, all requests bypass remediation until the first stream sync completes — banned IPs will be allowed through during this window. Only disable when startup availability is more important than blocking at startup.
- DefaultDecisionSeconds
- int64
- default: 60
@@ -604,7 +598,6 @@ http:
LogFilePath: ""
updateIntervalSeconds: 60
updateMaxFailure: 0
streamStartupBlock: true
defaultDecisionSeconds: 60
remediationStatusCode: 403
httpTimeoutSeconds: 10
+10 -16
View File
@@ -64,7 +64,7 @@ const (
//nolint:gochecknoglobals
var (
isCrowdsecStreamStartup = true
isStartup = true
isCrowdsecStreamHealthy = true
updateFailure int64
streamTicker chan bool
@@ -123,7 +123,7 @@ type Bouncer struct {
// New creates the crowdsec bouncer plugin.
//
//nolint:nestif,gocyclo,gocognit
//nolint:gocyclo
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
config.LogLevel = strings.ToUpper(config.LogLevel)
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
@@ -291,11 +291,8 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
return nil, err
}
}
if config.StreamStartupBlock {
handleStreamTicker(bouncer)
} else {
go handleStreamTicker(bouncer)
}
handleStreamTicker(bouncer)
isStartup = false
streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() {
handleStreamTicker(bouncer)
})
@@ -304,7 +301,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
// Start metrics ticker if not already running
if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 {
lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker
go handleMetricsTicker(bouncer)
handleMetricsTicker(bouncer)
metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() {
handleMetricsTicker(bouncer)
})
@@ -627,7 +624,6 @@ func handleStreamCache(bouncer *Bouncer) error {
_, err := bouncer.cacheClient.Get(cacheTimeoutKey)
if err == nil {
bouncer.log.Debug("handleStreamCache:alreadyUpdated")
isCrowdsecStreamStartup = false
return nil
}
if err.Error() != cache.CacheMiss {
@@ -638,7 +634,7 @@ func handleStreamCache(bouncer *Bouncer) error {
Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost,
Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute,
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isCrowdsecStreamStartup),
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup),
}
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil)
if err != nil {
@@ -659,7 +655,7 @@ func handleStreamCache(bouncer *Bouncer) error {
case "captcha":
value = cache.CaptchaValue
default:
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
}
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
}
@@ -668,7 +664,6 @@ func handleStreamCache(bouncer *Bouncer) error {
bouncer.cacheClient.Delete(decision.Value)
}
bouncer.log.Debug("handleStreamCache:updated")
isCrowdsecStreamStartup = false
return nil
}
@@ -679,8 +674,8 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
} else {
req, _ = http.NewRequest(http.MethodGet, stringURL, nil)
}
req.Header.Set(bouncer.crowdsecHeader, bouncer.crowdsecKey)
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
req.Header.Add(bouncer.crowdsecHeader, bouncer.crowdsecKey)
req.Header.Add("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
res, err := bouncer.httpClient.Do(req)
if err != nil {
@@ -718,7 +713,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
Path: bouncer.appsecPath,
}
var req *http.Request
if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil {
if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil && httpReq.ContentLength > 0 {
var bodyBuffer bytes.Buffer
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
@@ -744,7 +739,6 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
req.Header.Set(crowdsecAppsecUserAgent, httpReq.Header.Get("User-Agent"))
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
res, err := bouncer.httpAppsecClient.Do(req)
if err != nil {
-2
View File
@@ -84,7 +84,6 @@ type Config struct {
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
StreamStartupBlock bool `json:"streamStartupBlock,omitempty"`
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
@@ -147,7 +146,6 @@ func New() *Config {
UpdateIntervalSeconds: 60,
MetricsUpdateIntervalSeconds: 600,
UpdateMaxFailure: 0,
StreamStartupBlock: true,
DefaultDecisionSeconds: 60,
RemediationStatusCode: http.StatusForbidden,
HTTPTimeoutSeconds: 10,
-84
View File
@@ -1,84 +0,0 @@
# Binary e2e suite (Traefik binary + mock LAPI)
This suite runs **Traefik as a downloaded binary** with the plugin loaded from
the local source tree, and replaces Crowdsec with a small **HTTP mock**
([`mocklapi/`](mocklapi/main.go), a stdlib-only Go command). No Docker, no real
Crowdsec.
It is what **CI runs** (`make e2e_mock`). A separate, local-only **Docker
suite** (real Traefik + Crowdsec, under `tests/e2e/scenarios`) is kept for
high-fidelity debugging against a real Crowdsec but is not exercised in CI; it
ships in its own PR (#333).
## Scope — what this suite tests
These tests validate the **plugin's own behaviour**: the request flow through
the Traefik middleware, the live / none / stream modes, caching, trusted-IP
bypass, ban / captcha page rendering, and the AppSec request path (header
forwarding + enforcing the engine's allow/block verdict).
The mock stands in for Crowdsec, emulating the slice of the LAPI HTTP contract
the plugin consumes — including a single, deterministic AppSec rule (block any
URI containing `rpc2`, the probe from [`examples/appsec-enabled`](../../../examples/appsec-enabled)).
It is not the real WAF engine, so this suite exercises the plugin's AppSec
*wiring* rather than the detection accuracy of OWASP CRS / virtual patching —
that lives upstream in Crowdsec.
## What runs
| Component | How |
|-----------|-----|
| Traefik | Binary `v3.7.1`, downloaded into `.cache/` (reused across local runs; re-downloaded on fresh CI runners) |
| Plugin | Loaded via `experimental.localPlugins` from the repo root (symlinked into `plugins-local/`) |
| LAPI | `mocklapi` — a stdlib-only Go command (its own nested module), compiled and cached under `.cache/`, driven through `/admin` endpoints instead of `cscli` |
| AppSec | WAF stand-in built into the mock — blocks URIs containing `rpc2`, allows the rest |
| Backend | A plain HTTP responder built into the mock |
Fixed ports (override with env vars if needed): Traefik `8000`, LAPI `8090`,
backend `8091`, AppSec `8092`.
## Running locally
Prerequisites: `bash`, `curl`, `go`, `tar`. On first use the Traefik binary is
fetched and the mock is compiled into `.cache/`. That cache is reused across
local runs; CI runs on fresh runners, so both are recreated on every CI run.
```bash
# one scenario
make e2e_mock_stream-mode
# or directly
./tests/e2e/mock/scenarios/stream-mode/run.sh
# the whole suite
make e2e_mock
```
## Layout
```
mock/
lib/
common.sh # stack lifecycle, Traefik download, mock build, assertions, admin client
traefik.yml # static Traefik config (shared by all scenarios)
mocklapi/
go.mod # nested module — kept out of the plugin's build/lint/vendor
main.go # mock LAPI + AppSec stand-in + backend
scenarios/
<name>/
dynamic.yml # Traefik dynamic config (router + bouncer middleware + backend)
run.sh # assertions for the scenario
*.html # optional fixtures (ban / captcha templates)
```
`dynamic.yml` uses placeholders (`@@APIKEY@@`, `@@LAPI_HOST@@`,
`@@BACKEND_URL@@`, `@@SCENARIO_DIR@@`) that `common.sh` substitutes at runtime.
## Adding a scenario
1. Create `scenarios/<name>/dynamic.yml` and `run.sh` (copy `stream-mode/` as a
template).
2. In `run.sh`, define a `body` function with the assertions and call
`run_scenario "<name>" "$HERE" body`.
3. Drive decisions with `lapi_add_decision <ip> [type] [duration]` and
`lapi_delete_decision <ip>`.
4. Add `<name>` to `E2E_MOCK_SCENARIOS` in the `Makefile`.
-248
View File
@@ -1,248 +0,0 @@
#!/usr/bin/env bash
# Shared helpers for the binary (mock) e2e suite.
#
# Unlike the Docker suite under tests/e2e/scenarios, this one runs Traefik as a
# downloaded binary and replaces Crowdsec with a small HTTP mock (the mocklapi
# Go command). It validates the plugin's own behaviour (modes, cache, trusted
# IPs, ban / captcha rendering, AppSec wiring) — not the accuracy of Crowdsec's
# detection or its WAF engine, which the mock only stands in for.
#
# Dependencies: bash, curl, go, tar. The Traefik binary is downloaded and the
# mock is compiled into .cache/ on first use. That cache persists across local
# runs; CI runs on fresh runners, so both are recreated on every CI run.
set -euo pipefail
# Pinned to match the Docker suite (tests/e2e/scenarios/*/docker-compose.yml).
TRAEFIK_VERSION="${TRAEFIK_VERSION:-v3.7.1}"
WEB_PORT="${WEB_PORT:-8000}"
LAPI_PORT="${LAPI_PORT:-8090}"
BACKEND_PORT="${BACKEND_PORT:-8091}"
APPSEC_PORT="${APPSEC_PORT:-8092}"
LAPI_KEY="${LAPI_KEY:-e2e-mock-key}"
MOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$MOCK_LIB_DIR/../../../.." && pwd)"
CACHE_DIR="$MOCK_LIB_DIR/../.cache"
# Populated by start_stack / run_scenario, consumed by the EXIT trap.
WORKDIR=""
TRAEFIK_PID=""
MOCK_PID=""
SCENARIO_NAME=""
SCENARIO_LOG=""
# Resolve (and cache) the Traefik binary for this host, echoing its path.
ensure_traefik() {
local bin="$CACHE_DIR/traefik-$TRAEFIK_VERSION"
if [[ -x "$bin" ]]; then
echo "$bin"
return 0
fi
mkdir -p "$CACHE_DIR"
local os arch
case "$(uname -s)" in
Linux) os=linux ;;
Darwin) os=darwin ;;
*) echo "ensure_traefik: unsupported OS $(uname -s)" >&2; return 1 ;;
esac
case "$(uname -m)" in
x86_64 | amd64) arch=amd64 ;;
aarch64 | arm64) arch=arm64 ;;
*) echo "ensure_traefik: unsupported arch $(uname -m)" >&2; return 1 ;;
esac
local url="https://github.com/traefik/traefik/releases/download/${TRAEFIK_VERSION}/traefik_${TRAEFIK_VERSION}_${os}_${arch}.tar.gz"
echo "ensure_traefik: downloading $url" >&2
local tmp
tmp="$(mktemp -d)"
curl -sSfL "$url" -o "$tmp/traefik.tar.gz"
tar -xzf "$tmp/traefik.tar.gz" -C "$tmp" traefik
mv "$tmp/traefik" "$bin"
chmod +x "$bin"
rm -rf "$tmp"
echo "$bin"
}
# Build (and cache) the mock LAPI binary, echoing its path. Go's build cache
# makes the rebuild near-instant after the first run.
ensure_mock() {
local bin="$CACHE_DIR/mocklapi"
mkdir -p "$CACHE_DIR"
( cd "$MOCK_LIB_DIR/../mocklapi" && go build -o "$bin" . ) >&2
echo "$bin"
}
# Poll a URL until it returns the expected status code, or fail.
# Usage: wait_for_status URL CODE [TIMEOUT_SECONDS] [curl args...]
wait_for_status() {
local url="$1" expected="$2" timeout="${3:-30}"
shift 3 || true
local elapsed=0 got=""
while (( elapsed < timeout )); do
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url" || true)
if [[ "$got" == "$expected" ]]; then
return 0
fi
sleep 1
# Note: `((elapsed++))` returns exit 1 when elapsed is 0, which trips set -e.
elapsed=$((elapsed + 1))
done
echo "wait_for_status: $url expected $expected, last seen ${got:-<none>}" >&2
return 1
}
# Poll a URL until its body contains a substring, or fail. Used when the status
# code alone can't tell the states apart (e.g. captcha page vs backend, both 200).
# Usage: wait_for_body_contains URL NEEDLE [TIMEOUT_SECONDS] [curl args...]
wait_for_body_contains() {
local url="$1" needle="$2" timeout="${3:-30}"
shift 3 || true
local elapsed=0 body=""
while (( elapsed < timeout )); do
body=$(curl -s "$@" "$url" || true)
if grep -q "$needle" <<<"$body"; then
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "wait_for_body_contains: $url did not contain \"$needle\" within ${timeout}s" >&2
return 1
}
# Assert a single curl returns the expected status code.
# Usage: assert_status URL CODE [curl args...]
assert_status() {
local url="$1" expected="$2"
shift 2 || true
local got
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url")
if [[ "$got" != "$expected" ]]; then
echo "assert_status: $url expected $expected, got $got" >&2
return 1
fi
}
# Assert a response header matches a value (case-insensitive name).
# Usage: assert_header URL HEADER VALUE [curl args...]
assert_header() {
local url="$1" header="$2" expected="$3"
shift 3 || true
local got
got=$(curl -s -D - -o /dev/null "$@" "$url" | tr -d '\r' \
| awk -v h="${header,,}" -F': ' 'tolower($1) == h { print $2; exit }')
if [[ "$got" != "$expected" ]]; then
echo "assert_header: $url header $header expected \"$expected\", got \"$got\"" >&2
return 1
fi
}
# Assert a response body contains a substring.
# Usage: assert_body_contains URL NEEDLE [curl args...]
assert_body_contains() {
local url="$1" needle="$2"
shift 2 || true
local body
body=$(curl -s "$@" "$url")
if ! grep -q "$needle" <<<"$body"; then
echo "assert_body_contains: $url expected to contain \"$needle\", got:" >&2
echo "$body" >&2
return 1
fi
}
# --- mock admin client -------------------------------------------------------
lapi_add_decision() {
local ip="$1" type="${2:-ban}" duration="${3:-4h}"
curl -sS -X POST "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}&type=${type}&duration=${duration}" >/dev/null
}
lapi_delete_decision() {
local ip="$1"
curl -sS -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}" >/dev/null
}
# --- stack lifecycle ---------------------------------------------------------
# start_stack SCENARIO_DIR
# Spins up the mock + Traefik (with the scenario's dynamic.yml) and waits ready.
start_stack() {
local scenario_dir="$1"
local traefik_bin mock_bin
traefik_bin="$(ensure_traefik)"
mock_bin="$(ensure_mock)"
WORKDIR="$(mktemp -d)"
# Expose the plugin source where Traefik's localPlugins loader expects it.
mkdir -p "$WORKDIR/plugins-local/src/github.com/maxlerebourg"
ln -s "$REPO_ROOT" "$WORKDIR/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
cp "$MOCK_LIB_DIR/traefik.yml" "$WORKDIR/traefik.yml"
# Render the scenario's dynamic config with the live ports / key / paths.
sed \
-e "s|@@APIKEY@@|${LAPI_KEY}|g" \
-e "s|@@LAPI_HOST@@|127.0.0.1:${LAPI_PORT}|g" \
-e "s|@@APPSEC_HOST@@|127.0.0.1:${APPSEC_PORT}|g" \
-e "s|@@BACKEND_URL@@|http://127.0.0.1:${BACKEND_PORT}|g" \
-e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \
"$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml"
"$mock_bin" \
--lapi-addr "127.0.0.1:${LAPI_PORT}" \
--backend-addr "127.0.0.1:${BACKEND_PORT}" \
--appsec-addr "127.0.0.1:${APPSEC_PORT}" >"$WORKDIR/mock.log" 2>&1 &
MOCK_PID=$!
( cd "$WORKDIR" && exec "$traefik_bin" --configfile=traefik.yml ) >"$WORKDIR/traefik.log" 2>&1 &
TRAEFIK_PID=$!
wait_for_status "http://127.0.0.1:${LAPI_PORT}/health" 200 30
# AppSec stand-in: a bare GET carries no "rpc2" URI, so it answers 200 (allow).
wait_for_status "http://127.0.0.1:${APPSEC_PORT}/" 200 30
# /ping is served by Traefik itself once it is up (plugin compilation included).
wait_for_status "http://127.0.0.1:${WEB_PORT}/ping" 200 60
}
stop_stack() {
[[ -n "$TRAEFIK_PID" ]] && kill "$TRAEFIK_PID" 2>/dev/null || true
[[ -n "$MOCK_PID" ]] && kill "$MOCK_PID" 2>/dev/null || true
[[ -n "$TRAEFIK_PID" ]] && wait "$TRAEFIK_PID" 2>/dev/null || true
[[ -n "$MOCK_PID" ]] && wait "$MOCK_PID" 2>/dev/null || true
[[ -n "$WORKDIR" && -d "$WORKDIR" ]] && rm -rf "$WORKDIR" || true
}
dump_diagnostics() {
echo "=== traefik.log ==="
cat "$WORKDIR/traefik.log" 2>/dev/null || true
echo "=== mock.log ==="
cat "$WORKDIR/mock.log" 2>/dev/null || true
}
# EXIT trap: runs after the scenario body (or after a failed assertion under
# `set -e`), so it relies only on globals, never on run_scenario's locals.
_scenario_cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics > "$SCENARIO_LOG" 2>&1 || true
echo "[$SCENARIO_NAME] failed. Logs written to $SCENARIO_LOG" >&2
fi
stop_stack
exit $rc
}
# run_scenario SCENARIO_NAME SCENARIO_DIR BODY_FN
# Wraps lifecycle + diagnostics so each run.sh stays declarative.
run_scenario() {
SCENARIO_NAME="$1"
local dir="$2" body="$3"
SCENARIO_LOG="/tmp/e2e-mock-${SCENARIO_NAME}.log"
trap _scenario_cleanup EXIT
echo "[$SCENARIO_NAME] starting binary stack (Traefik + mock LAPI)..."
start_stack "$dir"
"$body"
echo "[$SCENARIO_NAME] OK"
}
-28
View File
@@ -1,28 +0,0 @@
# Static Traefik configuration for the binary e2e suite.
# The dynamic part (router + bouncer middleware + backend service) lives in
# dynamic.yml, generated per scenario by common.sh.
entryPoints:
web:
address: ":8000"
forwardedHeaders:
# The test's curl sets X-Forwarded-For; preserve it through the proxy.
insecure: true
log:
level: INFO
accessLog: {}
# /ping on the web entrypoint is the readiness probe — no dashboard/API needed.
ping:
entryPoint: web
providers:
file:
filename: dynamic.yml
watch: false
experimental:
localPlugins:
bouncer:
moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
-6
View File
@@ -1,6 +0,0 @@
// Standalone module so this test helper stays out of the plugin module:
// it is excluded from the plugin's `go build ./...`, `go test ./...`,
// golangci-lint and `go mod vendor`. Stdlib only no dependencies.
module mocklapi
go 1.22
-135
View File
@@ -1,135 +0,0 @@
// Command mocklapi is a minimal Crowdsec LAPI stand-in for the binary e2e
// suite. It answers only the few LAPI routes the plugin calls — live/none
// decision lookups, the stream poll and the usage-metrics push — and lets the
// test drive decisions through /admin instead of `cscli`. It also serves the
// stub upstream that Traefik proxies allowed requests to.
//
// It is NOT a Crowdsec/AppSec conformance harness — the real WAF engine (OWASP
// CRS, virtual patching) is out of scope. The AppSec endpoint here emulates a
// single deterministic rule so the suite can exercise the plugin's AppSec
// wiring (header forwarding, allow/block handling) end to end. See the README.
package main
import (
"encoding/json"
"flag"
"log"
"net/http"
"strings"
"sync"
)
// Decision is the subset of a LAPI decision the plugin actually reads.
type Decision struct {
Value string `json:"value"`
Type string `json:"type"`
Duration string `json:"duration"`
}
var (
mu sync.Mutex
active = map[string]Decision{} // ip -> decision currently in force
deleted = map[string]Decision{} // ip -> decision to report in the stream "deleted" list
)
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func list(m map[string]Decision) []Decision {
out := make([]Decision, 0, len(m))
for _, d := range m {
out = append(out, d)
}
return out
}
func main() {
lapiAddr := flag.String("lapi-addr", "127.0.0.1:8090", "address for the LAPI mock")
// The stub upstream Traefik proxies allowed requests to — the binary-suite
// equivalent of the traefik/whoami container. Not AppSec.
backendAddr := flag.String("backend-addr", "127.0.0.1:8091", "address for the stub upstream service")
// AppSec WAF stand-in (the real engine listens on :7422). Not a CRS engine.
appsecAddr := flag.String("appsec-addr", "127.0.0.1:8092", "address for the AppSec mock")
flag.Parse()
go func() {
log.Fatal(http.ListenAndServe(*backendAddr, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("E2E_BACKEND_OK\n"))
})))
}()
// AppSec mock: the plugin forwards the request metadata in X-Crowdsec-Appsec-*
// headers and reads our status — 200 allows, 403 blocks. We emulate one
// deterministic virtual-patching rule (block any URI containing "rpc2", the
// exact probe from examples/appsec-enabled) so the plugin's AppSec path is
// exercised without standing up the real WAF.
go func() {
log.Fatal(http.ListenAndServe(*appsecAddr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "rpc2") {
w.WriteHeader(http.StatusForbidden)
}
})))
}()
mux := http.NewServeMux()
// Readiness probe for the test harness (empty body, 200).
mux.HandleFunc("/health", func(http.ResponseWriter, *http.Request) {})
// live / none mode: the plugin asks about one IP and expects a decision
// array, or the literal `null` when there is none.
mux.HandleFunc("/v1/decisions", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if d, ok := active[r.URL.Query().Get("ip")]; ok {
writeJSON(w, []Decision{d})
return
}
_, _ = w.Write([]byte("null"))
})
// stream mode: report the whole active set as "new" and anything removed as
// "deleted". Re-sending the same on every poll is harmless — the plugin just
// re-adds to / re-deletes from its cache.
mux.HandleFunc("/v1/decisions/stream", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
defer mu.Unlock()
writeJSON(w, map[string][]Decision{"new": list(active), "deleted": list(deleted)})
})
// usage-metrics push: accept and ignore.
mux.HandleFunc("/v1/usage-metrics", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
})
// Test control plane: add / remove decisions instead of cscli.
mux.HandleFunc("/admin/decisions", func(_ http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
ip := q.Get("ip")
mu.Lock()
defer mu.Unlock()
switch r.Method {
case http.MethodPost:
dtype := q.Get("type")
if dtype == "" {
dtype = "ban"
}
duration := q.Get("duration")
if duration == "" {
duration = "4h"
}
active[ip] = Decision{Value: ip, Type: dtype, Duration: duration}
delete(deleted, ip)
case http.MethodDelete:
if d, ok := active[ip]; ok {
deleted[ip] = d
delete(active, ip)
}
}
})
log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s", *lapiAddr, *backendAddr, *appsecAddr)
log.Fatal(http.ListenAndServe(*lapiAddr, mux))
}
@@ -1,29 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
# IP bouncing disabled — this scenario exercises AppSec only.
crowdsecMode: none
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
crowdsecAppsecEnabled: "true"
crowdsecAppsecScheme: http
crowdsecAppsecHost: "@@APPSEC_HOST@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
-23
View File
@@ -1,23 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=appsec
# AppSec wiring check: the plugin forwards each request to the AppSec engine and
# enforces its verdict. The mock emulates one virtual-patching rule (block any
# URI containing "rpc2"), mirroring examples/appsec-enabled. This proves the
# plugin's AppSec path end to end (header forwarding + allow/block handling); it
# does not test the real WAF's detection accuracy.
body() {
echo "[$SCENARIO] benign request must pass (AppSec allows)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] request whose URI contains 'rpc2' must be blocked (AppSec 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo/rpc2" 403 -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -1,9 +0,0 @@
<!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>
@@ -1,33 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: stream
updateIntervalSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
captchaProvider: turnstile
# Cloudflare Turnstile public test keys: render a valid widget without
# contacting a real API key.
captchaSiteKey: "1x00000000000000000000AA"
captchaSecretKey: "1x0000000000000000000000000000000AA"
captchaHtmlFilePath: "@@SCENARIO_DIR@@/captcha.html"
captchaGracePeriodSeconds: "10"
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=captcha
body() {
echo "[$SCENARIO] adding captcha decision for 1.2.3.4"
lapi_add_decision 1.2.3.4 captcha 5m
# Status stays 200 before/after (captcha page vs backend), so gate on the body
# marker appearing once the captcha decision has been polled.
echo "[$SCENARIO] captcha page must be served once the decision is polled (200 + marker)"
wait_for_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CAPTCHA_PAGE_MARKER" 15 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] captcha response is HTTP 200 (the captcha page itself, not a 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] non-flagged IP must still pass through to the backend"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -1,8 +0,0 @@
<!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>
@@ -1,28 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: stream
updateIntervalSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
banHtmlFilePath: "@@SCENARIO_DIR@@/ban.html"
remediationHeadersCustomName: "X-E2E-Remediation"
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=custom-ban-page
body() {
echo "[$SCENARIO] adding ban decision"
lapi_add_decision 1.2.3.4 ban 5m
echo "[$SCENARIO] banned response becomes 403 once the next stream poll lands"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] banned response Content-Type is HTML"
assert_header "http://127.0.0.1:${WEB_PORT}/foo" Content-Type "text/html; charset=utf-8" -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] banned response body contains the custom marker"
assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CUSTOM_BAN_PAGE_MARKER" -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] banned response carries the custom remediation header (remediationHeadersCustomName)"
assert_header "http://127.0.0.1:${WEB_PORT}/foo" X-E2E-Remediation "ban" -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -1,26 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: live
defaultDecisionSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=live-mode
body() {
echo "[$SCENARIO] no decision -> first hit queries LAPI, returns 200, caches 'allowed' for 2s"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
lapi_add_decision 1.2.3.4 ban 5m
# Stays 200 until the cached 'allowed' (defaultDecisionSeconds) expires, then
# the re-query sees the ban — poll instead of guessing the cache TTL.
echo "[$SCENARIO] hit must turn 403 once the cached 'allowed' expires and LAPI is re-queried"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] another non-banned IP must still pass"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -1,25 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: none
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=none-mode
body() {
echo "[$SCENARIO] no decision -> request passes (LAPI queried per request)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
lapi_add_decision 1.2.3.4 ban 5m
echo "[$SCENARIO] none mode has no cache -> next request must be blocked immediately"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] deleting decision"
lapi_delete_decision 1.2.3.4
echo "[$SCENARIO] previously banned IP must pass again immediately"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -1,26 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: stream
updateIntervalSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
@@ -1,30 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=stream-mode
body() {
echo "[$SCENARIO] no decision yet -> request allowed"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
lapi_add_decision 1.2.3.4 ban 5m
echo "[$SCENARIO] banned IP must be blocked once the next stream poll lands (HTTP 403)"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] non-banned IP must still pass (HTTP 200)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
echo "[$SCENARIO] deleting ban decision"
lapi_delete_decision 1.2.3.4
echo "[$SCENARIO] previously banned IP must pass again once the deletion is polled"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -1,28 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: stream
updateIntervalSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
clientTrustedIps:
- "1.2.3.4/32"
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=trusted-ips
body() {
echo "[$SCENARIO] banning the trusted IP 1.2.3.4 and an untrusted IP 5.6.7.8"
lapi_add_decision 1.2.3.4 ban 5m
lapi_add_decision 5.6.7.8 ban 5m
# The untrusted IP turning 403 is our signal that the bans have been polled;
# it also doubles as the control proving the bouncer is active.
echo "[$SCENARIO] untrusted banned IP must be blocked once the bans are polled (HTTP 403)"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 5.6.7.8"
echo "[$SCENARIO] trusted IP must bypass the bouncer even though it is banned"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
+1 -1
View File
@@ -1,4 +1,4 @@
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
// pluginVersion is updated automatically by the release workflow.
var pluginVersion = "1.6.X" //nolint:gochecknoglobals
var pluginVersion = "1.5.0" //nolint:gochecknoglobals