Compare commits

..
Author SHA1 Message Date
mhxandClaude Opus 5 93cd13bc10 🔥 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>
2026-08-07 20:14:58 +02:00
mhxandClaude Opus 5 9f99c431c3 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>
2026-08-07 20:14:55 +02:00
mhx 3f78887e3a 🐛 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.
2026-08-03 13:56:04 +02:00
mhx 99673b1087 🐛 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.
2026-08-03 13:56:04 +02:00
mhx 4bac63ca83 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.
2026-08-03 13:56:04 +02:00
mhx b1562ef2b9 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.
2026-08-03 13:41:11 +02:00
mhx 3b7168a789 🔊 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.
2026-08-03 13:40:44 +02:00
mhx adffe64319 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.
2026-08-03 13:40:41 +02:00
mhx 1b1247823c ♻️ 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.
2026-08-03 13:39:53 +02:00
maxlerebourg 1ba556e918 🍱 fix lint 2026-07-31 14:39:09 +02:00
maxlerebourg 6b0518859d 🐛 Support range decision on stream mode 2026-07-31 14:08:59 +02:00
github-actions[bot]andgithub-actions[bot] bef5dfaadb 🔖 release v1.7.1 (#367)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-31 13:05:36 +02:00
99cf9712f4 cicd: bump the version before tagging so releases report their own version (#365)
*  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>
2026-07-30 20:18:15 +02:00
maxlerebourgandRenovate Bot ed4a9e8262 ⬆️ renovate: Update all (#362)
Co-authored-by: Renovate Bot <22881669+maxlerebourg@users.noreply.github.com>
2026-07-27 21:09:55 +02:00
maxlerebourg f6ef95cf38 🐛 fix default value for CrowdsecAppsecUnreadableBodyBlock (#361) 2026-07-27 08:53:32 +02:00
maxlerebourgandRenovate Bot 9daba9739c ⬆️ renovate: Update all (#350)
Co-authored-by: Renovate Bot <22881669+maxlerebourg@users.noreply.github.com>
2026-07-26 17:06:02 +02:00
maxlerebourg e98b8ed5ba Renovate update version.go (#360)
*  Renovate update version.go

* 🍱 renovate every day
2026-07-26 17:05:43 +02:00
bb44aef718 feat: Allow cache reading from replicas (#342)
* 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>
2026-07-26 13:51:22 +02:00
31874263f2 🐛 Do not consider body unreadable when it's http.NoBody (#352)
* 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>
2026-07-24 20:49:43 +02:00
maxlerebourgandRenovate Bot 1c98c70f14 ⬆️ renovate: Update all (#349)
Co-authored-by: Renovate Bot <22881669+maxlerebourg@users.noreply.github.com>
2026-07-05 19:56:15 +02:00
mathieuHaandClaude Fable 5 be13c49144 🐛 ci(renovate): fix OOM crash and stop using Mend's default gitAuthor (#347)
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>
2026-07-05 19:36:44 +02:00
maxlerebourgandRenovate Bot 5a2998bc62 ⬆️ renovate: Update all (#345)
Co-authored-by: Renovate Bot <renovate@whitesourcesoftware.com>
2026-07-03 10:19:28 +02:00
26ce12f7e3 🤖 chore: adopt Renovate for automated dependency updates (#330)
* ⬆️ 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>
2026-07-03 09:22:14 +02:00
d32f271195 🐛 appsec: do not buffer unreadable (gRPC/HTTP2) request bodies (#332)
* 🐛 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>
2026-07-01 12:44:58 +02:00
Daniel Berteaudandmaxlerebourg 1c1672c856 Consider 502, 503 and 504 as unavaible for appsec (#338)
* Consider 502, 503 and 504 as unavaible for appsec

Fixes #337

*  add test and the function isReverseProxyError

---------

Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
2026-06-30 22:03:06 +02:00
dependabot[bot] 21895fbb9d ⬆️ Bump actions/cache from 5 to 6 (#344)
Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-29 10:11:22 +02:00
omerandmaxlerebourg 7c73cb38dd Add parameter to configure the ban page Content-Type response header (#325)
* 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>
2026-06-28 22:30:48 +02:00
mathieuHaandClaude Opus 4.8 f4dcd933c8 🐛 fall back to system trust store when no custom TLS CA is set (#331)
* 🐛 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>
2026-06-23 09:27:32 +02:00
dependabot[bot] 67b33dcf13 ⬆️ Bump actions/checkout from 6 to 7 (#341)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 08:56:27 +02:00
dependabot[bot] 661a89ea9c ⬆️ Bump actions/upload-artifact from 4 to 7 (#336)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 08:49:57 +02:00
mathieuHaandClaude Opus 4.8 14b9c47ab2 tests: CI end-to-end suite — Traefik binary + mock LAPI (#329)
*  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>
2026-06-14 18:49:18 +02:00
mathieuHaandClaude Opus 4.8 f5d580578c 📝 docs: fix Mermaid diagrams not rendering (lowercase destroy keyword) (#335)
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>
2026-06-14 18:40:56 +02:00
83 changed files with 2961 additions and 1242 deletions
-24
View File
@@ -1,24 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
# Maintain dependencies for Go
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
# Maintain dependencies for build tools
- package-ecosystem: "gomod"
directory: "/tools"
schedule:
interval: "weekly"
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+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@v7
- 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@v7
with:
name: e2e-logs
path: /tmp/e2e-mock-*.log
if-no-files-found: ignore
+8 -3
View File
@@ -15,8 +15,13 @@ jobs:
name: Main Process
runs-on: ubuntu-latest
env:
GO_VERSION: 1.23
# Keep in sync with go.mod. Capped at 1.22 because the plugin is run by
# yaegi (bundled in Traefik) and even Traefik v3.7.1 ships yaegi v0.16.1,
# which only supports Go 1.22. Building on the floor makes go build / go
# test reject newer stdlib before yaegi_test does.
GO_VERSION: 1.22
GOLANGCI_LINT_VERSION: v1.63.4
# yaegi_test guard — pin to the version current Traefik bundles.
YAEGI_VERSION: v0.16.1
CGO_ENABLED: 0
defaults:
@@ -33,14 +38,14 @@ jobs:
# https://github.com/marketplace/actions/checkout
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: go/src/github.com/${{ github.repository }}
fetch-depth: 0
# https://github.com/marketplace/actions/cache
- name: Cache Go modules
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ${{ github.workspace }}/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+83
View File
@@ -0,0 +1,83 @@
name: Release (1/2) Prepare
# Step 1 of the release process: bump pluginVersion *before* the tag exists.
#
# The version reported to the Crowdsec LAPI lives in version.go, so it has to
# be correct in the very commit the tag points at. Anything that patches
# version.go after the release is published is too late: Traefik's plugin
# service caches the plugin archive per module+version, so users keep the
# source that was there when the tag was first resolved (see #322, #363).
#
# This workflow opens a "release" PR containing only that bump. Merging it
# triggers Release (2/2) Publish, which creates the tag and the GitHub release
# on the merged commit.
on:
workflow_dispatch:
inputs:
version:
description: "Version to release, e.g. v1.7.1 or v1.8.0-alpha"
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
prepare:
name: Open release PR for ${{ inputs.version }}
runs-on: ubuntu-latest
steps:
- name: Check out main
uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
- name: Validate version
env:
VERSION: ${{ inputs.version }}
run: |
if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
echo "::error::'$VERSION' is not a vX.Y.Z / vX.Y.Z-suffix version"
exit 1
fi
if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
echo "::error::tag $VERSION already exists"
exit 1
fi
- name: Bump version.go
env:
VERSION: ${{ inputs.version }}
run: |
sed -i 's/pluginVersion = "[^"]*"/pluginVersion = "'"$VERSION"'"/' version.go
cat version.go
if git diff --quiet -- version.go; then
echo "::error::version.go already reads $VERSION, nothing to release"
exit 1
fi
- name: Push release branch and open PR
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git switch -c "release/$VERSION"
git commit -am "🔖 release $VERSION"
git push -u origin "release/$VERSION"
cat > /tmp/pr-body.md <<EOF
Bumps \`pluginVersion\` to \`$VERSION\` so the tag carries the version
the plugin reports to the Crowdsec LAPI.
Merging this PR tags \`$VERSION\` on the resulting commit and publishes
the GitHub release automatically.
> Keep the PR title as-is: **Release (2/2) Publish** matches on it.
EOF
gh pr create --base main --head "release/$VERSION" --title "🔖 release $VERSION" --body-file /tmp/pr-body.md
+53
View File
@@ -0,0 +1,53 @@
name: Release (2/2) Publish
# Step 2 of the release process: tag and publish the commit prepared by
# Release (1/2) Prepare.
#
# Triggered by the release PR landing on main. The tag is created on that
# commit, so version.go inside the released source always matches the tag —
# no post-release patching, no force-moved tags.
on:
push:
branches: [main]
paths: ["version.go"]
permissions:
contents: write
jobs:
publish:
name: Tag and publish
runs-on: ubuntu-latest
steps:
- name: Check out the pushed commit
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Resolve release version
id: resolve
run: |
version="$(git log -1 --format='%B' | grep -oP '🔖 release \Kv[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?' || true)"
[ -z "$version" ] && { echo "version.go changed outside a release commit, nothing to do"; echo "release=false" >> "$GITHUB_OUTPUT"; exit 0; }
in_source="$(sed -n 's/.*pluginVersion = "\([^"]*\)".*/\1/p' version.go)"
[ "$in_source" != "$version" ] && { echo "::error::commit says $version but version.go reads $in_source"; exit 1; }
git rev-parse -q --verify "refs/tags/$version" >/dev/null && { echo "::error::tag $version already exists"; exit 1; }
echo "release=true" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "prerelease=$([[ "$version" == *-* ]] && echo '--prerelease')" >> "$GITHUB_OUTPUT"
- name: Tag and create the GitHub release
if: steps.resolve.outputs.release == 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.resolve.outputs.version }}
PRERELEASE: ${{ steps.resolve.outputs.prerelease }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$VERSION" -m "$VERSION"
git push origin "$VERSION"
gh release create "$VERSION" --title "$VERSION" --generate-notes $PRERELEASE
-46
View File
@@ -1,46 +0,0 @@
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 }}"
+41
View File
@@ -0,0 +1,41 @@
name: Renovate
# Self-hosted Renovate: opens dependency-update PRs on a daily schedule.
# Config lives in /renovate.json. Requires a repo/org secret RENOVATE_TOKEN
# (a PAT with `repo` + `workflow` scope, or a fine-grained token with
# contents:write + pull-requests:write) so Renovate can push branches and open
# PRs. Trigger manually from the Actions tab via "Run workflow" to test.
on:
schedule:
- cron: "0 4 * * *" # every day at 04:00 UTC
workflow_dispatch:
inputs:
logLevel:
description: "Renovate log level"
required: false
default: "info"
permissions:
contents: read
concurrency:
group: renovate
cancel-in-progress: false
jobs:
renovate:
runs-on: ubuntu-latest
steps:
- name: Run Renovate
uses: renovatebot/github-action@v46.1.21
with:
token: ${{ secrets.RENOVATE_TOKEN }}
env:
RENOVATE_REPOSITORIES: ${{ github.repository }}
RENOVATE_ONBOARDING: "false"
RENOVATE_REQUIRE_CONFIG: "required"
# The grouped "all" branch holds many upgrades; changelog/PR-body
# rendering for it blew the default 4GB V8 heap (exit 134 OOM).
NODE_OPTIONS: "--max-old-space-size=8192"
LOG_LEVEL: ${{ github.event.inputs.logLevel || 'info' }}
+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/
+2 -1
View File
@@ -7,7 +7,7 @@ linters-settings:
disable:
- fieldalignment
gocyclo:
min-complexity: 15
min-complexity: 20
goconst:
min-len: 5
min-occurrences: 4
@@ -41,6 +41,7 @@ linters-settings:
- $test
allow:
- $gostd
- github.com/maxlerebourg/simpleredis
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
+7 -6
View File
@@ -1,8 +1,10 @@
.PHONY: lint test vendor clean e2e
.PHONY: lint test vendor clean e2e_mock
export GO111MODULE=on
E2E_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec
# Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs.
# The local Docker suite (make e2e) lives in a separate PR/branch.
E2E_MOCK_SCENARIOS := $(notdir $(wildcard tests/e2e/mock/scenarios/*))
default: lint test
@@ -15,10 +17,10 @@ test:
yaegi_test:
yaegi test -v .
e2e: $(addprefix e2e_,$(E2E_SCENARIOS))
e2e_mock: $(addprefix e2e_mock_,$(E2E_MOCK_SCENARIOS))
e2e_%:
./tests/e2e/scenarios/$*/run.sh
e2e_mock_%:
bash ./tests/e2e/mock/scenarios/$*/run.sh
vendor:
go mod vendor
@@ -122,4 +124,3 @@ show_metrics:
show_decisions:
docker exec crowdsec cscli decisions list
+53 -38
View File
@@ -68,7 +68,7 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage
create participant CrowdsecLAPI
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a Crowdsec Decision ?
Destroy CrowdsecLAPI
destroy CrowdsecLAPI
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
TraefikPlugin->>User: No, HTTP 403
```
@@ -82,9 +82,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage
create participant CrowdsecLAPI
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
Destroy CrowdsecLAPI
destroy CrowdsecLAPI
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
Destroy TraefikPlugin
destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response
```
@@ -105,10 +105,10 @@ sequenceDiagram
PluginCache-->>TraefikPlugin: Nothing, all good!
create participant CrowdsecLAPI
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
Destroy CrowdsecLAPI
destroy CrowdsecLAPI
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
Destroy PluginCache
destroy PluginCache
PluginCache-->>TraefikPlugin: Done
TraefikPlugin->>User: No, HTTP 403
```
@@ -125,10 +125,10 @@ sequenceDiagram
PluginCache-->>TraefikPlugin: Nothing, all good!
create participant CrowdsecLAPI
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
Destroy CrowdsecLAPI
destroy CrowdsecLAPI
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
Destroy PluginCache
destroy PluginCache
PluginCache-->>TraefikPlugin: Done
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response
@@ -145,11 +145,11 @@ sequenceDiagram
participant TraefikPlugin
participant CrowdsecLAPI
TraefikPlugin->>CrowdsecLAPI: What are the current decisions
Destroy CrowdsecLAPI
destroy CrowdsecLAPI
CrowdsecLAPI->>TraefikPlugin: Here is the list
create participant PluginCache
TraefikPlugin-->>PluginCache: Store this list
Destroy PluginCache
destroy PluginCache
PluginCache-->>TraefikPlugin: Done
```
@@ -162,9 +162,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage
create participant PluginCache
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
Destroy PluginCache
destroy PluginCache
PluginCache-->>TraefikPlugin: Yes a ban decision
Destroy TraefikPlugin
destroy TraefikPlugin
TraefikPlugin->>User: No, HTTP 403
```
@@ -177,9 +177,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage
create participant PluginCache
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
Destroy PluginCache
destroy PluginCache
PluginCache-->>TraefikPlugin: Nothing, all good!
Destroy TraefikPlugin
destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response
```
@@ -195,11 +195,11 @@ sequenceDiagram
participant TraefikPlugin
participant CrowdsecCAPI
TraefikPlugin->>CrowdsecCAPI: What are the current decisions from CAPI
Destroy CrowdsecCAPI
destroy CrowdsecCAPI
CrowdsecCAPI->>TraefikPlugin: Here is the list
create participant PluginCache
TraefikPlugin-->>PluginCache: Store this list
Destroy PluginCache
destroy PluginCache
PluginCache-->>TraefikPlugin: Done
```
@@ -212,9 +212,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage
create participant PluginCache
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
Destroy PluginCache
destroy PluginCache
PluginCache-->>TraefikPlugin: Yes a ban decision
Destroy TraefikPlugin
destroy TraefikPlugin
TraefikPlugin->>User: No, HTTP 403
```
@@ -227,9 +227,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage
create participant PluginCache
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
Destroy PluginCache
destroy PluginCache
PluginCache-->>TraefikPlugin: Nothing, all good!
Destroy TraefikPlugin
destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response
```
@@ -247,9 +247,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage
create participant CrowdsecAppSec
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
Destroy CrowdsecAppSec
destroy CrowdsecAppSec
CrowdsecAppSec-->>TraefikPlugin: Yes I think so
Destroy TraefikPlugin
destroy TraefikPlugin
TraefikPlugin->>User: No, HTTP 403
```
@@ -262,9 +262,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage
create participant CrowdsecAppSec
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
Destroy CrowdsecAppSec
destroy CrowdsecAppSec
CrowdsecAppSec-->>TraefikPlugin: No I don't think so
Destroy TraefikPlugin
destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response
```
@@ -285,12 +285,12 @@ sequenceDiagram
User->>TraefikPlugin: Fine, done!
create participant ProviderCaptcha
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
Destroy ProviderCaptcha
destroy ProviderCaptcha
ProviderCaptcha-->>TraefikPlugin: Yes
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
Destroy PluginCache
destroy PluginCache
PluginCache-->>TraefikPlugin: Done
Destroy TraefikPlugin
destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response
```
@@ -362,7 +362,7 @@ make run
- CrowdsecAppsecTlsCertificateAuthority
- string
- default: ""
- PEM-encoded Certificate Authority of Appsec
- PEM-encoded Certificate Authority used to verify Appsec's server certificate. When empty (and `crowdsecAppsecTlsInsecureVerify` is `false`), the host's system trust store is used.
- CrowdsecAppsecScheme
- string
- default: value of `CrowdsecLapiScheme`, expected values are: `http`, `https`
@@ -382,6 +382,10 @@ make run
- int64
- default: 10485760 (= 10MB)
- Transmit only the first number of bytes to Crowdsec Appsec Server.
- CrowdsecAppsecUnreadableBodyBlock
- bool
- default: true
- Behaviour when the request body cannot be buffered for inspection (HTTP/2 or HTTP/3 request without a `Content-Length`, typically a bidirectional gRPC stream). When `false` the request is forwarded to the Appsec Server with headers only (the body is left to stream through untouched). When `true` the request is blocked outright. Mirrors the reference bouncers' `APPSEC_DROP_UNREADABLE_BODY` option.
- CrowdsecAppsecKey
- string
- default: value of `CrowdsecLapiKey`
@@ -408,7 +412,7 @@ make run
- CrowdsecLapiTlsCertificateAuthority
- string
- default: ""
- PEM-encoded Certificate Authority of the Crowdsec LAPI
- PEM-encoded Certificate Authority used to verify the LAPI's server certificate. When empty (and `crowdsecLapiTlsInsecureVerify` is `false`), the host's system trust store is used.
- CrowdsecLapiTlsCertificateBouncer
- string
- default: ""
@@ -440,7 +444,12 @@ make run
- RedisCacheHost
- string
- default: "redis:6379"
- hostname and port for the Redis service
- hostname and port for the Redis write host (primary)
- RedisCacheReadHosts
- []string
- default: []
- List of Redis replica hostnames (host:port) to use for read operations. Reads are distributed round-robin across replicas. Falls back to RedisCacheHost when empty.
- Note: when set, reads are not retried against RedisCacheHost (the primary) if the replicas are unreachable. With RedisCacheUnreachableBlock at its default (true), a replica outage will therefore block/delay requests even though the primary is healthy.
- RedisCachePassword
- string
- default: ""
@@ -513,14 +522,14 @@ make run
- int64
- default: 1800 (= 30 minutes)
- Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid
- CaptchaHTMLFilePath
- CaptchaFilePath
- string
- default: /captcha.html
- Path where the captcha template is stored
- BanHTMLFilePath
- Path where the captcha template is stored. The Content-Type header is automatically inferred from the file extension.
- BanFilePath
- string
- default: ""
- Path where the ban html file is stored (default empty ""=disabled)
- Path where the ban file is stored (default empty ""=disabled). The Content-Type header is automatically inferred from the file extension.
- TraceHeadersCustomName
- string
- default: ""
@@ -616,6 +625,7 @@ http:
crowdsecAppsecFailureBlock: true
crowdsecAppsecUnreachableBlock: true
crowdsecAppsecBodyLimit: 10485760
crowdsecAppsecUnreadableBodyBlock: false
crowdsecLapiKey: privateKey-foo
crowdsecLapiScheme: http
crowdsecLapiHost: crowdsec:8080
@@ -635,7 +645,10 @@ http:
forwardedHeadersCustomName: X-Custom-Header
remediationHeadersCustomName: cs-remediation
redisCacheEnabled: false
redisCacheHost: "redis:6379"
redisCacheHost: "redis-primary:6379"
redisCacheReadHosts:
- "redis-replica-1:6379"
- "redis-replica-2:6379"
redisCachePassword: password
redisCacheDatabase: "5"
redisCacheUnreachableBlock: true
@@ -727,16 +740,18 @@ A script is available to generate certificates in `examples/tls-auth/gencerts.sh
#### Use HTTPS to communicate with the LAPI
To communicate with the LAPI in HTTPS you need to either accept any certificates by setting the `crowdsecLapiTLSInsecureVerify` to true or add the CA used by the server certificate of Crowdsec using `crowdsecLapiTLSCertificateAuthority` or `crowdsecLapiTLSCertificateAuthorityFile`.
Set the `crowdsecLapiScheme` to https.
Set `crowdsecLapiScheme` to `https`. The plugin then validates Crowdsec's server certificate. Three options:
- **Publicly trusted certificate** (e.g. Let's Encrypt behind a reverse proxy): leave `crowdsecLapiTLSCertificateAuthority` empty and `crowdsecLapiTLSInsecureVerify` `false`. The plugin falls back to the host's system trust store (the `traefik` image ships `ca-certificates`).
- **Private/self-signed CA**: set `crowdsecLapiTLSCertificateAuthority` (or `…File`) to the PEM-encoded CA that signed Crowdsec's server cert.
- **Skip verification entirely** (not recommended for production): set `crowdsecLapiTLSInsecureVerify` to `true`.
Crowdsec must be listening in HTTPS for this to work.
Please see the [tls-auth example](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/tls-auth/README.md) or the official documentation: [docs.crowdsec.net/docs/local_api/tls_auth/](https://docs.crowdsec.net/docs/local_api/tls_auth/)
#### Use HTTPS to communicate with the Appsec
To communicate with the Appsec in HTTPS you need to either accept any certificates by setting the `crowdsecAppsecTLSInsecureVerify` to true or add the CA used by the server certificate of Crowdsec using `crowdsecAppsecTLSCertificateAuthority` or `crowdsecAppsecTLSCertificateAuthorityFile`.
Set the `crowdsecAppsecScheme` to https.
Set `crowdsecAppsecScheme` to `https`. Same three options as for the LAPI, prefixed `crowdsecAppsec…` instead of `crowdsecLapi…`: empty CA + secure verify falls back to the system trust store, a custom CA pins to your private PKI, and `crowdsecAppsecTLSInsecureVerify=true` skips verification altogether.
Currently AppSec does not support mTLS authentication for the AppSec Component.
+78 -19
View File
@@ -9,7 +9,6 @@ import (
"encoding/json"
"errors"
"fmt"
htmltemplate "html/template"
"io"
"log/slog"
"net/http"
@@ -92,6 +91,7 @@ type Bouncer struct {
appsecKey string
appsecFailureBlock bool
appsecUnreachableBlock bool
appsecUnreadableBodyBlock bool
appsecBodyLimit int64
crowdsecScheme string
crowdsecHost string
@@ -110,7 +110,8 @@ type Bouncer struct {
crowdsecStreamRoute string
crowdsecHeader string
redisUnreachableBlock bool
banTemplate *htmltemplate.Template
banTemplate *template.Template
banTemplateContentType string
traceCustomHeader string
clientPoolStrategy *ip.PoolStrategy
serverPoolStrategy *ip.PoolStrategy
@@ -123,10 +124,18 @@ type Bouncer struct {
// New creates the crowdsec bouncer plugin.
//
//nolint:nestif,gocyclo,gocognit
//nolint:nestif,gocyclo,gocognit,funlen,maintidx
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
config.LogLevel = strings.ToUpper(config.LogLevel)
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
if config.BanFilePath == "" && config.BanHTMLFilePath != "" {
config.BanFilePath = config.BanHTMLFilePath
}
if config.CaptchaHTMLFilePath != "" {
config.CaptchaFilePath = config.CaptchaHTMLFilePath
}
err := configuration.ValidateParams(config, log)
if err != nil {
log.Error("New:validateParams " + err.Error())
@@ -184,9 +193,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
}
}
var banTemplate *htmltemplate.Template
if config.BanHTMLFilePath != "" {
banTemplate, _ = configuration.GetHTMLTemplate(config.BanHTMLFilePath)
var banTemplate *template.Template
var banTemplateContentType string
if config.BanFilePath != "" {
banTemplate, banTemplateContentType, _ = configuration.GetTemplate(config.BanFilePath)
}
bouncer := &Bouncer{
@@ -203,6 +213,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
appsecKey: config.CrowdsecAppsecKey,
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
appsecUnreadableBodyBlock: config.CrowdsecAppsecUnreadableBodyBlock,
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
crowdsecScheme: config.CrowdsecLapiScheme,
crowdsecHost: config.CrowdsecLapiHost,
@@ -219,6 +230,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
remediationStatusCode: config.RemediationStatusCode,
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
banTemplate: banTemplate,
banTemplateContentType: banTemplateContentType,
traceCustomHeader: config.TraceHeadersCustomName,
crowdsecStreamRoute: crowdsecStreamRoute,
crowdsecHeader: crowdsecHeader,
@@ -256,6 +268,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
log,
config.RedisCacheEnabled,
config.RedisCacheHost,
config.RedisCacheReadHosts,
config.RedisCachePassword,
config.RedisCacheDatabase,
)
@@ -276,7 +289,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
config.CaptchaSiteKey,
config.CaptchaSecretKey,
config.RemediationHeadersCustomName,
config.CaptchaHTMLFilePath,
config.CaptchaFilePath,
config.CaptchaGracePeriodSeconds,
)
if err != nil {
@@ -317,7 +330,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
// ServeHTTP principal function of plugin.
//
//nolint:nestif,gocyclo
//nolint:nestif,gocognit
func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if !bouncer.enabled {
bouncer.next.ServeHTTP(rw, req)
@@ -379,6 +392,16 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// Right here if we cannot join the stream we forbid the request to go on.
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
if isCrowdsecStreamHealthy {
cidrValue, cidrErr := bouncer.cacheClient.GetCIDR(remoteIP)
if cidrErr == nil {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cidr:hit isBanned:%v", remoteIP, cidrValue))
if cidrValue == cache.NoBannedValue {
bouncer.handleNextServeHTTP(rw, req, remoteIP)
} else {
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, cidrValue)
}
return
}
bouncer.handleNextServeHTTP(rw, req, remoteIP)
} else {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure))
@@ -433,14 +456,9 @@ func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Req
if bouncer.remediationCustomHeader != "" {
rw.Header().Set(bouncer.remediationCustomHeader, "ban")
}
if bouncer.banTemplate == nil {
rw.Header().Set("Content-Type", bouncer.banTemplateContentType)
rw.WriteHeader(bouncer.remediationStatusCode)
return
}
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(bouncer.remediationStatusCode)
if req.Method == http.MethodHead {
if bouncer.banTemplate == nil || req.Method == http.MethodHead {
return
}
templateData := map[string]string{
@@ -661,17 +679,31 @@ func handleStreamCache(bouncer *Bouncer) error {
default:
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
}
if strings.Contains(decision.Value, "/") {
bouncer.cacheClient.SetCIDR(decision.Value, value, int64(duration.Seconds()))
} else {
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
}
}
}
for _, decision := range stream.Deleted {
if strings.Contains(decision.Value, "/") {
bouncer.cacheClient.DeleteCIDR(decision.Value)
} else {
bouncer.cacheClient.Delete(decision.Value)
}
}
bouncer.log.Debug("handleStreamCache:updated")
isCrowdsecStreamStartup = false
return nil
}
func isReverseProxyError(statusCode int) bool {
return statusCode == http.StatusBadGateway ||
statusCode == http.StatusServiceUnavailable ||
statusCode == http.StatusGatewayTimeout
}
func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, error) {
var req *http.Request
if len(data) > 0 {
@@ -683,7 +715,7 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
res, err := bouncer.httpClient.Do(req)
if err != nil {
if err != nil || isReverseProxyError(res.StatusCode) {
return nil, fmt.Errorf("crowdsecQuery:unreachable url:%s %w", stringURL, err)
}
defer func() {
@@ -711,6 +743,27 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
return body, nil
}
// isBodyUnreadable reports whether the request body cannot be buffered before
// forwarding it to the Appsec component. An HTTP/2 or HTTP/3 request without a
// Content-Length (typically a bidirectional gRPC stream) keeps its body open
// for the whole life of the stream and never reaches EOF, so reading it with
// io.ReadAll would block until the request times out and is wrongly turned into
// a 403. This mirrors the reference lua-cs-bouncer behavior, which refuses to
// read the body of an HTTP/2+ request that has no Content-Length.
func isBodyUnreadable(httpReq *http.Request) bool {
return httpReq.Body != nil && httpReq.Body != http.NoBody && httpReq.ProtoMajor >= 2 && httpReq.ContentLength < 0
}
// isMethodWithBody used only when isBodyUnreadable returns true but the request method can't have body.
func isMethodWithBody(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
default:
return false
}
}
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
routeURL := url.URL{
Scheme: bouncer.appsecScheme,
@@ -718,7 +771,13 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
Path: bouncer.appsecPath,
}
var req *http.Request
if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil {
switch {
case isBodyUnreadable(httpReq):
if bouncer.appsecUnreadableBodyBlock && isMethodWithBody(httpReq.Method) {
return errors.New("appsecQuery:unreadableBody dropped")
}
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
case bouncer.appsecBodyLimit > 0 && httpReq.Body != nil:
var bodyBuffer bytes.Buffer
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
@@ -729,7 +788,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
// Conserve body intact after reading it for other middlewares and service
httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body))
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
} else {
default:
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
}
@@ -747,7 +806,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
res, err := bouncer.httpAppsecClient.Do(req)
if err != nil {
if err != nil || isReverseProxyError(res.StatusCode) {
bouncer.log.Error("appsecQuery:unreachable")
if bouncer.appsecUnreachableBlock {
return fmt.Errorf("appsecQuery:unreachable %w", err)
+2 -2
View File
@@ -32,13 +32,13 @@ func getTestConfig() *configuration.Config {
ForwardedHeadersTrustedIPs: []string{"127.0.0.1"},
ForwardedHeadersCustomName: "",
RemediationStatusCode: 403,
BanHTMLFilePath: "",
BanFilePath: "",
RemediationHeadersCustomName: "",
CaptchaProvider: "",
CaptchaSiteKey: "",
CaptchaSecretKey: "",
CaptchaGracePeriodSeconds: 1,
CaptchaHTMLFilePath: "",
CaptchaFilePath: "",
RedisCacheEnabled: false,
RedisCacheHost: "",
RedisCachePassword: "",
+226 -3
View File
@@ -2,16 +2,20 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
import (
"context"
htmltemplate "html/template"
"io"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
"text/template"
"time"
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
)
func TestServeHTTP(t *testing.T) {
@@ -190,11 +194,11 @@ func Test_crowdsecQuery(t *testing.T) {
func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
html := "<html>You are banned</html>"
banTemplate, _ := htmltemplate.New("html").Parse(html)
banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html)
tests := []struct {
name string
method string
banTemplate *htmltemplate.Template
banTemplate *template.Template
expectBodyContent bool
}{
{
@@ -235,6 +239,7 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
remediationStatusCode: http.StatusForbidden,
remediationCustomHeader: "X-Test-Remediation",
banTemplate: tt.banTemplate,
banTemplateContentType: "text/html; charset=utf-8",
}
rw := httptest.NewRecorder()
@@ -269,6 +274,50 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
}
}
func TestHandleBanServeHTTPContentType(t *testing.T) {
html := "<html>You are banned</html>"
banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html)
tests := []struct {
name string
banTemplate *template.Template
banTemplateContentType string
}{
{
name: "Default HTML content type",
banTemplate: banTemplate,
banTemplateContentType: "text/html; charset=utf-8",
},
{
name: "Custom JSON content type",
banTemplate: banTemplate,
banTemplateContentType: "application/json",
},
{
name: "Content type set even when banTemplate is nil",
banTemplate: nil,
banTemplateContentType: "application/json",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bouncer := &Bouncer{
remediationStatusCode: http.StatusForbidden,
banTemplate: tt.banTemplate,
banTemplateContentType: tt.banTemplateContentType,
}
rw := httptest.NewRecorder()
req := &http.Request{Method: http.MethodGet}
bouncer.handleBanServeHTTP(rw, req, "0.0.0.0", "TEST")
if got := rw.Header().Get("Content-Type"); got != tt.banTemplateContentType {
t.Errorf("Expected Content-Type %q, got %q", tt.banTemplateContentType, got)
}
})
}
}
func TestCaptchaMethodBasedLogic(t *testing.T) {
tests := []struct {
name string
@@ -332,3 +381,177 @@ func TestCaptchaMethodBasedLogic(t *testing.T) {
})
}
}
// blockingBody simulates a request body that never reaches EOF, like a
// bidirectional gRPC stream that keeps its body open for the whole life of
// the connection. Reading from it blocks until the test is done.
type blockingBody struct {
done <-chan struct{}
}
func (b blockingBody) Read(_ []byte) (int, error) {
<-b.done
return 0, io.EOF
}
func (blockingBody) Close() error { return nil }
func Test_isBodyUnreadable(t *testing.T) {
realBody := func() io.ReadCloser { return io.NopCloser(strings.NewReader("data")) }
tests := []struct {
name string
protoMajor int
contentLength int64
body io.ReadCloser
want bool
}{
{name: "http2 grpc stream without content-length", protoMajor: 2, contentLength: -1, body: realBody(), want: true},
{name: "http3 stream without content-length", protoMajor: 3, contentLength: -1, body: realBody(), want: true},
{name: "http2 with content-length", protoMajor: 2, contentLength: 42, body: realBody(), want: false},
{name: "http1.1 chunked without content-length", protoMajor: 1, contentLength: -1, body: realBody(), want: false},
{name: "http2 without body", protoMajor: 2, contentLength: -1, body: nil, want: false},
{name: "http2 with http.NoBody", protoMajor: 2, contentLength: -1, body: http.NoBody, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, "http://localhost", nil)
req.ProtoMajor = tt.protoMajor
req.ContentLength = tt.contentLength
req.Body = tt.body
if got := isBodyUnreadable(req); got != tt.want {
t.Errorf("isBodyUnreadable() = %v, want %v", got, tt.want)
}
})
}
}
// newStreamingRequest builds an HTTP/2 request whose body never reaches EOF,
// like a bidirectional gRPC stream (issue #323).
func newStreamingRequest(done <-chan struct{}) *http.Request {
req, _ := http.NewRequest(http.MethodPost, "http://localhost/signalexchange.SignalExchange/ConnectStream", blockingBody{done: done})
req.Header.Set("Content-Type", "application/grpc")
req.ProtoMajor = 2
req.ContentLength = -1
return req
}
// Test_appsecQuery_streamingDoesNotBlock is a regression test for issue #323:
// a gRPC streaming request whose body never reaches EOF must not be buffered
// (io.ReadAll would block until timeout and wrongly produce a 403). The appsec
// query must complete promptly, inspecting headers only.
func Test_appsecQuery_streamingDoesNotBlock(t *testing.T) {
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusOK)
}))
defer appsecServer.Close()
appsecURL, _ := url.Parse(appsecServer.URL)
bouncer := &Bouncer{
appsecScheme: appsecURL.Scheme,
appsecHost: appsecURL.Host,
appsecPath: "/",
appsecBodyLimit: 10485760,
appsecUnreachableBlock: true,
appsecFailureBlock: true,
httpAppsecClient: appsecServer.Client(),
log: logger.New("INFO", ""),
}
done := make(chan struct{})
defer close(done)
finished := make(chan error, 1)
go func() {
finished <- appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done))
}()
select {
case err := <-finished:
if err != nil {
t.Errorf("appsecQuery() on streaming request returned error: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("appsecQuery() blocked on a streaming request body (issue #323 regression)")
}
}
// Test_appsecQuery_dropUnreadableBody verifies that, when configured to do so,
// a request with an unreadable body is dropped (blocked) instead of forwarded
// without its body, mirroring the reference APPSEC_DROP_UNREADABLE_BODY option.
func Test_appsecQuery_dropUnreadableBody(t *testing.T) {
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusOK)
}))
defer appsecServer.Close()
appsecURL, _ := url.Parse(appsecServer.URL)
bouncer := &Bouncer{
appsecScheme: appsecURL.Scheme,
appsecHost: appsecURL.Host,
appsecPath: "/",
appsecBodyLimit: 10485760,
appsecUnreadableBodyBlock: true,
httpAppsecClient: appsecServer.Client(),
log: logger.New("INFO", ""),
}
done := make(chan struct{})
defer close(done)
finished := make(chan error, 1)
go func() {
finished <- appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done))
}()
select {
case err := <-finished:
if err == nil {
t.Error("appsecQuery() expected an error to block the request, got nil")
}
case <-time.After(2 * time.Second):
t.Fatal("appsecQuery() blocked on a streaming request body (issue #323 regression)")
}
}
func newUnreadableGetRequest(done <-chan struct{}) *http.Request {
req, _ := http.NewRequest(http.MethodGet, "http://localhost/", blockingBody{done: done})
req.ProtoMajor = 3
req.ContentLength = -1
return req
}
// Test_appsecQuery_unreadableBodyGetNotDropped is a regression test for issue #351
func Test_appsecQuery_unreadableBodyGetNotDropped(t *testing.T) {
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusOK)
}))
defer appsecServer.Close()
appsecURL, _ := url.Parse(appsecServer.URL)
bouncer := &Bouncer{
appsecScheme: appsecURL.Scheme,
appsecHost: appsecURL.Host,
appsecPath: "/",
appsecBodyLimit: 10485760,
appsecUnreadableBodyBlock: true,
httpAppsecClient: appsecServer.Client(),
log: logger.New("INFO", ""),
}
done := make(chan struct{})
defer close(done)
finished := make(chan error, 1)
go func() {
finished <- appsecQuery(bouncer, "1.2.3.4", newUnreadableGetRequest(done))
}()
select {
case err := <-finished:
if err != nil {
t.Errorf("appsecQuery() on an HTTP/3 GET without content-length returned error: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("appsecQuery() blocked on an HTTP/3 GET request body (issue #351 regression)")
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
services:
traefik:
image: "traefik:v3.5.0"
image: "traefik:v3.7.9"
container_name: "traefik"
restart: unless-stopped
command:
@@ -80,7 +80,7 @@ services:
- "traefik.http.routers.router-bar3.entrypoints=web"
- "traefik.http.routers.router-bar3.middlewares=crowdsec2@docker"
crowdsec:
image: crowdsecurity/crowdsec:v1.6.8
image: crowdsecurity/crowdsec:v1.7.8
container_name: "crowdsec"
restart: unless-stopped
environment:
+3 -3
View File
@@ -1,6 +1,6 @@
services:
traefik:
image: "traefik:v3.0.0"
image: "traefik:v3.7.9"
container_name: "traefik"
restart: unless-stopped
command:
@@ -12,7 +12,7 @@ services:
- "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.3.0"
- "--experimental.plugins.bouncer.version=v1.7.0"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
# - './ban.html:/ban.html:ro'
@@ -59,7 +59,7 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
crowdsec:
image: crowdsecurity/crowdsec:v1.6.8
image: crowdsecurity/crowdsec:v1.7.8
container_name: "crowdsec"
restart: unless-stopped
environment:
+3 -3
View File
@@ -1,6 +1,6 @@
services:
traefik:
image: "traefik:v3.5.0"
image: "traefik:v3.7.9"
container_name: "traefik"
restart: unless-stopped
command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.5.0"
- "--experimental.plugins.bouncer.version=v1.7.0"
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
@@ -47,7 +47,7 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
crowdsec:
image: crowdsecurity/crowdsec:v1.6.1-2
image: crowdsecurity/crowdsec:v1.7.8
container_name: "crowdsec"
restart: unless-stopped
environment:
+4 -4
View File
@@ -1,6 +1,6 @@
services:
cloudflare:
image: "traefik:v3.0.0"
image: "traefik:v3.7.9"
container_name: "cloudflare"
restart: unless-stopped
command:
@@ -19,7 +19,7 @@ services:
- 8080:8080
traefik:
image: "traefik:v3.0.0"
image: "traefik:v3.7.9"
container_name: "traefik"
restart: unless-stopped
command:
@@ -33,7 +33,7 @@ services:
- "--entrypoints.web.forwardedheaders.trustedips=172.21.0.5"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.3.0"
- "--experimental.plugins.bouncer.version=v1.7.0"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- logs-traefik:/var/log/traefik
@@ -79,7 +79,7 @@ services:
crowdsec:
image: crowdsecurity/crowdsec:v1.6.1-2
image: crowdsecurity/crowdsec:v1.7.8
container_name: "crowdsec"
restart: unless-stopped
environment:
+7 -7
View File
@@ -52,7 +52,7 @@ More information is available on configuring Crowdsec in the [official documenta
```yaml
...
crowdsec:
image: crowdsecurity/crowdsec:v1.6.1-2
image: crowdsecurity/crowdsec:v1.6.8
volumes:
# For captcha and ban mixed decision
- './profiles.yaml:/etc/crowdsec/profiles.yaml:ro'
@@ -100,9 +100,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage
create participant PluginCache
TraefikPlugin-->>PluginCache: Does the user IP has a crowdsec decision ?
Destroy PluginCache
destroy PluginCache
PluginCache-->>TraefikPlugin: Nothing, all good!
Destroy TraefikPlugin
destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response
```
@@ -121,12 +121,12 @@ sequenceDiagram
User->>TraefikPlugin: Fine, done!
create participant ProviderCaptcha
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
Destroy ProviderCaptcha
destroy ProviderCaptcha
ProviderCaptcha-->>TraefikPlugin: Yes
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
Destroy PluginCache
destroy PluginCache
PluginCache-->>TraefikPlugin: Done
Destroy TraefikPlugin
destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response
```
@@ -140,7 +140,7 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage
create participant PluginCache
TraefikPlugin-->>PluginCache: Does the User IP has a Crowdsec Decision ?
Destroy PluginCache
destroy PluginCache
PluginCache-->>TraefikPlugin: Yes a ban Decision
TraefikPlugin->>User: No, HTTP 403
```
+3 -3
View File
@@ -1,6 +1,6 @@
services:
traefik:
image: "traefik:v3.0.0"
image: "traefik:v3.7.9"
container_name: "traefik"
restart: unless-stopped
command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.3.0"
- "--experimental.plugins.bouncer.version=v1.7.0"
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
@@ -55,7 +55,7 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
crowdsec:
image: crowdsecurity/crowdsec:v1.6.1-2
image: crowdsecurity/crowdsec:v1.7.8
container_name: "crowdsec"
restart: unless-stopped
environment:
+3 -3
View File
@@ -9,11 +9,11 @@ This can be usefull as some browser (Firefox for instance) return a 403 blank we
```yaml
labels:
# Define ban HTML file path
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
# Define ban file path
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html"
```
The ban HTML file must be present in the Traefik container (bind mounted or added during a custom build).
The ban file must be present in the Traefik container (bind mounted or added during a custom build).
It is not directly accessible from Traefik even when importing the plugin, so [download](https://raw.githubusercontent.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/master/ban.html) it locally to expose it to Traefik.
```yaml
+5 -5
View File
@@ -1,6 +1,6 @@
services:
traefik:
image: "traefik:v3.0.0"
image: "traefik:v3.7.9"
container_name: "traefik"
restart: unless-stopped
command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.3.0"
- "--experimental.plugins.bouncer.version=v1.7.0"
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
@@ -42,11 +42,11 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
# Define ban HTML file path
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
# Define ban file path
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html"
crowdsec:
image: crowdsecurity/crowdsec:v1.6.1-2
image: crowdsecurity/crowdsec:v1.7.8
container_name: "crowdsec"
restart: unless-stopped
environment:
+3 -3
View File
@@ -1,6 +1,6 @@
services:
traefik:
image: "traefik:v3.5.0"
image: "traefik:v3.7.9"
container_name: "traefik"
restart: unless-stopped
command:
@@ -14,7 +14,7 @@ services:
- "--entrypoints.web.forwardedheaders.trustedips=172.18.0.0/24"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.4.5"
- "--experimental.plugins.bouncer.version=v1.7.0"
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
@@ -59,7 +59,7 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
crowdsec:
image: crowdsecurity/crowdsec:v1.6.1-2
image: crowdsecurity/crowdsec:v1.7.8
container_name: "crowdsec"
restart: unless-stopped
environment:
+1 -1
View File
@@ -1,5 +1,5 @@
image:
tag: v1.6.1-2
tag: v1.7.8-2
agent:
acquisition:
+2 -2
View File
@@ -1,5 +1,5 @@
image:
tag: v3.0.0
tag: v3.7.9
logs:
general:
@@ -15,4 +15,4 @@ experimental:
plugins:
bouncer:
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
version: "v1.3.0"
version: "v1.7.0"
+4 -4
View File
@@ -1,6 +1,6 @@
services:
traefik:
image: "traefik:v3.0.0"
image: "traefik:v3.7.9"
container_name: "traefik"
restart: unless-stopped
command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.3.0"
- "--experimental.plugins.bouncer.version=v1.7.0"
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
@@ -71,7 +71,7 @@ services:
crowdsec:
image: crowdsecurity/crowdsec:v1.6.1-2
image: crowdsecurity/crowdsec:v1.7.8
container_name: "crowdsec"
restart: unless-stopped
environment:
@@ -87,7 +87,7 @@ services:
- "traefik.enable=false"
redis-secure:
image: "redis:7.0.12-alpine"
image: "redis:8.8.1-alpine"
container_name: "redis-secure"
hostname: redis-secure
restart: unless-stopped
+2 -2
View File
@@ -1,6 +1,6 @@
services:
traefik:
image: "traefik:v3.0.0"
image: "traefik:v3.7.9"
container_name: "traefik"
restart: unless-stopped
command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.3.0"
- "--experimental.plugins.bouncer.version=v1.7.0"
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
+1 -1
View File
@@ -1,4 +1,4 @@
FROM ubuntu:24.04
FROM ubuntu:26.04
RUN apt-get update && apt-get install -y curl wget
RUN VERSION=$(curl --silent "https://api.github.com/repos/cloudflare/cfssl/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') && VNUMBER=${VERSION#"v"} && wget https://github.com/cloudflare/cfssl/releases/download/${VERSION}/cfssl_${VNUMBER}_linux_amd64 -O cfssl && chmod +x cfssl && mv cfssl /usr/local/bin
+3 -3
View File
@@ -1,6 +1,6 @@
services:
traefik:
image: "traefik:v3.5.0"
image: "traefik:v3.7.9"
container_name: "traefik"
restart: unless-stopped
command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.5.0"
- "--experimental.plugins.bouncer.version=v1.7.0"
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
@@ -71,7 +71,7 @@ services:
# Define AppSec host and port informations
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
crowdsec:
image: crowdsecurity/crowdsec:latest
image: crowdsecurity/crowdsec:v1.7.8
container_name: "crowdsec"
restart: unless-stopped
environment:
+3 -3
View File
@@ -1,6 +1,6 @@
services:
traefik:
image: "traefik:v3.0.0"
image: "traefik:v3.7.9"
container_name: "traefik"
restart: unless-stopped
command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.3.0"
- "--experimental.plugins.bouncer.version=v1.7.0"
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
@@ -65,7 +65,7 @@ services:
crowdsec:
image: crowdsecurity/crowdsec:v1.6.1-2
image: crowdsecurity/crowdsec:v1.7.8
container_name: "crowdsec"
restart: unless-stopped
environment:
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
go 1.22
go 1.22.12
require (
github.com/leprosus/golang-ttl-map v1.1.7
+128 -22
View File
@@ -6,9 +6,14 @@ import (
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"sync/atomic"
ttl_map "github.com/leprosus/golang-ttl-map"
simpleredis "github.com/maxlerebourg/simpleredis"
"github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
)
const (
@@ -24,13 +29,21 @@ const (
CacheMiss = "cache:miss"
// CacheUnreachable error string when cache is unreachable.
CacheUnreachable = "cache:unreachable"
// cidrPrefix namespaces a CIDR decision, one cache key per CIDR.
cidrPrefix = "cidr:"
// cidrPrefixLensKey holds the prefix lengths that have a decision, so a lookup probes
// only those. Its absence means no CIDR decision was ever stored.
cidrPrefixLensKey = "cidrprefixlens"
// cidrPrefixLensSeparator separates the prefix lengths in cidrPrefixLensKey.
cidrPrefixLensSeparator = ","
// cidrPrefixLensDuration has to outlive every decision it describes, so it is
// effectively infinite. It cannot be zero: the local cache ignores a zero duration
// and redis rejects a non positive EX.
cidrPrefixLensDuration = 10 * 365 * 24 * 60 * 60
)
//nolint:gochecknoglobals
var (
redis simpleredis.SimpleRedis
cache = ttl_map.New()
)
var cache = ttl_map.New()
type localCache struct{}
@@ -53,32 +66,47 @@ func (localCache) delete(key string) {
type redisCache struct {
log *slog.Logger
writer simpleredis.SimpleRedis
readers []simpleredis.SimpleRedis
counter atomic.Uint64
}
func (redisCache) get(key string) (string, error) {
value, err := redis.Get(key)
func (rc *redisCache) nextReader() *simpleredis.SimpleRedis {
n := len(rc.readers)
if n == 0 {
return &rc.writer
}
idx := rc.counter.Add(1) % uint64(n)
return &rc.readers[idx]
}
func (rc *redisCache) get(key string) (string, error) {
value, err := rc.nextReader().Get(key)
if err != nil {
switch err.Error() {
case simpleredis.RedisMiss:
return "", errors.New(CacheMiss)
case simpleredis.RedisUnreachable:
return "", errors.New(CacheUnreachable)
default:
return "", err
}
}
valueString := string(value)
if err == nil && len(valueString) > 0 {
if len(valueString) > 0 {
return valueString, nil
}
errRedisMessage := err.Error()
if errRedisMessage == simpleredis.RedisMiss {
return "", errors.New(CacheMiss)
}
if errRedisMessage == simpleredis.RedisUnreachable {
return "", errors.New(CacheUnreachable)
}
return "", err
}
func (rc redisCache) set(key, value string, duration int64) {
if err := redis.Set(key, []byte(value), duration); err != nil {
func (rc *redisCache) set(key, value string, duration int64) {
if err := rc.writer.Set(key, []byte(value), duration); err != nil {
rc.log.Error("cache:setDecisionRedisCache" + err.Error())
}
}
func (rc redisCache) delete(key string) {
if err := redis.Del(key); err != nil {
func (rc *redisCache) delete(key string) {
if err := rc.writer.Del(key); err != nil {
rc.log.Error("cache:deleteDecisionRedisCache " + err.Error())
}
}
@@ -96,15 +124,21 @@ type Client struct {
}
// New Initialize cache client.
func (c *Client) New(log *slog.Logger, isRedis bool, host, pass, database string) {
func (c *Client) New(log *slog.Logger, isRedis bool, writeHost string, readHosts []string, pass, database string) {
c.log = log
if isRedis {
redis.Init(host, pass, database)
c.cache = &redisCache{log: log}
rc := &redisCache{log: log}
rc.writer.Init(writeHost, pass, database)
for _, h := range readHosts {
var r simpleredis.SimpleRedis
r.Init(h, pass, database)
rc.readers = append(rc.readers, r)
}
c.cache = rc
} else {
c.cache = &localCache{}
}
c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v", isRedis))
c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v writeHost:%v readHosts:%v", isRedis, writeHost, readHosts))
}
// Delete delete decision in cache.
@@ -125,3 +159,75 @@ func (c *Client) Set(key string, value string, duration int64) {
c.log.Debug(fmt.Sprintf("cache:Set key:%v value:%v duration:%vs", key, value, duration))
c.cache.set(key, value, duration)
}
// DeleteCIDR removes a CIDR decision from the cache.
func (c *Client) DeleteCIDR(cidr string) {
normalized := ip.NormalizeCIDR(cidr)
if normalized == "" {
c.log.Error(fmt.Sprintf("cache:DeleteCIDR:invalidCIDR cidr:%v decision is left in cache", cidr))
return
}
cidr = normalized
c.cache.delete(cidrPrefix + cidr)
c.log.Debug(fmt.Sprintf("cache:DeleteCIDR cidr:%v", cidr))
}
// GetCIDR checks if an IP matches a CIDR decision in the cache.
// Only probes the prefix lengths that have a decision: it is on the request path.
func (c *Client) GetCIDR(ipStr string) (string, error) {
prefixLens, err := c.cache.get(cidrPrefixLensKey)
if err != nil {
return "", err
}
for _, key := range ip.CIDRLookupKeys(ipStr, parsePrefixLens(prefixLens)) {
value, getErr := c.cache.get(cidrPrefix + key)
if getErr == nil {
return value, nil
}
}
return "", errors.New(CacheMiss)
}
// SetCIDR stores a CIDR decision in the cache.
func (c *Client) SetCIDR(cidr, value string, duration int64) {
normalized := ip.NormalizeCIDR(cidr)
prefixLen := ip.CIDRPrefixLen(cidr)
if normalized == "" || prefixLen < 0 {
c.log.Error(fmt.Sprintf("cache:SetCIDR:invalidCIDR cidr:%v value:%v decision is not enforced", cidr, value))
return
}
// Publish the length first, or a concurrent lookup misses the decision.
c.addCIDRPrefixLen(prefixLen)
c.cache.set(cidrPrefix+normalized, value, duration)
c.log.Debug(fmt.Sprintf("cache:SetCIDR cidr:%v value:%v duration:%vs", normalized, value, duration))
}
// addCIDRPrefixLen records a prefix length in the set probed on lookup. The set only grows:
// a stale length costs one extra read, dropping one too early leaves decisions unmatched.
func (c *Client) addCIDRPrefixLen(prefixLen int) {
prefixLens, err := c.cache.get(cidrPrefixLensKey)
if err == nil {
for _, known := range parsePrefixLens(prefixLens) {
if known == prefixLen {
return
}
}
prefixLens += cidrPrefixLensSeparator + strconv.Itoa(prefixLen)
} else {
prefixLens = strconv.Itoa(prefixLen)
}
c.cache.set(cidrPrefixLensKey, prefixLens, cidrPrefixLensDuration)
c.log.Debug(fmt.Sprintf("cache:addCIDRPrefixLen prefixLens:%v", prefixLens))
}
// parsePrefixLens decodes the set of prefix lengths stored in cidrPrefixLensKey.
func parsePrefixLens(value string) []int {
fields := strings.Split(value, cidrPrefixLensSeparator)
prefixLens := make([]int, 0, len(fields))
for _, field := range fields {
if prefixLen, err := strconv.Atoi(field); err == nil {
prefixLens = append(prefixLens, prefixLen)
}
}
return prefixLens
}
+222
View File
@@ -3,9 +3,11 @@
package cache
import (
"errors"
"testing"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
simpleredis "github.com/maxlerebourg/simpleredis"
)
func Test_Get(t *testing.T) {
@@ -122,3 +124,223 @@ func Test_Delete(t *testing.T) {
})
}
}
// indexOfReader returns the position of r inside rc.readers, or -1 when r is the writer (the no-readers fallback).
func indexOfReader(rc *redisCache, r *simpleredis.SimpleRedis) int {
if r == &rc.writer {
return -1
}
for i := range rc.readers {
if r == &rc.readers[i] {
return i
}
}
return -2
}
func Test_nextReader(t *testing.T) {
// The counter starts at 0, so the first Add(1) yields index 1, then 2, 0, 1, ... over n readers.
tests := []struct {
name string
readers int
want []int
}{
{name: "round-robin over three readers", readers: 3, want: []int{1, 2, 0, 1, 2, 0, 1}},
{name: "single reader always selected", readers: 1, want: []int{0, 0, 0, 0, 0}},
{name: "no readers fall back to writer", readers: 0, want: []int{-1, -1, -1}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rc := &redisCache{log: logger.New("INFO", "")}
rc.readers = make([]simpleredis.SimpleRedis, tt.readers)
for call, want := range tt.want {
if got := indexOfReader(rc, rc.nextReader()); got != want {
t.Errorf("call %d: nextReader() -> reader[%d], want reader[%d]", call, got, want)
}
}
})
}
}
// countingCache is an isolated cacheInterface recording how many reads a lookup costs,
// so a CIDR lookup can be checked for both its result and its price.
type countingCache struct {
values map[string]string
reads int
}
func newCountingCache() *countingCache {
return &countingCache{values: map[string]string{}}
}
func (c *countingCache) get(key string) (string, error) {
c.reads++
if value, found := c.values[key]; found && value != "" {
return value, nil
}
return "", errors.New(CacheMiss)
}
func (c *countingCache) set(key, value string, _ int64) {
c.values[key] = value
}
func (c *countingCache) delete(key string) {
delete(c.values, key)
}
func newCIDRClient(decisions map[string]string) (*Client, *countingCache) {
counting := newCountingCache()
client := &Client{cache: counting, log: logger.New("INFO", "")}
for cidr, value := range decisions {
client.SetCIDR(cidr, value, 60)
}
return client, counting
}
func Test_GetCIDR(t *testing.T) {
decisions := map[string]string{
"10.0.0.0/24": BannedValue,
"192.168.1.42/24": CaptchaValue, // not a network address, host bits are dropped
"2001:db8::/32": BannedValue,
}
tests := []struct {
name string
clientIP string
want string
wantErr bool
}{
{name: "IP inside a banned range", clientIP: "10.0.0.7", want: BannedValue},
{name: "network address itself", clientIP: "10.0.0.0", want: BannedValue},
{name: "broadcast address of the range", clientIP: "10.0.0.255", want: BannedValue},
{name: "IP just outside the range", clientIP: "10.0.1.0", wantErr: true},
{name: "IP inside a captcha range", clientIP: "192.168.1.7", want: CaptchaValue},
{name: "IP inside an IPv6 range", clientIP: "2001:db8::dead:beef", want: BannedValue},
{name: "IP outside the IPv6 range", clientIP: "2001:db9::1", wantErr: true},
{name: "IPv4 mapped client against an IPv4 range", clientIP: "::ffff:10.0.0.7", want: BannedValue},
{name: "unknown IP", clientIP: "8.8.8.8", wantErr: true},
{name: "invalid IP", clientIP: "not-an-ip", wantErr: true},
{name: "empty IP", clientIP: "", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, _ := newCIDRClient(decisions)
got, err := client.GetCIDR(tt.clientIP)
if (err != nil) != tt.wantErr {
t.Fatalf("GetCIDR(%q) error = %v, wantErr %v", tt.clientIP, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("GetCIDR(%q) = %q, want %q", tt.clientIP, got, tt.want)
}
})
}
}
// Test_GetCIDR_MostSpecific pins precedence: the narrowest range wins, so a captcha on
// a /24 is not overruled by a ban on its /8.
func Test_GetCIDR_MostSpecific(t *testing.T) {
client, _ := newCIDRClient(map[string]string{
"10.0.0.0/8": BannedValue,
"10.1.0.0/16": CaptchaValue,
"10.1.2.0/24": BannedValue,
"2001:db8::/32": BannedValue,
"2001:db8::/48": CaptchaValue,
})
tests := []struct {
clientIP string
want string
}{
{clientIP: "10.1.2.3", want: BannedValue},
{clientIP: "10.1.3.3", want: CaptchaValue},
{clientIP: "10.2.3.4", want: BannedValue},
{clientIP: "2001:db8::1", want: CaptchaValue},
{clientIP: "2001:db8:1::1", want: BannedValue},
}
for _, tt := range tests {
t.Run(tt.clientIP, func(t *testing.T) {
got, err := client.GetCIDR(tt.clientIP)
if err != nil {
t.Fatalf("GetCIDR(%q) unexpected error %v", tt.clientIP, err)
}
if got != tt.want {
t.Errorf("GetCIDR(%q) = %q, want %q", tt.clientIP, got, tt.want)
}
})
}
}
func Test_DeleteCIDR(t *testing.T) {
client, _ := newCIDRClient(map[string]string{
"10.0.0.0/8": BannedValue,
"10.1.2.0/24": CaptchaValue,
})
client.DeleteCIDR("10.1.2.0/24")
// The wider decision is untouched and takes over.
if got, err := client.GetCIDR("10.1.2.3"); err != nil || got != BannedValue {
t.Errorf("after deleting the /24, GetCIDR = %q %v, want %q", got, err, BannedValue)
}
client.DeleteCIDR("10.0.0.0/8")
if _, err := client.GetCIDR("10.1.2.3"); err == nil {
t.Error("GetCIDR should miss once every decision is deleted")
}
}
func Test_SetCIDR_InvalidIsNotStored(t *testing.T) {
for _, cidr := range []string{"", "garbage", "10.0.0.1", "10.0.0.0/33", "10.0.0.0/-1"} {
t.Run(cidr, func(t *testing.T) {
_, counting := newCIDRClient(map[string]string{cidr: BannedValue})
if len(counting.values) != 0 {
t.Errorf("SetCIDR(%q) stored %v, want nothing", cidr, counting.values)
}
})
}
}
// Test_GetCIDR_Reads guards the cost of the lookup: it must probe only the prefix
// lengths that have a decision, not every possible one.
func Test_GetCIDR_Reads(t *testing.T) {
tests := []struct {
name string
decisions map[string]string
clientIP string
wantReads int
}{
{name: "no decision at all, IPv4", decisions: nil, clientIP: "10.0.0.1", wantReads: 1},
{name: "no decision at all, IPv6", decisions: nil, clientIP: "2001:db8::1", wantReads: 1},
{
name: "one prefix length, hit",
decisions: map[string]string{"10.0.0.0/24": BannedValue},
clientIP: "10.0.0.1",
wantReads: 2,
},
{
name: "one prefix length, miss",
decisions: map[string]string{"10.0.0.0/24": BannedValue},
clientIP: "11.0.0.1",
wantReads: 2,
},
{
name: "three prefix lengths, miss probes each once",
decisions: map[string]string{"10.0.0.0/8": BannedValue, "10.1.0.0/16": BannedValue, "10.1.2.0/24": BannedValue},
clientIP: "11.0.0.1",
wantReads: 4,
},
{
name: "IPv6 client does not probe every length",
decisions: map[string]string{"2001:db8::/32": BannedValue},
clientIP: "2001:dead::1",
wantReads: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, counting := newCIDRClient(tt.decisions)
counting.reads = 0
// Only the number of reads matters here, the result is covered above.
_, _ = client.GetCIDR(tt.clientIP)
if counting.reads != tt.wantReads {
t.Errorf("GetCIDR(%q) did %d cache reads, want %d", tt.clientIP, counting.reads, tt.wantReads)
}
})
}
}
+8 -6
View File
@@ -4,11 +4,11 @@ package captcha
import (
"encoding/json"
"fmt"
"html/template"
"log/slog"
"net/http"
"net/url"
"strings"
"text/template"
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
@@ -21,7 +21,8 @@ type Client struct {
secretKey string
remediationCustomHeader string
gracePeriodSeconds int64
captchaTemplate *template.Template
templateContentType string
template *template.Template
cacheClient *cache.Client
httpClient *http.Client
log *slog.Logger
@@ -74,8 +75,9 @@ func (c *Client) New(log *slog.Logger, cacheClient *cache.Client, httpClient *ht
c.siteKey = siteKey
c.secretKey = secretKey
c.remediationCustomHeader = remediationCustomHeader
html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
c.captchaTemplate = html
template, contentType, _ := configuration.GetTemplate(captchaTemplatePath)
c.template = template
c.templateContentType = contentType
c.gracePeriodSeconds = gracePeriodSeconds
c.log = log
c.httpClient = httpClient
@@ -100,12 +102,12 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
return
}
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.Header().Set("Content-Type", c.templateContentType)
if c.remediationCustomHeader != "" {
rw.Header().Set(c.remediationCustomHeader, "captcha")
}
rw.WriteHeader(http.StatusOK)
err = c.captchaTemplate.Execute(rw, map[string]string{
err = c.template.Execute(rw, map[string]string{
"SiteKey": c.siteKey,
"FrontendJS": c.infoProvider.js,
"FrontendKey": c.infoProvider.key,
+57 -24
View File
@@ -6,7 +6,6 @@ import (
"crypto/x509"
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
"net/url"
@@ -15,6 +14,7 @@ import (
"reflect"
"regexp"
"strings"
"text/template"
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
)
@@ -63,6 +63,7 @@ type Config struct {
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
CrowdsecAppsecUnreadableBodyBlock bool `json:"crowdsecAppsecUnreadableBodyBlock,omitempty"`
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
@@ -95,12 +96,15 @@ type Config struct {
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
RedisCacheHost string `json:"redisCacheHost,omitempty"`
RedisCacheReadHosts []string `json:"redisCacheReadHosts,omitempty"`
RedisCachePassword string `json:"redisCachePassword,omitempty"`
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
BanFilePath string `json:"banFilePath,omitempty"`
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
CaptchaFilePath string `json:"captchaFilePath,omitempty"`
CaptchaProvider string `json:"captchaProvider,omitempty"`
CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"`
CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"`
@@ -133,6 +137,7 @@ func New() *Config {
CrowdsecAppsecEnabled: false,
CrowdsecAppsecFailureBlock: true,
CrowdsecAppsecUnreachableBlock: true,
CrowdsecAppsecUnreadableBodyBlock: true,
CrowdsecAppsecBodyLimit: 10485760,
CrowdsecAppsecScheme: "",
CrowdsecAppsecHost: "crowdsec:7422",
@@ -159,8 +164,8 @@ func New() *Config {
CaptchaSiteKey: "",
CaptchaSecretKey: "",
CaptchaGracePeriodSeconds: 1800,
CaptchaHTMLFilePath: "/captcha.html",
BanHTMLFilePath: "",
CaptchaFilePath: "/captcha.html",
BanFilePath: "",
TraceHeadersCustomName: "",
RemediationHeadersCustomName: "",
ForwardedHeadersCustomName: "X-Forwarded-For",
@@ -168,6 +173,7 @@ func New() *Config {
ClientTrustedIPs: []string{},
RedisCacheEnabled: false,
RedisCacheHost: "redis:6379",
RedisCacheReadHosts: []string{},
RedisCachePassword: "",
RedisCacheDatabase: "",
RedisCacheUnreachableBlock: true,
@@ -201,28 +207,50 @@ func GetVariable(config *Config, key string) (string, error) {
return strings.TrimSpace(value), nil
}
// GetHTMLTemplate get compiled HTML template.
func GetHTMLTemplate(path string) (*template.Template, error) {
var err error
func getContentTypeFromPath(path string) string {
if path == "" {
return nil, errors.New("no html template provided")
return ""
}
ext := strings.ToLower(filepath.Ext(path))
contentTypeMap := map[string]string{
".html": "text/html; charset=utf-8",
".htm": "text/html; charset=utf-8",
".json": "application/json",
".txt": "text/plain",
".xml": "application/xml",
".js": "application/javascript",
".css": "text/css",
}
if contentType, ok := contentTypeMap[ext]; ok {
return contentType
}
// Default to HTML for backward compatibility
return "text/html; charset=utf-8"
}
// GetTemplate get compiled template with {{ and }} delimiters.
// Uses text/template for all file types to avoid HTML escaping issues.
func GetTemplate(path string) (*template.Template, string, error) {
if path == "" {
return nil, "", errors.New("no template file provided")
}
contentType := getContentTypeFromPath(path)
//nolint:gosec
b, err := os.ReadFile(path)
if err != nil {
return nil, err
return nil, "", err
}
html := string(b)
compiledTemplate, err := template.New("html").Parse(html)
content := string(b)
compiledTemplate, err := template.New(filepath.Base(path)).Delims("{{", "}}").Parse(content)
if err != nil {
return nil, fmt.Errorf("impossible to compile html template: %w", err)
return nil, "", fmt.Errorf("impossible to compile template %s: %w", path, err)
}
return compiledTemplate, nil
return compiledTemplate, contentType, nil
}
// ValidateParams validate all the param gave by user.
//
//nolint:gocyclo,gocognit
//nolint:gocyclo,gocognit,nestif
func ValidateParams(config *Config, log *slog.Logger) error {
if err := validateParamsRequired(config); err != nil {
return err
@@ -260,12 +288,14 @@ func ValidateParams(config *Config, log *slog.Logger) error {
if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil {
return err
}
if _, err := GetHTMLTemplate(config.CaptchaHTMLFilePath); err != nil {
if config.CaptchaFilePath != "" {
if _, _, err := GetTemplate(config.CaptchaFilePath); err != nil {
return err
}
}
if config.BanHTMLFilePath != "" {
if _, err := GetHTMLTemplate(config.BanHTMLFilePath); err != nil {
}
if config.BanFilePath != "" {
if _, _, err := GetTemplate(config.BanFilePath); err != nil {
return err
}
}
@@ -361,7 +391,8 @@ func validateParamsTLS(config *Config) error {
return err
}
if certAuth == "" {
return errors.New("CrowdsecLapiTLSCertificateAuthority must be specified when CrowdsecLapiScheme='https' and CrowdsecLapiTLSInsecureVerify=false")
// No custom CA — runtime will fall back to the system trust store.
return nil
}
tlsConfig := new(tls.Config)
tlsConfig.RootCAs = x509.NewCertPool()
@@ -453,29 +484,31 @@ func validateParamsRequired(config *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("getTLSConfig:" + prefix + "Scheme https:no")
return tlsConfig, nil
}
// RootCAs is intentionally left nil unless a custom CA is provided:
// crypto/tls then falls back to x509.SystemCertPool(), which is what we
// want when the LAPI is exposed behind a reverse proxy with a publicly
// trusted certificate (e.g. Let's Encrypt).
//nolint:nestif
if insecureVerify {
tlsConfig.InsecureSkipVerify = 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 {
certAuthority, err := GetVariable(config, prefix+"TLSCertificateAuthority")
if err != nil {
return nil, err
}
if len(certAuthority) > 0 {
tlsConfig.RootCAs = x509.NewCertPool()
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("getTLSConfig:" + prefix + " cannot load CA and verify cert is enabled")
}
log.Debug("getTLSConfig:" + prefix + "TLSCertificateAuthority CA added successfully")
} else {
log.Debug("getTLSConfig:" + prefix + " no CA provided, using system trust store")
}
}
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
+88 -19
View File
@@ -1,13 +1,25 @@
package configuration
import (
"crypto/tls"
"reflect"
"testing"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
)
// validPEM is a minimal self-signed certificate accepted by AppendCertsFromPEM,
// shared by the TLS tests below.
const validPEM = `-----BEGIN CERTIFICATE-----
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
6MF9+Yw1Yy0t
-----END CERTIFICATE-----`
func getMinimalConfig() *Config {
cfg := New()
cfg.CrowdsecLapiKey = "test"
@@ -111,7 +123,7 @@ func Test_ValidateParams(t *testing.T) {
{name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true},
// HTTPS enabled
{name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false},
{name: "Not validate https without cert authority", args: args{config: cfg7}, wantErr: true},
{name: "Validate https without cert authority (falls back to system trust store)", args: args{config: cfg7}, wantErr: false},
{name: "Valid log level uppercase INFO", args: args{config: cfg8}, wantErr: false},
{name: "Valid log level lowercase info", args: args{config: cfg9}, wantErr: false},
{name: "Invalid log level Warning", args: args{config: cfg10}, wantErr: true},
@@ -126,19 +138,24 @@ func Test_ValidateParams(t *testing.T) {
}
func Test_validateParamsTLS(t *testing.T) {
type args struct {
config *Config
}
cfgEmpty := getMinimalConfig()
cfgValid := getMinimalConfig()
cfgValid.CrowdsecLapiTLSCertificateAuthority = validPEM
cfgInvalidCA := getMinimalConfig()
cfgInvalidCA.CrowdsecLapiTLSCertificateAuthority = "not a pem"
tests := []struct {
name string
args args
config *Config
wantErr bool
}{
// TODO: Add test cases.
{name: "Empty CA is accepted (system trust store used at runtime)", config: cfgEmpty, wantErr: false},
{name: "Valid PEM CA is accepted", config: cfgValid, wantErr: false},
{name: "Invalid CA is rejected", config: cfgInvalidCA, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := validateParamsTLS(tt.args.config); (err != nil) != tt.wantErr {
if err := validateParamsTLS(tt.config); (err != nil) != tt.wantErr {
t.Errorf("validateParamsTLS() error = %v, wantErr %v", err, tt.wantErr)
}
})
@@ -233,26 +250,78 @@ func Test_validateParamsAPIKey(t *testing.T) {
func Test_GetTLSConfigCrowdsec(t *testing.T) {
log := logger.New("INFO", "")
type args struct {
config *Config
}
httpCfg := getMinimalConfig()
httpCfg.CrowdsecLapiScheme = HTTP
httpsSystemCA := getMinimalConfig()
httpsSystemCA.CrowdsecLapiScheme = HTTPS
httpsCustomCA := getMinimalConfig()
httpsCustomCA.CrowdsecLapiScheme = HTTPS
httpsCustomCA.CrowdsecLapiTLSCertificateAuthority = validPEM
httpsInsecure := getMinimalConfig()
httpsInsecure.CrowdsecLapiScheme = HTTPS
httpsInsecure.CrowdsecLapiTLSInsecureVerify = true
httpsBadCA := getMinimalConfig()
httpsBadCA.CrowdsecLapiScheme = HTTPS
httpsBadCA.CrowdsecLapiTLSCertificateAuthority = "not a pem"
tests := []struct {
name string
args args
want *tls.Config
config *Config
wantErr bool
wantRootCAsNil bool
wantInsecureSkip bool
}{
// TODO: Add test cases.
{name: "HTTP scheme returns empty tls.Config", config: httpCfg, wantRootCAsNil: true},
{name: "HTTPS without CA leaves RootCAs nil (system trust store)", config: httpsSystemCA, wantRootCAsNil: true},
{name: "HTTPS with custom CA populates RootCAs", config: httpsCustomCA, wantRootCAsNil: false},
{name: "HTTPS with insecure verify sets InsecureSkipVerify", config: httpsInsecure, wantRootCAsNil: true, wantInsecureSkip: true},
{name: "HTTPS with garbage CA is rejected", config: httpsBadCA, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetTLSConfigCrowdsec(tt.args.config, log, false)
got, err := GetTLSConfigCrowdsec(tt.config, log, false)
if (err != nil) != tt.wantErr {
t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
t.Errorf("GetTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("getTLSConfigCrowdsec() = %v, want %v", got, tt.want)
if tt.wantErr {
return
}
if (got.RootCAs == nil) != tt.wantRootCAsNil {
t.Errorf("GetTLSConfigCrowdsec() RootCAs nil = %v, want nil = %v", got.RootCAs == nil, tt.wantRootCAsNil)
}
if got.InsecureSkipVerify != tt.wantInsecureSkip {
t.Errorf("GetTLSConfigCrowdsec() InsecureSkipVerify = %v, want %v", got.InsecureSkipVerify, tt.wantInsecureSkip)
}
})
}
}
func Test_getContentTypeFromPath(t *testing.T) {
tests := []struct {
name string
path string
expected string
}{
{name: "HTML file with .html extension", path: "/ban.html", expected: "text/html; charset=utf-8"},
{name: "JSON file", path: "/ban.json", expected: "application/json"},
{name: "Text file", path: "/ban.txt", expected: "text/plain"},
{name: "File with mixed case extension", path: "/ban.HtMl", expected: "text/html; charset=utf-8"},
{name: "Unknown extension defaults to HTML", path: "/ban.xyz", expected: "text/html; charset=utf-8"},
{name: "File without extension", path: "/ban", expected: "text/html; charset=utf-8"},
{name: "Empty path", path: "", expected: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := getContentTypeFromPath(tt.path)
if got != tt.expected {
t.Errorf("GetContentTypeFromPath(%q) = %q, want %q", tt.path, got, tt.expected)
}
})
}
+85
View File
@@ -0,0 +1,85 @@
package ip
import (
"net"
"strings"
)
const (
maxIPv4PrefixLen = 32
maxIPv6PrefixLen = 128
)
// CIDRKeys returns all possible CIDR prefixes of an IP, from the most specific (/32 for IPv4, /128 for IPv6) to the least specific (/0).
func CIDRKeys(ipStr string) []string {
parsed, maxBits := parseForPrefix(ipStr)
if parsed == nil {
return nil
}
keys := make([]string, 0, maxBits+1)
for bits := maxBits; bits >= 0; bits-- {
keys = append(keys, cidrKey(parsed, bits, maxBits))
}
return keys
}
// CIDRLookupKeys returns the keys of the CIDRs containing an IP for the given prefix lengths
// only, most specific first. Duplicates and lengths of the other family are skipped.
func CIDRLookupKeys(ipStr string, prefixLens []int) []string {
parsed, maxBits := parseForPrefix(ipStr)
if parsed == nil {
return nil
}
var wanted [maxIPv6PrefixLen + 1]bool
for _, bits := range prefixLens {
if bits >= 0 && bits <= maxBits {
wanted[bits] = true
}
}
keys := make([]string, 0, len(prefixLens))
for bits := maxBits; bits >= 0; bits-- {
if wanted[bits] {
keys = append(keys, cidrKey(parsed, bits, maxBits))
}
}
return keys
}
// NormalizeCIDR parses a CIDR string and returns its normalized form, or an empty string if invalid.
func NormalizeCIDR(cidrStr string) string {
_, ipNet, err := net.ParseCIDR(strings.TrimSpace(cidrStr))
if err != nil {
return ""
}
return ipNet.String()
}
// CIDRPrefixLen returns the prefix length of a CIDR, or -1 if it is not a valid CIDR.
func CIDRPrefixLen(cidrStr string) int {
_, ipNet, err := net.ParseCIDR(strings.TrimSpace(cidrStr))
if err != nil {
return -1
}
prefixLen, _ := ipNet.Mask.Size()
return prefixLen
}
// parseForPrefix returns the IP in the native form of its family, and that family's bit length.
func parseForPrefix(ipStr string) (net.IP, int) {
parsed := net.ParseIP(ipStr)
if parsed == nil {
return nil, 0
}
if parsed4 := parsed.To4(); parsed4 != nil {
return parsed4, maxIPv4PrefixLen
}
return parsed.To16(), maxIPv6PrefixLen
}
// cidrKey builds the key of the CIDR of bits length containing the IP.
// It formats through net.IPNet like NormalizeCIDR, so writes and lookups agree.
func cidrKey(parsed net.IP, bits, maxBits int) string {
mask := net.CIDRMask(bits, maxBits)
ipNet := net.IPNet{IP: parsed.Mask(mask), Mask: mask}
return ipNet.String()
}
+301
View File
@@ -0,0 +1,301 @@
package ip
import (
"strconv"
"strings"
"testing"
)
func TestCIDRKeys(t *testing.T) {
tests := []struct {
ip string
wantKeys int
checks map[int]string
}{
{
ip: "10.0.0.1",
wantKeys: 33,
checks: map[int]string{0: "10.0.0.1/32", 8: "10.0.0.0/24", 32: "0.0.0.0/0"},
},
{
ip: "2001:db8::1",
wantKeys: 129,
checks: map[int]string{0: "2001:db8::1/128", 32: "2001:db8::/96", 128: "::/0"},
},
}
for _, tt := range tests {
t.Run(tt.ip, func(t *testing.T) {
keys := CIDRKeys(tt.ip)
if keys == nil {
t.Fatal("CIDRKeys returned nil")
}
if len(keys) != tt.wantKeys {
t.Fatalf("expected %d keys, got %d", tt.wantKeys, len(keys))
}
for idx, want := range tt.checks {
if keys[idx] != want {
t.Errorf("keys[%d] should be %s, got %s", idx, want, keys[idx])
}
}
})
}
}
func TestCIDRKeys_MostToLeastSpecific(t *testing.T) {
ips := []string{"10.0.0.1", "2001:db8::1"}
for _, ip := range ips {
t.Run(ip, func(t *testing.T) {
keys := CIDRKeys(ip)
for i := 1; i < len(keys); i++ {
prevBits := strings.Split(keys[i-1], "/")[1]
curBits := strings.Split(keys[i], "/")[1]
prevN, _ := strconv.Atoi(prevBits)
curN, _ := strconv.Atoi(curBits)
if prevN <= curN {
t.Errorf("keys should go from most specific to least specific at index %d: /%d <= /%d", i, prevN, curN)
}
}
})
}
}
func TestCIDRKeys_IPVariants(t *testing.T) {
tests := []struct {
ip string
wantKeys int
}{
{"0.0.0.0", 33},
{"255.255.255.255", 33},
{"1.2.3.4", 33},
{"10.0.0.1", 33},
{"192.168.1.1", 33},
{"invalid", 0},
{"", 0},
{" ", 0},
}
for _, tt := range tests {
t.Run(tt.ip, func(t *testing.T) {
keys := CIDRKeys(tt.ip)
if len(keys) != tt.wantKeys {
t.Errorf("CIDRKeys(%q) returned %d keys, want %d", tt.ip, len(keys), tt.wantKeys)
}
})
}
}
func TestCIDRKeys_VerifyNetworkAddress(t *testing.T) {
keys := CIDRKeys("10.1.2.3")
tests := []struct {
bits int
want string
}{
{24, "10.1.2.0/24"},
{16, "10.1.0.0/16"},
{8, "10.0.0.0/8"},
}
for _, tt := range tests {
if got := keys[32-tt.bits]; got != tt.want {
t.Errorf("/%d network should be %s, got %s", tt.bits, tt.want, got)
}
}
}
func TestCIDRKeys_VerifyNetworkAddressIPv6(t *testing.T) {
keys := CIDRKeys("2001:db8:1:2:3:4:5:6")
tests := []struct {
bits int
want string
}{
{64, "2001:db8:1:2::/64"},
{48, "2001:db8:1::/48"},
{32, "2001:db8::/32"},
}
for _, tt := range tests {
if got := keys[128-tt.bits]; got != tt.want {
t.Errorf("/%d network should be %s, got %s", tt.bits, tt.want, got)
}
}
}
// TestCIDRKeys_MatchNormalizeCIDR covers the invariant range support rests on: the key
// written for a decision is a key looked up for the IPs it covers, and only those.
func TestCIDRKeys_MatchNormalizeCIDR(t *testing.T) {
tests := []struct {
cidr string
ip string
match bool
}{
{cidr: "10.0.0.0/8", ip: "10.1.2.3", match: true},
{cidr: "10.0.0.0/24", ip: "10.0.0.1", match: true},
{cidr: "10.0.0.0/24", ip: "10.0.1.1", match: false},
{cidr: "1.2.3.4/32", ip: "1.2.3.4", match: true},
{cidr: "1.2.3.4/32", ip: "1.2.3.5", match: false},
{cidr: "0.0.0.0/0", ip: "8.8.8.8", match: true},
// LAPI does not have to send a network address, the host bits are dropped.
{cidr: "10.0.0.5/24", ip: "10.0.0.9", match: true},
{cidr: " 192.168.1.0/24 ", ip: "192.168.1.42", match: true},
{cidr: "2001:db8::/32", ip: "2001:db8::1", match: true},
{cidr: "2001:db8::/32", ip: "2001:db9::1", match: false},
{cidr: "::/0", ip: "2001:db8::1", match: true},
// An IPv4 range and an IPv4 mapped client still have to meet.
{cidr: "10.0.0.0/8", ip: "::ffff:10.1.2.3", match: true},
{cidr: "::ffff:10.0.0.0/104", ip: "10.1.2.3", match: true},
// Families do not mix.
{cidr: "::/0", ip: "8.8.8.8", match: false},
{cidr: "2001:db8::/32", ip: "::ffff:10.0.0.1", match: false},
}
for _, tt := range tests {
t.Run(tt.cidr+"_"+tt.ip, func(t *testing.T) {
key := NormalizeCIDR(tt.cidr)
if key == "" {
t.Fatalf("NormalizeCIDR(%q) returned nothing", tt.cidr)
}
found := false
for _, candidate := range CIDRKeys(tt.ip) {
if candidate == key {
found = true
break
}
}
if found != tt.match {
t.Errorf("key %q of %q found in CIDRKeys(%q) = %v, want %v", key, tt.cidr, tt.ip, found, tt.match)
}
})
}
}
func TestCIDRLookupKeys(t *testing.T) {
tests := []struct {
name string
ip string
prefixLens []int
want []string
}{
{
name: "most specific first",
ip: "10.1.2.3",
prefixLens: []int{8, 32, 16},
want: []string{"10.1.2.3/32", "10.1.0.0/16", "10.0.0.0/8"},
},
{
name: "duplicates are dropped",
ip: "10.1.2.3",
prefixLens: []int{24, 24, 24},
want: []string{"10.1.2.0/24"},
},
{
name: "lengths of the other family are skipped",
ip: "10.1.2.3",
prefixLens: []int{48, 64, 24},
want: []string{"10.1.2.0/24"},
},
{
name: "out of range lengths are skipped",
ip: "10.1.2.3",
prefixLens: []int{-1, 33, 129, 8},
want: []string{"10.0.0.0/8"},
},
{
name: "ipv6 keeps its own lengths",
ip: "2001:db8::1",
prefixLens: []int{32, 64},
want: []string{"2001:db8::/64", "2001:db8::/32"},
},
{
name: "no length gives no key",
ip: "10.1.2.3",
prefixLens: []int{},
want: []string{},
},
{
name: "invalid ip gives no key",
ip: "invalid",
prefixLens: []int{24},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CIDRLookupKeys(tt.ip, tt.prefixLens)
if len(got) != len(tt.want) {
t.Fatalf("CIDRLookupKeys(%q, %v) = %v, want %v", tt.ip, tt.prefixLens, got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("CIDRLookupKeys(%q, %v)[%d] = %q, want %q", tt.ip, tt.prefixLens, i, got[i], tt.want[i])
}
}
})
}
}
// TestCIDRLookupKeys_SubsetOfCIDRKeys: restricting the lengths only removes candidates,
// it never changes the key of a length that is kept.
func TestCIDRLookupKeys_SubsetOfCIDRKeys(t *testing.T) {
for _, ipStr := range []string{"10.1.2.3", "2001:db8::1", "::ffff:10.1.2.3"} {
t.Run(ipStr, func(t *testing.T) {
all := CIDRKeys(ipStr)
maxBits := len(all) - 1
for bits := 0; bits <= maxBits; bits++ {
got := CIDRLookupKeys(ipStr, []int{bits})
if len(got) != 1 {
t.Fatalf("CIDRLookupKeys(%q, [%d]) returned %d keys", ipStr, bits, len(got))
}
if want := all[maxBits-bits]; got[0] != want {
t.Errorf("CIDRLookupKeys(%q, [%d]) = %q, want %q", ipStr, bits, got[0], want)
}
}
})
}
}
func TestCIDRPrefixLen(t *testing.T) {
tests := []struct {
input string
want int
}{
{"10.0.0.0/8", 8},
{"10.0.0.0/32", 32},
{"0.0.0.0/0", 0},
{"10.0.0.5/24", 24},
{" 10.0.0.0/16 ", 16},
{"2001:db8::/32", 32},
{"2001:db8::/128", 128},
{"::/0", 0},
{"10.0.0.1", -1},
{"invalid", -1},
{"", -1},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := CIDRPrefixLen(tt.input); got != tt.want {
t.Errorf("CIDRPrefixLen(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
func TestNormalizeCIDR(t *testing.T) {
tests := []struct {
input string
want string
}{
{"10.0.0.0/8", "10.0.0.0/8"},
{"10.0.0.0/16", "10.0.0.0/16"},
{"192.168.1.0/24", "192.168.1.0/24"},
{"2001:db8::/32", "2001:db8::/32"},
{"0.0.0.0/0", "0.0.0.0/0"},
{"::/0", "::/0"},
{"invalid", ""},
{"", ""},
{" 10.0.0.0/8 ", "10.0.0.0/8"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := NormalizeCIDR(tt.input)
if got != tt.want {
t.Errorf("NormalizeCIDR(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
+77
View File
@@ -0,0 +1,77 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"gitAuthor": "Renovate Bot <22881669+maxlerebourg@users.noreply.github.com>",
"fetchChangeLogs": "off",
"labels": ["dependencies"],
"ignorePaths": ["**/vendor/**", "**/node_modules/**"],
"rangeStrategy": "bump",
"prConcurrentLimit": 1,
"branchPrefix": "renovate/",
"commitMessagePrefix": "⬆️ renovate: ",
"groupName": "all",
"dependencyDashboard": false,
"packageRules": [
{
"description": "Cap the Go version at what yaegi supports. The plugin is interpreted by yaegi (bundled in Traefik), and even Traefik v3.7.1 ships yaegi v0.16.1 = Go 1.22. A newer Go would break the plugin on every current Traefik. Raise this only once Traefik ships a yaegi supporting a newer Go.",
"matchManagers": ["gomod"],
"matchDepNames": ["go", "toolchain"],
"allowedVersions": "<1.23"
},
{
"description": "whoami is a throwaway demo backend; leave it on latest",
"matchPackageNames": ["traefik/whoami"],
"enabled": false
}
],
"customManagers": [
{
"description": "Plugin self-pin in docker-compose CLI args (--experimental.plugins.bouncer.version=vX)",
"customType": "regex",
"managerFilePatterns": ["/(^|/)docker-compose[^/]*\\.ya?ml$/"],
"matchStrings": [
"experimental\\.plugins\\.bouncer\\.version=(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)"
],
"depNameTemplate": "maxlerebourg/crowdsec-bouncer-traefik-plugin",
"datasourceTemplate": "github-tags"
},
{
"description": "Plugin self-pin in the Traefik Helm values (version: \"vX\")",
"customType": "regex",
"managerFilePatterns": ["/^examples/kubernetes/traefik/values\\.ya?ml$/"],
"matchStrings": [
"version:\\s*\"(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)\""
],
"depNameTemplate": "maxlerebourg/crowdsec-bouncer-traefik-plugin",
"datasourceTemplate": "github-tags"
},
{
"description": "Traefik image tag in the Traefik Helm values (no repository key, so match by file)",
"customType": "regex",
"managerFilePatterns": ["/^examples/kubernetes/traefik/values\\.ya?ml$/"],
"matchStrings": ["tag:\\s*(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)"],
"depNameTemplate": "traefik",
"datasourceTemplate": "docker"
},
{
"description": "Crowdsec image tag in the Crowdsec Helm values (no repository key, so match by file)",
"customType": "regex",
"managerFilePatterns": [
"/^examples/kubernetes/crowdsec/values\\.ya?ml$/"
],
"matchStrings": ["tag:\\s*(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)"],
"depNameTemplate": "crowdsecurity/crowdsec",
"datasourceTemplate": "docker"
},
{
"description": "Pinned Traefik binary in the e2e mock suite (TRAEFIK_VERSION:-vX in common.sh)",
"customType": "regex",
"managerFilePatterns": ["/^tests/e2e/mock/lib/common\\.sh$/"],
"matchStrings": [
"TRAEFIK_VERSION:-(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)"
],
"depNameTemplate": "traefik/traefik",
"datasourceTemplate": "github-releases"
}
]
}
-55
View File
@@ -1,55 +0,0 @@
# End-to-end test suite
These tests spin up real Traefik + Crowdsec containers and exercise the
plugin in the same conditions Traefik uses in production: loaded from a
local path, no module download, no mocking.
Each scenario lives in its own directory under `scenarios/` and owns:
- `docker-compose.yml` — the stack to spin up
- `run.sh` — orchestration + assertions
- optional fixtures (`acquis.yaml`, `ban.html`, ...)
## Running locally
Prerequisites: `docker`, `docker compose`, `curl`, `bash`.
Run a single scenario:
```bash
./tests/e2e/scenarios/stream-mode/run.sh
# or
make e2e_stream-mode
```
Run everything:
```bash
make e2e
```
Scenarios run **sequentially** on a single host: they share the canonical
`crowdsec` container name (so `cscli` commands work uniformly) and the same
`8000:80` port. Each scenario uses its own Docker Compose project
(`-p e2e-<scenario>`) and tears its stack down on exit, so the next one
starts clean. `make e2e` runs them one after another; Docker reuses the
images pulled by the first scenario, so the Traefik / Crowdsec / whoami
images are downloaded only once for the whole suite.
## Writing a new scenario
1. Copy `scenarios/stream-mode/` as a template.
2. Rename `container_name`s (keep `crowdsec` for the LAPI container).
3. Edit `run.sh` to express the behavior under test.
4. Add the scenario name to `E2E_SCENARIOS` in the `Makefile`.
## CI
This Docker suite is **local-only** and intentionally not run in CI: it boots a
real Crowdsec (and downloads the AppSec collections for that scenario), which is
heavier and less deterministic than CI needs. CI instead runs a lighter
binary + mock-LAPI suite that exercises the plugin without Docker or a real
Crowdsec.
Run this suite locally with `make e2e` (all scenarios) or
`make e2e_<scenario>` (one scenario).
-78
View File
@@ -1,78 +0,0 @@
#!/usr/bin/env bash
# Shared helpers for end-to-end scenarios.
# Dependencies: bash, curl, docker.
# Wait until the Crowdsec LAPI reports ready, or fail after timeout.
# Usage: wait_crowdsec_ready [container_name] [timeout_seconds]
wait_crowdsec_ready() {
local container="${1:-crowdsec}"
local timeout="${2:-90}"
local elapsed=0
while (( elapsed < timeout )); do
if docker exec "$container" cscli lapi status >/dev/null 2>&1; then
return 0
fi
sleep 1
((elapsed++))
done
echo "wait_crowdsec_ready: timed out after ${timeout}s waiting for $container" >&2
return 1
}
# Poll URL until it returns the expected status code, or fail.
# Usage: wait_for_status URL CODE [TIMEOUT_SECONDS] [curl args...]
wait_for_status() {
local url="$1" expected="$2" timeout="${3:-30}"
shift 3 || true
local elapsed=0
local got=""
while (( elapsed < timeout )); do
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url" || true)
if [[ "$got" == "$expected" ]]; then
return 0
fi
sleep 1
((elapsed++))
done
echo "wait_for_status: $url expected $expected, last seen ${got:-<none>}" >&2
return 1
}
# Assert a single curl returns the expected status code.
# Usage: assert_status URL CODE [curl args...]
assert_status() {
local url="$1" expected="$2"
shift 2 || true
local got
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url")
if [[ "$got" != "$expected" ]]; then
echo "assert_status: $url expected $expected, got $got" >&2
return 1
fi
}
# Assert a response header matches a value (case-insensitive name).
# Usage: assert_header URL HEADER VALUE [curl args...]
assert_header() {
local url="$1" header="$2" expected="$3"
shift 3 || true
local got
got=$(curl -s -D - -o /dev/null "$@" "$url" | tr -d '\r' \
| awk -v h="${header,,}" -F': ' 'tolower($1) == h { print $2; exit }')
if [[ "$got" != "$expected" ]]; then
echo "assert_header: $url header $header expected \"$expected\", got \"$got\"" >&2
return 1
fi
}
# Dump diagnostic info for a compose project; called on failure.
# Usage: dump_diagnostics PROJECT COMPOSE_FILE
dump_diagnostics() {
local project="$1" compose_file="$2"
echo "=== docker compose ps ==="
docker compose -p "$project" -f "$compose_file" ps || true
echo "=== docker compose logs ==="
docker compose -p "$project" -f "$compose_file" logs --no-color || true
echo "=== cscli decisions list ==="
docker exec crowdsec cscli decisions list 2>/dev/null || true
}
+91
View File
@@ -0,0 +1,91 @@
# 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`. Serves plain HTTP, or HTTPS when `--lapi-tls-cert/--lapi-tls-key` are passed (the `tls-system-ca` scenario) |
| 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` (plus `openssl` for the
`tls-system-ca` scenario, which mints a throwaway CA at runtime). 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.
The `tls-system-ca` scenario verifies that, with no custom CA configured, the
bouncer falls back to the OS/system trust store for an HTTPS LAPI: it serves the
mock over TLS and points the Traefik process's `SSL_CERT_FILE` at the test CA
(trusted → 200) or an empty bundle (untrusted → 403, proving it still verifies).
```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`.
+275
View File
@@ -0,0 +1,275 @@
#!/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.9}"
WEB_PORT="${WEB_PORT:-8000}"
LAPI_PORT="${LAPI_PORT:-8090}"
BACKEND_PORT="${BACKEND_PORT:-8091}"
APPSEC_PORT="${APPSEC_PORT:-8092}"
REDIS_PORT="${REDIS_PORT:-8093}"
REDIS_READ_PORT="${REDIS_READ_PORT:-8094}"
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:-15}"
shift 3 || true
local elapsed=0 got=""
while (( elapsed < timeout )); do
got=$(curl -s -m 1 -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:-15}"
shift 3 || true
local elapsed=0 body=""
while (( elapsed < timeout )); do
body=$(curl -s -m 1 "$@" "$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 --connect-timeout 1 -m 5 -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 --connect-timeout 1 -m 5 -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 --connect-timeout 1 -m 5 "$@" "$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 --connect-timeout 1 -m 5 -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 --connect-timeout 1 -m 5 -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}" >/dev/null
}
lapi_set_stream_fail() {
curl -sS --connect-timeout 1 -m 5 -X POST "http://127.0.0.1:${LAPI_PORT}/admin/stream-fail" >/dev/null
}
lapi_clear_stream_fail() {
curl -sS --connect-timeout 1 -m 5 -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/stream-fail" >/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|@@REDIS_HOST@@|127.0.0.1:${REDIS_PORT}|g" \
-e "s|@@REDIS_READ_HOST@@|127.0.0.1:${REDIS_READ_PORT}|g" \
-e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \
"$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml"
# Opt-in HTTPS LAPI: a scenario exports LAPI_TLS_CERT/LAPI_TLS_KEY to serve the
# LAPI over TLS (used by tls-system-ca). Default empty -> plaintext as before.
local mock_tls_args=() lapi_scheme=http lapi_curl=()
if [[ -n "${LAPI_TLS_CERT:-}" && -n "${LAPI_TLS_KEY:-}" ]]; then
mock_tls_args=(--lapi-tls-cert "$LAPI_TLS_CERT" --lapi-tls-key "$LAPI_TLS_KEY")
lapi_scheme=https
lapi_curl=(-k) # the readiness probe ignores trust; the bouncer's trust is what we test
fi
"$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}" \
--redis-addr "127.0.0.1:${REDIS_PORT}" \
--redis-read-addr "127.0.0.1:${REDIS_READ_PORT}" \
"${mock_tls_args[@]}" >"$WORKDIR/mock.log" 2>&1 &
MOCK_PID=$!
# Opt-in trust store for the Traefik process: a scenario exports
# TRAEFIK_SSL_CERT_FILE to point Go's x509.SystemCertPool() at a specific CA
# bundle. Empty -> Go's default system store (unchanged behaviour).
( cd "$WORKDIR" && SSL_CERT_FILE="${TRAEFIK_SSL_CERT_FILE:-}" exec "$traefik_bin" --configfile=traefik.yml ) >"$WORKDIR/traefik.log" 2>&1 &
TRAEFIK_PID=$!
wait_for_status "${lapi_scheme}://127.0.0.1:${LAPI_PORT}/health" 200 30 "${lapi_curl[@]}"
# 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.12
+237
View File
@@ -0,0 +1,237 @@
// 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, and a hardcoded Redis
// stand-in for exercising the redis cache path.
//
// 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 (
"bufio"
"encoding/json"
"flag"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
)
// 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
// streamFail makes /v1/decisions/stream return 500 when set, to exercise the
// bouncer's fail-closed behaviour on consecutive stream poll failures.
streamFail atomic.Bool
)
func writeJSON(w http.ResponseWriter, v any) {
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
}
// --- Redis mock (inline-command wire format, as spoken by simpleredis) ---
// serveRedis is a hardcoded stand-in. When verdicts is true it plays a replica
// that holds decisions: every line is scanned for known IPs, 1.2.3.4 → "f"
// (clean), 1.2.3.5 → "t" (banned); any other GET is a miss ($-1). When verdicts
// is false it plays the primary and answers every GET with a miss, so a
// scenario can prove reads are served from the replica and not the primary.
// SET, DEL, AUTH, SELECT get +OK (they don't read the response anyway).
func serveRedis(addr string, verdicts bool) {
ln, err := net.Listen("tcp", addr)
if err != nil {
log.Fatal(err)
}
defer ln.Close()
for {
conn, err := ln.Accept()
if err != nil {
continue
}
go func(conn net.Conn) {
defer conn.Close()
rd := bufio.NewReader(conn)
for {
line, _, err := rd.ReadLine()
if err != nil {
return
}
s := string(line)
switch {
case verdicts && strings.Contains(s, "1.2.3.4"):
conn.Write([]byte("$1\r\nf\r\n"))
case verdicts && strings.Contains(s, "1.2.3.5"):
conn.Write([]byte("$1\r\nt\r\n"))
case strings.HasPrefix(strings.ToUpper(s), "GET "):
conn.Write([]byte("$-1\r\n"))
default:
conn.Write([]byte("+OK\r\n"))
}
}
}(conn)
}
}
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")
// Redis stand-ins on plain TCP ports, enough to exercise the plugin's redis
// cache path. The primary answers every GET with a miss; the replica serves
// the hardcoded verdicts, so a scenario pointing redisCacheReadHosts at the
// replica proves reads are offloaded to replicas.
redisAddr := flag.String("redis-addr", "127.0.0.1:8093", "address for the Redis primary mock (writes; GET always misses)")
redisReadAddr := flag.String("redis-read-addr", "127.0.0.1:8094", "address for the Redis replica mock (serves cached verdicts)")
// Optional TLS for the LAPI: when both are set the LAPI is served over HTTPS
// (cert signed by the scenario's throwaway CA) so the suite can exercise the
// bouncer's system-trust-store path. Backend and AppSec stay plaintext.
lapiTLSCert := flag.String("lapi-tls-cert", "", "PEM cert to serve the LAPI over HTTPS (optional)")
lapiTLSKey := flag.String("lapi-tls-key", "", "PEM key for --lapi-tls-cert")
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"), "403") {
w.WriteHeader(http.StatusForbidden)
}
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "500") {
w.WriteHeader(http.StatusInternalServerError)
}
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "502") {
w.WriteHeader(http.StatusBadGateway)
}
// Read body
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
defer r.Body.Close()
if strings.Contains(string(body), "a=0") {
w.WriteHeader(http.StatusForbidden)
return
}
})))
}()
go serveRedis(*redisAddr, false)
go serveRedis(*redisReadAddr, true)
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) {
if streamFail.Load() {
w.WriteHeader(http.StatusInternalServerError)
return
}
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: make the stream endpoint fail (POST) or recover (DELETE).
mux.HandleFunc("/admin/stream-fail", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
streamFail.Store(true)
case http.MethodDelete:
streamFail.Store(false)
}
w.WriteHeader(http.StatusOK)
})
// Test control plane: add / remove decisions instead of cscli.
mux.HandleFunc("/admin/decisions", func(_ http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
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)
}
}
})
if *lapiTLSCert != "" && *lapiTLSKey != "" {
log.Printf("mocklapi: LAPI on %s (TLS), backend on %s, appsec on %s, redis on %s (read %s)", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr, *redisReadAddr)
log.Fatal(http.ListenAndServeTLS(*lapiAddr, *lapiTLSCert, *lapiTLSKey, mux))
}
log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s, redis on %s (read %s)", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr, *redisReadAddr)
log.Fatal(http.ListenAndServe(*lapiAddr, mux))
}
@@ -0,0 +1,32 @@
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: appsec
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
crowdsecAppsecEnabled: "true"
crowdsecAppsecFailureBlock: "true"
crowdsecAppsecBodyLimit: 4
crowdsecAppsecUnreachableBlock: "false"
crowdsecAppsecScheme: http
crowdsecAppsecHost: "@@APPSEC_HOST@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
+44
View File
@@ -0,0 +1,44 @@
#!/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 200)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] request that return 403 must be blocked (AppSec 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo/403" 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] request that return 500 must be blocked (because CrowdsecAppsecFailureBlock = true) (AppSec 500)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo/500" 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] request that return 502 must pass (because CrowdsecAppsecUnreachableBlock = false) (Proxy error 502)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo/502" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] request that send bad body after crowdsecAppsecBodyLimit must pass (AppSec 200)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4" -X POST -d "______&a=0"
echo "[$SCENARIO] request that send bad body before crowdsecAppsecBodyLimit must pass (AppSec 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" -X POST -d "a=0&______"
echo "[$SCENARIO] request http2 that send no body GET (AppSec 200)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:"
echo "[$SCENARIO] request http2 that send unreadable body GET (AppSec 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:" -d "test"
echo "[$SCENARIO] request http2 that send unreadable body POST (AppSec 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:" -X POST -d "test"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -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"
+29
View File
@@ -0,0 +1,29 @@
#!/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 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] 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,4 @@
{
"marker": "E2E_CUSTOM_BAN_PAGE_MARKER",
"body": "IP: {{ .ClientIP }}, reason: {{ .RemediationReason }}, trace: {{ .TraceID }}"
}
@@ -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"
crowdsecMode: stream
updateIntervalSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
banFilePath: "@@SCENARIO_DIR@@/ban.json"
remediationHeadersCustomName: "X-E2E-Remediation"
traceHeadersCustomName: x-trace
+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=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 "application/json" -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 body contains the IP and reason from templating"
assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "IP: 1.2.3.4, reason: LAPI, trace: 0123456789" -H "X-Forwarded-For: 1.2.3.4" -H "X-Trace: 0123456789"
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"
+35
View File
@@ -0,0 +1,35 @@
#!/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 and 2001:db8::1"
lapi_add_decision 1.2.3.4 ban 5m
lapi_add_decision "2001:db8::1" ban 5m
echo "[$SCENARIO] IP banned must be blocked (HTTP 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] IPv6 banned must be blocked (HTTP 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 2001:db8::1"
echo "[$SCENARIO] deleting decision"
lapi_delete_decision 1.2.3.4
lapi_delete_decision "2001:db8::1"
echo "[$SCENARIO] previously banned IP must pass again"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] previously banned IPv6 must pass again"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 2001:db8::1"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -0,0 +1,30 @@
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
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
redisCacheEnabled: "true"
redisCacheHost: "@@REDIS_HOST@@"
redisCacheReadHosts:
- "@@REDIS_READ_HOST@@"
- "@@REDIS_HOST@@"
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=redis
# The replica mock returns "f" (not banned) for 1.2.3.4 and "t" (banned) for 1.2.3.5.
# The primary mock always misses.
body() {
echo "[$SCENARIO] cached banned IP must not be blocked because call for primary (test rotation)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.5"
echo "[$SCENARIO] cached banned IP must be blocked"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.5"
echo "[$SCENARIO] cached clean IP must pass"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] unknown IP (redis miss) must fall through to LAPI and pass"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.6"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -0,0 +1,27 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: stream
updateIntervalSeconds: "1"
updateMaxFailure: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
+50
View File
@@ -0,0 +1,50 @@
#!/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 and 10.0.0.0/8"
lapi_add_decision 1.2.3.4 ban 5m
lapi_add_decision 10.0.0.0/24 ban 5m
echo "[$SCENARIO] banned IP must be blocked once the next stream poll lands (HTTP 403)"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
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] banned IP in CIDR must be blocked once polled (HTTP 403)"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 10.0.0.1"
echo "[$SCENARIO] deleting ban decision for 1.2.3.4 and 10.0.0.0/8"
lapi_delete_decision 1.2.3.4
lapi_delete_decision 10.0.0.0/24
echo "[$SCENARIO] previously banned IP must pass again once the deletion is polled"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] previously CIDR-banned IP must pass again once deletion is polled"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 10.0.0.1"
echo "[$SCENARIO] making the stream endpoint fail -> bouncer must pass for one more cycle (updateMaxFailure: 2)"
lapi_set_stream_fail
sleep 2 # update cache is every 1 seconds then waiting for minimum 1 cycle
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 8.8.8.8"
echo "[$SCENARIO] bouncer must block everything (isStreamHealthy: false)"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 8.8.8.8"
echo "[$SCENARIO] restoring the stream endpoint -> bouncer must recover and pass again"
lapi_clear_stream_fail
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 8.8.8.8"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -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: live
defaultDecisionSeconds: "2"
# HTTPS LAPI with NO custom CA configured: the bouncer must fall back to
# the OS/system trust store (which the scenario controls via SSL_CERT_FILE).
crowdsecLapiScheme: https
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Scenario: HTTPS LAPI with no custom CA configured -> the bouncer must fall back
# to the OS/system trust store (PR #331). In the binary suite the "system trust
# store" is whatever Go's x509.SystemCertPool() reads, which honours SSL_CERT_FILE
# on the Traefik process. We mint a throwaway CA, serve the mock LAPI over HTTPS
# with a cert signed by it, and run the stack twice:
#
# positive: SSL_CERT_FILE = our CA -> LAPI trusted -> 200
# negative: SSL_CERT_FILE = empty bundle -> LAPI not trusted -> 403
#
# live mode is fail-closed, so a TLS error becomes a 403. The negative run proves
# the patch still VERIFIES (it is not an insecure skip).
#
# Extra dependency vs other scenarios: openssl.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=tls-system-ca
SCENARIO_NAME="$SCENARIO"
SCENARIO_LOG="/tmp/e2e-mock-${SCENARIO}.log"
CERT_DIR="$(mktemp -d)"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics > "$SCENARIO_LOG" 2>&1 || true
echo "[$SCENARIO] failed. Logs written to $SCENARIO_LOG" >&2
fi
stop_stack
rm -rf "$CERT_DIR"
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] minting throwaway CA + LAPI cert (SAN=IP:127.0.0.1)..."
openssl ecparam -name prime256v1 -genkey -noout -out "$CERT_DIR/ca.key" 2>/dev/null
openssl req -x509 -new -key "$CERT_DIR/ca.key" -sha256 -days 3650 \
-subj "/CN=crowdsec-bouncer e2e test CA" -out "$CERT_DIR/ca.crt" 2>/dev/null
openssl ecparam -name prime256v1 -genkey -noout -out "$CERT_DIR/lapi.key" 2>/dev/null
openssl req -new -key "$CERT_DIR/lapi.key" -subj "/CN=lapi" -out "$CERT_DIR/lapi.csr" 2>/dev/null
openssl x509 -req -in "$CERT_DIR/lapi.csr" -CA "$CERT_DIR/ca.crt" -CAkey "$CERT_DIR/ca.key" \
-CAcreateserial -days 3650 -sha256 -out "$CERT_DIR/lapi.crt" \
-extfile <(printf "subjectAltName=IP:127.0.0.1\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth") 2>/dev/null
: > "$CERT_DIR/empty.crt" # an empty bundle = a system store that trusts nothing
# The mock serves the same CA-signed cert in both runs; only Traefik's trust differs.
export LAPI_TLS_CERT="$CERT_DIR/lapi.crt" LAPI_TLS_KEY="$CERT_DIR/lapi.key"
echo "[$SCENARIO] === positive: CA in the system trust store ==="
export TRAEFIK_SSL_CERT_FILE="$CERT_DIR/ca.crt"
start_stack "$HERE"
echo "[$SCENARIO] HTTPS LAPI verifies via system trust store -> request passes (200)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
stop_stack
echo "[$SCENARIO] === negative: CA absent from the system trust store ==="
export TRAEFIK_SSL_CERT_FILE="$CERT_DIR/empty.crt"
start_stack "$HERE"
echo "[$SCENARIO] LAPI cert not trusted -> TLS fails, fail-closed (403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4"
stop_stack
echo "[$SCENARIO] OK"
@@ -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
-6
View File
@@ -1,6 +0,0 @@
listen_addr: 0.0.0.0:7422
appsec_config: crowdsecurity/crs-inband
name: e2eAppSec
source: appsec
labels:
type: appsec
@@ -1,55 +0,0 @@
services:
traefik:
image: traefik:v3.7.1
container_name: e2e-appsec-traefik
command:
- "--log.level=INFO"
- "--accesslog"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure=true"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
ports:
- "8000:80"
depends_on:
crowdsec:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: e2e-appsec-whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.r.entrypoints=web"
- "traefik.http.routers.r.middlewares=bouncer@docker"
- "traefik.http.services.s.loadbalancer.server.port=80"
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
# IP bouncing disabled — we only test AppSec body inspection here.
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=none"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecappsecenabled=true"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
container_name: crowdsec
environment:
COLLECTIONS: "crowdsecurity/appsec-crs-inband"
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
CUSTOM_HOSTNAME: crowdsec
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
volumes:
- ./acquis.yaml:/etc/crowdsec/acquis.yaml:ro
healthcheck:
test: ["CMD", "cscli", "lapi", "status"]
interval: 2s
timeout: 3s
retries: 60
start_period: 15s
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=appsec
PROJECT="e2e-${SCENARIO}"
COMPOSE_FILE="$HERE/docker-compose.yml"
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
echo "Scenario failed. Logs written to $LOG_FILE"
fi
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] starting stack (AppSec collections download — may take ~30s on first boot)..."
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
echo "[$SCENARIO] waiting for crowdsec readiness..."
wait_crowdsec_ready crowdsec 180
echo "[$SCENARIO] waiting for traefik readiness..."
wait_for_status http://localhost:8000/foo 200 30
echo "[$SCENARIO] benign request must pass"
assert_status http://localhost:8000/foo 200
echo "[$SCENARIO] SQL-injection-like query string must be blocked by OWASP CRS (inband)"
# Classic SQLi probe: ?id=1' OR '1'='1 — caught by CRS rule 942100/942130 (paranoia 1).
assert_status "http://localhost:8000/foo?id=1%27%20OR%20%271%27%3D%271" 403
echo "[$SCENARIO] OK"
@@ -1,59 +0,0 @@
services:
traefik:
image: traefik:v3.7.1
container_name: e2e-captcha-traefik
command:
- "--log.level=INFO"
- "--accesslog"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure=true"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
- ./captcha.html:/captcha.html:ro
ports:
- "8000:80"
depends_on:
crowdsec:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: e2e-captcha-whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.r.entrypoints=web"
- "traefik.http.routers.r.middlewares=bouncer@docker"
- "traefik.http.services.s.loadbalancer.server.port=80"
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchaprovider=turnstile"
# Cloudflare Turnstile public test keys (always pass / always fail variants exist).
# These render a valid widget without contacting any real API key.
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchasitekey=1x00000000000000000000AA"
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchasecretkey=1x0000000000000000000000000000000AA"
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchahtmlfilepath=/captcha.html"
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchagraceperiodseconds=10"
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
container_name: crowdsec
environment:
DISABLE_ONLINE_API: "true"
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
CUSTOM_HOSTNAME: crowdsec
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
healthcheck:
test: ["CMD", "cscli", "lapi", "status"]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
-53
View File
@@ -1,53 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=captcha
PROJECT="e2e-${SCENARIO}"
COMPOSE_FILE="$HERE/docker-compose.yml"
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
echo "Scenario failed. Logs written to $LOG_FILE"
fi
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] starting stack..."
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
echo "[$SCENARIO] waiting for crowdsec readiness..."
wait_crowdsec_ready
echo "[$SCENARIO] waiting for traefik readiness..."
wait_for_status http://localhost:8000/foo 200 30
echo "[$SCENARIO] adding captcha decision for 1.2.3.4"
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type captcha --duration 5m
echo "[$SCENARIO] waiting one stream tick + buffer..."
sleep 6
echo "[$SCENARIO] captcha response must be HTTP 200 (the captcha page itself, not a 403)"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] captcha response body must contain the captcha template marker"
body=$(curl -s http://localhost:8000/foo -H "X-Forwarded-For: 1.2.3.4")
if ! grep -q "E2E_CAPTCHA_PAGE_MARKER" <<<"$body"; then
echo "Expected response body to contain E2E_CAPTCHA_PAGE_MARKER, got:" >&2
echo "$body" >&2
exit 1
fi
echo "[$SCENARIO] non-flagged IP must still pass through to the backend"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8"
echo "[$SCENARIO] OK"
@@ -1,8 +0,0 @@
<!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>
@@ -1,53 +0,0 @@
services:
traefik:
image: traefik:v3.7.1
container_name: e2e-banpage-traefik
command:
- "--log.level=INFO"
- "--accesslog"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure=true"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
- ./ban.html:/ban.html:ro
ports:
- "8000:80"
depends_on:
crowdsec:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: e2e-banpage-whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.r.entrypoints=web"
- "traefik.http.routers.r.middlewares=bouncer@docker"
- "traefik.http.services.s.loadbalancer.server.port=80"
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
- "traefik.http.middlewares.bouncer.plugin.bouncer.banhtmlfilepath=/ban.html"
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
container_name: crowdsec
environment:
DISABLE_ONLINE_API: "true"
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
CUSTOM_HOSTNAME: crowdsec
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
healthcheck:
test: ["CMD", "cscli", "lapi", "status"]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
@@ -1,53 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=custom-ban-page
PROJECT="e2e-${SCENARIO}"
COMPOSE_FILE="$HERE/docker-compose.yml"
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
echo "Scenario failed. Logs written to $LOG_FILE"
fi
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] starting stack..."
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
echo "[$SCENARIO] waiting for crowdsec readiness..."
wait_crowdsec_ready
echo "[$SCENARIO] waiting for traefik readiness..."
wait_for_status http://localhost:8000/foo 200 30
echo "[$SCENARIO] adding ban decision"
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
echo "[$SCENARIO] waiting one stream tick + buffer..."
sleep 6
echo "[$SCENARIO] banned response status is 403"
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] banned response Content-Type is HTML"
assert_header http://localhost:8000/foo Content-Type "text/html; charset=utf-8" -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] banned response body contains the custom marker"
body=$(curl -s http://localhost:8000/foo -H "X-Forwarded-For: 1.2.3.4")
if ! grep -q "E2E_CUSTOM_BAN_PAGE_MARKER" <<<"$body"; then
echo "Expected response body to contain E2E_CUSTOM_BAN_PAGE_MARKER, got:" >&2
echo "$body" >&2
exit 1
fi
echo "[$SCENARIO] OK"
@@ -1,51 +0,0 @@
services:
traefik:
image: traefik:v3.7.1
container_name: e2e-live-traefik
command:
- "--log.level=INFO"
- "--accesslog"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure=true"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
ports:
- "8000:80"
depends_on:
crowdsec:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: e2e-live-whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.r.entrypoints=web"
- "traefik.http.routers.r.middlewares=bouncer@docker"
- "traefik.http.services.s.loadbalancer.server.port=80"
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=live"
- "traefik.http.middlewares.bouncer.plugin.bouncer.defaultdecisionseconds=2"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
container_name: crowdsec
environment:
DISABLE_ONLINE_API: "true"
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
CUSTOM_HOSTNAME: crowdsec
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
healthcheck:
test: ["CMD", "cscli", "lapi", "status"]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=live-mode
PROJECT="e2e-${SCENARIO}"
COMPOSE_FILE="$HERE/docker-compose.yml"
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
echo "Scenario failed. Logs written to $LOG_FILE"
fi
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] starting stack..."
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
echo "[$SCENARIO] waiting for crowdsec readiness..."
wait_crowdsec_ready
echo "[$SCENARIO] waiting for traefik readiness..."
wait_for_status http://localhost:8000/foo 200 30
echo "[$SCENARIO] no decision -> first hit queries LAPI, returns 200, caches 'allowed' for 2s"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
echo "[$SCENARIO] waiting for defaultDecisionSeconds cache to expire..."
sleep 3
echo "[$SCENARIO] next hit must re-query LAPI and now see the ban"
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] another non-banned IP must still pass"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8"
echo "[$SCENARIO] OK"
@@ -1,50 +0,0 @@
services:
traefik:
image: traefik:v3.7.1
container_name: e2e-none-traefik
command:
- "--log.level=INFO"
- "--accesslog"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure=true"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
ports:
- "8000:80"
depends_on:
crowdsec:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: e2e-none-whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.r.entrypoints=web"
- "traefik.http.routers.r.middlewares=bouncer@docker"
- "traefik.http.services.s.loadbalancer.server.port=80"
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=none"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
container_name: crowdsec
environment:
DISABLE_ONLINE_API: "true"
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
CUSTOM_HOSTNAME: crowdsec
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
healthcheck:
test: ["CMD", "cscli", "lapi", "status"]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=none-mode
PROJECT="e2e-${SCENARIO}"
COMPOSE_FILE="$HERE/docker-compose.yml"
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
echo "Scenario failed. Logs written to $LOG_FILE"
fi
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] starting stack..."
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
echo "[$SCENARIO] waiting for crowdsec readiness..."
wait_crowdsec_ready
echo "[$SCENARIO] waiting for traefik readiness..."
wait_for_status http://localhost:8000/foo 200 30
echo "[$SCENARIO] no decision -> request passes (LAPI queried per request)"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision"
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
echo "[$SCENARIO] none mode has no cache -> next request must be blocked immediately"
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] deleting decision"
docker exec crowdsec cscli decisions delete --ip 1.2.3.4
echo "[$SCENARIO] previously banned IP must pass again immediately"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] OK"
@@ -1,51 +0,0 @@
services:
traefik:
image: traefik:v3.7.1
container_name: e2e-stream-traefik
command:
- "--log.level=INFO"
- "--accesslog"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure=true"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
ports:
- "8000:80"
depends_on:
crowdsec:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: e2e-stream-whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.r.entrypoints=web"
- "traefik.http.routers.r.middlewares=bouncer@docker"
- "traefik.http.services.s.loadbalancer.server.port=80"
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
container_name: crowdsec
environment:
DISABLE_ONLINE_API: "true"
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
CUSTOM_HOSTNAME: crowdsec
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
healthcheck:
test: ["CMD", "cscli", "lapi", "status"]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=stream-mode
PROJECT="e2e-${SCENARIO}"
COMPOSE_FILE="$HERE/docker-compose.yml"
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
echo "Scenario failed. Logs written to $LOG_FILE"
fi
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] starting stack..."
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
echo "[$SCENARIO] waiting for crowdsec readiness..."
wait_crowdsec_ready
echo "[$SCENARIO] waiting for traefik readiness..."
wait_for_status http://localhost:8000/foo 200 30
echo "[$SCENARIO] no decision yet -> request allowed"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
echo "[$SCENARIO] waiting one stream tick + buffer..."
sleep 6
echo "[$SCENARIO] banned IP must be blocked (HTTP 403)"
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] non-banned IP must still pass (HTTP 200)"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8"
echo "[$SCENARIO] deleting ban decision"
docker exec crowdsec cscli decisions delete --ip 1.2.3.4
echo "[$SCENARIO] waiting one stream tick + buffer..."
sleep 6
echo "[$SCENARIO] previously banned IP must pass again"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] OK"
@@ -1,52 +0,0 @@
services:
traefik:
image: traefik:v3.7.1
container_name: e2e-trusted-traefik
command:
- "--log.level=INFO"
- "--accesslog"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure=true"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
ports:
- "8000:80"
depends_on:
crowdsec:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: e2e-trusted-whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.r.entrypoints=web"
- "traefik.http.routers.r.middlewares=bouncer@docker"
- "traefik.http.services.s.loadbalancer.server.port=80"
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
- "traefik.http.middlewares.bouncer.plugin.bouncer.clienttrustedips=1.2.3.4/32"
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
container_name: crowdsec
environment:
DISABLE_ONLINE_API: "true"
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
CUSTOM_HOSTNAME: crowdsec
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
healthcheck:
test: ["CMD", "cscli", "lapi", "status"]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=trusted-ips
PROJECT="e2e-${SCENARIO}"
COMPOSE_FILE="$HERE/docker-compose.yml"
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
echo "Scenario failed. Logs written to $LOG_FILE"
fi
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] starting stack..."
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
echo "[$SCENARIO] waiting for crowdsec readiness..."
wait_crowdsec_ready
echo "[$SCENARIO] waiting for traefik readiness..."
wait_for_status http://localhost:8000/foo 200 30
echo "[$SCENARIO] banning the trusted IP 1.2.3.4 and an untrusted IP 5.6.7.8"
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
docker exec crowdsec cscli decisions add --ip 5.6.7.8 --type ban --duration 5m
echo "[$SCENARIO] waiting one stream tick + buffer..."
sleep 6
echo "[$SCENARIO] trusted IP must bypass the bouncer even though it is banned"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] untrusted banned IP must be blocked (control: proves the bouncer is active)"
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 5.6.7.8"
echo "[$SCENARIO] OK"
+3 -2
View File
@@ -1,4 +1,5 @@
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
// pluginVersion is updated automatically by the release workflow.
var pluginVersion = "1.6.X" //nolint:gochecknoglobals
// pluginVersion is what the plugin reports to the Crowdsec LAPI.
// Do not edit by hand: the "Release (1/2) Prepare" workflow bumps it.
var pluginVersion = "v1.7.1" //nolint:gochecknoglobals