mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66b57496f8 | ||
|
|
71d845faae | ||
|
|
0d8fd2a7a9 | ||
|
|
1f6a8991c8 |
@@ -1,7 +1,9 @@
|
|||||||
.PHONY: lint test vendor clean
|
.PHONY: lint test vendor clean e2e
|
||||||
|
|
||||||
export GO111MODULE=on
|
export GO111MODULE=on
|
||||||
|
|
||||||
|
E2E_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec
|
||||||
|
|
||||||
default: lint test
|
default: lint test
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@@ -13,6 +15,11 @@ test:
|
|||||||
yaegi_test:
|
yaegi_test:
|
||||||
yaegi test -v .
|
yaegi test -v .
|
||||||
|
|
||||||
|
e2e: $(addprefix e2e_,$(E2E_SCENARIOS))
|
||||||
|
|
||||||
|
e2e_%:
|
||||||
|
./tests/e2e/scenarios/$*/run.sh
|
||||||
|
|
||||||
vendor:
|
vendor:
|
||||||
go mod vendor
|
go mod vendor
|
||||||
|
|
||||||
|
|||||||
@@ -465,6 +465,12 @@ make run
|
|||||||
- int64
|
- int64
|
||||||
- default: 0
|
- 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)
|
- 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
|
- DefaultDecisionSeconds
|
||||||
- int64
|
- int64
|
||||||
- default: 60
|
- default: 60
|
||||||
@@ -598,6 +604,7 @@ http:
|
|||||||
LogFilePath: ""
|
LogFilePath: ""
|
||||||
updateIntervalSeconds: 60
|
updateIntervalSeconds: 60
|
||||||
updateMaxFailure: 0
|
updateMaxFailure: 0
|
||||||
|
streamStartupBlock: true
|
||||||
defaultDecisionSeconds: 60
|
defaultDecisionSeconds: 60
|
||||||
remediationStatusCode: 403
|
remediationStatusCode: 403
|
||||||
httpTimeoutSeconds: 10
|
httpTimeoutSeconds: 10
|
||||||
|
|||||||
+16
-10
@@ -64,7 +64,7 @@ const (
|
|||||||
|
|
||||||
//nolint:gochecknoglobals
|
//nolint:gochecknoglobals
|
||||||
var (
|
var (
|
||||||
isStartup = true
|
isCrowdsecStreamStartup = true
|
||||||
isCrowdsecStreamHealthy = true
|
isCrowdsecStreamHealthy = true
|
||||||
updateFailure int64
|
updateFailure int64
|
||||||
streamTicker chan bool
|
streamTicker chan bool
|
||||||
@@ -123,7 +123,7 @@ type Bouncer struct {
|
|||||||
|
|
||||||
// New creates the crowdsec bouncer plugin.
|
// New creates the crowdsec bouncer plugin.
|
||||||
//
|
//
|
||||||
//nolint:gocyclo
|
//nolint:nestif,gocyclo,gocognit
|
||||||
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
||||||
config.LogLevel = strings.ToUpper(config.LogLevel)
|
config.LogLevel = strings.ToUpper(config.LogLevel)
|
||||||
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
|
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
|
||||||
@@ -291,8 +291,11 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handleStreamTicker(bouncer)
|
if config.StreamStartupBlock {
|
||||||
isStartup = false
|
handleStreamTicker(bouncer)
|
||||||
|
} else {
|
||||||
|
go handleStreamTicker(bouncer)
|
||||||
|
}
|
||||||
streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() {
|
streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() {
|
||||||
handleStreamTicker(bouncer)
|
handleStreamTicker(bouncer)
|
||||||
})
|
})
|
||||||
@@ -301,7 +304,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
// Start metrics ticker if not already running
|
// Start metrics ticker if not already running
|
||||||
if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 {
|
if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 {
|
||||||
lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker
|
lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker
|
||||||
handleMetricsTicker(bouncer)
|
go handleMetricsTicker(bouncer)
|
||||||
metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() {
|
metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() {
|
||||||
handleMetricsTicker(bouncer)
|
handleMetricsTicker(bouncer)
|
||||||
})
|
})
|
||||||
@@ -624,6 +627,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
_, err := bouncer.cacheClient.Get(cacheTimeoutKey)
|
_, err := bouncer.cacheClient.Get(cacheTimeoutKey)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
bouncer.log.Debug("handleStreamCache:alreadyUpdated")
|
bouncer.log.Debug("handleStreamCache:alreadyUpdated")
|
||||||
|
isCrowdsecStreamStartup = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err.Error() != cache.CacheMiss {
|
if err.Error() != cache.CacheMiss {
|
||||||
@@ -634,7 +638,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
Scheme: bouncer.crowdsecScheme,
|
Scheme: bouncer.crowdsecScheme,
|
||||||
Host: bouncer.crowdsecHost,
|
Host: bouncer.crowdsecHost,
|
||||||
Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute,
|
Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute,
|
||||||
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup),
|
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isCrowdsecStreamStartup),
|
||||||
}
|
}
|
||||||
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil)
|
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -655,7 +659,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
case "captcha":
|
case "captcha":
|
||||||
value = cache.CaptchaValue
|
value = cache.CaptchaValue
|
||||||
default:
|
default:
|
||||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
|
||||||
}
|
}
|
||||||
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
|
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
|
||||||
}
|
}
|
||||||
@@ -664,6 +668,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
bouncer.cacheClient.Delete(decision.Value)
|
bouncer.cacheClient.Delete(decision.Value)
|
||||||
}
|
}
|
||||||
bouncer.log.Debug("handleStreamCache:updated")
|
bouncer.log.Debug("handleStreamCache:updated")
|
||||||
|
isCrowdsecStreamStartup = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,8 +679,8 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
|
|||||||
} else {
|
} else {
|
||||||
req, _ = http.NewRequest(http.MethodGet, stringURL, nil)
|
req, _ = http.NewRequest(http.MethodGet, stringURL, nil)
|
||||||
}
|
}
|
||||||
req.Header.Add(bouncer.crowdsecHeader, bouncer.crowdsecKey)
|
req.Header.Set(bouncer.crowdsecHeader, bouncer.crowdsecKey)
|
||||||
req.Header.Add("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||||
|
|
||||||
res, err := bouncer.httpClient.Do(req)
|
res, err := bouncer.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -713,7 +718,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
|||||||
Path: bouncer.appsecPath,
|
Path: bouncer.appsecPath,
|
||||||
}
|
}
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil && httpReq.ContentLength > 0 {
|
if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil {
|
||||||
var bodyBuffer bytes.Buffer
|
var bodyBuffer bytes.Buffer
|
||||||
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
|
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
|
||||||
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
|
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
|
||||||
@@ -739,6 +744,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
|||||||
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
|
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
|
||||||
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
|
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
|
||||||
req.Header.Set(crowdsecAppsecUserAgent, httpReq.Header.Get("User-Agent"))
|
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)
|
res, err := bouncer.httpAppsecClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ type Config struct {
|
|||||||
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
||||||
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
|
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
|
||||||
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
|
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
|
||||||
|
StreamStartupBlock bool `json:"streamStartupBlock,omitempty"`
|
||||||
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
||||||
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
|
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
|
||||||
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
||||||
@@ -146,6 +147,7 @@ func New() *Config {
|
|||||||
UpdateIntervalSeconds: 60,
|
UpdateIntervalSeconds: 60,
|
||||||
MetricsUpdateIntervalSeconds: 600,
|
MetricsUpdateIntervalSeconds: 600,
|
||||||
UpdateMaxFailure: 0,
|
UpdateMaxFailure: 0,
|
||||||
|
StreamStartupBlock: true,
|
||||||
DefaultDecisionSeconds: 60,
|
DefaultDecisionSeconds: 60,
|
||||||
RemediationStatusCode: http.StatusForbidden,
|
RemediationStatusCode: http.StatusForbidden,
|
||||||
HTTPTimeoutSeconds: 10,
|
HTTPTimeoutSeconds: 10,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# End-to-end test 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.
|
||||||
|
|
||||||
|
Each scenario lives in its own directory under `scenarios/` and owns:
|
||||||
|
|
||||||
|
- `docker-compose.yml` — the stack to spin up
|
||||||
|
- `run.sh` — orchestration + assertions
|
||||||
|
- optional fixtures (`acquis.yaml`, `ban.html`, ...)
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
Prerequisites: `docker`, `docker compose`, `curl`, `bash`.
|
||||||
|
|
||||||
|
Run a single scenario:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tests/e2e/scenarios/stream-mode/run.sh
|
||||||
|
# or
|
||||||
|
make e2e_stream-mode
|
||||||
|
```
|
||||||
|
|
||||||
|
Run everything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Scenarios run **sequentially** on a single host: they share the canonical
|
||||||
|
`crowdsec` container name (so `cscli` commands work uniformly) and the same
|
||||||
|
`8000:80` port. Each scenario uses its own Docker Compose project
|
||||||
|
(`-p e2e-<scenario>`) and tears its stack down on exit, so the next one
|
||||||
|
starts clean. `make e2e` runs them one after another; Docker reuses the
|
||||||
|
images pulled by the first scenario, so the Traefik / Crowdsec / whoami
|
||||||
|
images are downloaded only once for the whole suite.
|
||||||
|
|
||||||
|
## Writing a new scenario
|
||||||
|
|
||||||
|
1. Copy `scenarios/stream-mode/` as a template.
|
||||||
|
2. Rename `container_name`s (keep `crowdsec` for the LAPI container).
|
||||||
|
3. Edit `run.sh` to express the behavior under test.
|
||||||
|
4. Add the scenario name to `E2E_SCENARIOS` in the `Makefile`.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
This Docker suite is **local-only** and intentionally not run in CI: it boots a
|
||||||
|
real Crowdsec (and downloads the AppSec collections for that scenario), which is
|
||||||
|
heavier and less deterministic than CI needs. CI instead runs a lighter
|
||||||
|
binary + mock-LAPI suite that exercises the plugin without Docker or a real
|
||||||
|
Crowdsec.
|
||||||
|
|
||||||
|
Run this suite locally with `make e2e` (all scenarios) or
|
||||||
|
`make e2e_<scenario>` (one scenario).
|
||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared helpers for end-to-end scenarios.
|
||||||
|
# Dependencies: bash, curl, docker.
|
||||||
|
|
||||||
|
# Wait until the Crowdsec LAPI reports ready, or fail after timeout.
|
||||||
|
# Usage: wait_crowdsec_ready [container_name] [timeout_seconds]
|
||||||
|
wait_crowdsec_ready() {
|
||||||
|
local container="${1:-crowdsec}"
|
||||||
|
local timeout="${2:-90}"
|
||||||
|
local elapsed=0
|
||||||
|
while (( elapsed < timeout )); do
|
||||||
|
if docker exec "$container" cscli lapi status >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
((elapsed++))
|
||||||
|
done
|
||||||
|
echo "wait_crowdsec_ready: timed out after ${timeout}s waiting for $container" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Poll 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
|
||||||
|
local 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
|
||||||
|
((elapsed++))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dump diagnostic info for a compose project; called on failure.
|
||||||
|
# Usage: dump_diagnostics PROJECT COMPOSE_FILE
|
||||||
|
dump_diagnostics() {
|
||||||
|
local project="$1" compose_file="$2"
|
||||||
|
echo "=== docker compose ps ==="
|
||||||
|
docker compose -p "$project" -f "$compose_file" ps || true
|
||||||
|
echo "=== docker compose logs ==="
|
||||||
|
docker compose -p "$project" -f "$compose_file" logs --no-color || true
|
||||||
|
echo "=== cscli decisions list ==="
|
||||||
|
docker exec crowdsec cscli decisions list 2>/dev/null || true
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
listen_addr: 0.0.0.0:7422
|
||||||
|
appsec_config: crowdsecurity/crs-inband
|
||||||
|
name: e2eAppSec
|
||||||
|
source: appsec
|
||||||
|
labels:
|
||||||
|
type: appsec
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.7.1
|
||||||
|
container_name: e2e-appsec-traefik
|
||||||
|
command:
|
||||||
|
- "--log.level=INFO"
|
||||||
|
- "--accesslog"
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--providers.docker=true"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
- "--entryPoints.web.address=:80"
|
||||||
|
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||||
|
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
ports:
|
||||||
|
- "8000:80"
|
||||||
|
depends_on:
|
||||||
|
crowdsec:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
whoami:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: e2e-appsec-whoami
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
|
||||||
|
- "traefik.http.routers.r.entrypoints=web"
|
||||||
|
- "traefik.http.routers.r.middlewares=bouncer@docker"
|
||||||
|
- "traefik.http.services.s.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
|
||||||
|
# IP bouncing disabled — we only test AppSec body inspection here.
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=none"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecappsecenabled=true"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
|
||||||
|
|
||||||
|
crowdsec:
|
||||||
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
|
container_name: crowdsec
|
||||||
|
environment:
|
||||||
|
COLLECTIONS: "crowdsecurity/appsec-crs-inband"
|
||||||
|
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||||
|
CUSTOM_HOSTNAME: crowdsec
|
||||||
|
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
|
||||||
|
volumes:
|
||||||
|
- ./acquis.yaml:/etc/crowdsec/acquis.yaml:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "cscli", "lapi", "status"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 60
|
||||||
|
start_period: 15s
|
||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=../../lib/common.sh
|
||||||
|
source "$HERE/../../lib/common.sh"
|
||||||
|
|
||||||
|
SCENARIO=appsec
|
||||||
|
PROJECT="e2e-${SCENARIO}"
|
||||||
|
COMPOSE_FILE="$HERE/docker-compose.yml"
|
||||||
|
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
if (( rc != 0 )); then
|
||||||
|
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
|
||||||
|
echo "Scenario failed. Logs written to $LOG_FILE"
|
||||||
|
fi
|
||||||
|
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "[$SCENARIO] starting stack (AppSec collections download — may take ~30s on first boot)..."
|
||||||
|
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting for crowdsec readiness..."
|
||||||
|
wait_crowdsec_ready crowdsec 180
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting for traefik readiness..."
|
||||||
|
wait_for_status http://localhost:8000/foo 200 30
|
||||||
|
|
||||||
|
echo "[$SCENARIO] benign request must pass"
|
||||||
|
assert_status http://localhost:8000/foo 200
|
||||||
|
|
||||||
|
echo "[$SCENARIO] SQL-injection-like query string must be blocked by OWASP CRS (inband)"
|
||||||
|
# Classic SQLi probe: ?id=1' OR '1'='1 — caught by CRS rule 942100/942130 (paranoia 1).
|
||||||
|
assert_status "http://localhost:8000/foo?id=1%27%20OR%20%271%27%3D%271" 403
|
||||||
|
|
||||||
|
echo "[$SCENARIO] OK"
|
||||||
@@ -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,59 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.7.1
|
||||||
|
container_name: e2e-captcha-traefik
|
||||||
|
command:
|
||||||
|
- "--log.level=INFO"
|
||||||
|
- "--accesslog"
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--providers.docker=true"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
- "--entryPoints.web.address=:80"
|
||||||
|
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||||
|
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
- ./captcha.html:/captcha.html:ro
|
||||||
|
ports:
|
||||||
|
- "8000:80"
|
||||||
|
depends_on:
|
||||||
|
crowdsec:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
whoami:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: e2e-captcha-whoami
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
|
||||||
|
- "traefik.http.routers.r.entrypoints=web"
|
||||||
|
- "traefik.http.routers.r.middlewares=bouncer@docker"
|
||||||
|
- "traefik.http.services.s.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchaprovider=turnstile"
|
||||||
|
# Cloudflare Turnstile public test keys (always pass / always fail variants exist).
|
||||||
|
# These render a valid widget without contacting any real API key.
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchasitekey=1x00000000000000000000AA"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchasecretkey=1x0000000000000000000000000000000AA"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchahtmlfilepath=/captcha.html"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchagraceperiodseconds=10"
|
||||||
|
|
||||||
|
crowdsec:
|
||||||
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
|
container_name: crowdsec
|
||||||
|
environment:
|
||||||
|
DISABLE_ONLINE_API: "true"
|
||||||
|
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||||
|
CUSTOM_HOSTNAME: crowdsec
|
||||||
|
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "cscli", "lapi", "status"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
start_period: 5s
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=../../lib/common.sh
|
||||||
|
source "$HERE/../../lib/common.sh"
|
||||||
|
|
||||||
|
SCENARIO=captcha
|
||||||
|
PROJECT="e2e-${SCENARIO}"
|
||||||
|
COMPOSE_FILE="$HERE/docker-compose.yml"
|
||||||
|
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
if (( rc != 0 )); then
|
||||||
|
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
|
||||||
|
echo "Scenario failed. Logs written to $LOG_FILE"
|
||||||
|
fi
|
||||||
|
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "[$SCENARIO] starting stack..."
|
||||||
|
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting for crowdsec readiness..."
|
||||||
|
wait_crowdsec_ready
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting for traefik readiness..."
|
||||||
|
wait_for_status http://localhost:8000/foo 200 30
|
||||||
|
|
||||||
|
echo "[$SCENARIO] adding captcha decision for 1.2.3.4"
|
||||||
|
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type captcha --duration 5m
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting one stream tick + buffer..."
|
||||||
|
sleep 6
|
||||||
|
|
||||||
|
echo "[$SCENARIO] captcha response must be HTTP 200 (the captcha page itself, not a 403)"
|
||||||
|
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] captcha response body must contain the captcha template marker"
|
||||||
|
body=$(curl -s http://localhost:8000/foo -H "X-Forwarded-For: 1.2.3.4")
|
||||||
|
if ! grep -q "E2E_CAPTCHA_PAGE_MARKER" <<<"$body"; then
|
||||||
|
echo "Expected response body to contain E2E_CAPTCHA_PAGE_MARKER, got:" >&2
|
||||||
|
echo "$body" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$SCENARIO] non-flagged IP must still pass through to the backend"
|
||||||
|
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] OK"
|
||||||
@@ -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,53 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.7.1
|
||||||
|
container_name: e2e-banpage-traefik
|
||||||
|
command:
|
||||||
|
- "--log.level=INFO"
|
||||||
|
- "--accesslog"
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--providers.docker=true"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
- "--entryPoints.web.address=:80"
|
||||||
|
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||||
|
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
- ./ban.html:/ban.html:ro
|
||||||
|
ports:
|
||||||
|
- "8000:80"
|
||||||
|
depends_on:
|
||||||
|
crowdsec:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
whoami:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: e2e-banpage-whoami
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
|
||||||
|
- "traefik.http.routers.r.entrypoints=web"
|
||||||
|
- "traefik.http.routers.r.middlewares=bouncer@docker"
|
||||||
|
- "traefik.http.services.s.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.banhtmlfilepath=/ban.html"
|
||||||
|
|
||||||
|
crowdsec:
|
||||||
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
|
container_name: crowdsec
|
||||||
|
environment:
|
||||||
|
DISABLE_ONLINE_API: "true"
|
||||||
|
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||||
|
CUSTOM_HOSTNAME: crowdsec
|
||||||
|
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "cscli", "lapi", "status"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
start_period: 5s
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/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
|
||||||
|
PROJECT="e2e-${SCENARIO}"
|
||||||
|
COMPOSE_FILE="$HERE/docker-compose.yml"
|
||||||
|
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
if (( rc != 0 )); then
|
||||||
|
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
|
||||||
|
echo "Scenario failed. Logs written to $LOG_FILE"
|
||||||
|
fi
|
||||||
|
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "[$SCENARIO] starting stack..."
|
||||||
|
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting for crowdsec readiness..."
|
||||||
|
wait_crowdsec_ready
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting for traefik readiness..."
|
||||||
|
wait_for_status http://localhost:8000/foo 200 30
|
||||||
|
|
||||||
|
echo "[$SCENARIO] adding ban decision"
|
||||||
|
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting one stream tick + buffer..."
|
||||||
|
sleep 6
|
||||||
|
|
||||||
|
echo "[$SCENARIO] banned response status is 403"
|
||||||
|
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] banned response Content-Type is HTML"
|
||||||
|
assert_header http://localhost:8000/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"
|
||||||
|
body=$(curl -s http://localhost:8000/foo -H "X-Forwarded-For: 1.2.3.4")
|
||||||
|
if ! grep -q "E2E_CUSTOM_BAN_PAGE_MARKER" <<<"$body"; then
|
||||||
|
echo "Expected response body to contain E2E_CUSTOM_BAN_PAGE_MARKER, got:" >&2
|
||||||
|
echo "$body" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$SCENARIO] OK"
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.7.1
|
||||||
|
container_name: e2e-live-traefik
|
||||||
|
command:
|
||||||
|
- "--log.level=INFO"
|
||||||
|
- "--accesslog"
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--providers.docker=true"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
- "--entryPoints.web.address=:80"
|
||||||
|
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||||
|
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
ports:
|
||||||
|
- "8000:80"
|
||||||
|
depends_on:
|
||||||
|
crowdsec:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
whoami:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: e2e-live-whoami
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
|
||||||
|
- "traefik.http.routers.r.entrypoints=web"
|
||||||
|
- "traefik.http.routers.r.middlewares=bouncer@docker"
|
||||||
|
- "traefik.http.services.s.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=live"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.defaultdecisionseconds=2"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
|
||||||
|
|
||||||
|
crowdsec:
|
||||||
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
|
container_name: crowdsec
|
||||||
|
environment:
|
||||||
|
DISABLE_ONLINE_API: "true"
|
||||||
|
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||||
|
CUSTOM_HOSTNAME: crowdsec
|
||||||
|
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "cscli", "lapi", "status"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
start_period: 5s
|
||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/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
|
||||||
|
PROJECT="e2e-${SCENARIO}"
|
||||||
|
COMPOSE_FILE="$HERE/docker-compose.yml"
|
||||||
|
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
if (( rc != 0 )); then
|
||||||
|
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
|
||||||
|
echo "Scenario failed. Logs written to $LOG_FILE"
|
||||||
|
fi
|
||||||
|
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "[$SCENARIO] starting stack..."
|
||||||
|
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting for crowdsec readiness..."
|
||||||
|
wait_crowdsec_ready
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting for traefik readiness..."
|
||||||
|
wait_for_status http://localhost:8000/foo 200 30
|
||||||
|
|
||||||
|
echo "[$SCENARIO] no decision -> first hit queries LAPI, returns 200, caches 'allowed' for 2s"
|
||||||
|
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
|
||||||
|
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 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://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] another non-banned IP must still pass"
|
||||||
|
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] OK"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.7.1
|
||||||
|
container_name: e2e-none-traefik
|
||||||
|
command:
|
||||||
|
- "--log.level=INFO"
|
||||||
|
- "--accesslog"
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--providers.docker=true"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
- "--entryPoints.web.address=:80"
|
||||||
|
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||||
|
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
ports:
|
||||||
|
- "8000:80"
|
||||||
|
depends_on:
|
||||||
|
crowdsec:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
whoami:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: e2e-none-whoami
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
|
||||||
|
- "traefik.http.routers.r.entrypoints=web"
|
||||||
|
- "traefik.http.routers.r.middlewares=bouncer@docker"
|
||||||
|
- "traefik.http.services.s.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=none"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
|
||||||
|
|
||||||
|
crowdsec:
|
||||||
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
|
container_name: crowdsec
|
||||||
|
environment:
|
||||||
|
DISABLE_ONLINE_API: "true"
|
||||||
|
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||||
|
CUSTOM_HOSTNAME: crowdsec
|
||||||
|
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "cscli", "lapi", "status"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
start_period: 5s
|
||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/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
|
||||||
|
PROJECT="e2e-${SCENARIO}"
|
||||||
|
COMPOSE_FILE="$HERE/docker-compose.yml"
|
||||||
|
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
if (( rc != 0 )); then
|
||||||
|
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
|
||||||
|
echo "Scenario failed. Logs written to $LOG_FILE"
|
||||||
|
fi
|
||||||
|
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "[$SCENARIO] starting stack..."
|
||||||
|
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting for crowdsec readiness..."
|
||||||
|
wait_crowdsec_ready
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting for traefik readiness..."
|
||||||
|
wait_for_status http://localhost:8000/foo 200 30
|
||||||
|
|
||||||
|
echo "[$SCENARIO] no decision -> request passes (LAPI queried per request)"
|
||||||
|
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] adding ban decision"
|
||||||
|
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
|
||||||
|
|
||||||
|
echo "[$SCENARIO] none mode has no cache -> next request must be blocked immediately"
|
||||||
|
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] deleting decision"
|
||||||
|
docker exec crowdsec cscli decisions delete --ip 1.2.3.4
|
||||||
|
|
||||||
|
echo "[$SCENARIO] previously banned IP must pass again immediately"
|
||||||
|
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] OK"
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.7.1
|
||||||
|
container_name: e2e-stream-traefik
|
||||||
|
command:
|
||||||
|
- "--log.level=INFO"
|
||||||
|
- "--accesslog"
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--providers.docker=true"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
- "--entryPoints.web.address=:80"
|
||||||
|
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||||
|
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
ports:
|
||||||
|
- "8000:80"
|
||||||
|
depends_on:
|
||||||
|
crowdsec:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
whoami:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: e2e-stream-whoami
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
|
||||||
|
- "traefik.http.routers.r.entrypoints=web"
|
||||||
|
- "traefik.http.routers.r.middlewares=bouncer@docker"
|
||||||
|
- "traefik.http.services.s.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
|
||||||
|
|
||||||
|
crowdsec:
|
||||||
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
|
container_name: crowdsec
|
||||||
|
environment:
|
||||||
|
DISABLE_ONLINE_API: "true"
|
||||||
|
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||||
|
CUSTOM_HOSTNAME: crowdsec
|
||||||
|
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "cscli", "lapi", "status"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
start_period: 5s
|
||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/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
|
||||||
|
PROJECT="e2e-${SCENARIO}"
|
||||||
|
COMPOSE_FILE="$HERE/docker-compose.yml"
|
||||||
|
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
if (( rc != 0 )); then
|
||||||
|
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
|
||||||
|
echo "Scenario failed. Logs written to $LOG_FILE"
|
||||||
|
fi
|
||||||
|
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "[$SCENARIO] starting stack..."
|
||||||
|
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting for crowdsec readiness..."
|
||||||
|
wait_crowdsec_ready
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting for traefik readiness..."
|
||||||
|
wait_for_status http://localhost:8000/foo 200 30
|
||||||
|
|
||||||
|
echo "[$SCENARIO] no decision yet -> request allowed"
|
||||||
|
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
|
||||||
|
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting one stream tick + buffer..."
|
||||||
|
sleep 6
|
||||||
|
|
||||||
|
echo "[$SCENARIO] banned IP must be blocked (HTTP 403)"
|
||||||
|
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] non-banned IP must still pass (HTTP 200)"
|
||||||
|
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] deleting ban decision"
|
||||||
|
docker exec crowdsec cscli decisions delete --ip 1.2.3.4
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting one stream tick + buffer..."
|
||||||
|
sleep 6
|
||||||
|
|
||||||
|
echo "[$SCENARIO] previously banned IP must pass again"
|
||||||
|
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] OK"
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.7.1
|
||||||
|
container_name: e2e-trusted-traefik
|
||||||
|
command:
|
||||||
|
- "--log.level=INFO"
|
||||||
|
- "--accesslog"
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--providers.docker=true"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
- "--entryPoints.web.address=:80"
|
||||||
|
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||||
|
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
ports:
|
||||||
|
- "8000:80"
|
||||||
|
depends_on:
|
||||||
|
crowdsec:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
whoami:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: e2e-trusted-whoami
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
|
||||||
|
- "traefik.http.routers.r.entrypoints=web"
|
||||||
|
- "traefik.http.routers.r.middlewares=bouncer@docker"
|
||||||
|
- "traefik.http.services.s.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
|
||||||
|
- "traefik.http.middlewares.bouncer.plugin.bouncer.clienttrustedips=1.2.3.4/32"
|
||||||
|
|
||||||
|
crowdsec:
|
||||||
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
|
container_name: crowdsec
|
||||||
|
environment:
|
||||||
|
DISABLE_ONLINE_API: "true"
|
||||||
|
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||||
|
CUSTOM_HOSTNAME: crowdsec
|
||||||
|
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "cscli", "lapi", "status"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
start_period: 5s
|
||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/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
|
||||||
|
PROJECT="e2e-${SCENARIO}"
|
||||||
|
COMPOSE_FILE="$HERE/docker-compose.yml"
|
||||||
|
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
if (( rc != 0 )); then
|
||||||
|
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
|
||||||
|
echo "Scenario failed. Logs written to $LOG_FILE"
|
||||||
|
fi
|
||||||
|
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "[$SCENARIO] starting stack..."
|
||||||
|
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting for crowdsec readiness..."
|
||||||
|
wait_crowdsec_ready
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting for traefik readiness..."
|
||||||
|
wait_for_status http://localhost:8000/foo 200 30
|
||||||
|
|
||||||
|
echo "[$SCENARIO] banning the trusted IP 1.2.3.4 and an untrusted IP 5.6.7.8"
|
||||||
|
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
|
||||||
|
docker exec crowdsec cscli decisions add --ip 5.6.7.8 --type ban --duration 5m
|
||||||
|
|
||||||
|
echo "[$SCENARIO] waiting one stream tick + buffer..."
|
||||||
|
sleep 6
|
||||||
|
|
||||||
|
echo "[$SCENARIO] trusted IP must bypass the bouncer even though it is banned"
|
||||||
|
assert_status http://localhost:8000/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://localhost:8000/foo 403 -H "X-Forwarded-For: 5.6.7.8"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] OK"
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||||
|
|
||||||
// pluginVersion is updated automatically by the release workflow.
|
// pluginVersion is updated automatically by the release workflow.
|
||||||
var pluginVersion = "1.5.0" //nolint:gochecknoglobals
|
var pluginVersion = "1.6.X" //nolint:gochecknoglobals
|
||||||
|
|||||||
Reference in New Issue
Block a user