mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 04:28:50 +02:00
* feat: Allow cache reading from replicas * 🍱 fix logic * ✨ add testing for redis with mock * 🍱 fix permission * 📝 test(e2e/redis): fix swapped IP→verdict comments The mock returns "f" (not banned) for 1.2.3.4 and "t" (banned) for 1.2.3.5, and the run.sh assertions match that. Both doc comments described the opposite mapping; correct them to match the code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ✅ test(e2e/redis): exercise read-from-replica path The redis scenario only set redisCacheHost, so it validated the writer but never the round-robin reader path this feature adds. Split the mock into two roles: the primary (--redis-addr) now answers every GET with a miss, while the replica (--redis-read-addr) serves the hardcoded verdicts. The scenario points redisCacheReadHosts at the replica (twice, to drive round-robin), so the banned-IP-blocked assertion only passes if the plugin actually reads decisions from the replica rather than the primary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 📝 docs: note replicas don't fall back to primary on outage When RedisCacheReadHosts is set, reads are not retried against the primary if the replicas are unreachable. Document that this, combined with the default RedisCacheUnreachableBlock=true, means a replica outage can block traffic while the primary is healthy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 🐛 fix(cache): avoid nil-pointer panic on empty redis read redisCache.get fell through to `switch err.Error()` when Get returned a nil error with an empty value, panicking on the nil error. simpleredis never returns that combination today (a miss yields RedisMiss), so it was unreachable in practice — but the read path is safer treating an empty, error-free read as a cache miss, which also guarantees err is non-nil before err.Error() is called. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 🍱 add test for rotation * 🐛 readd redis/run.sh * 🐛 fix redis/run.sh * 🐛 fix test label; remove log * 🍱 add tests for roundRobin * 🐛 fix test --------- Co-authored-by: maxlerebourg <maxlerebourg@gmail.com> Co-authored-by: mhx <mathieu@hanotaux.fr> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
268 lines
9.4 KiB
Bash
268 lines
9.4 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}"
|
|
REDIS_PORT="${REDIS_PORT:-8093}"
|
|
REDIS_READ_PORT="${REDIS_READ_PORT:-8094}"
|
|
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|@@REDIS_HOST@@|127.0.0.1:${REDIS_PORT}|g" \
|
|
-e "s|@@REDIS_READ_HOST@@|127.0.0.1:${REDIS_READ_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}" \
|
|
--redis-addr "127.0.0.1:${REDIS_PORT}" \
|
|
--redis-read-addr "127.0.0.1:${REDIS_READ_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"
|
|
}
|