Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:03:19 +02:00
mhxandClaude Opus 4.8 b201143844 tests: add binary e2e suite (Traefik binary + Go mock LAPI), run it in CI
Add a second e2e suite that runs Traefik as a downloaded binary with the
plugin loaded from source, and replaces Crowdsec with a small stdlib-only Go
LAPI mock driven via /admin endpoints. No Docker, no real Crowdsec.

The mock lives in its own nested Go module (tests/e2e/mock/mocklapi) so it
stays out of the plugin module's build, lint, test and vendor.

This suite validates the plugin's own behaviour (live/none/stream modes,
caching, trusted-IP bypass, ban/captcha rendering). Crowdsec and AppSec
correctness are out of scope on purpose — they are validated upstream by the
maintainer — so the AppSec scenario is intentionally absent and the README
says so to avoid misfiled issues.

CI now runs this suite only (`make e2e_mock`), since it needs neither Docker
nor a real Crowdsec. The Docker suite (tests/e2e/scenarios) is kept for local
debugging (`make e2e`).

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

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

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

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

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

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

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

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

* 🍱 fix

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint

* 🍱 update readme

* 📚 clarify StreamStartupBlock warning in README

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

* 🐛 clear isCrowdsecStreamStartup on alreadyUpdated path

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

---------

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

* Move to slog

* fixes

* missing test file

* Fix tests

* Add wrapper with trace

* fix

* fix lint

* LINT

* LINT

* 🍱 Use only 4 level of logs

* fix after merge

* 🍱 fix lint

* 🍱 fix lint + remove trace logger from tests

* 🍱 fix tests

* 🍱 fix lint

* 🍱 try to fix test

* 🍱 Fix tests

* 🍱 fix README + adjust logs

---------

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

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

* Update README.md

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

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

* Disablie lint check for no global variables

* Remove trailing new-line

* Update pluginVersion declaration style

* 🍱 Simplify for tests

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

