mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
🐛 Support range decision on stream mode
This commit is contained in:
@@ -78,11 +78,11 @@ ensure_mock() {
|
||||
# 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}"
|
||||
local url="$1" expected="$2" timeout="${3:-15}"
|
||||
shift 3 || true
|
||||
local elapsed=0 got=""
|
||||
while (( elapsed < timeout )); do
|
||||
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url" || true)
|
||||
got=$(curl -s -m 1 -o /dev/null -w '%{http_code}' "$@" "$url" || true)
|
||||
if [[ "$got" == "$expected" ]]; then
|
||||
return 0
|
||||
fi
|
||||
@@ -98,11 +98,11 @@ wait_for_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}"
|
||||
local url="$1" needle="$2" timeout="${3:-15}"
|
||||
shift 3 || true
|
||||
local elapsed=0 body=""
|
||||
while (( elapsed < timeout )); do
|
||||
body=$(curl -s "$@" "$url" || true)
|
||||
body=$(curl -s -m 1 "$@" "$url" || true)
|
||||
if grep -q "$needle" <<<"$body"; then
|
||||
return 0
|
||||
fi
|
||||
@@ -119,7 +119,7 @@ assert_status() {
|
||||
local url="$1" expected="$2"
|
||||
shift 2 || true
|
||||
local got
|
||||
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url")
|
||||
got=$(curl -s -m 1 -o /dev/null -w '%{http_code}' "$@" "$url")
|
||||
if [[ "$got" != "$expected" ]]; then
|
||||
echo "assert_status: $url expected $expected, got $got" >&2
|
||||
return 1
|
||||
@@ -132,7 +132,7 @@ 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' \
|
||||
got=$(curl -s -m 1 -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
|
||||
@@ -146,7 +146,7 @@ assert_body_contains() {
|
||||
local url="$1" needle="$2"
|
||||
shift 2 || true
|
||||
local body
|
||||
body=$(curl -s "$@" "$url")
|
||||
body=$(curl -s -m 1 "$@" "$url")
|
||||
if ! grep -q "$needle" <<<"$body"; then
|
||||
echo "assert_body_contains: $url expected to contain \"$needle\", got:" >&2
|
||||
echo "$body" >&2
|
||||
@@ -158,12 +158,20 @@ assert_body_contains() {
|
||||
|
||||
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
|
||||
curl -sS -m 1 -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
|
||||
curl -sS -m 1 -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}" >/dev/null
|
||||
}
|
||||
|
||||
lapi_set_stream_fail() {
|
||||
curl -sS -m 1 -X POST "http://127.0.0.1:${LAPI_PORT}/admin/stream-fail" >/dev/null
|
||||
}
|
||||
|
||||
lapi_clear_stream_fail() {
|
||||
curl -sS -m 1 -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/stream-fail" >/dev/null
|
||||
}
|
||||
|
||||
# --- stack lifecycle ---------------------------------------------------------
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Decision is the subset of a LAPI decision the plugin actually reads.
|
||||
@@ -34,6 +35,9 @@ 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
|
||||
// streamFail makes /v1/decisions/stream return 500 when set, to exercise the
|
||||
// bouncer's fail-closed behaviour on consecutive stream poll failures.
|
||||
streamFail atomic.Bool
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
@@ -173,6 +177,10 @@ func main() {
|
||||
// "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) {
|
||||
if streamFail.Load() {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
writeJSON(w, map[string][]Decision{"new": list(active), "deleted": list(deleted)})
|
||||
@@ -183,6 +191,17 @@ func main() {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
|
||||
// Test control plane: make the stream endpoint fail (POST) or recover (DELETE).
|
||||
mux.HandleFunc("/admin/stream-fail", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
streamFail.Store(true)
|
||||
case http.MethodDelete:
|
||||
streamFail.Store(false)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Test control plane: add / remove decisions instead of cscli.
|
||||
mux.HandleFunc("/admin/decisions", func(_ http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
@@ -11,17 +11,25 @@ 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"
|
||||
echo "[$SCENARIO] adding ban decision for 1.2.3.4 and 2001:db8::1"
|
||||
lapi_add_decision 1.2.3.4 ban 5m
|
||||
lapi_add_decision "2001:db8::1" ban 5m
|
||||
|
||||
echo "[$SCENARIO] none mode has no cache -> next request must be blocked immediately"
|
||||
echo "[$SCENARIO] IP banned 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] IPv6 banned must be blocked (HTTP 403)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 2001:db8::1"
|
||||
|
||||
echo "[$SCENARIO] deleting decision"
|
||||
lapi_delete_decision 1.2.3.4
|
||||
lapi_delete_decision "2001:db8::1"
|
||||
|
||||
echo "[$SCENARIO] previously banned IP must pass again immediately"
|
||||
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"
|
||||
|
||||
echo "[$SCENARIO] previously banned IPv6 must pass again"
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 2001:db8::1"
|
||||
}
|
||||
|
||||
run_scenario "$SCENARIO" "$HERE" body
|
||||
|
||||
@@ -18,7 +18,8 @@ http:
|
||||
bouncer:
|
||||
enabled: "true"
|
||||
crowdsecMode: stream
|
||||
updateIntervalSeconds: "2"
|
||||
updateIntervalSeconds: "1"
|
||||
updateMaxFailure: "2"
|
||||
crowdsecLapiScheme: http
|
||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||
crowdsecLapiKey: "@@APIKEY@@"
|
||||
|
||||
@@ -11,8 +11,9 @@ 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"
|
||||
echo "[$SCENARIO] adding ban decision for 1.2.3.4 and 10.0.0.0/8"
|
||||
lapi_add_decision 1.2.3.4 ban 5m
|
||||
lapi_add_decision 10.0.0.0/24 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"
|
||||
@@ -20,11 +21,30 @@ body() {
|
||||
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"
|
||||
echo "[$SCENARIO] banned IP in CIDR must be blocked once polled (HTTP 403)"
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 10.0.0.1"
|
||||
|
||||
echo "[$SCENARIO] deleting ban decision for 1.2.3.4 and 10.0.0.0/8"
|
||||
lapi_delete_decision 1.2.3.4
|
||||
lapi_delete_decision 10.0.0.0/24
|
||||
|
||||
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"
|
||||
|
||||
echo "[$SCENARIO] previously CIDR-banned IP must pass again once deletion is polled"
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 10.0.0.1"
|
||||
|
||||
echo "[$SCENARIO] making the stream endpoint fail -> bouncer must pass for one more cycle (updateMaxFailure: 2)"
|
||||
lapi_set_stream_fail
|
||||
sleep 2 # update cache is every 1 seconds then waiting for minimum 1 cycle
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 8.8.8.8"
|
||||
|
||||
echo "[$SCENARIO] bouncer must block everything (isStreamHealthy: false)"
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 8.8.8.8"
|
||||
|
||||
echo "[$SCENARIO] restoring the stream endpoint -> bouncer must recover and pass again"
|
||||
lapi_clear_stream_fail
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 8.8.8.8"
|
||||
}
|
||||
|
||||
run_scenario "$SCENARIO" "$HERE" body
|
||||
|
||||
Reference in New Issue
Block a user