Compare commits

..
Author SHA1 Message Date
mhxandClaude Opus 4.8 8497f7de75 e2e mock: add AppSec scenario + custom remediation header assertion
Address review feedback on the binary e2e suite:

- mocklapi: add an AppSec WAF stand-in (--appsec-addr) that blocks any URI
  containing "rpc2" — the exact probe from examples/appsec-enabled — and allows
  the rest. Lets the suite exercise the plugin's AppSec wiring (header
  forwarding + allow/block enforcement) without the real CRS engine.
- new scenarios/appsec: benign request passes, /foo/rpc2 is 403.
- custom-ban-page: assert the banned response carries the custom remediation
  header (remediationHeadersCustomName), per review.
- README: drop the "don't open issues / AppSec intentionally absent" framing;
  describe what the suite actually covers, including AppSec wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:33:52 +02:00
mhxandClaude Opus 4.8 070a82992a ⬇️ e2e mock: keep Go floor at 1.22 (yaegi ceiling)
Revert the mock module back to go 1.22 and make the e2e workflow read the Go
version from go.mod (go-version-file) instead of hardcoding 1.23.

Rationale: the plugin is interpreted by yaegi, and even Traefik v3.7.1 ships
yaegi v0.16.1 (Go 1.22), so the project stays on 1.22. The earlier bump to 1.23
is dropped (plugin go.mod stays 1.22, see #330 for the Renovate cap + CI pin).
Mock + all six scenarios verified on Go 1.22.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:03:19 +02:00
mhxandClaude Opus 4.8 c91ff6cc59 ⬆️ e2e mock: bump module go directive to 1.23
Align the mock module with the project's Go version (CI uses 1.23). Part of
standardising the whole project on Go 1.23; the plugin module is bumped
separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:03:19 +02:00
mhxandClaude Opus 4.8 f6b3983299 e2e mock: replace fixed sleeps with condition polling
The `sleep 4` / `sleep 3` after a decision change were magic numbers tied to
updateIntervalSeconds / defaultDecisionSeconds. Replace them with waits on the
actual condition:

- After a ban/unban, poll with wait_for_status until the expected code shows up
  (stream propagation / live-mode cache TTL).
- Captcha keeps status 200 before and after, so gate on the body marker via a
  new wait_for_body_contains helper.
- Control assertions that must NOT change stay immediate (assert_status).

Self-documenting, faster on the happy path (returns on the first poll that
sees the change), and more robust under slow CI. No fixed sleeps remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:03:19 +02:00
mhxandClaude Opus 4.8 b67edfe308 🔥 e2e mock: simplify the Crowdsec LAPI mock
Per review, trim the mock to the minimum the plugin actually exercises:

- Drop the stream delta bookkeeping (startup flag + "already streamed" set).
  The plugin re-Sets/Deletes its cache on every poll, so reporting the whole
  active set as "new" and removed ones as "deleted" is enough.
- Shrink the Decision struct to the three fields the plugin reads
  (value/type/duration); drop id/origin/scope/scenario and the id counter.
- Drop API-key auth and the /admin/reset endpoint — no scenario exercises
  either. Also drop the now-unused lapi_reset helper.
- Replace the store struct + methods with two package-level maps + a mutex.

mocklapi/main.go: 234 -> 118 lines. All six scenarios still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:03:19 +02:00
mhxandClaude Opus 4.8 fcebbfe902 ♻️ tests: move local Docker e2e suite to its own PR (#333)
Per review, split the e2e work so each PR is focused. CI runs the binary +
mock-LAPI suite (this PR); the heavier, local-only Docker suite (real Traefik
+ Crowdsec, incl. appsec) now lives in #333.

Removes tests/e2e/scenarios, tests/e2e/lib and the Docker-suite README, and
drops the `e2e` Make target here (kept in #333). The binary/mock suite and its
`e2e_mock` target are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:03:19 +02:00
mhxandClaude Opus 4.8 8580a085b9 🔧 e2e mock: address review — clarify backend flag, move ignore to root
- Document the --backend-addr flag: it is the stub upstream service Traefik
  proxies allowed requests to (the traefik/whoami equivalent), not AppSec.
- Move the .cache/ ignore rule from the per-suite .gitignore to the repo root
  .gitignore, and make the wording accurate: the cache persists across local
  runs but is recreated on every (fresh-runner) CI run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:03:19 +02:00
mhxandClaude Opus 4.8 b201143844 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>
2026-06-06 12:03:19 +02:00
mhxandClaude Opus 4.8 33def87c30 ♻️ tests: run e2e suite in a single sequential job
The matrix spawned one runner per scenario, so the Traefik, Crowdsec and
whoami images were pulled — and Crowdsec booted — once per scenario. Run
the whole suite in a single job with `make -k e2e` instead: Docker caches
the images locally so they are pulled only once, and `-k` keeps the
remaining scenarios running after a failure (make still exits non-zero).

Scenarios already share the canonical `crowdsec` container name and the
8000 port, so they were meant to run sequentially anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:03:19 +02:00
mhx d1bdd7b423 🐛 tests: appsec scenario uses OWASP CRS inband collection
appsec-virtual-patching only ships CVE-specific rules, not generic
SQLi. Switch to crowdsecurity/appsec-crs-inband (the blocking OWASP
Core Rule Set) and use a SQLi probe that CRS paranoia level 1
matches (rule 942100/942130).
2026-06-06 12:03:19 +02:00
mhx 45abab69ec 🐛 tests: set CROWDSEC_BYPASS_DB_VOLUME_CHECK for v1.7+
Crowdsec v1.7 refuses to start without an explicit volume mount on
/var/lib/crowdsec/data (or the bypass env var). For E2E we don't need
db persistence — set the bypass everywhere so the stack boots.
2026-06-06 12:03:19 +02:00
mhx 4c3bbf81fd tests: revert to docker provider + bump versions + add 6 scenarios
Reverts the stream-mode scenario to the Traefik docker provider (the
file provider was a workaround for a local docker daemon API version
mismatch, irrelevant in CI). Bumps Traefik to v3.7.1 and Crowdsec to
v1.7.8 across all scenarios.

Adds six new E2E scenarios:
- live-mode: short defaultDecisionSeconds, verifies cache-then-recheck
- none-mode: verifies LAPI is queried per request, no caching
- trusted-ips: clientTrustedIPs bypass even when the trusted IP is banned
- custom-ban-page: BanHTMLFilePath body + Content-Type validation
- captcha: captcha decision serves the captcha page (HTTP 200)
- appsec: SQLi probe blocked by appsec-virtual-patching

Each scenario uses an isolated compose project, mounts the repo as a
local plugin, and asserts behavior via curl. Workflow matrix and
Makefile E2E_SCENARIOS updated accordingly.

Refs #328
2026-06-06 12:03:19 +02:00
mhx 900c7ebdc2 tests: end-to-end suite scaffold + stream-mode scenario
Add tests/e2e/ structure with shared bash helpers and the first
scenario (stream-mode): spin up real Traefik + Crowdsec via docker
compose, mount the repo as a local plugin, add a ban via cscli, verify
the bouncer blocks the matching X-Forwarded-For, then delete the
decision and verify pass-through.

Adds .github/workflows/e2e.yml with one matrix job per scenario
(stream-mode for now), and Makefile targets `e2e` and `e2e_<scenario>`
for local runs.

Refs #328
2026-06-06 12:03:19 +02:00
maxlerebourgandmhx 71d845faae ️ Do not block middleware startup due to stream intialization (#281)
* 🍱 not block on stream startup

* 🍱 fix

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint

* 🍱 update readme

* 📚 clarify StreamStartupBlock warning in README

Make the fail-open implication of StreamStartupBlock=false explicit:
banned IPs are allowed through until the first stream sync completes.

* 🐛 clear isCrowdsecStreamStartup on alreadyUpdated path

When cacheTimeoutKey is already set (another instance/process has
populated the stream cache within the update window), the early return
in handleStreamCache used to leave isCrowdsecStreamStartup at true,
causing this instance to keep sending startup=true on subsequent ticks.
Flip the flag in the early-return path so startup is correctly tracked
across multi-instance deployments sharing a cache.

---------

Co-authored-by: mhx <mathieu@hanotaux.fr>
2026-06-06 11:07:18 +02:00
maxlerebourg 0d8fd2a7a9 🍱 Header.Add -> Set, log Info -> Debug, version bump (#324) 2026-05-05 15:51:35 +02:00
maxlerebourg 1f6a8991c8 🐛 fix appsec bypass with invalid content-length (#321) 2026-04-27 08:33:11 +02:00
Davidandmaxlerebourg 7f776fe0fe 🔊 Improve Logging: move to slog and add trace level (#276)
* Add Warn and Trace loglevels

* Move to slog

* fixes

* missing test file

* Fix tests

* Add wrapper with trace

* fix

* fix lint

* LINT

* LINT

* 🍱 Use only 4 level of logs

* fix after merge

* 🍱 fix lint

* 🍱 fix lint + remove trace logger from tests

* 🍱 fix tests

* 🍱 fix lint

* 🍱 try to fix test

* 🍱 Fix tests

* 🍱 fix README + adjust logs

---------

Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
2026-03-13 18:03:12 +01:00
cliffjao e54c1d5c4f Update README.md to show simplified dynamic configuration (#313)
* Update README.md to show simplified dynamic configuration

The full dynamic configuration can be pretty intimidating as a starting point. Provide a simplified dynamic configuration to get up & running quickly.

* Update README.md

Set enabled to true
2026-02-21 19:45:02 +01:00
Shadoweee77andmaxlerebourg c2bbc4dac5 cicd: Automate proper version tagging in the bouncer.go that is reported to Crowdsec LAPI (#314)
* cicd(versioning): Automate proper version tagging in the bouncer.go that is reported to Crowdsec LAPI

* cicd(versioning): Move pluginVersion to it's own file

* Disablie lint check for no global variables

* Remove trailing new-line

* Update pluginVersion declaration style

* 🍱 Simplify for tests

Removed the verification step after updating the version and modified the commit message to include an emoji.

---------

Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
2026-02-19 23:41:55 +01:00
maxlerebourg 889c5b55fe Add solved-captcha as option of remediationCustomHeader (#310) 2026-01-23 11:36:32 +01:00
maxlerebourgandCopilot Autofix powered by AI efb3a67019 🔒️ Potential fix for code scanning alert no. 8: Workflow does not contain permissions (#311)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-01-22 09:44:33 +01:00
38 changed files with 1659 additions and 107 deletions
+42
View File
@@ -0,0 +1,42 @@
name: E2E
on:
push:
branches: [main]
pull_request:
concurrency:
group: e2e-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
e2e:
name: e2e (binary + mock LAPI)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
# Track go.mod (Go 1.22) — the plugin's yaegi-bound floor. Keeps the
# single source of truth and builds the mock on the supported version.
go-version-file: go.mod
# 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-mock-*.log
if-no-files-found: ignore
+3
View File
@@ -1,5 +1,8 @@
name: Main
permissions:
contents: read
on:
push:
branches:
+46
View File
@@ -0,0 +1,46 @@
name: Release Version Update
on:
release:
types: [published]
permissions:
contents: write
jobs:
update-version:
name: Update version in source
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: main
- name: Extract version from tag
id: get_version
run: |
TAG="${{ github.event.release.tag_name }}"
VERSION="${TAG#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Update version in version.go
run: |
sed -i 's/pluginVersion = "[^"]*"/pluginVersion = "'"${{ steps.get_version.outputs.version }}"'"/' version.go
cat version.go
- name: Commit, push, and retag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add version.go
if git diff --cached --quiet; then
echo "Version already up to date, nothing to commit"
exit 0
fi
git commit -m "⬆️ chore: bump version to ${{ steps.get_version.outputs.version }}"
git push origin main
# Move the release tag to include the version update
git tag -f "${{ steps.get_version.outputs.tag }}"
git push -f origin "${{ steps.get_version.outputs.tag }}"
+4
View File
@@ -5,3 +5,7 @@ conf
db
logs
docker-compose.dev.yml
# Binary e2e suite working cache: Traefik binary + compiled mock. Persisted
# across local runs; CI runs on fresh runners so it is recreated every time.
tests/e2e/mock/.cache/
+1
View File
@@ -73,6 +73,7 @@ linters:
- gci
- mnd
- exportloopref
- contextcheck
issues:
exclude-use-default: false
max-same-issues: 0
+10 -1
View File
@@ -1,7 +1,11 @@
.PHONY: lint test vendor clean
.PHONY: lint test vendor clean e2e_mock
export GO111MODULE=on
# Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs.
# The local Docker suite (make e2e) lives in a separate PR/branch.
E2E_MOCK_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec
default: lint test
lint:
@@ -13,6 +17,11 @@ test:
yaegi_test:
yaegi test -v .
e2e_mock: $(addprefix e2e_mock_,$(E2E_MOCK_SCENARIOS))
e2e_mock_%:
./tests/e2e/mock/scenarios/$*/run.sh
vendor:
go mod vendor
+45 -3
View File
@@ -329,8 +329,12 @@ make run
- Enable the plugin
- LogLevel
- string
- default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR`
- default: `INFO`, expected values are: `DEBUG`, `INFO`, `WARN`, `ERROR`
- Log are written to `stdout` / `stderr` or file if LogFilePath is provided
- LogFormat
- string
- default: `common`, expected values are: `common`, `json`
- Log format: `common` for traditional text logs, `json` for structured JSON logs
- LogFilePath
- string
- default: ""
@@ -420,7 +424,7 @@ make run
- RemediationHeadersCustomName
- string
- default: ""
- Name of the header you want in response when request are cancelled (possible value of the header `ban` or `captcha`)
- Name of the header you want in response when request are handled by plugin (possible value of the header `ban`, `captcha` or `solved-captcha`)
- ForwardedHeadersCustomName
- string
- default: "X-Forwarded-For"
@@ -461,6 +465,12 @@ make run
- int64
- 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)
- 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
- int64
- default: 60
@@ -536,7 +546,37 @@ experimental:
```
```yaml
# Dynamic configuration
# Simplified dynamic configuration
http:
routers:
my-router:
rule: host(`whoami.localhost`)
service: service-foo
entryPoints:
- web
middlewares:
- crowdsec
services:
service-foo:
loadBalancer:
servers:
- url: http://127.0.0.1:5000
middlewares:
crowdsec:
plugin:
bouncer:
enabled: true
logLevel: DEBUG
crowdsecMode: live
crowdsecLapiKey: privateKey-foo
crowdsecLapiHost: crowdsec:8080
```
```yaml
# Full dynamic configuration
http:
routers:
@@ -560,9 +600,11 @@ http:
bouncer:
enabled: false
logLevel: DEBUG
logFormat: common
LogFilePath: ""
updateIntervalSeconds: 60
updateMaxFailure: 0
streamStartupBlock: true
defaultDecisionSeconds: 60
remediationStatusCode: 403
httpTimeoutSeconds: 10
+27 -20
View File
@@ -11,6 +11,7 @@ import (
"fmt"
htmltemplate "html/template"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
@@ -63,7 +64,7 @@ const (
//nolint:gochecknoglobals
var (
isStartup = true
isCrowdsecStreamStartup = true
isCrowdsecStreamHealthy = true
updateFailure int64
streamTicker chan bool
@@ -117,16 +118,16 @@ type Bouncer struct {
httpAppsecClient *http.Client
cacheClient *cache.Client
captchaClient *captcha.Client
log *logger.Log
log *slog.Logger
}
// 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) {
config.LogLevel = strings.ToUpper(config.LogLevel)
log := logger.New(config.LogLevel, config.LogFilePath)
err := configuration.ValidateParams(config)
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
err := configuration.ValidateParams(config, log)
if err != nil {
log.Error("New:validateParams " + err.Error())
return nil, err
@@ -290,8 +291,11 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
return nil, err
}
}
handleStreamTicker(bouncer)
isStartup = false
if config.StreamStartupBlock {
handleStreamTicker(bouncer)
} else {
go handleStreamTicker(bouncer)
}
streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() {
handleStreamTicker(bouncer)
})
@@ -300,7 +304,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
// Start metrics ticker if not already running
if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 {
lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker
handleMetricsTicker(bouncer)
go handleMetricsTicker(bouncer)
metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() {
handleMetricsTicker(bouncer)
})
@@ -455,7 +459,7 @@ func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Req
err := bouncer.banTemplate.Execute(rw, templateData)
if err != nil {
bouncer.log.Error("handleBanServeHTTP banTemplateServe " + err.Error())
bouncer.log.Warn("handleBanServeHTTP could not write template to ResponseWriter: " + err.Error())
}
}
@@ -486,7 +490,7 @@ func (bouncer *Bouncer) handleNextServeHTTP(rw http.ResponseWriter, req *http.Re
func handleStreamTicker(bouncer *Bouncer) {
if err := handleStreamCache(bouncer); err != nil {
bouncer.log.Debug(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
bouncer.log.Warn(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
if bouncer.updateMaxFailure != -1 && updateFailure >= bouncer.updateMaxFailure && isCrowdsecStreamHealthy {
isCrowdsecStreamHealthy = false
bouncer.log.Error(fmt.Sprintf("handleStreamTicker:error updateFailure:%d %s", updateFailure, err.Error()))
@@ -504,7 +508,7 @@ func handleMetricsTicker(bouncer *Bouncer) {
}
}
func startTicker(name string, updateInterval int64, log *logger.Log, work func()) chan bool {
func startTicker(name string, updateInterval int64, log *slog.Logger, work func()) chan bool {
ticker := time.NewTicker(time.Duration(updateInterval) * time.Second)
stop := make(chan bool, 1)
go func() {
@@ -571,7 +575,7 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
case "captcha":
value = cache.CaptchaValue
default:
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
}
if isLiveMode && bouncer.defaultDecisionTimeout > 0 {
durationSecond := int64(duration.Seconds())
@@ -607,11 +611,11 @@ func getToken(bouncer *Bouncer) error {
if err != nil {
return fmt.Errorf("getToken:parsingBody %w", err)
}
if login.Code == 200 && len(login.Token) > 0 {
if login.Code == http.StatusOK && len(login.Token) > 0 {
bouncer.crowdsecKey = login.Token
bouncer.log.Debug(fmt.Sprintf("getToken statusCode:%d", login.Code))
return nil
}
bouncer.log.Warn(fmt.Sprintf("getToken statusCode:%d", login.Code))
return fmt.Errorf("getToken statusCode:%d", login.Code)
}
@@ -623,6 +627,7 @@ func handleStreamCache(bouncer *Bouncer) error {
_, err := bouncer.cacheClient.Get(cacheTimeoutKey)
if err == nil {
bouncer.log.Debug("handleStreamCache:alreadyUpdated")
isCrowdsecStreamStartup = false
return nil
}
if err.Error() != cache.CacheMiss {
@@ -633,7 +638,7 @@ func handleStreamCache(bouncer *Bouncer) error {
Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost,
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)
if err != nil {
@@ -654,7 +659,7 @@ func handleStreamCache(bouncer *Bouncer) error {
case "captcha":
value = cache.CaptchaValue
default:
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
}
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
}
@@ -663,6 +668,7 @@ func handleStreamCache(bouncer *Bouncer) error {
bouncer.cacheClient.Delete(decision.Value)
}
bouncer.log.Debug("handleStreamCache:updated")
isCrowdsecStreamStartup = false
return nil
}
@@ -673,8 +679,8 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
} else {
req, _ = http.NewRequest(http.MethodGet, stringURL, nil)
}
req.Header.Add(bouncer.crowdsecHeader, bouncer.crowdsecKey)
req.Header.Add("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/1.X.X")
req.Header.Set(bouncer.crowdsecHeader, bouncer.crowdsecKey)
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
res, err := bouncer.httpClient.Do(req)
if err != nil {
@@ -712,7 +718,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
Path: bouncer.appsecPath,
}
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
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
@@ -738,6 +744,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
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)
if err != nil {
@@ -779,7 +786,7 @@ func reportMetrics(bouncer *Bouncer) error {
metrics := map[string]interface{}{
"remediation_components": []map[string]interface{}{
{
"version": "1.X.X",
"version": pluginVersion,
"type": "bouncer",
"name": "traefik_plugin",
"metrics": []map[string]interface{}{
+320
View File
@@ -0,0 +1,320 @@
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
)
// getTestConfig returns a minimal valid configuration for testing.
// Override specific fields by modifying the returned config.
func getTestConfig() *configuration.Config {
return &configuration.Config{
Enabled: true,
LogLevel: "INFO",
LogFormat: "common",
LogFilePath: "",
CrowdsecMode: "none",
CrowdsecLapiKey: "test-key",
CrowdsecLapiHost: "localhost",
CrowdsecLapiScheme: "http",
UpdateIntervalSeconds: 60,
DefaultDecisionSeconds: 60,
HTTPTimeoutSeconds: 10,
ForwardedHeadersTrustedIPs: []string{"127.0.0.1"},
ForwardedHeadersCustomName: "",
RemediationStatusCode: 403,
BanHTMLFilePath: "",
RemediationHeadersCustomName: "",
CaptchaProvider: "",
CaptchaSiteKey: "",
CaptchaSecretKey: "",
CaptchaGracePeriodSeconds: 1,
CaptchaHTMLFilePath: "",
RedisCacheEnabled: false,
RedisCacheHost: "",
RedisCachePassword: "",
RedisCacheDatabase: "",
RedisCacheUnreachableBlock: false,
CrowdsecAppsecEnabled: false,
CrowdsecAppsecHost: "",
CrowdsecAppsecPath: "",
CrowdsecAppsecFailureBlock: false,
CrowdsecAppsecUnreachableBlock: false,
CrowdsecLapiTLSInsecureVerify: true,
CrowdsecLapiTLSCertificateBouncer: "",
CrowdsecLapiTLSCertificateBouncerKey: "",
CrowdsecCapiMachineID: "",
CrowdsecCapiPassword: "",
CrowdsecCapiScenarios: []string{},
UpdateMaxFailure: 0,
MetricsUpdateIntervalSeconds: 0,
}
}
// Helper function to create and execute a bouncer request for testing
func createAndExecuteBouncerRequest(t *testing.T, config *configuration.Config) {
t.Helper()
// Create a mock next handler
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
})
// Create the bouncer plugin (this will initialize the logger with file output)
bouncerHandler, err := New(context.Background(), nextHandler, config, "test-bouncer")
if err != nil {
t.Fatalf("Failed to create bouncer: %v", err)
}
// Create a test request to trigger logging
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
req.RemoteAddr = "192.168.1.100:12345" // Use a non-trusted IP to trigger logging
rw := httptest.NewRecorder()
// Process the request (this should generate log entries)
bouncerHandler.ServeHTTP(rw, req)
// Give a moment for log writes to complete
time.Sleep(100 * time.Millisecond)
}
// Helper function to parse log file and extract found levels
func parseLogFileAndExtractLevels(t *testing.T, logFile string) map[string]bool {
t.Helper()
// Verify the log file was created and contains entries
if _, statErr := os.Stat(logFile); os.IsNotExist(statErr) {
t.Fatalf("Log file was not created: %s", logFile)
}
// Read the log file content
// #nosec G304 - logFile is a test-generated temporary file path
logContent, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
logString := string(logContent)
if len(logString) == 0 {
return make(map[string]bool) // Return empty map for empty log files
}
// Parse and verify JSON log entries
lines := strings.Split(strings.TrimSpace(logString), "\n")
foundLevels := make(map[string]bool)
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
var logEntry map[string]interface{}
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
t.Errorf("Invalid JSON log entry: %s, error: %v", line, err)
continue
}
// Verify required fields
validateLogEntry(t, logEntry)
// Track log levels we've seen
if level, ok := logEntry["level"].(string); ok {
foundLevels[level] = true
}
}
return foundLevels
}
// Helper function to validate log entry structure
func validateLogEntry(t *testing.T, logEntry map[string]interface{}) {
t.Helper()
if logEntry["time"] == nil {
t.Error("Log entry missing 'time' field")
}
if logEntry["level"] == nil {
t.Error("Log entry missing 'level' field")
}
if logEntry["msg"] == nil {
t.Error("Log entry missing 'msg' field")
}
if logEntry["component"] != "CrowdsecBouncerTraefikPlugin" {
t.Errorf("Expected component 'CrowdsecBouncerTraefikPlugin', got %v", logEntry["component"])
}
}
// Helper function to verify expected and forbidden log levels
func verifyLogLevels(t *testing.T, foundLevels map[string]bool, expectedLevels, forbiddenLevels []string, logLevel string) {
t.Helper()
// Handle case where no logs are expected
if len(expectedLevels) == 0 {
if len(foundLevels) > 0 {
t.Errorf("Expected no logs at %s level, but found: %v", logLevel, foundLevels)
}
} else {
// Verify we got some log entries
if len(foundLevels) == 0 {
t.Fatal("No valid log entries found")
}
// Verify expected levels are present
for _, expectedLevel := range expectedLevels {
if !foundLevels[expectedLevel] {
t.Errorf("Expected to find %s level logs, but didn't. Found levels: %v", expectedLevel, foundLevels)
}
}
}
// Verify forbidden levels are NOT present
for _, forbiddenLevel := range forbiddenLevels {
if foundLevels[forbiddenLevel] {
t.Errorf("Found forbidden %s level logs at %s level. Found levels: %v", forbiddenLevel, logLevel, foundLevels)
}
}
}
func TestBouncerFileLoggingLevels(t *testing.T) {
tests := []struct {
name string
logLevel string
expectedLevels []string // Levels that should appear
forbiddenLevels []string // Levels that should NOT appear
}{
{
name: "DEBUG level should show DEBUG only",
logLevel: "DEBUG",
expectedLevels: []string{"DEBUG"},
forbiddenLevels: []string{},
},
{
name: "INFO level should show no logs (bouncer doesn't generate INFO during normal operation)",
logLevel: "INFO",
expectedLevels: []string{}, // No logs expected for normal operation
forbiddenLevels: []string{"DEBUG"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temporary directory for log file
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "bouncer.log")
// Get test config and override specific fields
config := getTestConfig()
config.LogLevel = tt.logLevel
config.LogFormat = "json" // Use JSON format for easier parsing
config.LogFilePath = logFile
// Create and execute bouncer request
createAndExecuteBouncerRequest(t, config)
// Parse log file and extract found levels
foundLevels := parseLogFileAndExtractLevels(t, logFile)
// Handle empty log files for higher log levels (expected behavior)
if len(foundLevels) == 0 && len(tt.expectedLevels) > 0 {
t.Fatalf("Expected log entries but log file is empty for level %s", tt.logLevel)
}
if len(foundLevels) == 0 {
// Empty file is expected for this log level
t.Logf("LogLevel %s: No logs generated (expected behavior)", tt.logLevel)
return
}
// Verify expected and forbidden log levels
verifyLogLevels(t, foundLevels, tt.expectedLevels, tt.forbiddenLevels, tt.logLevel)
t.Logf("LogLevel %s: Successfully logged to file with levels: %v", tt.logLevel, foundLevels)
})
}
}
func TestBouncerFileLoggingCommonFormat(t *testing.T) {
// Create temporary directory for log file
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "bouncer-common.log")
// Get test config and override specific fields
config := getTestConfig()
config.LogLevel = "DEBUG"
config.LogFormat = "common" // Use common format
config.LogFilePath = logFile
// Create a mock next handler
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
})
// Create the bouncer plugin
bouncerHandler, err := New(context.Background(), nextHandler, config, "test-bouncer")
if err != nil {
t.Fatalf("Failed to create bouncer: %v", err)
}
// Create a test request to trigger logging
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
req.RemoteAddr = "192.168.1.100:12345"
rw := httptest.NewRecorder()
// Process the request
bouncerHandler.ServeHTTP(rw, req)
// Give a moment for log writes to complete
time.Sleep(100 * time.Millisecond)
// Verify the log file was created and contains entries
if _, statErr := os.Stat(logFile); os.IsNotExist(statErr) {
t.Fatalf("Log file was not created: %s", logFile)
}
// Read the log file content
// #nosec G304 - logFile is a test-generated temporary file path
logContent, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
logString := string(logContent)
if len(logString) == 0 {
t.Fatal("Log file is empty")
}
// Verify common format structure
lines := strings.Split(strings.TrimSpace(logString), "\n")
foundDebug := false
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
// Common format should contain time, level, msg, and component
if strings.Contains(line, "level=DEBUG") {
foundDebug = true
}
if !strings.Contains(line, "component=CrowdsecBouncerTraefikPlugin") {
t.Errorf("Log line missing component field: %s", line)
}
}
// We should see DEBUG level logs since we set LogLevel to DEBUG
if !foundDebug {
t.Errorf("Expected to find DEBUG level logs in common format. Log content:\n%s", logString)
}
t.Logf("Successfully logged to file %s in common format with %d lines", logFile, len(lines))
}
+4 -5
View File
@@ -5,11 +5,10 @@ package cache
import (
"errors"
"fmt"
"log/slog"
ttl_map "github.com/leprosus/golang-ttl-map"
simpleredis "github.com/maxlerebourg/simpleredis"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
)
const (
@@ -53,7 +52,7 @@ func (localCache) delete(key string) {
}
type redisCache struct {
log *logger.Log
log *slog.Logger
}
func (redisCache) get(key string) (string, error) {
@@ -93,11 +92,11 @@ type cacheInterface interface {
// Client Cache client.
type Client struct {
cache cacheInterface
log *logger.Log
log *slog.Logger
}
// New Initialize cache client.
func (c *Client) New(log *logger.Log, isRedis bool, host, pass, database string) {
func (c *Client) New(log *slog.Logger, isRedis bool, host, pass, database string) {
c.log = log
if isRedis {
redis.Init(host, pass, database)
+6 -3
View File
@@ -5,13 +5,13 @@ import (
"encoding/json"
"fmt"
"html/template"
"log/slog"
"net/http"
"net/url"
"strings"
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
)
// Client Captcha client.
@@ -24,7 +24,7 @@ type Client struct {
captchaTemplate *template.Template
cacheClient *cache.Client
httpClient *http.Client
log *logger.Log
log *slog.Logger
infoProvider *infoProvider
}
@@ -59,7 +59,7 @@ var infoProviders = map[string]*infoProvider{
}
// New Initialize captcha client.
func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
func (c *Client) New(log *slog.Logger, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
c.Valid = provider != ""
if !c.Valid {
return nil
@@ -94,6 +94,9 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
if valid {
c.log.Debug("captcha:ServeHTTP captcha:valid")
c.cacheClient.Set(remoteIP+"_captcha", cache.CaptchaDoneValue, c.gracePeriodSeconds)
if c.remediationCustomHeader != "" {
rw.Header().Set(c.remediationCustomHeader, "solved-captcha")
}
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
return
}
+19 -14
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
"net/url"
"os"
@@ -16,7 +17,6 @@ import (
"strings"
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
)
// Enums for crowdsec mode.
@@ -30,6 +30,7 @@ const (
HTTP = "http"
LogDEBUG = "DEBUG"
LogINFO = "INFO"
LogWARN = "WARN"
LogERROR = "ERROR"
ReasonTECH = "TECHNICAL_ISSUE"
ReasonLAPI = "LAPI"
@@ -44,6 +45,7 @@ const (
type Config struct {
Enabled bool `json:"enabled,omitempty"`
LogLevel string `json:"logLevel,omitempty"`
LogFormat string `json:"logFormat,omitempty"`
LogFilePath string `json:"logFilePath,omitempty"`
CrowdsecMode string `json:"crowdsecMode,omitempty"`
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
@@ -82,6 +84,7 @@ type Config struct {
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
StreamStartupBlock bool `json:"streamStartupBlock,omitempty"`
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
@@ -124,6 +127,7 @@ func New() *Config {
return &Config{
Enabled: false,
LogLevel: LogINFO,
LogFormat: "common",
LogFilePath: "",
CrowdsecMode: LiveMode,
CrowdsecAppsecEnabled: false,
@@ -143,6 +147,7 @@ func New() *Config {
UpdateIntervalSeconds: 60,
MetricsUpdateIntervalSeconds: 600,
UpdateMaxFailure: 0,
StreamStartupBlock: true,
DefaultDecisionSeconds: 60,
RemediationStatusCode: http.StatusForbidden,
HTTPTimeoutSeconds: 10,
@@ -218,7 +223,7 @@ func GetHTMLTemplate(path string) (*template.Template, error) {
// ValidateParams validate all the param gave by user.
//
//nolint:gocyclo,gocognit
func ValidateParams(config *Config) error {
func ValidateParams(config *Config, log *slog.Logger) error {
if err := validateParamsRequired(config); err != nil {
return err
}
@@ -227,10 +232,10 @@ func ValidateParams(config *Config) error {
return err
}
if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
if err := validateParamsIPs(log, config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
return err
}
if err := validateParamsIPs(config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
if err := validateParamsIPs(log, config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
return err
}
@@ -317,8 +322,8 @@ func ValidateParams(config *Config) error {
// Check logging configuration
// to upper allow of anycase of log level
if !contains([]string{LogERROR, LogDEBUG, LogINFO}, strings.ToUpper(config.LogLevel)) {
return fmt.Errorf("LogLevel should be one of (%s,%s,%s)", LogDEBUG, LogINFO, LogERROR)
if !contains([]string{LogDEBUG, LogINFO, LogWARN, LogERROR}, strings.ToUpper(config.LogLevel)) {
return fmt.Errorf("LogLevel should be one of (%s,%s,%s,%s)", LogDEBUG, LogINFO, LogWARN, LogERROR)
}
if config.LogFilePath != "" {
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
@@ -366,9 +371,9 @@ func validateParamsTLS(config *Config) error {
return nil
}
func validateParamsIPs(listIP []string, key string) error {
func validateParamsIPs(log *slog.Logger, listIP []string, key string) error {
if len(listIP) > 0 {
if _, err := ip.NewChecker(logger.New(LogINFO, ""), listIP); err != nil {
if _, err := ip.NewChecker(log, listIP); err != nil {
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
}
}
@@ -446,17 +451,17 @@ func validateParamsRequired(config *Config) error {
return nil
}
func getTLSConfig(config *Config, log *logger.Log, prefix, scheme string, insecureVerify bool) (*tls.Config, error) {
func getTLSConfig(config *Config, log *slog.Logger, prefix, scheme string, insecureVerify bool) (*tls.Config, error) {
tlsConfig := new(tls.Config)
tlsConfig.RootCAs = x509.NewCertPool()
if scheme != HTTPS {
log.Debug("getTLSConfigCrowdsec:" + prefix + "Scheme https:no")
log.Debug("getTLSConfig:" + prefix + "Scheme https:no")
return tlsConfig, nil
}
//nolint:nestif
if insecureVerify {
tlsConfig.InsecureSkipVerify = true
log.Debug("getTLSConfigCrowdsec:" + prefix + "TLSInsecureVerify tlsInsecure:true")
log.Debug("getTLSConfig:" + prefix + "TLSInsecureVerify tlsInsecure:true")
// If we return here and still want to use client auth this won't work
// return tlsConfig, nil
} else {
@@ -468,9 +473,9 @@ func getTLSConfig(config *Config, log *logger.Log, prefix, scheme string, insecu
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
// here we return because if CrowdsecLapiTLSInsecureVerify is false
// and CA not load, we can't communicate with https
return nil, errors.New("getTLSConfigCrowdsec:" + prefix + "cannot load CA and verify cert is enabled")
return nil, errors.New("getTLSConfig:" + prefix + " cannot load CA and verify cert is enabled")
}
log.Debug("getTLSConfigCrowdsec:" + prefix + "TLSCertificateAuthority CA added successfully")
log.Debug("getTLSConfig:" + prefix + "TLSCertificateAuthority CA added successfully")
}
}
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
@@ -494,7 +499,7 @@ func getTLSConfig(config *Config, log *logger.Log, prefix, scheme string, insecu
}
// GetTLSConfigCrowdsec get TLS config from Config.
func GetTLSConfigCrowdsec(config *Config, log *logger.Log, isAppsec bool) (*tls.Config, error) {
func GetTLSConfigCrowdsec(config *Config, log *slog.Logger, isAppsec bool) (*tls.Config, error) {
var prefix string
if isAppsec && config.CrowdsecAppsecScheme != "" {
prefix = "CrowdsecAppsec"
+6 -3
View File
@@ -72,6 +72,7 @@ func Test_GetVariable(t *testing.T) {
}
func Test_ValidateParams(t *testing.T) {
log := logger.New("INFO", "")
cfg1 := New()
cfg1.CrowdsecLapiKey = "test\n\n"
cfg2 := New()
@@ -117,7 +118,7 @@ func Test_ValidateParams(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := ValidateParams(tt.args.config); (err != nil) != tt.wantErr {
if err := ValidateParams(tt.args.config, log); (err != nil) != tt.wantErr {
t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr)
}
})
@@ -145,6 +146,7 @@ func Test_validateParamsTLS(t *testing.T) {
}
func Test_validateParamsIPs(t *testing.T) {
log := logger.New("INFO", "")
type args struct {
listIP []string
key string
@@ -164,7 +166,7 @@ func Test_validateParamsIPs(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := validateParamsIPs(tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
if err := validateParamsIPs(log, tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr)
}
})
@@ -230,6 +232,7 @@ func Test_validateParamsAPIKey(t *testing.T) {
}
func Test_GetTLSConfigCrowdsec(t *testing.T) {
log := logger.New("INFO", "")
type args struct {
config *Config
}
@@ -243,7 +246,7 @@ func Test_GetTLSConfigCrowdsec(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO", ""), false)
got, err := GetTLSConfigCrowdsec(tt.args.config, log, false)
if (err != nil) != tt.wantErr {
t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
return
+2 -3
View File
@@ -5,11 +5,10 @@ package ip
import (
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strings"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
)
// CHECKER
@@ -21,7 +20,7 @@ type Checker struct {
}
// NewChecker builds a new Checker given a list of CIDR-Strings to trusted IPs.
func NewChecker(log *logger.Log, trustedIPs []string) (*Checker, error) {
func NewChecker(log *slog.Logger, trustedIPs []string) (*Checker, error) {
checker := &Checker{}
for _, ipMaskRaw := range trustedIPs {
+68 -55
View File
@@ -1,79 +1,92 @@
// Package logger implements utility routines to write to stdout and stderr.
// It supports trace, debug, info and error level
// It supports trace, debug, info, warn and error level using Go's standard log/slog
package logger
import (
"fmt"
"io"
"log"
"log/slog"
"os"
"path/filepath"
)
// Log Logger struct.
type Log struct {
logError *log.Logger
logInfo *log.Logger
logDebug *log.Logger
// Custom log levels following slog best practices.
const (
LevelDebug = slog.LevelDebug
LevelInfo = slog.LevelInfo
LevelWarn = slog.LevelWarn
LevelError = slog.LevelError
)
// New creates a Log wrapper with default format (common).
func New(logLevel string, logFilePath string) *slog.Logger {
return NewWithFormat(logLevel, logFilePath, "common")
}
// New Set Default log level to info in case log level to defined.
func New(logLevel string, logFilePath string) *Log {
// Initialize loggers with discard output
logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
// NewWithFormat creates a Log wrapper with specified format (common or json).
func NewWithFormat(logLevel, logFilePath, logFormat string) *slog.Logger {
// Determine log level
var level slog.Level
switch logLevel {
case "ERROR":
level = LevelError
case "WARN":
level = LevelWarn
case "INFO":
level = LevelInfo
case "DEBUG":
level = LevelDebug
default:
// Default to INFO level
level = LevelInfo
}
// we initialize logger to STDOUT/STDERR first so if the file logger cannot be initialized we can inform the user
output := os.Stdout
errorOutput := os.Stderr
// prepare file logging if specified
// Set output destination
var output *os.File
if logFilePath != "" {
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err == nil {
output = logFile
errorOutput = logFile
} else {
_ = fmt.Errorf("LogFilePath is not writable %w", err)
// Fall back to stdout and log the error
output = os.Stdout
slog.Warn("LogFilePath is not writable, using stdout", "error", err)
}
} else {
output = os.Stdout
}
// Set error logger output
logError.SetOutput(errorOutput)
// Configure log levels
switch logLevel {
case "ERROR":
// Only error logging is enabled
case "INFO":
logInfo.SetOutput(output)
case "DEBUG":
logInfo.SetOutput(output)
logDebug.SetOutput(output)
default:
// Default to INFO level
logInfo.SetOutput(output)
// Create handler based on format with custom level names
var handler slog.Handler
opts := &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
// Customize level names to match our expected format
if a.Key == slog.LevelKey {
lvl, ok := a.Value.Any().(slog.Level)
if !ok {
return a
}
switch {
case lvl < LevelInfo:
a.Value = slog.StringValue("DEBUG")
case lvl < LevelWarn:
a.Value = slog.StringValue("INFO")
case lvl < LevelError:
a.Value = slog.StringValue("WARN")
default:
a.Value = slog.StringValue("ERROR")
}
}
return a
},
}
return &Log{
logError: logError,
logInfo: logInfo,
logDebug: logDebug,
if logFormat == "json" {
handler = slog.NewJSONHandler(output, opts)
} else {
// Common format (default)
handler = slog.NewTextHandler(output, opts)
}
}
// Info log to Stdout.
func (l *Log) Info(str string) {
l.logInfo.Printf("%s", str)
}
// Debug log to Stdout.
func (l *Log) Debug(str string) {
l.logDebug.Printf("%s", str)
}
// Error log to Stderr.
func (l *Log) Error(str string) {
l.logError.Printf("%s", str)
// Create logger with component attribute
return slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
}
+156
View File
@@ -0,0 +1,156 @@
package logger
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
)
func TestNew(t *testing.T) {
tests := []struct {
name string
logLevel string
}{
{name: "ERROR level", logLevel: "ERROR"},
{name: "WARN level", logLevel: "WARN"},
{name: "INFO level", logLevel: "INFO"},
{name: "DEBUG level", logLevel: "DEBUG"},
{name: "Default level (INFO)", logLevel: "INVALID"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := New(tt.logLevel, "")
// Verify logger is created
if logger == nil {
t.Fatal("Expected logger to be created, got nil")
}
// Verify it's a slog.Logger (we can call methods on it)
logger.Info("test initialization")
})
}
}
func TestJSONLogFormat(t *testing.T) {
var buf bytes.Buffer
// Create a logger with JSON handler to capture output
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
testMessage := "json test message"
logger.Info(testMessage)
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) != 1 {
t.Fatalf("Expected 1 log line, got %d", len(lines))
}
// Verify it's valid JSON
var logEntry map[string]interface{}
err := json.Unmarshal([]byte(lines[0]), &logEntry)
if err != nil {
t.Fatalf("Expected valid JSON output, got error: %v, output: %s", err, output)
}
// Verify JSON structure
if logEntry["level"] != "INFO" {
t.Errorf("Expected level 'INFO', got '%v'", logEntry["level"])
}
if logEntry["msg"] != testMessage {
t.Errorf("Expected message '%s', got '%v'", testMessage, logEntry["msg"])
}
if logEntry["time"] == nil {
t.Error("Expected timestamp to be set")
}
if logEntry["component"] != "CrowdsecBouncerTraefikPlugin" {
t.Errorf("Expected component 'CrowdsecBouncerTraefikPlugin', got '%v'", logEntry["component"])
}
}
func TestCommonLogFormat(t *testing.T) {
var buf bytes.Buffer
// Create a logger with text handler to capture output
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
testMessage := "common test message"
logger.Info(testMessage)
output := buf.String()
// Verify common format (should contain level and message)
if !strings.Contains(output, "level=INFO") {
t.Error("Expected common format with INFO level")
}
if !strings.Contains(output, testMessage) {
t.Error("Expected test message in common format")
}
if !strings.Contains(output, "component=CrowdsecBouncerTraefikPlugin") {
t.Error("Expected component field in common format")
}
// Should NOT be JSON (should be slog text format)
var logEntry map[string]interface{}
err := json.Unmarshal([]byte(strings.TrimSpace(output)), &logEntry)
if err == nil {
t.Error("Expected common format (not JSON), but got valid JSON")
}
}
func TestErrorLevel(t *testing.T) {
var buf bytes.Buffer
// Create a logger with ERROR level to capture output
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelError})
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
testMessage := "error only test"
// Test all log methods
logger.Error(testMessage)
logger.Warn(testMessage) // Should not appear
logger.Info(testMessage) // Should not appear
logger.Debug(testMessage) // Should not appear
output := buf.String()
// Only ERROR should appear
if !strings.Contains(output, "level=ERROR") {
t.Error("Expected ERROR message to appear")
}
// Other levels should NOT appear
unwantedLevels := []string{"level=WARN", "level=INFO", "level=DEBUG"}
for _, level := range unwantedLevels {
if strings.Contains(output, level) {
t.Errorf("Unexpected %s message appeared at ERROR level", level)
}
}
// Verify only one message appears
messageCount := strings.Count(output, testMessage)
if messageCount != 1 {
t.Errorf("Expected 1 occurrence of test message at ERROR level, got %d", messageCount)
}
}
func TestInvalidLogFile(t *testing.T) {
// Try to create logger with invalid file path
logger := New("INFO", "/invalid/path/that/does/not/exist/test.log")
// Logger should still be created (falls back to stdout)
if logger == nil {
t.Fatal("Expected logger to be created even with invalid file path")
}
// Should not panic when logging
logger.Info("test message")
}
+84
View File
@@ -0,0 +1,84 @@
# 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`). A separate, local-only **Docker
suite** (real Traefik + Crowdsec, under `tests/e2e/scenarios`) is kept for
high-fidelity debugging against a real Crowdsec but is not exercised in CI; it
ships in its own PR (#333).
## Scope — what this suite tests
These tests validate the **plugin's own behaviour**: the request flow through
the Traefik middleware, the live / none / stream modes, caching, trusted-IP
bypass, ban / captcha page rendering, and the AppSec request path (header
forwarding + enforcing the engine's allow/block verdict).
The mock stands in for Crowdsec, emulating the slice of the LAPI HTTP contract
the plugin consumes — including a single, deterministic AppSec rule (block any
URI containing `rpc2`, the probe from [`examples/appsec-enabled`](../../../examples/appsec-enabled)).
It is not the real WAF engine, so this suite exercises the plugin's AppSec
*wiring* rather than the detection accuracy of OWASP CRS / virtual patching —
that lives upstream in Crowdsec.
## What runs
| Component | How |
|-----------|-----|
| Traefik | Binary `v3.7.1`, downloaded into `.cache/` (reused across local runs; re-downloaded on fresh CI runners) |
| 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` |
| AppSec | WAF stand-in built into the mock — blocks URIs containing `rpc2`, allows the rest |
| Backend | A plain HTTP responder built into the mock |
Fixed ports (override with env vars if needed): Traefik `8000`, LAPI `8090`,
backend `8091`, AppSec `8092`.
## Running locally
Prerequisites: `bash`, `curl`, `go`, `tar`. On first use the Traefik binary is
fetched and the mock is compiled into `.cache/`. That cache is reused across
local runs; CI runs on fresh runners, so both are recreated on every CI run.
```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 + AppSec stand-in + 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]` and
`lapi_delete_decision <ip>`.
4. Add `<name>` to `E2E_MOCK_SCENARIOS` in the `Makefile`.
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env bash
# Shared helpers for the binary (mock) e2e suite.
#
# Unlike the Docker suite under tests/e2e/scenarios, this one runs Traefik as a
# downloaded binary and replaces Crowdsec with a small HTTP mock (the mocklapi
# Go command). It validates the plugin's own behaviour (modes, cache, trusted
# IPs, ban / captcha rendering, AppSec wiring) — not the accuracy of Crowdsec's
# detection or its WAF engine, which the mock only stands in for.
#
# Dependencies: bash, curl, go, tar. The Traefik binary is downloaded and the
# mock is compiled into .cache/ on first use. That cache persists across local
# runs; CI runs on fresh runners, so both are recreated on every CI run.
set -euo pipefail
# Pinned to match the Docker suite (tests/e2e/scenarios/*/docker-compose.yml).
TRAEFIK_VERSION="${TRAEFIK_VERSION:-v3.7.1}"
WEB_PORT="${WEB_PORT:-8000}"
LAPI_PORT="${LAPI_PORT:-8090}"
BACKEND_PORT="${BACKEND_PORT:-8091}"
APPSEC_PORT="${APPSEC_PORT:-8092}"
LAPI_KEY="${LAPI_KEY:-e2e-mock-key}"
MOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$MOCK_LIB_DIR/../../../.." && pwd)"
CACHE_DIR="$MOCK_LIB_DIR/../.cache"
# Populated by start_stack / run_scenario, consumed by the EXIT trap.
WORKDIR=""
TRAEFIK_PID=""
MOCK_PID=""
SCENARIO_NAME=""
SCENARIO_LOG=""
# Resolve (and cache) the Traefik binary for this host, echoing its path.
ensure_traefik() {
local bin="$CACHE_DIR/traefik-$TRAEFIK_VERSION"
if [[ -x "$bin" ]]; then
echo "$bin"
return 0
fi
mkdir -p "$CACHE_DIR"
local os arch
case "$(uname -s)" in
Linux) os=linux ;;
Darwin) os=darwin ;;
*) echo "ensure_traefik: unsupported OS $(uname -s)" >&2; return 1 ;;
esac
case "$(uname -m)" in
x86_64 | amd64) arch=amd64 ;;
aarch64 | arm64) arch=arm64 ;;
*) echo "ensure_traefik: unsupported arch $(uname -m)" >&2; return 1 ;;
esac
local url="https://github.com/traefik/traefik/releases/download/${TRAEFIK_VERSION}/traefik_${TRAEFIK_VERSION}_${os}_${arch}.tar.gz"
echo "ensure_traefik: downloading $url" >&2
local tmp
tmp="$(mktemp -d)"
curl -sSfL "$url" -o "$tmp/traefik.tar.gz"
tar -xzf "$tmp/traefik.tar.gz" -C "$tmp" traefik
mv "$tmp/traefik" "$bin"
chmod +x "$bin"
rm -rf "$tmp"
echo "$bin"
}
# Build (and cache) the mock LAPI binary, echoing its path. Go's build cache
# makes the rebuild near-instant after the first run.
ensure_mock() {
local bin="$CACHE_DIR/mocklapi"
mkdir -p "$CACHE_DIR"
( cd "$MOCK_LIB_DIR/../mocklapi" && go build -o "$bin" . ) >&2
echo "$bin"
}
# Poll a URL until it returns the expected status code, or fail.
# Usage: wait_for_status URL CODE [TIMEOUT_SECONDS] [curl args...]
wait_for_status() {
local url="$1" expected="$2" timeout="${3:-30}"
shift 3 || true
local elapsed=0 got=""
while (( elapsed < timeout )); do
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url" || true)
if [[ "$got" == "$expected" ]]; then
return 0
fi
sleep 1
# Note: `((elapsed++))` returns exit 1 when elapsed is 0, which trips set -e.
elapsed=$((elapsed + 1))
done
echo "wait_for_status: $url expected $expected, last seen ${got:-<none>}" >&2
return 1
}
# Poll a URL until its body contains a substring, or fail. Used when the status
# code alone can't tell the states apart (e.g. captcha page vs backend, both 200).
# Usage: wait_for_body_contains URL NEEDLE [TIMEOUT_SECONDS] [curl args...]
wait_for_body_contains() {
local url="$1" needle="$2" timeout="${3:-30}"
shift 3 || true
local elapsed=0 body=""
while (( elapsed < timeout )); do
body=$(curl -s "$@" "$url" || true)
if grep -q "$needle" <<<"$body"; then
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "wait_for_body_contains: $url did not contain \"$needle\" within ${timeout}s" >&2
return 1
}
# Assert a single curl returns the expected status code.
# Usage: assert_status URL CODE [curl args...]
assert_status() {
local url="$1" expected="$2"
shift 2 || true
local got
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url")
if [[ "$got" != "$expected" ]]; then
echo "assert_status: $url expected $expected, got $got" >&2
return 1
fi
}
# Assert a response header matches a value (case-insensitive name).
# Usage: assert_header URL HEADER VALUE [curl args...]
assert_header() {
local url="$1" header="$2" expected="$3"
shift 3 || true
local got
got=$(curl -s -D - -o /dev/null "$@" "$url" | tr -d '\r' \
| awk -v h="${header,,}" -F': ' 'tolower($1) == h { print $2; exit }')
if [[ "$got" != "$expected" ]]; then
echo "assert_header: $url header $header expected \"$expected\", got \"$got\"" >&2
return 1
fi
}
# Assert a response body contains a substring.
# Usage: assert_body_contains URL NEEDLE [curl args...]
assert_body_contains() {
local url="$1" needle="$2"
shift 2 || true
local body
body=$(curl -s "$@" "$url")
if ! grep -q "$needle" <<<"$body"; then
echo "assert_body_contains: $url expected to contain \"$needle\", got:" >&2
echo "$body" >&2
return 1
fi
}
# --- mock admin client -------------------------------------------------------
lapi_add_decision() {
local ip="$1" type="${2:-ban}" duration="${3:-4h}"
curl -sS -X POST "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}&type=${type}&duration=${duration}" >/dev/null
}
lapi_delete_decision() {
local ip="$1"
curl -sS -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}" >/dev/null
}
# --- stack lifecycle ---------------------------------------------------------
# start_stack SCENARIO_DIR
# Spins up the mock + Traefik (with the scenario's dynamic.yml) and waits ready.
start_stack() {
local scenario_dir="$1"
local traefik_bin mock_bin
traefik_bin="$(ensure_traefik)"
mock_bin="$(ensure_mock)"
WORKDIR="$(mktemp -d)"
# Expose the plugin source where Traefik's localPlugins loader expects it.
mkdir -p "$WORKDIR/plugins-local/src/github.com/maxlerebourg"
ln -s "$REPO_ROOT" "$WORKDIR/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
cp "$MOCK_LIB_DIR/traefik.yml" "$WORKDIR/traefik.yml"
# Render the scenario's dynamic config with the live ports / key / paths.
sed \
-e "s|@@APIKEY@@|${LAPI_KEY}|g" \
-e "s|@@LAPI_HOST@@|127.0.0.1:${LAPI_PORT}|g" \
-e "s|@@APPSEC_HOST@@|127.0.0.1:${APPSEC_PORT}|g" \
-e "s|@@BACKEND_URL@@|http://127.0.0.1:${BACKEND_PORT}|g" \
-e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \
"$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml"
"$mock_bin" \
--lapi-addr "127.0.0.1:${LAPI_PORT}" \
--backend-addr "127.0.0.1:${BACKEND_PORT}" \
--appsec-addr "127.0.0.1:${APPSEC_PORT}" >"$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
# AppSec stand-in: a bare GET carries no "rpc2" URI, so it answers 200 (allow).
wait_for_status "http://127.0.0.1:${APPSEC_PORT}/" 200 30
# /ping is served by Traefik itself once it is up (plugin compilation included).
wait_for_status "http://127.0.0.1:${WEB_PORT}/ping" 200 60
}
stop_stack() {
[[ -n "$TRAEFIK_PID" ]] && kill "$TRAEFIK_PID" 2>/dev/null || true
[[ -n "$MOCK_PID" ]] && kill "$MOCK_PID" 2>/dev/null || true
[[ -n "$TRAEFIK_PID" ]] && wait "$TRAEFIK_PID" 2>/dev/null || true
[[ -n "$MOCK_PID" ]] && wait "$MOCK_PID" 2>/dev/null || true
[[ -n "$WORKDIR" && -d "$WORKDIR" ]] && rm -rf "$WORKDIR" || true
}
dump_diagnostics() {
echo "=== traefik.log ==="
cat "$WORKDIR/traefik.log" 2>/dev/null || true
echo "=== mock.log ==="
cat "$WORKDIR/mock.log" 2>/dev/null || true
}
# EXIT trap: runs after the scenario body (or after a failed assertion under
# `set -e`), so it relies only on globals, never on run_scenario's locals.
_scenario_cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics > "$SCENARIO_LOG" 2>&1 || true
echo "[$SCENARIO_NAME] failed. Logs written to $SCENARIO_LOG" >&2
fi
stop_stack
exit $rc
}
# run_scenario SCENARIO_NAME SCENARIO_DIR BODY_FN
# Wraps lifecycle + diagnostics so each run.sh stays declarative.
run_scenario() {
SCENARIO_NAME="$1"
local dir="$2" body="$3"
SCENARIO_LOG="/tmp/e2e-mock-${SCENARIO_NAME}.log"
trap _scenario_cleanup EXIT
echo "[$SCENARIO_NAME] starting binary stack (Traefik + mock LAPI)..."
start_stack "$dir"
"$body"
echo "[$SCENARIO_NAME] OK"
}
+28
View File
@@ -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
+6
View File
@@ -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
+135
View File
@@ -0,0 +1,135 @@
// Command mocklapi is a minimal Crowdsec LAPI stand-in for the binary e2e
// suite. It answers only the few LAPI routes the plugin calls — live/none
// decision lookups, the stream poll and the usage-metrics push — and lets the
// test drive decisions through /admin instead of `cscli`. It also serves the
// stub upstream that Traefik proxies allowed requests to.
//
// It is NOT a Crowdsec/AppSec conformance harness — the real WAF engine (OWASP
// CRS, virtual patching) is out of scope. The AppSec endpoint here emulates a
// single deterministic rule so the suite can exercise the plugin's AppSec
// wiring (header forwarding, allow/block handling) end to end. See the README.
package main
import (
"encoding/json"
"flag"
"log"
"net/http"
"strings"
"sync"
)
// Decision is the subset of a LAPI decision the plugin actually reads.
type Decision struct {
Value string `json:"value"`
Type string `json:"type"`
Duration string `json:"duration"`
}
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
)
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func list(m map[string]Decision) []Decision {
out := make([]Decision, 0, len(m))
for _, d := range m {
out = append(out, d)
}
return out
}
func main() {
lapiAddr := flag.String("lapi-addr", "127.0.0.1:8090", "address for the LAPI mock")
// The stub upstream Traefik proxies allowed requests to — the binary-suite
// equivalent of the traefik/whoami container. Not AppSec.
backendAddr := flag.String("backend-addr", "127.0.0.1:8091", "address for the stub upstream service")
// AppSec WAF stand-in (the real engine listens on :7422). Not a CRS engine.
appsecAddr := flag.String("appsec-addr", "127.0.0.1:8092", "address for the AppSec mock")
flag.Parse()
go func() {
log.Fatal(http.ListenAndServe(*backendAddr, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("E2E_BACKEND_OK\n"))
})))
}()
// AppSec mock: the plugin forwards the request metadata in X-Crowdsec-Appsec-*
// headers and reads our status — 200 allows, 403 blocks. We emulate one
// deterministic virtual-patching rule (block any URI containing "rpc2", the
// exact probe from examples/appsec-enabled) so the plugin's AppSec path is
// exercised without standing up the real WAF.
go func() {
log.Fatal(http.ListenAndServe(*appsecAddr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "rpc2") {
w.WriteHeader(http.StatusForbidden)
}
})))
}()
mux := http.NewServeMux()
// Readiness probe for the test harness (empty body, 200).
mux.HandleFunc("/health", func(http.ResponseWriter, *http.Request) {})
// live / none mode: the plugin asks about one IP and expects a decision
// array, or the literal `null` when there is none.
mux.HandleFunc("/v1/decisions", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if d, ok := active[r.URL.Query().Get("ip")]; ok {
writeJSON(w, []Decision{d})
return
}
_, _ = w.Write([]byte("null"))
})
// stream mode: report the whole active set as "new" and anything removed as
// "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) {
mu.Lock()
defer mu.Unlock()
writeJSON(w, map[string][]Decision{"new": list(active), "deleted": list(deleted)})
})
// usage-metrics push: accept and ignore.
mux.HandleFunc("/v1/usage-metrics", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
})
// Test control plane: add / remove decisions instead of cscli.
mux.HandleFunc("/admin/decisions", func(_ http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
ip := q.Get("ip")
mu.Lock()
defer mu.Unlock()
switch r.Method {
case http.MethodPost:
dtype := q.Get("type")
if dtype == "" {
dtype = "ban"
}
duration := q.Get("duration")
if duration == "" {
duration = "4h"
}
active[ip] = Decision{Value: ip, Type: dtype, Duration: duration}
delete(deleted, ip)
case http.MethodDelete:
if d, ok := active[ip]; ok {
deleted[ip] = d
delete(active, ip)
}
}
})
log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s", *lapiAddr, *backendAddr, *appsecAddr)
log.Fatal(http.ListenAndServe(*lapiAddr, mux))
}
@@ -0,0 +1,29 @@
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"
# IP bouncing disabled — this scenario exercises AppSec only.
crowdsecMode: none
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
crowdsecAppsecEnabled: "true"
crowdsecAppsecScheme: http
crowdsecAppsecHost: "@@APPSEC_HOST@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=appsec
# AppSec wiring check: the plugin forwards each request to the AppSec engine and
# enforces its verdict. The mock emulates one virtual-patching rule (block any
# URI containing "rpc2"), mirroring examples/appsec-enabled. This proves the
# plugin's AppSec path end to end (header forwarding + allow/block handling); it
# does not test the real WAF's detection accuracy.
body() {
echo "[$SCENARIO] benign request must pass (AppSec allows)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] request whose URI contains 'rpc2' must be blocked (AppSec 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo/rpc2" 403 -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -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"
+26
View File
@@ -0,0 +1,26 @@
#!/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
# Status stays 200 before/after (captcha page vs backend), so gate on the body
# marker appearing once the captcha decision has been polled.
echo "[$SCENARIO] captcha page must be served once the decision is polled (200 + marker)"
wait_for_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CAPTCHA_PAGE_MARKER" 15 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] captcha response is 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] 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,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"
banHtmlFilePath: "@@SCENARIO_DIR@@/ban.html"
remediationHeadersCustomName: "X-E2E-Remediation"
+27
View File
@@ -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] banned response becomes 403 once the next stream poll lands"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -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"
echo "[$SCENARIO] banned response carries the custom remediation header (remediationHeadersCustomName)"
assert_header "http://127.0.0.1:${WEB_PORT}/foo" X-E2E-Remediation "ban" -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"
+26
View File
@@ -0,0 +1,26 @@
#!/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
# Stays 200 until the cached 'allowed' (defaultDecisionSeconds) expires, then
# the re-query sees the ban — poll instead of guessing the cache TTL.
echo "[$SCENARIO] hit must turn 403 once the cached 'allowed' expires and LAPI is re-queried"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -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"
+27
View File
@@ -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"
+30
View File
@@ -0,0 +1,30 @@
#!/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] 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"
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] 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"
}
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"
+24
View File
@@ -0,0 +1,24 @@
#!/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
# The untrusted IP turning 403 is our signal that the bans have been polled;
# it also doubles as the control proving the bouncer is active.
echo "[$SCENARIO] untrusted banned IP must be blocked once the bans are polled (HTTP 403)"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 5.6.7.8"
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"
}
run_scenario "$SCENARIO" "$HERE" body
+4
View File
@@ -0,0 +1,4 @@
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
// pluginVersion is updated automatically by the release workflow.
var pluginVersion = "1.6.X" //nolint:gochecknoglobals