---------

Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
2026-02-19 23:41:55 +01:00
maxlerebourg 889c5b55fe Add solved-captcha as option of remediationCustomHeader (#310) 2026-01-23 11:36:32 +01:00
maxlerebourgandCopilot Autofix powered by AI efb3a67019 🔒️ Potential fix for code scanning alert no. 8: Workflow does not contain permissions (#311)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-01-22 09:44:33 +01:00
omer 0780027252 🐛 Do not validate Crowdsec LAPI authentication credentials if bouncer is in Appsec mode (#305)
* Do not validate Crowdsec LAPI key and TLS authentication if bouncer is in AppSec mode

* Add extra validation checks for lapiKey or appsecKey definition

* Fix linting on changed error message

* Update configuration.go

- Show paramName in validateParamsApiKey
- Remove check for empty appsecKey or lapiKey (LAPI can have TLS auth)
- Remove check for emptry appsecKey if CrowdsecMode is Appsec

* Update configuration_test.go after updated validateParamsAPIKey
2026-01-20 23:42:24 +01:00
maxlerebourgandmhx 892909b9b8 🐛 fix start up config error for appsec and review doc for appsec tls (#300)
* 🐛 fix start up config error for appsec

* :doc: add documentation on appsec variables and missing conf parameter

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix after lot of tests

* update exemple tls with new variables tested

* fix exemple appsec with release and not localplugin

---------

Co-authored-by: mhx <mathieu@hanotaux.fr>
2025-12-21 21:52:19 +01:00
dependabot[bot] c26923dee5 ⬆️ Bump actions/cache from 4 to 5 (#303)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [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/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  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>
2025-12-20 14:30:49 +01:00
maxlerebourg a9d83f2097 Separate TLS conf for LAPI and Appsec (#293)
*   Separate TLS conf for LAPI and Appsec

* 🍱 fix lint

* 🍱 fix test

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint ?

* 🍱 fix lint

* 🍱 Add default for traceCustomHeaders
2025-12-11 07:58:12 +01:00
omer e20ccc5d0c Add support for injecting request header value into ban HTML template (#296)
* Add parameter to specify Request header value to inject in HTML template

* Add documentation for specifying request header value

* Fix example in docs for CustomHeader

* Update parameter name to traceCustomHeader

* Ensure naming is consitent with project
2025-12-10 07:54:07 +01:00
dependabot[bot] 50beb4294f ⬆️ Bump actions/checkout from 5 to 6 (#294)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [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/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  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>
2025-11-27 15:40:18 +01:00
45 changed files with 1983 additions and 273 deletions
+42
View File
@@ -0,0 +1,42 @@
name: E2E
on:
push:
branches: [main]
pull_request:
concurrency:
group: e2e-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
e2e:
name: e2e (binary + mock LAPI)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
# Track go.mod (Go 1.22) — the plugin's yaegi-bound floor. Keeps the
# single source of truth and builds the mock on the supported version.
go-version-file: go.mod
# CI runs the binary/mock suite only: Traefik as a downloaded binary +
# a small LAPI mock (no Docker, no real Crowdsec). It validates the
# plugin's own behaviour. Crowdsec / AppSec correctness is upstream's
# responsibility, so those are intentionally out of scope here. The
# Docker suite (tests/e2e/scenarios) stays available for local debugging.
# `-k` keeps going after a failing scenario so the logs cover all of
# them, while make still exits non-zero if any scenario failed.
- name: Run mock scenarios
run: make -k e2e_mock
- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-logs
path: /tmp/e2e-mock-*.log
if-no-files-found: ignore
+5 -2
View File
@@ -1,5 +1,8 @@
name: Main name: Main
permissions:
contents: read
on: on:
push: push:
branches: branches:
@@ -30,14 +33,14 @@ jobs:
# https://github.com/marketplace/actions/checkout # https://github.com/marketplace/actions/checkout
- name: Check out code - name: Check out code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
path: go/src/github.com/${{ github.repository }} path: go/src/github.com/${{ github.repository }}
fetch-depth: 0 fetch-depth: 0
# https://github.com/marketplace/actions/cache # https://github.com/marketplace/actions/cache
- name: Cache Go modules - name: Cache Go modules
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ${{ github.workspace }}/go/pkg/mod path: ${{ github.workspace }}/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+46
View File
@@ -0,0 +1,46 @@
name: Release Version Update
on:
release:
types: [published]
permissions:
contents: write
jobs:
update-version:
name: Update version in source
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: main
- name: Extract version from tag
id: get_version
run: |
TAG="${{ github.event.release.tag_name }}"
VERSION="${TAG#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Update version in version.go
run: |
sed -i 's/pluginVersion = "[^"]*"/pluginVersion = "'"${{ steps.get_version.outputs.version }}"'"/' version.go
cat version.go
- name: Commit, push, and retag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add version.go
if git diff --cached --quiet; then
echo "Version already up to date, nothing to commit"
exit 0
fi
git commit -m "⬆️ chore: bump version to ${{ steps.get_version.outputs.version }}"
git push origin main
# Move the release tag to include the version update
git tag -f "${{ steps.get_version.outputs.tag }}"
git push -f origin "${{ steps.get_version.outputs.tag }}"
+4
View File
@@ -5,3 +5,7 @@ conf
db db
logs logs
docker-compose.dev.yml docker-compose.dev.yml
# Binary e2e suite working cache: Traefik binary + compiled mock. Persisted
# across local runs; CI runs on fresh runners so it is recreated every time.
tests/e2e/mock/.cache/
+1
View File
@@ -73,6 +73,7 @@ linters:
- gci - gci
- mnd - mnd
- exportloopref - exportloopref
- contextcheck
issues: issues:
exclude-use-default: false exclude-use-default: false
max-same-issues: 0 max-same-issues: 0
+11 -2
View File
@@ -1,7 +1,11 @@
.PHONY: lint test vendor clean .PHONY: lint test vendor clean e2e_mock
export GO111MODULE=on export GO111MODULE=on
# Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs.
# The local Docker suite (make e2e) lives in a separate PR/branch.
E2E_MOCK_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec
default: lint test default: lint test
lint: lint:
@@ -13,6 +17,11 @@ test:
yaegi_test: yaegi_test:
yaegi test -v . yaegi test -v .
e2e_mock: $(addprefix e2e_mock_,$(E2E_MOCK_SCENARIOS))
e2e_mock_%:
./tests/e2e/mock/scenarios/$*/run.sh
vendor: vendor:
go mod vendor go mod vendor
@@ -99,7 +108,7 @@ clean_all_docker:
docker compose -f examples/redis-cache/docker-compose.yml down --remove-orphans docker compose -f examples/redis-cache/docker-compose.yml down --remove-orphans
docker compose -f examples/trusted-ips/docker-compose.yml down --remove-orphans docker compose -f examples/trusted-ips/docker-compose.yml down --remove-orphans
docker compose -f examples/tls-auth/docker-compose.yml down --remove-orphans docker compose -f examples/tls-auth/docker-compose.yml down --remove-orphans
docker compose -f examples/appsec-enabled/docker-compose.appsec-enabled.yml down --remove-orphans docker compose -f examples/appsec-enabled/docker-compose.yml down --remove-orphans
docker compose -f examples/captcha/docker-compose.yml down --remove-orphans docker compose -f examples/captcha/docker-compose.yml down --remove-orphans
docker compose -f examples/custom-captcha/docker-compose.yml down --remove-orphans docker compose -f examples/custom-captcha/docker-compose.yml down --remove-orphans
docker compose -f examples/custom-ban-page/docker-compose.yml down --remove-orphans docker compose -f examples/custom-ban-page/docker-compose.yml down --remove-orphans
+78 -9
View File
@@ -310,17 +310,16 @@ make run
### Note ### Note
> [!IMPORTANT] > [!IMPORTANT]
> Some of the behaviours and configuration parameters are shared globally across *all* crowdsec middlewares even if you declare different middlewares with different settings. > Some of the behaviours and configuration parameters are shared globally across _all_ crowdsec middlewares even if you declare different middlewares with different settings.
> >
> **Cache is shared by all services**: This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP > **Cache is shared by all services**: This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP
> >
> If you define different caches for different middlewares, only the first one to be instantiated will be bound to the crowdsec stream. > If you define different caches for different middlewares, only the first one to be instantiated will be bound to the crowdsec stream.
> >
> Overall, this middleware is designed in such a way that **only one instance of the plugin is *possible*.** You can have multiple crowdsec middlewares in the same cluster, the key parameters must be aligned (MetricsUpdateIntervalSeconds, CrowdsecMode, CrowdsecAppsecEnabled, etc.) > Overall, this middleware is designed in such a way that **only one instance of the plugin is _possible_.** You can have multiple crowdsec middlewares in the same cluster, the key parameters must be aligned (MetricsUpdateIntervalSeconds, CrowdsecMode, CrowdsecAppsecEnabled, etc.)
> [!WARNING] > [!WARNING]
> **Appsec maximum body limit is defaulted to 10MB** > **Appsec maximum body limit is defaulted to 10MB** > _Be careful when you upgrade to >1.4.x_
> *Be careful when you upgrade to >1.4.x*
### Variables ### Variables
@@ -330,8 +329,12 @@ make run
- Enable the plugin - Enable the plugin
- LogLevel - LogLevel
- string - string
- default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR` - default: `INFO`, expected values are: `DEBUG`, `INFO`, `WARN`, `ERROR`
- Log are written to `stdout` / `stderr` or file if LogFilePath is provided - Log are written to `stdout` / `stderr` or file if LogFilePath is provided
- LogFormat
- string
- default: `common`, expected values are: `common`, `json`
- Log format: `common` for traditional text logs, `json` for structured JSON logs
- LogFilePath - LogFilePath
- string - string
- default: "" - default: ""
@@ -351,7 +354,18 @@ make run
- CrowdsecAppsecHost - CrowdsecAppsecHost
- string - string
- default: "crowdsec:7422" - default: "crowdsec:7422"
- Crowdsec Appsec Server available on which host and port. The scheme will be handled by the CrowdsecLapiScheme var. - Crowdsec Appsec Server available on which host and port.
- CrowdsecAppsecTlsInsecureVerify
- bool
- default: false
- Disable verification of certificate presented by Appsec
- CrowdsecAppsecTlsCertificateAuthority
- string
- default: ""
- PEM-encoded Certificate Authority of Appsec
- CrowdsecAppsecScheme
- string
- default: value of `CrowdsecLapiScheme`, expected values are: `http`, `https`
- CrowdsecAppsecPath - CrowdsecAppsecPath
- string - string
- default: "/" - default: "/"
@@ -368,6 +382,10 @@ make run
- int64 - int64
- default: 10485760 (= 10MB) - default: 10485760 (= 10MB)
- Transmit only the first number of bytes to Crowdsec Appsec Server. - Transmit only the first number of bytes to Crowdsec Appsec Server.
- CrowdsecAppsecKey
- string
- default: value of `CrowdsecLapiKey`
- Crowdsec AppSec key for the bouncer.
- CrowdsecLapiScheme - CrowdsecLapiScheme
- string - string
- default: `http`, expected values are: `http`, `https` - default: `http`, expected values are: `http`, `https`
@@ -406,7 +424,7 @@ make run
- RemediationHeadersCustomName - RemediationHeadersCustomName
- string - string
- default: "" - default: ""
- Name of the header you want in response when request are cancelled (possible value of the header `ban` or `captcha`) - Name of the header you want in response when request are handled by plugin (possible value of the header `ban`, `captcha` or `solved-captcha`)
- ForwardedHeadersCustomName - ForwardedHeadersCustomName
- string - string
- default: "X-Forwarded-For" - default: "X-Forwarded-For"
@@ -447,6 +465,12 @@ make run
- int64 - int64
- default: 0 - default: 0
- Used only in `stream` and `alone` mode, the maximum number of time we can not reach Crowdsec before blocking traffic (set -1 to never block) - Used only in `stream` and `alone` mode, the maximum number of time we can not reach Crowdsec before blocking traffic (set -1 to never block)
- StreamStartupBlock
- bool
- default: true
- Used only in `stream` and `alone` mode, controls whether the initial stream update runs synchronously or asynchronously during plugin initialization
- When `true`, plugin initialization waits for Crowdsec to be ready before serving traffic.
- **Warning**: When `false`, all requests bypass remediation until the first stream sync completes — banned IPs will be allowed through during this window. Only disable when startup availability is more important than blocking at startup.
- DefaultDecisionSeconds - DefaultDecisionSeconds
- int64 - int64
- default: 60 - default: 60
@@ -497,6 +521,10 @@ make run
- string - string
- default: "" - default: ""
- Path where the ban html file is stored (default empty ""=disabled) - Path where the ban html file is stored (default empty ""=disabled)
- TraceHeadersCustomName
- string
- default: ""
- Request Header name whose value to inject in ban HTML response (default empty ""=disabled)
### Configuration ### Configuration
@@ -518,7 +546,37 @@ experimental:
``` ```
```yaml ```yaml
# Dynamic configuration # Simplified dynamic configuration
http:
routers:
my-router:
rule: host(`whoami.localhost`)
service: service-foo
entryPoints:
- web
middlewares:
- crowdsec
services:
service-foo:
loadBalancer:
servers:
- url: http://127.0.0.1:5000
middlewares:
crowdsec:
plugin:
bouncer:
enabled: true
logLevel: DEBUG
crowdsecMode: live
crowdsecLapiKey: privateKey-foo
crowdsecLapiHost: crowdsec:8080
```
```yaml
# Full dynamic configuration
http: http:
routers: routers:
@@ -542,14 +600,17 @@ http:
bouncer: bouncer:
enabled: false enabled: false
logLevel: DEBUG logLevel: DEBUG
logFormat: common
LogFilePath: "" LogFilePath: ""
updateIntervalSeconds: 60 updateIntervalSeconds: 60
updateMaxFailure: 0 updateMaxFailure: 0
streamStartupBlock: true
defaultDecisionSeconds: 60 defaultDecisionSeconds: 60
remediationStatusCode: 403 remediationStatusCode: 403
httpTimeoutSeconds: 10 httpTimeoutSeconds: 10
crowdsecMode: live crowdsecMode: live
crowdsecAppsecEnabled: false crowdsecAppsecEnabled: false
crowdsecAppsecScheme: ""
crowdsecAppsecHost: crowdsec:7422 crowdsecAppsecHost: crowdsec:7422
crowdsecAppsecPath: "/" crowdsecAppsecPath: "/"
crowdsecAppsecFailureBlock: true crowdsecAppsecFailureBlock: true
@@ -603,12 +664,13 @@ http:
captchaGracePeriodSeconds: 1800 captchaGracePeriodSeconds: 1800
captchaHTMLFilePath: /captcha.html captchaHTMLFilePath: /captcha.html
banHTMLFilePath: /ban.html banHTMLFilePath: /ban.html
traceHeadersCustomName: X-Request-ID
metricsUpdateIntervalSeconds: 600 metricsUpdateIntervalSeconds: 600
``` ```
#### Fill variable with value of file #### Fill variable with value of file
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey`, `CaptchaSecretKey` and `RedisCachePassword` can be provided with the content as raw or through a file path that Traefik can read. `CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecAppsecTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CrowdsecAppsecKey`, `CaptchaSiteKey`, `CaptchaSecretKey` and `RedisCachePassword` can be provided with the content as raw or through a file path that Traefik can read.
The file variable will be used as preference if both content and file are provided for the same variable. The file variable will be used as preference if both content and file are provided for the same variable.
Format is: Format is:
@@ -671,6 +733,13 @@ Set the `crowdsecLapiScheme` to https.
Crowdsec must be listening in HTTPS for this to work. 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/) 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.
Currently AppSec does not support mTLS authentication for the AppSec Component.
#### Manually add an IP to the blocklist (for testing purposes) #### Manually add an IP to the blocklist (for testing purposes)
```bash ```bash
+88 -30
View File
@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
htmltemplate "html/template" htmltemplate "html/template"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@@ -63,7 +64,7 @@ const (
//nolint:gochecknoglobals //nolint:gochecknoglobals
var ( var (
isStartup = true isCrowdsecStreamStartup = true
isCrowdsecStreamHealthy = true isCrowdsecStreamHealthy = true
updateFailure int64 updateFailure int64
streamTicker chan bool streamTicker chan bool
@@ -85,8 +86,10 @@ type Bouncer struct {
enabled bool enabled bool
appsecEnabled bool appsecEnabled bool
appsecScheme string
appsecHost string appsecHost string
appsecPath string appsecPath string
appsecKey string
appsecFailureBlock bool appsecFailureBlock bool
appsecUnreachableBlock bool appsecUnreachableBlock bool
appsecBodyLimit int64 appsecBodyLimit int64
@@ -108,21 +111,23 @@ type Bouncer struct {
crowdsecHeader string crowdsecHeader string
redisUnreachableBlock bool redisUnreachableBlock bool
banTemplate *htmltemplate.Template banTemplate *htmltemplate.Template
traceCustomHeader string
clientPoolStrategy *ip.PoolStrategy clientPoolStrategy *ip.PoolStrategy
serverPoolStrategy *ip.PoolStrategy serverPoolStrategy *ip.PoolStrategy
httpClient *http.Client httpClient *http.Client
httpAppsecClient *http.Client
cacheClient *cache.Client cacheClient *cache.Client
captchaClient *captcha.Client captchaClient *captcha.Client
log *logger.Log log *slog.Logger
} }
// New creates the crowdsec bouncer plugin. // New creates the crowdsec bouncer plugin.
// //
//nolint:gocyclo //nolint:nestif,gocyclo,gocognit
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) { func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
config.LogLevel = strings.ToUpper(config.LogLevel) config.LogLevel = strings.ToUpper(config.LogLevel)
log := logger.New(config.LogLevel, config.LogFilePath) log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
err := configuration.ValidateParams(config) err := configuration.ValidateParams(config, log)
if err != nil { if err != nil {
log.Error("New:validateParams " + err.Error()) log.Error("New:validateParams " + err.Error())
return nil, err return nil, err
@@ -131,6 +136,23 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
serverChecker, _ := ip.NewChecker(log, config.ForwardedHeadersTrustedIPs) serverChecker, _ := ip.NewChecker(log, config.ForwardedHeadersTrustedIPs)
clientChecker, _ := ip.NewChecker(log, config.ClientTrustedIPs) clientChecker, _ := ip.NewChecker(log, config.ClientTrustedIPs)
var tlsAppsecConfig *tls.Config
if config.CrowdsecAppsecEnabled {
tlsAppsecConfig, err = configuration.GetTLSConfigCrowdsec(config, log, true)
if config.CrowdsecAppsecScheme == "" {
config.CrowdsecAppsecScheme = config.CrowdsecLapiScheme
}
if err != nil {
log.Error("New:getTLSConfigCrowdsec fail to get tlsAppsecConfig " + err.Error())
return nil, err
}
apiAppsecKey, errAppsecKey := configuration.GetVariable(config, "CrowdsecAppsecKey")
if errAppsecKey != nil && len(tlsAppsecConfig.Certificates) == 0 {
log.Info("New:crowdsecLapiKey fail to get CrowdsecAppsecKey and no client certificate setup " + errAppsecKey.Error())
}
config.CrowdsecAppsecKey = apiAppsecKey
}
var tlsConfig *tls.Config var tlsConfig *tls.Config
crowdsecStreamRoute := "" crowdsecStreamRoute := ""
crowdsecHeader := "" crowdsecHeader := ""
@@ -140,24 +162,26 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
config.CrowdsecLapiScheme = configuration.HTTPS config.CrowdsecLapiScheme = configuration.HTTPS
config.CrowdsecLapiHost = crowdsecCapiHost config.CrowdsecLapiHost = crowdsecCapiHost
config.CrowdsecLapiPath = "/" config.CrowdsecLapiPath = "/"
config.CrowdsecAppsecEnabled = false
config.UpdateIntervalSeconds = 7200 // 2 hours config.UpdateIntervalSeconds = 7200 // 2 hours
crowdsecStreamRoute = crowdsecCapiStreamRoute crowdsecStreamRoute = crowdsecCapiStreamRoute
crowdsecHeader = crowdsecCapiHeader crowdsecHeader = crowdsecCapiHeader
} else { } else {
crowdsecStreamRoute = crowdsecLapiStreamRoute crowdsecStreamRoute = crowdsecLapiStreamRoute
crowdsecHeader = crowdsecLapiHeader crowdsecHeader = crowdsecLapiHeader
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log) tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log, false)
if err != nil { if err != nil {
log.Error("New:getTLSConfigCrowdsec fail to get tlsConfig " + err.Error()) log.Error("New:getTLSConfigCrowdsec fail to get tlsConfig " + err.Error())
return nil, err return nil, err
} }
apiKey, errAPIKey := configuration.GetVariable(config, "CrowdsecLapiKey") apiKey, errKey := configuration.GetVariable(config, "CrowdsecLapiKey")
if errAPIKey != nil && len(tlsConfig.Certificates) == 0 { if errKey != nil && len(tlsConfig.Certificates) == 0 {
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errAPIKey.Error()) log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errKey.Error())
return nil, errAPIKey return nil, errKey
} }
config.CrowdsecLapiKey = apiKey config.CrowdsecLapiKey = apiKey
if config.CrowdsecAppsecKey == "" {
config.CrowdsecAppsecKey = apiKey
}
} }
var banTemplate *htmltemplate.Template var banTemplate *htmltemplate.Template
@@ -173,8 +197,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
enabled: config.Enabled, enabled: config.Enabled,
crowdsecMode: config.CrowdsecMode, crowdsecMode: config.CrowdsecMode,
appsecEnabled: config.CrowdsecAppsecEnabled, appsecEnabled: config.CrowdsecAppsecEnabled,
appsecScheme: config.CrowdsecAppsecScheme,
appsecHost: config.CrowdsecAppsecHost, appsecHost: config.CrowdsecAppsecHost,
appsecPath: config.CrowdsecAppsecPath, appsecPath: config.CrowdsecAppsecPath,
appsecKey: config.CrowdsecAppsecKey,
appsecFailureBlock: config.CrowdsecAppsecFailureBlock, appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock, appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
appsecBodyLimit: config.CrowdsecAppsecBodyLimit, appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
@@ -193,6 +219,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
remediationStatusCode: config.RemediationStatusCode, remediationStatusCode: config.RemediationStatusCode,
redisUnreachableBlock: config.RedisCacheUnreachableBlock, redisUnreachableBlock: config.RedisCacheUnreachableBlock,
banTemplate: banTemplate, banTemplate: banTemplate,
traceCustomHeader: config.TraceHeadersCustomName,
crowdsecStreamRoute: crowdsecStreamRoute, crowdsecStreamRoute: crowdsecStreamRoute,
crowdsecHeader: crowdsecHeader, crowdsecHeader: crowdsecHeader,
log: log, log: log,
@@ -210,6 +237,14 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
}, },
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second, Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
}, },
httpAppsecClient: &http.Client{
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
TLSClientConfig: tlsAppsecConfig,
},
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
},
cacheClient: &cache.Client{}, cacheClient: &cache.Client{},
captchaClient: &captcha.Client{}, captchaClient: &captcha.Client{},
} }
@@ -256,8 +291,11 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
return nil, err return nil, err
} }
} }
handleStreamTicker(bouncer) if config.StreamStartupBlock {
isStartup = false handleStreamTicker(bouncer)
} else {
go handleStreamTicker(bouncer)
}
streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() { streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() {
handleStreamTicker(bouncer) handleStreamTicker(bouncer)
}) })
@@ -266,7 +304,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
// Start metrics ticker if not already running // Start metrics ticker if not already running
if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 { if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 {
lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker
handleMetricsTicker(bouncer) go handleMetricsTicker(bouncer)
metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() { metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() {
handleMetricsTicker(bouncer) handleMetricsTicker(bouncer)
}) })
@@ -348,6 +386,9 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
} }
} else { } else {
value, err := handleNoStreamCache(bouncer, remoteIP) value, err := handleNoStreamCache(bouncer, remoteIP)
if err != nil {
bouncer.log.Debug("handleNoStreamCache:crowdsecQuery " + err.Error())
}
if value == cache.NoBannedValue { if value == cache.NoBannedValue {
bouncer.handleNextServeHTTP(rw, req, remoteIP) bouncer.handleNextServeHTTP(rw, req, remoteIP)
} else { } else {
@@ -402,9 +443,23 @@ func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Req
if req.Method == http.MethodHead { if req.Method == http.MethodHead {
return return
} }
err := bouncer.banTemplate.Execute(rw, map[string]string{"RemediationReason": reason, "ClientIP": remoteIP}) templateData := map[string]string{
"RemediationReason": reason,
"ClientIP": remoteIP,
}
if bouncer.traceCustomHeader != "" {
headerVal := req.Header.Get(bouncer.traceCustomHeader)
if headerVal != "" {
templateData["TraceID"] = headerVal
}
}
err := bouncer.banTemplate.Execute(rw, templateData)
if err != nil { if err != nil {
bouncer.log.Error("handleBanServeHTTP banTemplateServe " + err.Error()) bouncer.log.Warn("handleBanServeHTTP could not write template to ResponseWriter: " + err.Error())
} }
} }
@@ -435,7 +490,7 @@ func (bouncer *Bouncer) handleNextServeHTTP(rw http.ResponseWriter, req *http.Re
func handleStreamTicker(bouncer *Bouncer) { func handleStreamTicker(bouncer *Bouncer) {
if err := handleStreamCache(bouncer); err != nil { if err := handleStreamCache(bouncer); err != nil {
bouncer.log.Debug(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error())) bouncer.log.Warn(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
if bouncer.updateMaxFailure != -1 && updateFailure >= bouncer.updateMaxFailure && isCrowdsecStreamHealthy { if bouncer.updateMaxFailure != -1 && updateFailure >= bouncer.updateMaxFailure && isCrowdsecStreamHealthy {
isCrowdsecStreamHealthy = false isCrowdsecStreamHealthy = false
bouncer.log.Error(fmt.Sprintf("handleStreamTicker:error updateFailure:%d %s", updateFailure, err.Error())) bouncer.log.Error(fmt.Sprintf("handleStreamTicker:error updateFailure:%d %s", updateFailure, err.Error()))
@@ -453,7 +508,7 @@ func handleMetricsTicker(bouncer *Bouncer) {
} }
} }
func startTicker(name string, updateInterval int64, log *logger.Log, work func()) chan bool { func startTicker(name string, updateInterval int64, log *slog.Logger, work func()) chan bool {
ticker := time.NewTicker(time.Duration(updateInterval) * time.Second) ticker := time.NewTicker(time.Duration(updateInterval) * time.Second)
stop := make(chan bool, 1) stop := make(chan bool, 1)
go func() { go func() {
@@ -520,7 +575,7 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
case "captcha": case "captcha":
value = cache.CaptchaValue value = cache.CaptchaValue
default: default:
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type) bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
} }
if isLiveMode && bouncer.defaultDecisionTimeout > 0 { if isLiveMode && bouncer.defaultDecisionTimeout > 0 {
durationSecond := int64(duration.Seconds()) durationSecond := int64(duration.Seconds())
@@ -556,11 +611,11 @@ func getToken(bouncer *Bouncer) error {
if err != nil { if err != nil {
return fmt.Errorf("getToken:parsingBody %w", err) return fmt.Errorf("getToken:parsingBody %w", err)
} }
if login.Code == 200 && len(login.Token) > 0 { if login.Code == http.StatusOK && len(login.Token) > 0 {
bouncer.crowdsecKey = login.Token bouncer.crowdsecKey = login.Token
bouncer.log.Debug(fmt.Sprintf("getToken statusCode:%d", login.Code))
return nil return nil
} }
bouncer.log.Warn(fmt.Sprintf("getToken statusCode:%d", login.Code))
return fmt.Errorf("getToken statusCode:%d", login.Code) return fmt.Errorf("getToken statusCode:%d", login.Code)
} }
@@ -572,6 +627,7 @@ func handleStreamCache(bouncer *Bouncer) error {
_, err := bouncer.cacheClient.Get(cacheTimeoutKey) _, err := bouncer.cacheClient.Get(cacheTimeoutKey)
if err == nil { if err == nil {
bouncer.log.Debug("handleStreamCache:alreadyUpdated") bouncer.log.Debug("handleStreamCache:alreadyUpdated")
isCrowdsecStreamStartup = false
return nil return nil
} }
if err.Error() != cache.CacheMiss { if err.Error() != cache.CacheMiss {
@@ -582,7 +638,7 @@ func handleStreamCache(bouncer *Bouncer) error {
Scheme: bouncer.crowdsecScheme, Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost, Host: bouncer.crowdsecHost,
Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute, Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute,
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup), RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isCrowdsecStreamStartup),
} }
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil) body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil)
if err != nil { if err != nil {
@@ -603,7 +659,7 @@ func handleStreamCache(bouncer *Bouncer) error {
case "captcha": case "captcha":
value = cache.CaptchaValue value = cache.CaptchaValue
default: default:
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type) bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
} }
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds())) bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
} }
@@ -612,6 +668,7 @@ func handleStreamCache(bouncer *Bouncer) error {
bouncer.cacheClient.Delete(decision.Value) bouncer.cacheClient.Delete(decision.Value)
} }
bouncer.log.Debug("handleStreamCache:updated") bouncer.log.Debug("handleStreamCache:updated")
isCrowdsecStreamStartup = false
return nil return nil
} }
@@ -622,8 +679,8 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
} else { } else {
req, _ = http.NewRequest(http.MethodGet, stringURL, nil) req, _ = http.NewRequest(http.MethodGet, stringURL, nil)
} }
req.Header.Add(bouncer.crowdsecHeader, bouncer.crowdsecKey) req.Header.Set(bouncer.crowdsecHeader, bouncer.crowdsecKey)
req.Header.Add("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/1.X.X") req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
res, err := bouncer.httpClient.Do(req) res, err := bouncer.httpClient.Do(req)
if err != nil { if err != nil {
@@ -656,12 +713,12 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error { func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
routeURL := url.URL{ routeURL := url.URL{
Scheme: bouncer.crowdsecScheme, Scheme: bouncer.appsecScheme,
Host: bouncer.appsecHost, Host: bouncer.appsecHost,
Path: bouncer.appsecPath, Path: bouncer.appsecPath,
} }
var req *http.Request var req *http.Request
if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil && httpReq.ContentLength > 0 { if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil {
var bodyBuffer bytes.Buffer var bodyBuffer bytes.Buffer
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit) limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
teeReader := io.TeeReader(limitedReader, &bodyBuffer) teeReader := io.TeeReader(limitedReader, &bodyBuffer)
@@ -681,14 +738,15 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
req.Header.Add(key, value) req.Header.Add(key, value)
} }
} }
req.Header.Set(crowdsecAppsecHeader, bouncer.crowdsecKey) req.Header.Set(crowdsecAppsecHeader, bouncer.appsecKey)
req.Header.Set(crowdsecAppsecIPHeader, ip) req.Header.Set(crowdsecAppsecIPHeader, ip)
req.Header.Set(crowdsecAppsecVerbHeader, httpReq.Method) req.Header.Set(crowdsecAppsecVerbHeader, httpReq.Method)
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host) req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String()) req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
req.Header.Set(crowdsecAppsecUserAgent, httpReq.Header.Get("User-Agent")) req.Header.Set(crowdsecAppsecUserAgent, httpReq.Header.Get("User-Agent"))
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
res, err := bouncer.httpClient.Do(req) res, err := bouncer.httpAppsecClient.Do(req)
if err != nil { if err != nil {
bouncer.log.Error("appsecQuery:unreachable") bouncer.log.Error("appsecQuery:unreachable")
if bouncer.appsecUnreachableBlock { if bouncer.appsecUnreachableBlock {
@@ -728,7 +786,7 @@ func reportMetrics(bouncer *Bouncer) error {
metrics := map[string]interface{}{ metrics := map[string]interface{}{
"remediation_components": []map[string]interface{}{ "remediation_components": []map[string]interface{}{
{ {
"version": "1.X.X", "version": pluginVersion,
"type": "bouncer", "type": "bouncer",
"name": "traefik_plugin", "name": "traefik_plugin",
"metrics": []map[string]interface{}{ "metrics": []map[string]interface{}{
+320
View File
@@ -0,0 +1,320 @@
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
)
// getTestConfig returns a minimal valid configuration for testing.
// Override specific fields by modifying the returned config.
func getTestConfig() *configuration.Config {
return &configuration.Config{
Enabled: true,
LogLevel: "INFO",
LogFormat: "common",
LogFilePath: "",
CrowdsecMode: "none",
CrowdsecLapiKey: "test-key",
CrowdsecLapiHost: "localhost",
CrowdsecLapiScheme: "http",
UpdateIntervalSeconds: 60,
DefaultDecisionSeconds: 60,
HTTPTimeoutSeconds: 10,
ForwardedHeadersTrustedIPs: []string{"127.0.0.1"},
ForwardedHeadersCustomName: "",
RemediationStatusCode: 403,
BanHTMLFilePath: "",
RemediationHeadersCustomName: "",
CaptchaProvider: "",
CaptchaSiteKey: "",
CaptchaSecretKey: "",
CaptchaGracePeriodSeconds: 1,
CaptchaHTMLFilePath: "",
RedisCacheEnabled: false,
RedisCacheHost: "",
RedisCachePassword: "",
RedisCacheDatabase: "",
RedisCacheUnreachableBlock: false,
CrowdsecAppsecEnabled: false,
CrowdsecAppsecHost: "",
CrowdsecAppsecPath: "",
CrowdsecAppsecFailureBlock: false,
CrowdsecAppsecUnreachableBlock: false,
CrowdsecLapiTLSInsecureVerify: true,
CrowdsecLapiTLSCertificateBouncer: "",
CrowdsecLapiTLSCertificateBouncerKey: "",
CrowdsecCapiMachineID: "",
CrowdsecCapiPassword: "",
CrowdsecCapiScenarios: []string{},
UpdateMaxFailure: 0,
MetricsUpdateIntervalSeconds: 0,
}
}
// Helper function to create and execute a bouncer request for testing
func createAndExecuteBouncerRequest(t *testing.T, config *configuration.Config) {
t.Helper()
// Create a mock next handler
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
})
// Create the bouncer plugin (this will initialize the logger with file output)
bouncerHandler, err := New(context.Background(), nextHandler, config, "test-bouncer")
if err != nil {
t.Fatalf("Failed to create bouncer: %v", err)
}
// Create a test request to trigger logging
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
req.RemoteAddr = "192.168.1.100:12345" // Use a non-trusted IP to trigger logging
rw := httptest.NewRecorder()
// Process the request (this should generate log entries)
bouncerHandler.ServeHTTP(rw, req)
// Give a moment for log writes to complete
time.Sleep(100 * time.Millisecond)
}
// Helper function to parse log file and extract found levels
func parseLogFileAndExtractLevels(t *testing.T, logFile string) map[string]bool {
t.Helper()
// Verify the log file was created and contains entries
if _, statErr := os.Stat(logFile); os.IsNotExist(statErr) {
t.Fatalf("Log file was not created: %s", logFile)
}
// Read the log file content
// #nosec G304 - logFile is a test-generated temporary file path
logContent, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
logString := string(logContent)
if len(logString) == 0 {
return make(map[string]bool) // Return empty map for empty log files
}
// Parse and verify JSON log entries
lines := strings.Split(strings.TrimSpace(logString), "\n")
foundLevels := make(map[string]bool)
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
var logEntry map[string]interface{}
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
t.Errorf("Invalid JSON log entry: %s, error: %v", line, err)
continue
}
// Verify required fields
validateLogEntry(t, logEntry)
// Track log levels we've seen
if level, ok := logEntry["level"].(string); ok {
foundLevels[level] = true
}
}
return foundLevels
}
// Helper function to validate log entry structure
func validateLogEntry(t *testing.T, logEntry map[string]interface{}) {
t.Helper()
if logEntry["time"] == nil {
t.Error("Log entry missing 'time' field")
}
if logEntry["level"] == nil {
t.Error("Log entry missing 'level' field")
}
if logEntry["msg"] == nil {
t.Error("Log entry missing 'msg' field")
}
if logEntry["component"] != "CrowdsecBouncerTraefikPlugin" {
t.Errorf("Expected component 'CrowdsecBouncerTraefikPlugin', got %v", logEntry["component"])
}
}
// Helper function to verify expected and forbidden log levels
func verifyLogLevels(t *testing.T, foundLevels map[string]bool, expectedLevels, forbiddenLevels []string, logLevel string) {
t.Helper()
// Handle case where no logs are expected
if len(expectedLevels) == 0 {
if len(foundLevels) > 0 {
t.Errorf("Expected no logs at %s level, but found: %v", logLevel, foundLevels)
}
} else {
// Verify we got some log entries
if len(foundLevels) == 0 {
t.Fatal("No valid log entries found")
}
// Verify expected levels are present
for _, expectedLevel := range expectedLevels {
if !foundLevels[expectedLevel] {
t.Errorf("Expected to find %s level logs, but didn't. Found levels: %v", expectedLevel, foundLevels)
}
}
}
// Verify forbidden levels are NOT present
for _, forbiddenLevel := range forbiddenLevels {
if foundLevels[forbiddenLevel] {
t.Errorf("Found forbidden %s level logs at %s level. Found levels: %v", forbiddenLevel, logLevel, foundLevels)
}
}
}
func TestBouncerFileLoggingLevels(t *testing.T) {
tests := []struct {
name string
logLevel string
expectedLevels []string // Levels that should appear
forbiddenLevels []string // Levels that should NOT appear
}{
{
name: "DEBUG level should show DEBUG only",
logLevel: "DEBUG",
expectedLevels: []string{"DEBUG"},
forbiddenLevels: []string{},
},
{
name: "INFO level should show no logs (bouncer doesn't generate INFO during normal operation)",
logLevel: "INFO",
expectedLevels: []string{}, // No logs expected for normal operation
forbiddenLevels: []string{"DEBUG"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temporary directory for log file
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "bouncer.log")
// Get test config and override specific fields
config := getTestConfig()
config.LogLevel = tt.logLevel
config.LogFormat = "json" // Use JSON format for easier parsing
config.LogFilePath = logFile
// Create and execute bouncer request
createAndExecuteBouncerRequest(t, config)
// Parse log file and extract found levels
foundLevels := parseLogFileAndExtractLevels(t, logFile)
// Handle empty log files for higher log levels (expected behavior)
if len(foundLevels) == 0 && len(tt.expectedLevels) > 0 {
t.Fatalf("Expected log entries but log file is empty for level %s", tt.logLevel)
}
if len(foundLevels) == 0 {
// Empty file is expected for this log level
t.Logf("LogLevel %s: No logs generated (expected behavior)", tt.logLevel)
return
}
// Verify expected and forbidden log levels
verifyLogLevels(t, foundLevels, tt.expectedLevels, tt.forbiddenLevels, tt.logLevel)
t.Logf("LogLevel %s: Successfully logged to file with levels: %v", tt.logLevel, foundLevels)
})
}
}
func TestBouncerFileLoggingCommonFormat(t *testing.T) {
// Create temporary directory for log file
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "bouncer-common.log")
// Get test config and override specific fields
config := getTestConfig()
config.LogLevel = "DEBUG"
config.LogFormat = "common" // Use common format
config.LogFilePath = logFile
// Create a mock next handler
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
})
// Create the bouncer plugin
bouncerHandler, err := New(context.Background(), nextHandler, config, "test-bouncer")
if err != nil {
t.Fatalf("Failed to create bouncer: %v", err)
}
// Create a test request to trigger logging
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
req.RemoteAddr = "192.168.1.100:12345"
rw := httptest.NewRecorder()
// Process the request
bouncerHandler.ServeHTTP(rw, req)
// Give a moment for log writes to complete
time.Sleep(100 * time.Millisecond)
// Verify the log file was created and contains entries
if _, statErr := os.Stat(logFile); os.IsNotExist(statErr) {
t.Fatalf("Log file was not created: %s", logFile)
}
// Read the log file content
// #nosec G304 - logFile is a test-generated temporary file path
logContent, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
logString := string(logContent)
if len(logString) == 0 {
t.Fatal("Log file is empty")
}
// Verify common format structure
lines := strings.Split(strings.TrimSpace(logString), "\n")
foundDebug := false
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
// Common format should contain time, level, msg, and component
if strings.Contains(line, "level=DEBUG") {
foundDebug = true
}
if !strings.Contains(line, "component=CrowdsecBouncerTraefikPlugin") {
t.Errorf("Log line missing component field: %s", line)
}
}
// We should see DEBUG level logs since we set LogLevel to DEBUG
if !foundDebug {
t.Errorf("Expected to find DEBUG level logs in common format. Log content:\n%s", logString)
}
t.Logf("Successfully logged to file %s in common format with %d lines", logFile, len(lines))
}
+4 -3
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: "traefik:v3.0.0" image: "traefik:v3.5.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -16,8 +16,8 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- logs-local:/var/log/traefik - logs-local:/var/log/traefik
- './ban.html:/ban.html:ro' - "./ban.html:/ban.html:ro"
- './captcha.html:/captcha.html:ro' - "./captcha.html:/captcha.html:ro"
- ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin - ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
ports: ports:
- 8000:80 - 8000:80
@@ -52,6 +52,7 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=stream" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=stream"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5=" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
- "traefik.http.middlewares.crowdsec.plugin.bouncer.ForwardedHeadersTrustedIPs=172.21.0.1/8"
bar2: bar2:
image: traefik/whoami image: traefik/whoami
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: "traefik:v3.0.0" image: "traefik:v3.5.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.3.0" - "--experimental.plugins.bouncer.version=v1.5.0"
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" # - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
+2
View File
@@ -50,8 +50,10 @@ make run_custom_ban_page
In the html of the ban page, you can use: In the html of the ban page, you can use:
- {{ .ClientIP }} to display the IP used to ban the request. - {{ .ClientIP }} to display the IP used to ban the request.
- {{ .RemediationReason }} that convert on runtime into why the ban page is served. It's an enum with "APPSEC", "LAPI", "TECHNICAL_ISSUE" and it is useful to help user understand why the request is blocked. - {{ .RemediationReason }} that convert on runtime into why the ban page is served. It's an enum with "APPSEC", "LAPI", "TECHNICAL_ISSUE" and it is useful to help user understand why the request is blocked.
- {{ .CustomHeader }} value of the specified Request Header (for example X-Request-ID)
``` ```
<script>var remediation = "{{ .RemediationReason }}"</script> <script>var remediation = "{{ .RemediationReason }}"</script>
<script>var clientIp = "{{ .ClientIP }}"</script> <script>var clientIp = "{{ .ClientIP }}"</script>
<script>var traceID = "{{ .TraceID }}"</script>
``` ```
With the above tweak and some other js, you can customize your ban page on runtime. With the above tweak and some other js, you can customize your ban page on runtime.
+30 -10
View File
@@ -1,7 +1,9 @@
# Example # Example
## Using https communication and tls authentication with Crowdsec ## Using https communication and tls authentication with Crowdsec
##### Summary ##### Summary
This example demonstrates the use of https between the Traefik plugin and the Crowdsec LAPI. This example demonstrates the use of https between the Traefik plugin and the Crowdsec LAPI.
It is possible to communicate with the LAPI in https and still authenticate with API key. It is possible to communicate with the LAPI in https and still authenticate with API key.
@@ -17,7 +19,9 @@ In that case the setting **crowdsecLapiTLSInsecureVerify** must be set to true.
It is recommended to validate the certificate presented by Crowdsec LAPI using the Certificate Authority which created it. It is recommended to validate the certificate presented by Crowdsec LAPI using the Certificate Authority which created it.
You can provide the Certificate Authority using: You can provide the Certificate Authority using:
* A file path readable by Traefik
- A file path readable by Traefik
```yaml ```yaml
http: http:
middlewares: middlewares:
@@ -26,9 +30,11 @@ http:
bouncer: bouncer:
crowdsecLapiTlsCertificateAuthorityFile: /etc/traefik/certs/crowdsecCA.pem crowdsecLapiTlsCertificateAuthorityFile: /etc/traefik/certs/crowdsecCA.pem
``` ```
* The PEM encoded certificate as a text variable
- The PEM encoded certificate as a text variable
In the static file configuration of Traefik In the static file configuration of Traefik
```yaml ```yaml
http: http:
middlewares: middlewares:
@@ -36,15 +42,17 @@ http:
plugin: plugin:
bouncer: bouncer:
crowdsecLapiTlsCertificateAuthority: |- crowdsecLapiTlsCertificateAuthority: |-
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
... ...
C6qNieSwcvWL7C03ri0DefTQMY54r5wP33QU5hJ71JoaZI3YTeT0Nf+NRL4hM++w C6qNieSwcvWL7C03ri0DefTQMY54r5wP33QU5hJ71JoaZI3YTeT0Nf+NRL4hM++w
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
-----END CERTIFICATE----- -----END CERTIFICATE-----
``` ```
In a dynamic configuration of a provider (ex docker) as a Label In a dynamic configuration of a provider (ex docker) as a Label
```yaml ```yaml
services: services:
whoami-foo: whoami-foo:
@@ -71,22 +79,34 @@ The service `whoami-foo` will authenticate with an **API key** over HTTPS after
The service `whoami-bar` will authenticate with a **client certificate** signed by the CA. The service `whoami-bar` will authenticate with a **client certificate** signed by the CA.
Access to a route that communicate via https and authenticate with API-key: Access to a route that communicate via https and authenticate with API-key:
``` ```
curl http://localhost:8000/foo curl http://localhost:8000/foo
``` ```
Access to a route that communicate via https and authenticate with a client certificate: Access to a route that communicate via https and authenticate with a client certificate:
``` ```
curl http://localhost:8000/bar curl http://localhost:8000/bar
``` ```
Access to the traefik dashboard Access to the traefik dashboard
``` ```
curl http://localhost:8080/dashboard/#/ curl http://localhost:8080/dashboard/#/
``` ```
To play the demo environnement run: To play the demo environnement run:
```bash ```bash
make run_tlsauth make run_tlsauth
``` ```
Note: Note:
> Traefik need to be restart if certificates are regenerated after his launch
> Traefik need to be restarted if certificates are regenerated after his launch, crowdsec also
## Separate LAPI and Appsec HTTP/S config
To separate TLS config for LAPI and Appsec, you can use all the TLS LAPI variable beginning with `CrowdsecLapi...` into `CrowdsecAppsec...`.
Don't forget to set `CrowdsecAppsecScheme: HTTP` or `HTTPS` to trigger the separate setup.
+9
View File
@@ -2,3 +2,12 @@ filenames:
- /var/log/traefik/access.log - /var/log/traefik/access.log
labels: labels:
type: traefik type: traefik
---
listen_addr: 0.0.0.0:7422
appsec_config: crowdsecurity/virtual-patching
name: myAppSecComponent
source: appsec
labels:
type: appsec
cert_file: /etc/crowdsec/certs/server.pem
key_file: /etc/crowdsec/certs/server-key.pem
+20 -14
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: "traefik:v3.0.0" image: "traefik:v3.5.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -13,15 +13,13 @@ services:
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.3.0" - "--experimental.plugins.bouncer.version=v1.5.0"
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" # - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- ./LAPIKEY:/etc/traefik/LAPIKEY:ro
- logs-tls-auth:/var/log/traefik - logs-tls-auth:/var/log/traefik
- crowdsec-certs-tls-auth:/etc/traefik/crowdsec-certs - crowdsec-certs-tls-auth:/etc/traefik/crowdsec-certs
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin # - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
ports: ports:
- 8000:80 - 8000:80
- 8080:8080 - 8080:8080
@@ -29,7 +27,7 @@ services:
- crowdsec - crowdsec
- gencert - gencert
# Use HTTPS scheme but with lapikey authentication # Use HTTPS scheme but with lapikey authentication
# whoami-foo: # whoami-foo:
# image: traefik/whoami # image: traefik/whoami
# container_name: "simple-service-foo" # container_name: "simple-service-foo"
@@ -38,7 +36,7 @@ services:
# - "traefik.enable=true" # - "traefik.enable=true"
# - "traefik.http.routers.router-foo.rule=Path(`/foo`)" # - "traefik.http.routers.router-foo.rule=Path(`/foo`)"
# - "traefik.http.routers.router-foo.entrypoints=web" # - "traefik.http.routers.router-foo.entrypoints=web"
# - "traefik.http.routers.router-foo.middlewares=crowdsec@docker" # - "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
# - "traefik.http.services.service-foo.loadbalancer.server.port=80" # - "traefik.http.services.service-foo.loadbalancer.server.port=80"
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true" # - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG" # - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
@@ -46,34 +44,41 @@ services:
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https" # - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem" # - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
# Use HTTPS scheme with TLS cert authentication # Use HTTPS scheme with TLS cert authentication
whoami-bar: whoami-bar:
image: traefik/whoami image: traefik/whoami
container_name: "simple-service-bar" container_name: "simple-service-bar"
restart: unless-stopped restart: unless-stopped
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.router-bar.rule=Path(`/bar`)" - "traefik.http.routers.router-bar.rule=PathPrefix(`/bar`)"
- "traefik.http.routers.router-bar.entrypoints=web" - "traefik.http.routers.router-bar.entrypoints=web"
- "traefik.http.routers.router-bar.middlewares=crowdsec@docker" - "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
- "traefik.http.services.service-bar.loadbalancer.server.port=80" - "traefik.http.services.service-bar.loadbalancer.server.port=80"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG" - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecMode=none"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecscheme=https"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecAppsecTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapikey=40796d93c2958f9e58345514e67740e5="
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerFile=/etc/traefik/crowdsec-certs/bouncer.pem" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerFile=/etc/traefik/crowdsec-certs/bouncer.pem"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerKeyFile=/etc/traefik/crowdsec-certs/bouncer-key.pem" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerKeyFile=/etc/traefik/crowdsec-certs/bouncer-key.pem"
# Enable AppSec
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
# Define AppSec host and port informations
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:v1.6.1-2 image: crowdsecurity/crowdsec:latest
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
COLLECTIONS: crowdsecurity/traefik
CUSTOM_HOSTNAME: crowdsec CUSTOM_HOSTNAME: crowdsec
# whoami-foo is authenticating with api key over https # whoami-foo is authenticating with api key over https
# whoami-bar is authenticating with tls cert over https # whoami-bar is authenticating with tls cert over https
BOUNCER_KEY_TRAEFIK_FOO: 40796d93c2958f9e58345514e67740e5 BOUNCER_KEY_TRAEFIK_FOO: 40796d93c2958f9e58345514e67740e5=
LOCAL_API_URL: https://127.0.0.1:8080 LOCAL_API_URL: https://127.0.0.1:8080
USE_TLS: "true" USE_TLS: "true"
CERT_FILE: "/etc/crowdsec/certs/server.pem" CERT_FILE: "/etc/crowdsec/certs/server.pem"
@@ -88,6 +93,7 @@ services:
# DISABLE_AGENT: "true" # DISABLE_AGENT: "true"
# Disabled for the examples # Disabled for the examples
DISABLE_ONLINE_API: "true" DISABLE_ONLINE_API: "true"
COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
volumes: volumes:
- ./config/acquis.yaml:/etc/crowdsec/acquis.yaml - ./config/acquis.yaml:/etc/crowdsec/acquis.yaml
# - ./config/config.yaml:/etc/crowdsec/config_local.yaml # - ./config/config.yaml:/etc/crowdsec/config_local.yaml
@@ -100,7 +106,7 @@ services:
- "traefik.enable=false" - "traefik.enable=false"
depends_on: depends_on:
- gencert - gencert
gencert: gencert:
build: . build: .
volumes: volumes:
+3 -1
View File
@@ -1,6 +1,8 @@
#!/bin/bash #!/bin/bash
stdout=/out/res.log if [ -f "/out/inter-key.pem" ]; then
exit 0
fi
cfssl gencert --initca /in/ca.json 2>${stdout} | cfssljson --bare "/out/ca" && \ cfssl gencert --initca /in/ca.json 2>${stdout} | cfssljson --bare "/out/ca" && \
# Generate an intermediate certificate that will be used to sign the client certificates # Generate an intermediate certificate that will be used to sign the client certificates
cfssl gencert --initca /in/intermediate.json 2>${stdout} | cfssljson --bare "/out/inter" && \ cfssl gencert --initca /in/intermediate.json 2>${stdout} | cfssljson --bare "/out/inter" && \
+4 -5
View File
@@ -5,11 +5,10 @@ package cache
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
ttl_map "github.com/leprosus/golang-ttl-map" ttl_map "github.com/leprosus/golang-ttl-map"
simpleredis "github.com/maxlerebourg/simpleredis" simpleredis "github.com/maxlerebourg/simpleredis"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
) )
const ( const (
@@ -53,7 +52,7 @@ func (localCache) delete(key string) {
} }
type redisCache struct { type redisCache struct {
log *logger.Log log *slog.Logger
} }
func (redisCache) get(key string) (string, error) { func (redisCache) get(key string) (string, error) {
@@ -93,11 +92,11 @@ type cacheInterface interface {
// Client Cache client. // Client Cache client.
type Client struct { type Client struct {
cache cacheInterface cache cacheInterface
log *logger.Log log *slog.Logger
} }
// New Initialize cache client. // New Initialize cache client.
func (c *Client) New(log *logger.Log, isRedis bool, host, pass, database string) { func (c *Client) New(log *slog.Logger, isRedis bool, host, pass, database string) {
c.log = log c.log = log
if isRedis { if isRedis {
redis.Init(host, pass, database) redis.Init(host, pass, database)
+6 -3
View File
@@ -5,13 +5,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache" cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration" configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
) )
// Client Captcha client. // Client Captcha client.
@@ -24,7 +24,7 @@ type Client struct {
captchaTemplate *template.Template captchaTemplate *template.Template
cacheClient *cache.Client cacheClient *cache.Client
httpClient *http.Client httpClient *http.Client
log *logger.Log log *slog.Logger
infoProvider *infoProvider infoProvider *infoProvider
} }
@@ -59,7 +59,7 @@ var infoProviders = map[string]*infoProvider{
} }
// New Initialize captcha client. // New Initialize captcha client.
func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error { func (c *Client) New(log *slog.Logger, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
c.Valid = provider != "" c.Valid = provider != ""
if !c.Valid { if !c.Valid {
return nil return nil
@@ -94,6 +94,9 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
if valid { if valid {
c.log.Debug("captcha:ServeHTTP captcha:valid") c.log.Debug("captcha:ServeHTTP captcha:valid")
c.cacheClient.Set(remoteIP+"_captcha", cache.CaptchaDoneValue, c.gracePeriodSeconds) c.cacheClient.Set(remoteIP+"_captcha", cache.CaptchaDoneValue, c.gracePeriodSeconds)
if c.remediationCustomHeader != "" {
rw.Header().Set(c.remediationCustomHeader, "solved-captcha")
}
http.Redirect(rw, r, r.URL.String(), http.StatusFound) http.Redirect(rw, r, r.URL.String(), http.StatusFound)
return return
} }
+167 -123
View File
@@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -16,7 +17,6 @@ import (
"strings" "strings"
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip" ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
) )
// Enums for crowdsec mode. // Enums for crowdsec mode.
@@ -30,6 +30,7 @@ const (
HTTP = "http" HTTP = "http"
LogDEBUG = "DEBUG" LogDEBUG = "DEBUG"
LogINFO = "INFO" LogINFO = "INFO"
LogWARN = "WARN"
LogERROR = "ERROR" LogERROR = "ERROR"
ReasonTECH = "TECHNICAL_ISSUE" ReasonTECH = "TECHNICAL_ISSUE"
ReasonLAPI = "LAPI" ReasonLAPI = "LAPI"
@@ -42,61 +43,74 @@ const (
// Config the plugin configuration. // Config the plugin configuration.
type Config struct { type Config struct {
Enabled bool `json:"enabled,omitempty"` Enabled bool `json:"enabled,omitempty"`
LogLevel string `json:"logLevel,omitempty"` LogLevel string `json:"logLevel,omitempty"`
LogFilePath string `json:"logFilePath,omitempty"` LogFormat string `json:"logFormat,omitempty"`
CrowdsecMode string `json:"crowdsecMode,omitempty"` LogFilePath string `json:"logFilePath,omitempty"`
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"` CrowdsecMode string `json:"crowdsecMode,omitempty"`
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"` CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"` CrowdsecAppsecScheme string `json:"crowdsecAppsecScheme,omitempty"`
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"` CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"` CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"`
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"` CrowdsecAppsecKey string `json:"crowdsecAppsecKey,omitempty"`
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"` CrowdsecAppsecKeyFile string `json:"crowdsecAppsecKeyFile,omitempty"`
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"` CrowdsecAppsecTLSInsecureVerify bool `json:"crowdsecAppsecTlsInsecureVerify,omitempty"`
CrowdsecLapiPath string `json:"crowdsecLapiPath,omitempty"` CrowdsecAppsecTLSCertificateAuthority string `json:"crowdsecAppsecTlsCertificateAuthority,omitempty"`
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"` CrowdsecAppsecTLSCertificateAuthorityFile string `json:"crowdsecAppsecTlsCertificateAuthorityFile,omitempty"`
CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"` CrowdsecAppsecTLSCertificateBouncer string `json:"crowdsecAppsecTlsCertificateBouncer,omitempty"`
CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"` CrowdsecAppsecTLSCertificateBouncerFile string `json:"crowdsecAppsecTlsCertificateBouncerFile,omitempty"`
CrowdsecLapiTLSCertificateAuthority string `json:"crowdsecLapiTlsCertificateAuthority,omitempty"` CrowdsecAppsecTLSCertificateBouncerKey string `json:"crowdsecAppsecTlsCertificateBouncerKey,omitempty"`
CrowdsecLapiTLSCertificateAuthorityFile string `json:"crowdsecLapiTlsCertificateAuthorityFile,omitempty"` CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
CrowdsecLapiTLSCertificateBouncer string `json:"crowdsecLapiTlsCertificateBouncer,omitempty"` CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
CrowdsecLapiTLSCertificateBouncerFile string `json:"crowdsecLapiTlsCertificateBouncerFile,omitempty"` CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
CrowdsecLapiTLSCertificateBouncerKey string `json:"crowdsecLapiTlsCertificateBouncerKey,omitempty"` CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
CrowdsecLapiTLSCertificateBouncerKeyFile string `json:"crowdsecLapiTlsCertificateBouncerKeyFile,omitempty"` CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
CrowdsecCapiMachineID string `json:"crowdsecCapiMachineId,omitempty"` CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
CrowdsecCapiMachineIDFile string `json:"crowdsecCapiMachineIdFile,omitempty"` CrowdsecLapiPath string `json:"crowdsecLapiPath,omitempty"`
CrowdsecCapiPassword string `json:"crowdsecCapiPassword,omitempty"` CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"` CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"`
CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"` CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"`
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"` CrowdsecLapiTLSCertificateAuthority string `json:"crowdsecLapiTlsCertificateAuthority,omitempty"`
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"` CrowdsecLapiTLSCertificateAuthorityFile string `json:"crowdsecLapiTlsCertificateAuthorityFile,omitempty"`
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"` CrowdsecLapiTLSCertificateBouncer string `json:"crowdsecLapiTlsCertificateBouncer,omitempty"`
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"` CrowdsecLapiTLSCertificateBouncerFile string `json:"crowdsecLapiTlsCertificateBouncerFile,omitempty"`
RemediationStatusCode int `json:"remediationStatusCode,omitempty"` CrowdsecLapiTLSCertificateBouncerKey string `json:"crowdsecLapiTlsCertificateBouncerKey,omitempty"`
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"` CrowdsecLapiTLSCertificateBouncerKeyFile string `json:"crowdsecLapiTlsCertificateBouncerKeyFile,omitempty"`
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"` CrowdsecCapiMachineID string `json:"crowdsecCapiMachineId,omitempty"`
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"` CrowdsecCapiMachineIDFile string `json:"crowdsecCapiMachineIdFile,omitempty"`
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"` CrowdsecCapiPassword string `json:"crowdsecCapiPassword,omitempty"`
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"` CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"`
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"` CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"`
RedisCacheHost string `json:"redisCacheHost,omitempty"` UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
RedisCachePassword string `json:"redisCachePassword,omitempty"` MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"` UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"` StreamStartupBlock bool `json:"streamStartupBlock,omitempty"`
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"` DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
CaptchaProvider string `json:"captchaProvider,omitempty"` TraceHeadersCustomName string `json:"traceHeadersCustomName,omitempty"`
CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"` RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"`
CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"` ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
CaptchaCustomKey string `json:"captchaCustomKey,omitempty"` ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"` ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"` RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"` RedisCacheHost string `json:"redisCacheHost,omitempty"`
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"` RedisCachePassword string `json:"redisCachePassword,omitempty"`
CaptchaSecretKeyFile string `json:"captchaSecretKeyFile,omitempty"` RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
CaptchaGracePeriodSeconds int64 `json:"captchaGracePeriodSeconds,omitempty"` RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
CaptchaProvider string `json:"captchaProvider,omitempty"`
CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"`
CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"`
CaptchaCustomKey string `json:"captchaCustomKey,omitempty"`
CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"`
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"`
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"`
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"`
CaptchaSecretKeyFile string `json:"captchaSecretKeyFile,omitempty"`
CaptchaGracePeriodSeconds int64 `json:"captchaGracePeriodSeconds,omitempty"`
} }
func contains(source []string, target string) bool { func contains(source []string, target string) bool {
@@ -111,46 +125,52 @@ func contains(source []string, target string) bool {
// New creates the default plugin configuration. // New creates the default plugin configuration.
func New() *Config { func New() *Config {
return &Config{ return &Config{
Enabled: false, Enabled: false,
LogLevel: LogINFO, LogLevel: LogINFO,
LogFilePath: "", LogFormat: "common",
CrowdsecMode: LiveMode, LogFilePath: "",
CrowdsecAppsecEnabled: false, CrowdsecMode: LiveMode,
CrowdsecAppsecHost: "crowdsec:7422", CrowdsecAppsecEnabled: false,
CrowdsecAppsecPath: "/", CrowdsecAppsecFailureBlock: true,
CrowdsecAppsecFailureBlock: true, CrowdsecAppsecUnreachableBlock: true,
CrowdsecAppsecUnreachableBlock: true, CrowdsecAppsecBodyLimit: 10485760,
CrowdsecAppsecBodyLimit: 10485760, CrowdsecAppsecScheme: "",
CrowdsecLapiScheme: HTTP, CrowdsecAppsecHost: "crowdsec:7422",
CrowdsecLapiHost: "crowdsec:8080", CrowdsecAppsecPath: "/",
CrowdsecLapiPath: "/", CrowdsecAppsecKey: "",
CrowdsecLapiKey: "", CrowdsecAppsecTLSInsecureVerify: false,
CrowdsecLapiTLSInsecureVerify: false, CrowdsecLapiScheme: HTTP,
UpdateIntervalSeconds: 60, CrowdsecLapiHost: "crowdsec:8080",
MetricsUpdateIntervalSeconds: 600, CrowdsecLapiPath: "/",
UpdateMaxFailure: 0, CrowdsecLapiKey: "",
DefaultDecisionSeconds: 60, CrowdsecLapiTLSInsecureVerify: false,
RemediationStatusCode: http.StatusForbidden, UpdateIntervalSeconds: 60,
HTTPTimeoutSeconds: 10, MetricsUpdateIntervalSeconds: 600,
CaptchaProvider: "", UpdateMaxFailure: 0,
CaptchaCustomJsURL: "", StreamStartupBlock: true,
CaptchaCustomValidateURL: "", DefaultDecisionSeconds: 60,
CaptchaCustomKey: "", RemediationStatusCode: http.StatusForbidden,
CaptchaCustomResponse: "", HTTPTimeoutSeconds: 10,
CaptchaSiteKey: "", CaptchaProvider: "",
CaptchaSecretKey: "", CaptchaCustomJsURL: "",
CaptchaGracePeriodSeconds: 1800, CaptchaCustomValidateURL: "",
CaptchaHTMLFilePath: "/captcha.html", CaptchaCustomKey: "",
BanHTMLFilePath: "", CaptchaCustomResponse: "",
RemediationHeadersCustomName: "", CaptchaSiteKey: "",
ForwardedHeadersCustomName: "X-Forwarded-For", CaptchaSecretKey: "",
ForwardedHeadersTrustedIPs: []string{}, CaptchaGracePeriodSeconds: 1800,
ClientTrustedIPs: []string{}, CaptchaHTMLFilePath: "/captcha.html",
RedisCacheEnabled: false, BanHTMLFilePath: "",
RedisCacheHost: "redis:6379", TraceHeadersCustomName: "",
RedisCachePassword: "", RemediationHeadersCustomName: "",
RedisCacheDatabase: "", ForwardedHeadersCustomName: "X-Forwarded-For",
RedisCacheUnreachableBlock: true, ForwardedHeadersTrustedIPs: []string{},
ClientTrustedIPs: []string{},
RedisCacheEnabled: false,
RedisCacheHost: "redis:6379",
RedisCachePassword: "",
RedisCacheDatabase: "",
RedisCacheUnreachableBlock: true,
} }
} }
@@ -203,7 +223,7 @@ func GetHTMLTemplate(path string) (*template.Template, error) {
// ValidateParams validate all the param gave by user. // ValidateParams validate all the param gave by user.
// //
//nolint:gocyclo,gocognit //nolint:gocyclo,gocognit
func ValidateParams(config *Config) error { func ValidateParams(config *Config, log *slog.Logger) error {
if err := validateParamsRequired(config); err != nil { if err := validateParamsRequired(config); err != nil {
return err return err
} }
@@ -212,10 +232,10 @@ func ValidateParams(config *Config) error {
return err return err
} }
if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil { if err := validateParamsIPs(log, config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
return err return err
} }
if err := validateParamsIPs(config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil { if err := validateParamsIPs(log, config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
return err return err
} }
@@ -262,6 +282,10 @@ func ValidateParams(config *Config) error {
if err != nil { if err != nil {
return err return err
} }
appsecKey, err := GetVariable(config, "CrowdsecAppsecKey")
if err != nil {
return err
}
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer") certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
if err != nil { if err != nil {
return err return err
@@ -270,12 +294,21 @@ func ValidateParams(config *Config) error {
if err != nil { if err != nil {
return err return err
} }
// We need to either have crowdsecLapiKey defined or the BouncerCert and Bouncerkey // We need to either have crowdsecLapiKey defined or the BouncerCert and Bouncerkey
if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") { if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") && config.CrowdsecMode != AppsecMode {
return errors.New("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be all empty") return errors.New("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be all empty")
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") { } else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
lapiKey = strings.TrimSpace(lapiKey) lapiKey = strings.TrimSpace(lapiKey)
if err = validateParamsAPIKey(lapiKey); err != nil { if err = validateParamsAPIKey(lapiKey, "CrowdsecLapiKey"); err != nil {
return err
}
}
// Validate CrowdsecAppsecKey if provided
if appsecKey != "" {
appsecKey = strings.TrimSpace(appsecKey)
if err = validateParamsAPIKey(appsecKey, "CrowdsecAppsecKey"); err != nil {
return err return err
} }
} }
@@ -289,8 +322,8 @@ func ValidateParams(config *Config) error {
// Check logging configuration // Check logging configuration
// to upper allow of anycase of log level // to upper allow of anycase of log level
if !contains([]string{LogERROR, LogDEBUG, LogINFO}, strings.ToUpper(config.LogLevel)) { if !contains([]string{LogDEBUG, LogINFO, LogWARN, LogERROR}, strings.ToUpper(config.LogLevel)) {
return fmt.Errorf("LogLevel should be one of (%s,%s,%s)", LogDEBUG, LogINFO, LogERROR) return fmt.Errorf("LogLevel should be one of (%s,%s,%s,%s)", LogDEBUG, LogINFO, LogWARN, LogERROR)
} }
if config.LogFilePath != "" { if config.LogFilePath != "" {
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) _, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
@@ -314,10 +347,10 @@ func validateURL(variable, scheme, host, path string) error {
// field name. RFC 7230 says: // field name. RFC 7230 says:
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA // valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
// See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators // See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators
func validateParamsAPIKey(lapiKey string) error { func validateParamsAPIKey(key string, paramName string) error {
reg := regexp.MustCompile("^[a-zA-Z0-9 !#$%&'*+-.^_`|~=/]*$") reg := regexp.MustCompile("^[a-zA-Z0-9 !#$%&'*+-.^_`|~=/]*$")
if !reg.MatchString(lapiKey) { if !reg.MatchString(key) {
return fmt.Errorf("CrowdsecLapiKey doesn't valid this regexp: '/%s/'", reg.String()) return fmt.Errorf("%s doesn't validate this regexp: '/%s/'", paramName, reg.String())
} }
return nil return nil
} }
@@ -338,9 +371,9 @@ func validateParamsTLS(config *Config) error {
return nil return nil
} }
func validateParamsIPs(listIP []string, key string) error { func validateParamsIPs(log *slog.Logger, listIP []string, key string) error {
if len(listIP) > 0 { if len(listIP) > 0 {
if _, err := ip.NewChecker(logger.New(LogINFO, ""), listIP); err != nil { if _, err := ip.NewChecker(log, listIP); err != nil {
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err) return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
} }
} }
@@ -412,26 +445,27 @@ func validateParamsRequired(config *Config) error {
if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) { if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) {
return errors.New("CrowdsecLapiScheme: must be one of 'http' or 'https'") return errors.New("CrowdsecLapiScheme: must be one of 'http' or 'https'")
} }
if !contains([]string{HTTP, HTTPS, ""}, config.CrowdsecAppsecScheme) {
return errors.New("CrowdsecAppsecScheme: must be one of 'http' or 'https'")
}
return nil return nil
} }
// GetTLSConfigCrowdsec get TLS config from Config. func getTLSConfig(config *Config, log *slog.Logger, prefix, scheme string, insecureVerify bool) (*tls.Config, error) {
//
//nolint:nestif
func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error) {
tlsConfig := new(tls.Config) tlsConfig := new(tls.Config)
tlsConfig.RootCAs = x509.NewCertPool() tlsConfig.RootCAs = x509.NewCertPool()
//nolint:gocritic if scheme != HTTPS {
if config.CrowdsecLapiScheme != HTTPS { log.Debug("getTLSConfig:" + prefix + "Scheme https:no")
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiScheme https:no")
return tlsConfig, nil return tlsConfig, nil
} else if config.CrowdsecLapiTLSInsecureVerify { }
//nolint:nestif
if insecureVerify {
tlsConfig.InsecureSkipVerify = true tlsConfig.InsecureSkipVerify = true
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSInsecureVerify tlsInsecure:true") log.Debug("getTLSConfig:" + prefix + "TLSInsecureVerify tlsInsecure:true")
// If we return here and still want to use client auth this won't work // If we return here and still want to use client auth this won't work
// return tlsConfig, nil // return tlsConfig, nil
} else { } else {
certAuthority, err := GetVariable(config, "CrowdsecLapiTLSCertificateAuthority") certAuthority, err := GetVariable(config, prefix+"TLSCertificateAuthority")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -439,17 +473,16 @@ func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error)
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) { if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
// here we return because if CrowdsecLapiTLSInsecureVerify is false // here we return because if CrowdsecLapiTLSInsecureVerify is false
// and CA not load, we can't communicate with https // and CA not load, we can't communicate with https
return nil, errors.New("getTLSConfigCrowdsec:cannot load CA and verify cert is enabled") return nil, errors.New("getTLSConfig:" + prefix + " cannot load CA and verify cert is enabled")
} }
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSCertificateAuthority CA added successfully") log.Debug("getTLSConfig:" + prefix + "TLSCertificateAuthority CA added successfully")
} }
} }
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
if err != nil { if err != nil {
return nil, err return nil, err
} }
certBouncerKey, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncerKey") certBouncerKey, err := GetVariable(config, prefix+"TLSCertificateBouncerKey")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -464,3 +497,14 @@ func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error)
return tlsConfig, nil return tlsConfig, nil
} }
// GetTLSConfigCrowdsec get TLS config from Config.
func GetTLSConfigCrowdsec(config *Config, log *slog.Logger, isAppsec bool) (*tls.Config, error) {
var prefix string
if isAppsec && config.CrowdsecAppsecScheme != "" {
prefix = "CrowdsecAppsec"
return getTLSConfig(config, log, prefix, config.CrowdsecAppsecScheme, config.CrowdsecAppsecTLSInsecureVerify)
}
prefix = "CrowdsecLapi"
return getTLSConfig(config, log, prefix, config.CrowdsecLapiScheme, config.CrowdsecLapiTLSInsecureVerify)
}
+15 -11
View File
@@ -72,6 +72,7 @@ func Test_GetVariable(t *testing.T) {
} }
func Test_ValidateParams(t *testing.T) { func Test_ValidateParams(t *testing.T) {
log := logger.New("INFO", "")
cfg1 := New() cfg1 := New()
cfg1.CrowdsecLapiKey = "test\n\n" cfg1.CrowdsecLapiKey = "test\n\n"
cfg2 := New() cfg2 := New()
@@ -117,7 +118,7 @@ func Test_ValidateParams(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if err := ValidateParams(tt.args.config); (err != nil) != tt.wantErr { if err := ValidateParams(tt.args.config, log); (err != nil) != tt.wantErr {
t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr)
} }
}) })
@@ -145,6 +146,7 @@ func Test_validateParamsTLS(t *testing.T) {
} }
func Test_validateParamsIPs(t *testing.T) { func Test_validateParamsIPs(t *testing.T) {
log := logger.New("INFO", "")
type args struct { type args struct {
listIP []string listIP []string
key string key string
@@ -164,7 +166,7 @@ func Test_validateParamsIPs(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if err := validateParamsIPs(tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr { if err := validateParamsIPs(log, tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr)
} }
}) })
@@ -205,23 +207,24 @@ func Test_validateParamsRequired(t *testing.T) {
func Test_validateParamsAPIKey(t *testing.T) { func Test_validateParamsAPIKey(t *testing.T) {
type args struct { type args struct {
lapiKey string lapiKey string
paramName string
} }
tests := []struct { tests := []struct {
name string name string
args args args args
wantErr bool wantErr bool
}{ }{
{name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~"}, wantErr: false}, {name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~", paramName: "CrowdsecParamName"}, wantErr: false},
{name: "Not validate a @", args: args{lapiKey: "test@"}, wantErr: true}, {name: "Not validate a @", args: args{lapiKey: "test@", paramName: "CrowdsecParamName"}, wantErr: true},
{name: "Not validate a (", args: args{lapiKey: "test("}, wantErr: true}, {name: "Not validate a (", args: args{lapiKey: "test(", paramName: "CrowdsecParamName"}, wantErr: true},
{name: "Not validate a [", args: args{lapiKey: "test["}, wantErr: true}, {name: "Not validate a [", args: args{lapiKey: "test[", paramName: "CrowdsecParamName"}, wantErr: true},
{name: "Not validate a ?", args: args{lapiKey: "test?"}, wantErr: true}, {name: "Not validate a ?", args: args{lapiKey: "test?", paramName: "CrowdsecParamName"}, wantErr: true},
{name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n"}, wantErr: true}, {name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n", paramName: "CrowdsecParamName"}, wantErr: true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if err := validateParamsAPIKey(tt.args.lapiKey); (err != nil) != tt.wantErr { if err := validateParamsAPIKey(tt.args.lapiKey, tt.args.paramName); (err != nil) != tt.wantErr {
t.Errorf("validateParamsAPIKey() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("validateParamsAPIKey() error = %v, wantErr %v", err, tt.wantErr)
} }
}) })
@@ -229,6 +232,7 @@ func Test_validateParamsAPIKey(t *testing.T) {
} }
func Test_GetTLSConfigCrowdsec(t *testing.T) { func Test_GetTLSConfigCrowdsec(t *testing.T) {
log := logger.New("INFO", "")
type args struct { type args struct {
config *Config config *Config
} }
@@ -242,7 +246,7 @@ func Test_GetTLSConfigCrowdsec(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO", "")) got, err := GetTLSConfigCrowdsec(tt.args.config, log, false)
if (err != nil) != tt.wantErr { 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 return
+2 -3
View File
@@ -5,11 +5,10 @@ package ip
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net" "net"
"net/http" "net/http"
"strings" "strings"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
) )
// CHECKER // CHECKER
@@ -21,7 +20,7 @@ type Checker struct {
} }
// NewChecker builds a new Checker given a list of CIDR-Strings to trusted IPs. // NewChecker builds a new Checker given a list of CIDR-Strings to trusted IPs.
func NewChecker(log *logger.Log, trustedIPs []string) (*Checker, error) { func NewChecker(log *slog.Logger, trustedIPs []string) (*Checker, error) {
checker := &Checker{} checker := &Checker{}
for _, ipMaskRaw := range trustedIPs { for _, ipMaskRaw := range trustedIPs {
+68 -55
View File
@@ -1,79 +1,92 @@
// Package logger implements utility routines to write to stdout and stderr. // Package logger implements utility routines to write to stdout and stderr.
// It supports trace, debug, info and error level // It supports trace, debug, info, warn and error level using Go's standard log/slog
package logger package logger
import ( import (
"fmt" "log/slog"
"io"
"log"
"os" "os"
"path/filepath" "path/filepath"
) )
// Log Logger struct. // Custom log levels following slog best practices.
type Log struct { const (
logError *log.Logger LevelDebug = slog.LevelDebug
logInfo *log.Logger LevelInfo = slog.LevelInfo
logDebug *log.Logger LevelWarn = slog.LevelWarn
LevelError = slog.LevelError
)
// New creates a Log wrapper with default format (common).
func New(logLevel string, logFilePath string) *slog.Logger {
return NewWithFormat(logLevel, logFilePath, "common")
} }
// New Set Default log level to info in case log level to defined. // NewWithFormat creates a Log wrapper with specified format (common or json).
func New(logLevel string, logFilePath string) *Log { func NewWithFormat(logLevel, logFilePath, logFormat string) *slog.Logger {
// Initialize loggers with discard output // Determine log level
logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime) var level slog.Level
logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime) switch logLevel {
logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime) case "ERROR":
level = LevelError
case "WARN":
level = LevelWarn
case "INFO":
level = LevelInfo
case "DEBUG":
level = LevelDebug
default:
// Default to INFO level
level = LevelInfo
}
// we initialize logger to STDOUT/STDERR first so if the file logger cannot be initialized we can inform the user // Set output destination
output := os.Stdout var output *os.File
errorOutput := os.Stderr
// prepare file logging if specified
if logFilePath != "" { if logFilePath != "" {
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err == nil { if err == nil {
output = logFile output = logFile
errorOutput = logFile
} else { } else {
_ = fmt.Errorf("LogFilePath is not writable %w", err) // Fall back to stdout and log the error
output = os.Stdout
slog.Warn("LogFilePath is not writable, using stdout", "error", err)
} }
} else {
output = os.Stdout
} }
// Set error logger output // Create handler based on format with custom level names
logError.SetOutput(errorOutput) var handler slog.Handler
opts := &slog.HandlerOptions{
// Configure log levels Level: level,
switch logLevel { ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
case "ERROR": // Customize level names to match our expected format
// Only error logging is enabled if a.Key == slog.LevelKey {
case "INFO": lvl, ok := a.Value.Any().(slog.Level)
logInfo.SetOutput(output) if !ok {
case "DEBUG": return a
logInfo.SetOutput(output) }
logDebug.SetOutput(output) switch {
default: case lvl < LevelInfo:
// Default to INFO level a.Value = slog.StringValue("DEBUG")
logInfo.SetOutput(output) case lvl < LevelWarn:
a.Value = slog.StringValue("INFO")
case lvl < LevelError:
a.Value = slog.StringValue("WARN")
default:
a.Value = slog.StringValue("ERROR")
}
}
return a
},
} }
return &Log{ if logFormat == "json" {
logError: logError, handler = slog.NewJSONHandler(output, opts)
logInfo: logInfo, } else {
logDebug: logDebug, // Common format (default)
handler = slog.NewTextHandler(output, opts)
} }
}
// Info log to Stdout. // Create logger with component attribute
func (l *Log) Info(str string) { return slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
l.logInfo.Printf("%s", str)
}
// Debug log to Stdout.
func (l *Log) Debug(str string) {
l.logDebug.Printf("%s", str)
}
// Error log to Stderr.
func (l *Log) Error(str string) {
l.logError.Printf("%s", str)
} }
+156
View File
@@ -0,0 +1,156 @@
package logger
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
)
func TestNew(t *testing.T) {
tests := []struct {
name string
logLevel string
}{
{name: "ERROR level", logLevel: "ERROR"},
{name: "WARN level", logLevel: "WARN"},
{name: "INFO level", logLevel: "INFO"},
{name: "DEBUG level", logLevel: "DEBUG"},
{name: "Default level (INFO)", logLevel: "INVALID"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := New(tt.logLevel, "")
// Verify logger is created
if logger == nil {
t.Fatal("Expected logger to be created, got nil")
}
// Verify it's a slog.Logger (we can call methods on it)
logger.Info("test initialization")
})
}
}
func TestJSONLogFormat(t *testing.T) {
var buf bytes.Buffer
// Create a logger with JSON handler to capture output
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
testMessage := "json test message"
logger.Info(testMessage)
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) != 1 {
t.Fatalf("Expected 1 log line, got %d", len(lines))
}
// Verify it's valid JSON
var logEntry map[string]interface{}
err := json.Unmarshal([]byte(lines[0]), &logEntry)
if err != nil {
t.Fatalf("Expected valid JSON output, got error: %v, output: %s", err, output)
}
// Verify JSON structure
if logEntry["level"] != "INFO" {
t.Errorf("Expected level 'INFO', got '%v'", logEntry["level"])
}
if logEntry["msg"] != testMessage {
t.Errorf("Expected message '%s', got '%v'", testMessage, logEntry["msg"])
}
if logEntry["time"] == nil {
t.Error("Expected timestamp to be set")
}
if logEntry["component"] != "CrowdsecBouncerTraefikPlugin" {
t.Errorf("Expected component 'CrowdsecBouncerTraefikPlugin', got '%v'", logEntry["component"])
}
}
func TestCommonLogFormat(t *testing.T) {
var buf bytes.Buffer
// Create a logger with text handler to capture output
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
testMessage := "common test message"
logger.Info(testMessage)
output := buf.String()
// Verify common format (should contain level and message)
if !strings.Contains(output, "level=INFO") {
t.Error("Expected common format with INFO level")
}
if !strings.Contains(output, testMessage) {
t.Error("Expected test message in common format")
}
if !strings.Contains(output, "component=CrowdsecBouncerTraefikPlugin") {
t.Error("Expected component field in common format")
}
// Should NOT be JSON (should be slog text format)
var logEntry map[string]interface{}
err := json.Unmarshal([]byte(strings.TrimSpace(output)), &logEntry)
if err == nil {
t.Error("Expected common format (not JSON), but got valid JSON")
}
}
func TestErrorLevel(t *testing.T) {
var buf bytes.Buffer
// Create a logger with ERROR level to capture output
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelError})
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
testMessage := "error only test"
// Test all log methods
logger.Error(testMessage)
logger.Warn(testMessage) // Should not appear
logger.Info(testMessage) // Should not appear
logger.Debug(testMessage) // Should not appear
output := buf.String()
// Only ERROR should appear
if !strings.Contains(output, "level=ERROR") {
t.Error("Expected ERROR message to appear")
}
// Other levels should NOT appear
unwantedLevels := []string{"level=WARN", "level=INFO", "level=DEBUG"}
for _, level := range unwantedLevels {
if strings.Contains(output, level) {
t.Errorf("Unexpected %s message appeared at ERROR level", level)
}
}
// Verify only one message appears
messageCount := strings.Count(output, testMessage)
if messageCount != 1 {
t.Errorf("Expected 1 occurrence of test message at ERROR level, got %d", messageCount)
}
}
func TestInvalidLogFile(t *testing.T) {
// Try to create logger with invalid file path
logger := New("INFO", "/invalid/path/that/does/not/exist/test.log")
// Logger should still be created (falls back to stdout)
if logger == nil {
t.Fatal("Expected logger to be created even with invalid file path")
}
// Should not panic when logging
logger.Info("test message")
}
+84
View File
@@ -0,0 +1,84 @@
# Binary e2e suite (Traefik binary + mock LAPI)
This suite runs **Traefik as a downloaded binary** with the plugin loaded from
the local source tree, and replaces Crowdsec with a small **HTTP mock**
([`mocklapi/`](mocklapi/main.go), a stdlib-only Go command). No Docker, no real
Crowdsec.
It is what **CI runs** (`make e2e_mock`). A separate, local-only **Docker
suite** (real Traefik + Crowdsec, under `tests/e2e/scenarios`) is kept for
high-fidelity debugging against a real Crowdsec but is not exercised in CI; it
ships in its own PR (#333).
## Scope — what this suite tests
These tests validate the **plugin's own behaviour**: the request flow through
the Traefik middleware, the live / none / stream modes, caching, trusted-IP
bypass, ban / captcha page rendering, and the AppSec request path (header
forwarding + enforcing the engine's allow/block verdict).
The mock stands in for Crowdsec, emulating the slice of the LAPI HTTP contract
the plugin consumes — including a single, deterministic AppSec rule (block any
URI containing `rpc2`, the probe from [`examples/appsec-enabled`](../../../examples/appsec-enabled)).
It is not the real WAF engine, so this suite exercises the plugin's AppSec
*wiring* rather than the detection accuracy of OWASP CRS / virtual patching —
that lives upstream in Crowdsec.
## What runs
| Component | How |
|-----------|-----|
| Traefik | Binary `v3.7.1`, downloaded into `.cache/` (reused across local runs; re-downloaded on fresh CI runners) |
| Plugin | Loaded via `experimental.localPlugins` from the repo root (symlinked into `plugins-local/`) |
| LAPI | `mocklapi` — a stdlib-only Go command (its own nested module), compiled and cached under `.cache/`, driven through `/admin` endpoints instead of `cscli` |
| AppSec | WAF stand-in built into the mock — blocks URIs containing `rpc2`, allows the rest |
| Backend | A plain HTTP responder built into the mock |
Fixed ports (override with env vars if needed): Traefik `8000`, LAPI `8090`,
backend `8091`, AppSec `8092`.
## Running locally
Prerequisites: `bash`, `curl`, `go`, `tar`. On first use the Traefik binary is
fetched and the mock is compiled into `.cache/`. That cache is reused across
local runs; CI runs on fresh runners, so both are recreated on every CI run.
```bash
# one scenario
make e2e_mock_stream-mode
# or directly
./tests/e2e/mock/scenarios/stream-mode/run.sh
# the whole suite
make e2e_mock
```
## Layout
```
mock/
lib/
common.sh # stack lifecycle, Traefik download, mock build, assertions, admin client
traefik.yml # static Traefik config (shared by all scenarios)
mocklapi/
go.mod # nested module — kept out of the plugin's build/lint/vendor
main.go # mock LAPI + AppSec stand-in + backend
scenarios/
<name>/
dynamic.yml # Traefik dynamic config (router + bouncer middleware + backend)
run.sh # assertions for the scenario
*.html # optional fixtures (ban / captcha templates)
```
`dynamic.yml` uses placeholders (`@@APIKEY@@`, `@@LAPI_HOST@@`,
`@@BACKEND_URL@@`, `@@SCENARIO_DIR@@`) that `common.sh` substitutes at runtime.
## Adding a scenario
1. Create `scenarios/<name>/dynamic.yml` and `run.sh` (copy `stream-mode/` as a
template).
2. In `run.sh`, define a `body` function with the assertions and call
`run_scenario "<name>" "$HERE" body`.
3. Drive decisions with `lapi_add_decision <ip> [type] [duration]` and
`lapi_delete_decision <ip>`.
4. Add `<name>` to `E2E_MOCK_SCENARIOS` in the `Makefile`.
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env bash
# Shared helpers for the binary (mock) e2e suite.
#
# Unlike the Docker suite under tests/e2e/scenarios, this one runs Traefik as a
# downloaded binary and replaces Crowdsec with a small HTTP mock (the mocklapi
# Go command). It validates the plugin's own behaviour (modes, cache, trusted
# IPs, ban / captcha rendering, AppSec wiring) — not the accuracy of Crowdsec's
# detection or its WAF engine, which the mock only stands in for.
#
# Dependencies: bash, curl, go, tar. The Traefik binary is downloaded and the
# mock is compiled into .cache/ on first use. That cache persists across local
# runs; CI runs on fresh runners, so both are recreated on every CI run.
set -euo pipefail
# Pinned to match the Docker suite (tests/e2e/scenarios/*/docker-compose.yml).
TRAEFIK_VERSION="${TRAEFIK_VERSION:-v3.7.1}"
WEB_PORT="${WEB_PORT:-8000}"
LAPI_PORT="${LAPI_PORT:-8090}"
BACKEND_PORT="${BACKEND_PORT:-8091}"
APPSEC_PORT="${APPSEC_PORT:-8092}"
LAPI_KEY="${LAPI_KEY:-e2e-mock-key}"
MOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$MOCK_LIB_DIR/../../../.." && pwd)"
CACHE_DIR="$MOCK_LIB_DIR/../.cache"
# Populated by start_stack / run_scenario, consumed by the EXIT trap.
WORKDIR=""
TRAEFIK_PID=""
MOCK_PID=""
SCENARIO_NAME=""
SCENARIO_LOG=""
# Resolve (and cache) the Traefik binary for this host, echoing its path.
ensure_traefik() {
local bin="$CACHE_DIR/traefik-$TRAEFIK_VERSION"
if [[ -x "$bin" ]]; then
echo "$bin"
return 0
fi
mkdir -p "$CACHE_DIR"
local os arch
case "$(uname -s)" in
Linux) os=linux ;;
Darwin) os=darwin ;;
*) echo "ensure_traefik: unsupported OS $(uname -s)" >&2; return 1 ;;
esac
case "$(uname -m)" in
x86_64 | amd64) arch=amd64 ;;
aarch64 | arm64) arch=arm64 ;;
*) echo "ensure_traefik: unsupported arch $(uname -m)" >&2; return 1 ;;
esac
local url="https://github.com/traefik/traefik/releases/download/${TRAEFIK_VERSION}/traefik_${TRAEFIK_VERSION}_${os}_${arch}.tar.gz"
echo "ensure_traefik: downloading $url" >&2
local tmp
tmp="$(mktemp -d)"
curl -sSfL "$url" -o "$tmp/traefik.tar.gz"
tar -xzf "$tmp/traefik.tar.gz" -C "$tmp" traefik
mv "$tmp/traefik" "$bin"
chmod +x "$bin"
rm -rf "$tmp"
echo "$bin"
}
# Build (and cache) the mock LAPI binary, echoing its path. Go's build cache
# makes the rebuild near-instant after the first run.
ensure_mock() {
local bin="$CACHE_DIR/mocklapi"
mkdir -p "$CACHE_DIR"
( cd "$MOCK_LIB_DIR/../mocklapi" && go build -o "$bin" . ) >&2
echo "$bin"
}
# Poll a URL until it returns the expected status code, or fail.
# Usage: wait_for_status URL CODE [TIMEOUT_SECONDS] [curl args...]
wait_for_status() {
local url="$1" expected="$2" timeout="${3:-30}"
shift 3 || true
local elapsed=0 got=""
while (( elapsed < timeout )); do
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url" || true)
if [[ "$got" == "$expected" ]]; then
return 0
fi
sleep 1
# Note: `((elapsed++))` returns exit 1 when elapsed is 0, which trips set -e.
elapsed=$((elapsed + 1))
done
echo "wait_for_status: $url expected $expected, last seen ${got:-<none>}" >&2
return 1
}
# Poll a URL until its body contains a substring, or fail. Used when the status
# code alone can't tell the states apart (e.g. captcha page vs backend, both 200).
# Usage: wait_for_body_contains URL NEEDLE [TIMEOUT_SECONDS] [curl args...]
wait_for_body_contains() {
local url="$1" needle="$2" timeout="${3:-30}"
shift 3 || true
local elapsed=0 body=""
while (( elapsed < timeout )); do
body=$(curl -s "$@" "$url" || true)
if grep -q "$needle" <<<"$body"; then
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "wait_for_body_contains: $url did not contain \"$needle\" within ${timeout}s" >&2
return 1
}
# Assert a single curl returns the expected status code.
# Usage: assert_status URL CODE [curl args...]
assert_status() {
local url="$1" expected="$2"
shift 2 || true
local got
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url")
if [[ "$got" != "$expected" ]]; then
echo "assert_status: $url expected $expected, got $got" >&2
return 1
fi
}
# Assert a response header matches a value (case-insensitive name).
# Usage: assert_header URL HEADER VALUE [curl args...]
assert_header() {
local url="$1" header="$2" expected="$3"
shift 3 || true
local got
got=$(curl -s -D - -o /dev/null "$@" "$url" | tr -d '\r' \
| awk -v h="${header,,}" -F': ' 'tolower($1) == h { print $2; exit }')
if [[ "$got" != "$expected" ]]; then
echo "assert_header: $url header $header expected \"$expected\", got \"$got\"" >&2
return 1
fi
}
# Assert a response body contains a substring.
# Usage: assert_body_contains URL NEEDLE [curl args...]
assert_body_contains() {
local url="$1" needle="$2"
shift 2 || true
local body
body=$(curl -s "$@" "$url")
if ! grep -q "$needle" <<<"$body"; then
echo "assert_body_contains: $url expected to contain \"$needle\", got:" >&2
echo "$body" >&2
return 1
fi
}
# --- mock admin client -------------------------------------------------------
lapi_add_decision() {
local ip="$1" type="${2:-ban}" duration="${3:-4h}"
curl -sS -X POST "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}&type=${type}&duration=${duration}" >/dev/null
}
lapi_delete_decision() {
local ip="$1"
curl -sS -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}" >/dev/null
}
# --- stack lifecycle ---------------------------------------------------------
# start_stack SCENARIO_DIR
# Spins up the mock + Traefik (with the scenario's dynamic.yml) and waits ready.
start_stack() {
local scenario_dir="$1"
local traefik_bin mock_bin
traefik_bin="$(ensure_traefik)"
mock_bin="$(ensure_mock)"
WORKDIR="$(mktemp -d)"
# Expose the plugin source where Traefik's localPlugins loader expects it.
mkdir -p "$WORKDIR/plugins-local/src/github.com/maxlerebourg"
ln -s "$REPO_ROOT" "$WORKDIR/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
cp "$MOCK_LIB_DIR/traefik.yml" "$WORKDIR/traefik.yml"
# Render the scenario's dynamic config with the live ports / key / paths.
sed \
-e "s|@@APIKEY@@|${LAPI_KEY}|g" \
-e "s|@@LAPI_HOST@@|127.0.0.1:${LAPI_PORT}|g" \
-e "s|@@APPSEC_HOST@@|127.0.0.1:${APPSEC_PORT}|g" \
-e "s|@@BACKEND_URL@@|http://127.0.0.1:${BACKEND_PORT}|g" \
-e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \
"$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml"
"$mock_bin" \
--lapi-addr "127.0.0.1:${LAPI_PORT}" \
--backend-addr "127.0.0.1:${BACKEND_PORT}" \
--appsec-addr "127.0.0.1:${APPSEC_PORT}" >"$WORKDIR/mock.log" 2>&1 &
MOCK_PID=$!
( cd "$WORKDIR" && exec "$traefik_bin" --configfile=traefik.yml ) >"$WORKDIR/traefik.log" 2>&1 &
TRAEFIK_PID=$!
wait_for_status "http://127.0.0.1:${LAPI_PORT}/health" 200 30
# AppSec stand-in: a bare GET carries no "rpc2" URI, so it answers 200 (allow).
wait_for_status "http://127.0.0.1:${APPSEC_PORT}/" 200 30
# /ping is served by Traefik itself once it is up (plugin compilation included).
wait_for_status "http://127.0.0.1:${WEB_PORT}/ping" 200 60
}
stop_stack() {
[[ -n "$TRAEFIK_PID" ]] && kill "$TRAEFIK_PID" 2>/dev/null || true
[[ -n "$MOCK_PID" ]] && kill "$MOCK_PID" 2>/dev/null || true
[[ -n "$TRAEFIK_PID" ]] && wait "$TRAEFIK_PID" 2>/dev/null || true
[[ -n "$MOCK_PID" ]] && wait "$MOCK_PID" 2>/dev/null || true
[[ -n "$WORKDIR" && -d "$WORKDIR" ]] && rm -rf "$WORKDIR" || true
}
dump_diagnostics() {
echo "=== traefik.log ==="
cat "$WORKDIR/traefik.log" 2>/dev/null || true
echo "=== mock.log ==="
cat "$WORKDIR/mock.log" 2>/dev/null || true
}
# EXIT trap: runs after the scenario body (or after a failed assertion under
# `set -e`), so it relies only on globals, never on run_scenario's locals.
_scenario_cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics > "$SCENARIO_LOG" 2>&1 || true
echo "[$SCENARIO_NAME] failed. Logs written to $SCENARIO_LOG" >&2
fi
stop_stack
exit $rc
}
# run_scenario SCENARIO_NAME SCENARIO_DIR BODY_FN
# Wraps lifecycle + diagnostics so each run.sh stays declarative.
run_scenario() {
SCENARIO_NAME="$1"
local dir="$2" body="$3"
SCENARIO_LOG="/tmp/e2e-mock-${SCENARIO_NAME}.log"
trap _scenario_cleanup EXIT
echo "[$SCENARIO_NAME] starting binary stack (Traefik + mock LAPI)..."
start_stack "$dir"
"$body"
echo "[$SCENARIO_NAME] OK"
}
+28
View File
@@ -0,0 +1,28 @@
# Static Traefik configuration for the binary e2e suite.
# The dynamic part (router + bouncer middleware + backend service) lives in
# dynamic.yml, generated per scenario by common.sh.
entryPoints:
web:
address: ":8000"
forwardedHeaders:
# The test's curl sets X-Forwarded-For; preserve it through the proxy.
insecure: true
log:
level: INFO
accessLog: {}
# /ping on the web entrypoint is the readiness probe — no dashboard/API needed.
ping:
entryPoint: web
providers:
file:
filename: dynamic.yml
watch: false
experimental:
localPlugins:
bouncer:
moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
+6
View File
@@ -0,0 +1,6 @@
// Standalone module so this test helper stays out of the plugin module:
// it is excluded from the plugin's `go build ./...`, `go test ./...`,
// golangci-lint and `go mod vendor`. Stdlib only no dependencies.
module mocklapi
go 1.22
+135
View File
@@ -0,0 +1,135 @@
// Command mocklapi is a minimal Crowdsec LAPI stand-in for the binary e2e
// suite. It answers only the few LAPI routes the plugin calls — live/none
// decision lookups, the stream poll and the usage-metrics push — and lets the
// test drive decisions through /admin instead of `cscli`. It also serves the
// stub upstream that Traefik proxies allowed requests to.
//
// It is NOT a Crowdsec/AppSec conformance harness — the real WAF engine (OWASP
// CRS, virtual patching) is out of scope. The AppSec endpoint here emulates a
// single deterministic rule so the suite can exercise the plugin's AppSec
// wiring (header forwarding, allow/block handling) end to end. See the README.
package main
import (
"encoding/json"
"flag"
"log"
"net/http"
"strings"
"sync"
)
// Decision is the subset of a LAPI decision the plugin actually reads.
type Decision struct {
Value string `json:"value"`
Type string `json:"type"`
Duration string `json:"duration"`
}
var (
mu sync.Mutex
active = map[string]Decision{} // ip -> decision currently in force
deleted = map[string]Decision{} // ip -> decision to report in the stream "deleted" list
)
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func list(m map[string]Decision) []Decision {
out := make([]Decision, 0, len(m))
for _, d := range m {
out = append(out, d)
}
return out
}
func main() {
lapiAddr := flag.String("lapi-addr", "127.0.0.1:8090", "address for the LAPI mock")
// The stub upstream Traefik proxies allowed requests to — the binary-suite
// equivalent of the traefik/whoami container. Not AppSec.
backendAddr := flag.String("backend-addr", "127.0.0.1:8091", "address for the stub upstream service")
// AppSec WAF stand-in (the real engine listens on :7422). Not a CRS engine.
appsecAddr := flag.String("appsec-addr", "127.0.0.1:8092", "address for the AppSec mock")
flag.Parse()
go func() {
log.Fatal(http.ListenAndServe(*backendAddr, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("E2E_BACKEND_OK\n"))
})))
}()
// AppSec mock: the plugin forwards the request metadata in X-Crowdsec-Appsec-*
// headers and reads our status — 200 allows, 403 blocks. We emulate one
// deterministic virtual-patching rule (block any URI containing "rpc2", the
// exact probe from examples/appsec-enabled) so the plugin's AppSec path is
// exercised without standing up the real WAF.
go func() {
log.Fatal(http.ListenAndServe(*appsecAddr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "rpc2") {
w.WriteHeader(http.StatusForbidden)
}
})))
}()
mux := http.NewServeMux()
// Readiness probe for the test harness (empty body, 200).
mux.HandleFunc("/health", func(http.ResponseWriter, *http.Request) {})
// live / none mode: the plugin asks about one IP and expects a decision
// array, or the literal `null` when there is none.
mux.HandleFunc("/v1/decisions", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if d, ok := active[r.URL.Query().Get("ip")]; ok {
writeJSON(w, []Decision{d})
return
}
_, _ = w.Write([]byte("null"))
})
// stream mode: report the whole active set as "new" and anything removed as
// "deleted". Re-sending the same on every poll is harmless — the plugin just
// re-adds to / re-deletes from its cache.
mux.HandleFunc("/v1/decisions/stream", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
defer mu.Unlock()
writeJSON(w, map[string][]Decision{"new": list(active), "deleted": list(deleted)})
})
// usage-metrics push: accept and ignore.
mux.HandleFunc("/v1/usage-metrics", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
})
// Test control plane: add / remove decisions instead of cscli.
mux.HandleFunc("/admin/decisions", func(_ http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
ip := q.Get("ip")
mu.Lock()
defer mu.Unlock()
switch r.Method {
case http.MethodPost:
dtype := q.Get("type")
if dtype == "" {
dtype = "ban"
}
duration := q.Get("duration")
if duration == "" {
duration = "4h"
}
active[ip] = Decision{Value: ip, Type: dtype, Duration: duration}
delete(deleted, ip)
case http.MethodDelete:
if d, ok := active[ip]; ok {
deleted[ip] = d
delete(active, ip)
}
}
})
log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s", *lapiAddr, *backendAddr, *appsecAddr)
log.Fatal(http.ListenAndServe(*lapiAddr, mux))
}
@@ -0,0 +1,29 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
# IP bouncing disabled — this scenario exercises AppSec only.
crowdsecMode: none
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
crowdsecAppsecEnabled: "true"
crowdsecAppsecScheme: http
crowdsecAppsecHost: "@@APPSEC_HOST@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=appsec
# AppSec wiring check: the plugin forwards each request to the AppSec engine and
# enforces its verdict. The mock emulates one virtual-patching rule (block any
# URI containing "rpc2"), mirroring examples/appsec-enabled. This proves the
# plugin's AppSec path end to end (header forwarding + allow/block handling); it
# does not test the real WAF's detection accuracy.
body() {
echo "[$SCENARIO] benign request must pass (AppSec allows)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] request whose URI contains 'rpc2' must be blocked (AppSec 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo/rpc2" 403 -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>E2E captcha marker</title></head>
<body>
<h1 id="e2e-captcha-marker">E2E_CAPTCHA_PAGE_MARKER</h1>
<script src="{{ .FrontendJS }}"></script>
<div class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}"></div>
</body>
</html>
@@ -0,0 +1,33 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: stream
updateIntervalSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
captchaProvider: turnstile
# Cloudflare Turnstile public test keys: render a valid widget without
# contacting a real API key.
captchaSiteKey: "1x00000000000000000000AA"
captchaSecretKey: "1x0000000000000000000000000000000AA"
captchaHtmlFilePath: "@@SCENARIO_DIR@@/captcha.html"
captchaGracePeriodSeconds: "10"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=captcha
body() {
echo "[$SCENARIO] adding captcha decision for 1.2.3.4"
lapi_add_decision 1.2.3.4 captcha 5m
# Status stays 200 before/after (captcha page vs backend), so gate on the body
# marker appearing once the captcha decision has been polled.
echo "[$SCENARIO] captcha page must be served once the decision is polled (200 + marker)"
wait_for_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CAPTCHA_PAGE_MARKER" 15 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] captcha response is HTTP 200 (the captcha page itself, not a 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] non-flagged IP must still pass through to the backend"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>E2E ban marker</title></head>
<body>
<h1 id="e2e-ban-marker">E2E_CUSTOM_BAN_PAGE_MARKER</h1>
<p>IP: {{ .ClientIP }} reason: {{ .RemediationReason }}</p>
</body>
</html>
@@ -0,0 +1,28 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: stream
updateIntervalSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
banHtmlFilePath: "@@SCENARIO_DIR@@/ban.html"
remediationHeadersCustomName: "X-E2E-Remediation"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=custom-ban-page
body() {
echo "[$SCENARIO] adding ban decision"
lapi_add_decision 1.2.3.4 ban 5m
echo "[$SCENARIO] banned response becomes 403 once the next stream poll lands"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] banned response Content-Type is HTML"
assert_header "http://127.0.0.1:${WEB_PORT}/foo" Content-Type "text/html; charset=utf-8" -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] banned response body contains the custom marker"
assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CUSTOM_BAN_PAGE_MARKER" -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] banned response carries the custom remediation header (remediationHeadersCustomName)"
assert_header "http://127.0.0.1:${WEB_PORT}/foo" X-E2E-Remediation "ban" -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -0,0 +1,26 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: live
defaultDecisionSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=live-mode
body() {
echo "[$SCENARIO] no decision -> first hit queries LAPI, returns 200, caches 'allowed' for 2s"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
lapi_add_decision 1.2.3.4 ban 5m
# Stays 200 until the cached 'allowed' (defaultDecisionSeconds) expires, then
# the re-query sees the ban — poll instead of guessing the cache TTL.
echo "[$SCENARIO] hit must turn 403 once the cached 'allowed' expires and LAPI is re-queried"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] another non-banned IP must still pass"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -0,0 +1,25 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: none
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=none-mode
body() {
echo "[$SCENARIO] no decision -> request passes (LAPI queried per request)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
lapi_add_decision 1.2.3.4 ban 5m
echo "[$SCENARIO] none mode has no cache -> next request must be blocked immediately"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] deleting decision"
lapi_delete_decision 1.2.3.4
echo "[$SCENARIO] previously banned IP must pass again immediately"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -0,0 +1,26 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: stream
updateIntervalSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=stream-mode
body() {
echo "[$SCENARIO] no decision yet -> request allowed"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
lapi_add_decision 1.2.3.4 ban 5m
echo "[$SCENARIO] banned IP must be blocked once the next stream poll lands (HTTP 403)"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] non-banned IP must still pass (HTTP 200)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
echo "[$SCENARIO] deleting ban decision"
lapi_delete_decision 1.2.3.4
echo "[$SCENARIO] previously banned IP must pass again once the deletion is polled"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -0,0 +1,28 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: stream
updateIntervalSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
clientTrustedIps:
- "1.2.3.4/32"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=trusted-ips
body() {
echo "[$SCENARIO] banning the trusted IP 1.2.3.4 and an untrusted IP 5.6.7.8"
lapi_add_decision 1.2.3.4 ban 5m
lapi_add_decision 5.6.7.8 ban 5m
# The untrusted IP turning 403 is our signal that the bans have been polled;
# it also doubles as the control proving the bouncer is active.
echo "[$SCENARIO] untrusted banned IP must be blocked once the bans are polled (HTTP 403)"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 5.6.7.8"
echo "[$SCENARIO] trusted IP must bypass the bouncer even though it is banned"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
+4
View File
@@ -0,0 +1,4 @@
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
// pluginVersion is updated automatically by the release workflow.
var pluginVersion = "1.6.X" //nolint:gochecknoglobals