mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-07-19 18:52:20 +02:00
262 lines
9.1 KiB
Bash
262 lines
9.1 KiB
Bash
#!/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.6}"
|
|
|
|
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"
|
|
|
|
# Opt-in HTTPS LAPI: a scenario exports LAPI_TLS_CERT/LAPI_TLS_KEY to serve the
|
|
# LAPI over TLS (used by tls-system-ca). Default empty -> plaintext as before.
|
|
local mock_tls_args=() lapi_scheme=http lapi_curl=()
|
|
if [[ -n "${LAPI_TLS_CERT:-}" && -n "${LAPI_TLS_KEY:-}" ]]; then
|
|
mock_tls_args=(--lapi-tls-cert "$LAPI_TLS_CERT" --lapi-tls-key "$LAPI_TLS_KEY")
|
|
lapi_scheme=https
|
|
lapi_curl=(-k) # the readiness probe ignores trust; the bouncer's trust is what we test
|
|
fi
|
|
|
|
"$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}" \
|
|
"${mock_tls_args[@]}" >"$WORKDIR/mock.log" 2>&1 &
|
|
MOCK_PID=$!
|
|
|
|
# Opt-in trust store for the Traefik process: a scenario exports
|
|
# TRAEFIK_SSL_CERT_FILE to point Go's x509.SystemCertPool() at a specific CA
|
|
# bundle. Empty -> Go's default system store (unchanged behaviour).
|
|
( cd "$WORKDIR" && SSL_CERT_FILE="${TRAEFIK_SSL_CERT_FILE:-}" exec "$traefik_bin" --configfile=traefik.yml ) >"$WORKDIR/traefik.log" 2>&1 &
|
|
TRAEFIK_PID=$!
|
|
|
|
wait_for_status "${lapi_scheme}://127.0.0.1:${LAPI_PORT}/health" 200 30 "${lapi_curl[@]}"
|
|
# 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"
|
|
}
|