mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-07-20 11:09:00 +02:00
✨ 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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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>`).
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Cached Traefik binary and compiled mock, produced on first run.
|
||||
.cache/
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Executable
+27
@@ -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
@@ -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"
|
||||
Executable
+27
@@ -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"
|
||||
Executable
+27
@@ -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"
|
||||
Executable
+36
@@ -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"
|
||||
Executable
+25
@@ -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
|
||||
Reference in New Issue
Block a user