* ♻️ cidr: build keys through net.IPNet instead of hand-masking bytes
CIDRKeys masked the address byte by byte and formatted the result with
string concatenation, while SetCIDR/DeleteCIDR format their keys with
net.IPNet.String() via NormalizeCIDR. The two agreed only by coincidence:
any divergence in formatting silently stops every range decision from
matching, with no test covering the invariant.
Mask with net.IP.Mask and format through net.IPNet.String() so both sides
go through the same formatter. Output is byte for byte identical to the
previous implementation (checked against a golden dump of both IPv4 and
IPv6 keys, including ::ffff: forms).
Dropping the inner byte loops also removes the only intrange violation in
the tree, so the linter exclusion added for them is no longer needed, and
the redundant import alias on pkg/ip goes away with it.
* ⚡ cidr: only probe the prefix lengths that have a decision
In stream mode nothing caches a negative result per IP, so the exact IP
lookup misses on every legitimate request and each one fell through to
GetCIDR, which probed every possible prefix length: 33 cache reads for an
IPv4 client, 129 for an IPv6 one, even when no range decision existed at
all. On the local cache that is wasted work on the request path; with
redis it is 33 to 129 sequential round trips per request.
Keep the set of prefix lengths that have at least one decision under a
single key, written before the decision itself, and probe only those.
Measured cache reads per request: 1 with no range decision (was 33 / 129),
2 with a single /24 in use, 4 with four prefix lengths in use.
The set only grows, so a deleted or expired decision leaves a length
behind that costs one extra read rather than risking an unmatched
decision, and it is written with an effectively infinite duration since it
has to outlive every decision it describes. If it is ever missing while
decisions live (a redis eviction under maxmemory), range decisions stop
matching until the next one arrives; it is the hottest key of the
namespace, so an LRU policy evicts it last.
* 🔊 cidr: log the decisions dropped for an unparsable CIDR
SetCIDR and DeleteCIDR returned silently when NormalizeCIDR rejected the
value, so a range decision the plugin does not understand is not enforced
and nothing says why. Every other operation of the package logs, and this
one fails open, which is the direction worth shouting about.
Log at Error with the raw value and what the consequence is, so an
unexpected decision format shows up in the logs instead of looking like a
decision that was applied.
* ✅ cidr: cover the invariant the range matching rests on
The helpers were tested in isolation but nothing tied them together, and
what actually has to hold is that the key SetCIDR writes for a decision is
one of the keys GetCIDR looks up for an IP that decision covers. A
formatting change on either side would have silently stopped every range
decision from matching with all tests green.
TestCIDRKeys_MatchNormalizeCIDR pins that both ways, including the cases
worth being explicit about: a decision that is not on a network address,
IPv4 mapped clients against an IPv4 range, and that the two families do not
mix. Dropping the mask in cidrKey fails 9 of its cases.
Also covers CIDRLookupKeys against CIDRKeys length by length, the IPv6 side
of the network address test, and CIDRPrefixLen.
* ✅ cache: cover the CIDR operations and what a lookup costs
pkg/cache had tests for Get, Set and Delete but none for their CIDR
counterparts, so the range keyspace was only exercised end to end by the
e2e scenario.
Test_GetCIDR covers hits, the boundaries of a range, IPv6, IPv4 mapped
clients and invalid input. Test_GetCIDR_MostSpecific pins the precedence
between overlapping decisions, which is deliberate behaviour that nothing
was holding in place: a captcha on a /24 is not overruled by a ban on its
/8. Test_DeleteCIDR checks the wider decision survives a narrower one being
removed, and Test_SetCIDR_InvalidIsNotStored that a rejected value stores
nothing at all.
Test_GetCIDR_Reads counts cache reads through an isolated cacheInterface,
so the cost of a lookup is part of the contract: probing every prefix
length again turns it into 34 reads for IPv4 and 130 for IPv6 and fails.
* 🐛 e2e: stop racing the deadline in the stream failure check
handleStreamTicker compares updateFailure to updateMaxFailure before
incrementing it, so with updateMaxFailure 2 and a 1s interval the bouncer
gives up on the third consecutive failed poll, roughly 3s after the
endpoint starts failing. The check slept 2s and then polled for a 200 for
up to 15s, leaving about a second of margin, and once that window closes it
never reopens: a runner under load turns this into a 15s wait followed by a
failure.
Assert the 200 immediately after the endpoint starts failing, which is
always inside the window, and keep polling for the 403 that follows.
* 🐛 e2e: do not fail a scenario on a single slow response
The new -m 1 is right for the polling helpers, where a timed out request is
just another attempt, but the assertions and the mock control plane have no
retry: one request that takes over a second on a loaded runner fails the
scenario, and since common.sh runs under set -euo pipefail a timed out
lapi_add_decision aborts it before the decision even exists.
Bound the connect at 1s instead and give the whole request 5s in the seven
places that get a single attempt. Nothing waits longer on the happy path.
* ⏪ e2e: restore the stream-mode failure check
Revert 99673b1. The check is not ours to change: it must stay as it was.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 🔥 e2e: drop the comment above wait_for_status
The timeout change in 3f78887 stands; only the comment goes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ✨ cicd: bump the version before tagging instead of after
The version reported to the Crowdsec LAPI lives in version.go, so it must
be correct in the very commit the tag points at. Every mechanism so far
updated it *after* the tag existed, which cannot work:
- release.yml ran on `release: published` and force-moved the tag. It also
failed on all four of its runs and was removed in #360.
- The Renovate customManager on version.go uses the github-tags datasource,
so it can only propose vX once vX is already tagged. The bump always lands
after the tag.
Result: v1.7.0 is tagged at a commit reading v1.6.0 (#363), same shape as
the earlier #322.
Replace both with a two-step flow that bumps first and tags last, so the
released source always matches its tag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 🍱 reduce loc + remove claude code comment
* 🍱 remove useless spellcheck disable
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
* feat: Allow cache reading from replicas
* 🍱 fix logic
* ✨ add testing for redis with mock
* 🍱 fix permission
* 📝 test(e2e/redis): fix swapped IP→verdict comments
The mock returns "f" (not banned) for 1.2.3.4 and "t" (banned) for
1.2.3.5, and the run.sh assertions match that. Both doc comments
described the opposite mapping; correct them to match the code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✅ test(e2e/redis): exercise read-from-replica path
The redis scenario only set redisCacheHost, so it validated the writer
but never the round-robin reader path this feature adds. Split the mock
into two roles: the primary (--redis-addr) now answers every GET with a
miss, while the replica (--redis-read-addr) serves the hardcoded
verdicts. The scenario points redisCacheReadHosts at the replica (twice,
to drive round-robin), so the banned-IP-blocked assertion only passes if
the plugin actually reads decisions from the replica rather than the
primary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 📝 docs: note replicas don't fall back to primary on outage
When RedisCacheReadHosts is set, reads are not retried against the
primary if the replicas are unreachable. Document that this, combined
with the default RedisCacheUnreachableBlock=true, means a replica outage
can block traffic while the primary is healthy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(cache): avoid nil-pointer panic on empty redis read
redisCache.get fell through to `switch err.Error()` when Get returned a
nil error with an empty value, panicking on the nil error. simpleredis
never returns that combination today (a miss yields RedisMiss), so it
was unreachable in practice — but the read path is safer treating an
empty, error-free read as a cache miss, which also guarantees err is
non-nil before err.Error() is called.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🍱 add test for rotation
* 🐛 readd redis/run.sh
* 🐛 fix redis/run.sh
* 🐛 fix test label; remove log
* 🍱 add tests for roundRobin
* 🐛 fix test
---------
Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
Co-authored-by: mhx <mathieu@hanotaux.fr>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Do not consider body unreadable when it's http.NoBody
Fix#351
* 🐛 appsec: only drop unreadable bodies for methods that carry one
Gate the crowdsecAppsecUnreadableBodyBlock drop on the request method,
mirroring the reference lua-cs-bouncer METHODS_WITH_BODY list
(POST/PUT/PATCH/DELETE). Browser GETs over HTTP/3 never carry a
Content-Length (quic-go always wraps the stream in a non-nil body with
ContentLength -1), so they were all wrongly dropped; they are now
forwarded to appsec headers-only, while gRPC streams (always POST) stay
droppable.
Fix#351
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🍱 add test and remove useless comment
* 🍱 add test for GET with unreadable body
* 🍱 fix log
* 🍱 fix test
---------
Co-authored-by: mhx <mathieu@hanotaux.fr>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
The 2026-07-03 runs surfaced two config problems:
- The grouped "renovate/all" branch made Renovate fetch and hold the
changelog of every upgrade instance separately (Traefik v3.0.0->v3.7.6
was fetched ~10x, once per compose file), which blew the default 4GB
V8 heap: "FATAL ERROR: ... JavaScript heap out of memory" (exit 134),
killing the run after the branch was pushed but before the PR was
opened. Disable changelog fetching (release notes were truncated in
the grouped PR body anyway) and raise the Node heap to 8GB as a
safety net.
- No gitAuthor was set, so commits were authored as Mend's
renovate@whitesourcesoftware.com, which GitHub flags "Unverified"
(Vigilant Mode) and Renovate warns about on every run. Use the token
owner's noreply address instead.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ⬆️ bump example image versions to latest stable
Align every docker-compose and Helm-values example on the same set:
- Traefik v3.0.0 / v3.5.0 → v3.7.1
- Crowdsec v1.6.1-2 / v1.6.8 / latest → v1.7.8
- Plugin pin v1.3.0 / v1.4.5 / v1.5.0 → v1.6.0
No env / volume changes needed: every Crowdsec example already mounts
/var/lib/crowdsec/data, so the v1.7 strict volume check is already
satisfied (CROWDSEC_BYPASS_DB_VOLUME_CHECK is only required when
running without persistence, as in the E2E suite).
* 🤖 chore: adopt Renovate (weekly, self-hosted) to replace manual version bumps
Automate the kind of bump this PR did by hand. Renovate covers what Dependabot
could not reach here:
- example docker-compose image tags (traefik / crowdsec) — native manager;
- the plugin self-pin (`experimental.plugins.bouncer.version=` in compose args
and `version:` in the Traefik Helm values) — customManager, github-tags;
- the Crowdsec/Traefik image tags in the Helm values (no `repository` key, so
matched by file) — customManager, docker;
- the pinned Traefik binary in the e2e mock suite (`TRAEFIK_VERSION`) —
customManager, github-releases;
- go.mod + GitHub Actions — native managers (this is why we drop Dependabot:
running both would open duplicate PRs).
Runs weekly via a self-hosted workflow (Mondays 04:00 UTC, plus manual
dispatch). Needs a RENOVATE_TOKEN secret (documented in the workflow). Renovate
PRs trigger the existing e2e CI, so a Traefik/Crowdsec bump is validated to
actually boot and bounce before merge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🔒 ci: pin Go to 1.22 (yaegi ceiling) and cap Renovate's Go updates
The plugin is interpreted by yaegi, bundled in Traefik. Even the latest Traefik
(v3.7.1) ships yaegi v0.16.1, which only supports Go 1.22 — so the plugin's real
ceiling is Go 1.22 on every current Traefik, regardless of the Go version
Traefik itself is built with.
- main.yml: build/test on Go 1.22 (was 1.23) so `go build`/`go test` reject
newer stdlib early; yaegi v0.16.1 stays the required yaegi_test guard.
- renovate.json: cap the go.mod `go`/`toolchain` directive at `<1.23` so
Renovate keeps deps/actions/toolchain current but never pushes the plugin
past what yaegi supports. Raise the cap when Traefik ships a newer yaegi.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 ci(renovate): stop ignoring examples/ and tests/ so custom managers run
config:recommended ships a default ignorePaths that excludes **/examples/**
and **/tests/** (and **/vendor/**). That silently disabled 4 of the 5 custom
managers and all example/helm coverage this PR adds — Renovate only saw the
root docker-compose, go.mod and workflows.
Override ignorePaths to keep only vendor/node_modules. Verified via
`renovate --platform=local --dry-run=full`: package files detected go from
7 -> 32, and every custom manager now extracts its dep (plugin self-pin
across 11 compose files, traefik/crowdsec helm tags, e2e TRAEFIK_VERSION pin).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ⏪ chore: drop one-time example version bumps, let Renovate own them
The manual version catch-up across the example docker-compose files and the
Helm values is now redundant: Renovate (this PR) covers all of them via its
native docker-compose/docker managers plus the custom managers for the plugin
self-pin and the e2e Traefik binary. Reverting these files to keep the PR a
clean "adopt Renovate" change; Renovate will open the bump PRs itself.
Note: examples/tls-auth uses crowdsecurity/crowdsec:latest again — Renovate
does not pin rolling tags, so that one stays floating unless pinned separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 📌 examples(tls-auth): pin crowdsec to v1.6.8 instead of :latest
Rolling `latest` tags aren't reproducible and Renovate can't manage them.
v1.6.8 is already the crowdsec tag used in the root docker-compose files and,
unlike the `-2` build-suffixed tags, Renovate bumps it (→ v1.7.8).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 📌 examples: normalize crowdsec v1.6.1-2 -> v1.6.8 so Renovate can manage it
Renovate's docker versioning won't advance a build-suffixed tag (v1.6.1-2) to
a clean release (v1.7.8), so those 8 examples would have stayed frozen. v1.6.8
is already used in the root compose files and Renovate bumps it. Also updated
the captcha README snippet to match its compose.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🍱 group all + no dashboard + branch prefix
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
* 🐛 fix appsec silently 403-ing gRPC streams with unreadable body
A bidirectional gRPC stream is an HTTP/2 request with no Content-Length
whose body never reaches EOF. Since #321 removed the ContentLength guard,
appsecQuery buffered it with io.ReadAll, which blocked until the request
timed out and was turned into a 403 (issue #323). The backend was never
reached (OriginStatus:0).
Mirror the reference lua-cs-bouncer behaviour: detect an unreadable body
(ProtoMajor >= 2 && ContentLength < 0) and, instead of buffering it,
forward the request to Appsec with headers only. Add a new
CrowdsecAppsecDropUnreadableBody option (default false) that mirrors the
reference APPSEC_DROP_UNREADABLE_BODY: when true, such requests are
blocked outright instead of forwarded without their body.
Readable HTTP/1.1 bodies are still buffered and inspected, so the bypass
closed by #321 stays closed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🚨 appsec: satisfy linters (gocritic ifElseChain, misspell)
Rewrite the body-handling if/else chain in appsecQuery as a switch
(gocritic) and use US spelling "behavior" (misspell).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🔇 appsec: drop redundant unreadable-body debug log
Address review on #332: the caller (handleNextServeHTTP) already logs the
returned error with the request IP, so the inner Debug line duplicated it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🍱 increase gocyclo
* 🍱 fix lint
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
* Consider 502, 503 and 504 as unavaible for appsec
Fixes#337
* ✨ add test and the function isReverseProxyError
---------
Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
* Add parameter to configure Ban Response Content-Type
* Add testing for new BanResponseContentType parameter
* Ensure there is a fallback to default Content-Type is user provided empty value
* Set Content-Type even if banTemplate is nil
* Add more edge cases for testing ban response Content-Type
* Add CR/LF validation for BanResponseContentType
* Add CaptchaResponseContentType to allow separate Content-Type configuration for captcha responses
* Add testing for new CaptchaResponseContentType
* Update README
* Split nil and CR/LF response Content-Type value validation into separate function
* Throw error instead of setting the default in case of empty parameter declaration
* Update testing accordingly
* ✨ remove HTML from var name, add tests and infer content type from filePath
* 🍱 fix lint ?
* 🍱 fix lint
* 🍱 fix lint
* 🍱 fix lint + naming
* 🍱 fix lint
* 🍱 fuck lint
---------
Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
* 🐛 fall back to system trust store when no custom TLS CA is set
Closes#327.
Until now, configuring `crowdsecLapiScheme=https` forced the operator
to either provide `crowdsecLapiTLSCertificateAuthority` (a custom CA)
or set `crowdsecLapiTLSInsecureVerify=true` — there was no way to rely
on the host's system trust store, which is the expected setup when the
LAPI sits behind a reverse proxy with a publicly trusted (e.g. Let's
Encrypt) certificate.
Two contributing bugs:
- `validateParamsTLS` rejected an empty CA up-front.
- `getTLSConfig` always allocated an empty `tls.Config.RootCAs`,
which silently disabled the standard library's fall-back to
`x509.SystemCertPool()`.
Fix: drop the validation error for the empty-CA case and only allocate
`RootCAs` when a custom CA is actually provided. Same change applies
symmetrically to the AppSec path since the helper is shared.
Add unit tests covering the four meaningful states (HTTP, HTTPS with
system CA, HTTPS with custom CA, HTTPS with insecure verify) plus the
malformed-PEM rejection. README updated to document the system trust
store as an explicit option for both LAPI and AppSec HTTPS.
* 📝 fix gofmt alignment in TLS test struct
* ✅ update existing test: https without CA is now accepted
* ♻️ tests: hoist shared validPEM to package level, rename cfgGarbage
Address review on #331:
- the self-signed validPEM block was duplicated in two test funcs; declare it
once at package level and drop both local copies.
- rename cfgGarbage -> cfgInvalidCA (and its test case) for a descriptive name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ e2e mock: add tls-system-ca scenario (HTTPS LAPI via system trust store)
CI regression coverage for this PR: with no custom CA configured, the bouncer
must fall back to the OS/system trust store for an HTTPS LAPI.
In the binary suite the "system trust store" is whatever Go's
x509.SystemCertPool() reads, which honours SSL_CERT_FILE on the Traefik process.
The scenario mints a throwaway CA, serves the mock LAPI over HTTPS with a cert
signed by it, and runs the stack twice:
- positive: SSL_CERT_FILE = our CA -> LAPI trusted -> 200
- negative: SSL_CERT_FILE = empty bundle -> not trusted -> 403 (fail-closed)
The negative run proves the patch still VERIFIES (not an insecure skip).
- mocklapi: optional --lapi-tls-cert/--lapi-tls-key to serve the LAPI over TLS.
- common.sh: opt-in LAPI_TLS_CERT/KEY (HTTPS mock) and TRAEFIK_SSL_CERT_FILE
(inject SSL_CERT_FILE into Traefik); both default-empty, other scenarios
unaffected.
- adds openssl as a scenario-only dependency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ 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
* ✨ 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
* 🐛 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.
* 🐛 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).
* ♻️ 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>
* ✨ 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>
* 🔧 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>
* ♻️ 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>
* 🔥 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>
* ⚡ 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>
* ⬆️ 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>
* ⬇️ 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>
* ✅ 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>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The sequence diagrams used the keyword `Destroy` (capital D). Mermaid
keywords are case-sensitive, so the invalid token aborted parsing and
every diagram failed to render on GitHub.
Lowercase all `Destroy` -> `destroy` in README.md and the captcha
example README. The `create` keyword was already correct, and each
create/destroy is properly paired with an adjacent message.
Fixes#270
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🍱 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>
* 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
* 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>
* Do not validate Crowdsec LAPI key and TLS authentication if bouncer is in AppSec mode
* Add extra validation checks for lapiKey or appsecKey definition
* Fix linting on changed error message
* Update configuration.go
- Show paramName in validateParamsApiKey
- Remove check for empty appsecKey or lapiKey (LAPI can have TLS auth)
- Remove check for emptry appsecKey if CrowdsecMode is Appsec
* Update configuration_test.go after updated validateParamsAPIKey
* 🐛 fix start up config error for appsec
* :doc: add documentation on appsec variables and missing conf parameter
* 🍱 fix lint
* 🍱 fix lint
* 🍱 fix lint
* 🍱 fix after lot of tests
* update exemple tls with new variables tested
* fix exemple appsec with release and not localplugin
---------
Co-authored-by: mhx <mathieu@hanotaux.fr>
* Add parameter to specify Request header value to inject in HTML template
* Add documentation for specifying request header value
* Fix example in docs for CustomHeader
* Update parameter name to traceCustomHeader
* Ensure naming is consitent with project
* xx
* Remove outdated comment on decision caching
Remove comment about decision caching based on timeout.
---------
Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>