Compare commits

...
38 Commits
Author SHA1 Message Date
mhxandClaude Opus 4.8 66b57496f8 tests: add local Docker end-to-end suite (Traefik + Crowdsec)
A per-scenario end-to-end suite that boots real Traefik + Crowdsec containers
and exercises the plugin loaded from a local path. Scenarios: stream / live /
none modes, trusted IPs, custom ban page, captcha, and appsec.

This suite is local-only (run with `make e2e`): it boots a real Crowdsec and,
for appsec, downloads the OWASP CRS collections — heavier and less
deterministic than CI needs. CI runs a separate, lighter binary + mock-LAPI
suite instead (see the companion PR).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:04:08 +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
maxlerebourg 4ab4f3f183 Transform banTemplate to add blocking reason and client IP (#290)
*  Transform banTemplate to add blocking reason

* 🍱 fix test

* 🍱 fix lint

* 🍱 fix test

* 🍱 fix lint

* 🍱 fix lint

* 🍱 add doc and fix lint

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint

* 🍱 lint html

* 🍱 fix comments + fix wicketpeeker readme

* 🍱 Give ClientIP in ban page

* 🍱 fix test
2025-11-15 10:42:14 +01:00
usr3 2aac531ea7 📝 docs: fix captcha profile reference doc link (#289)
Fixed broken link referencing the captcha profile in Crowdsec documentation
2025-10-30 14:20:02 +01:00
Patrick Evans e8e60c958f 📝 docs: fix wicketkeeper name references (#285) 2025-10-19 12:53:07 +02:00
Davidandmaxlerebourg a2d3708bc3 Do not cache decisions if DefaultDecisionSeconds=0 (#280)
* xx

* Remove outdated comment on decision caching

Remove comment about decision caching based on timeout.

---------

Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
2025-10-18 11:58:00 +02:00
DavidandMax Lerebourg 65a2f79fb3 🐛Not write response body for HEAD requests (#277)
* Fixes

* XX

* Fix

* 🍱 Lint

* 🍱 remove useless comments

---------

Co-authored-by: Max Lerebourg <maxlerebourg@gmail.com>
2025-10-06 11:19:19 +02:00
dependabot[bot] a2ecc95dc9 ⬆️ Bump actions/setup-go from 5 to 6 (#268)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  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-09-08 16:23:10 +02:00
7c4f5163e9 add custom selfhosted captcha (#259)
*  Add wicketkeeper captcha

*  Anom config

* 🍱 fix readme

* 🍱 fix lint

* 🍱 fix lint

* 🍱 normalize

* 🍱 fix lint

* 🍱 fix lint

*  Add env for RemediationStatusCode (#250)

*  Add env for defaultStatusCode

* 📝 doc

* change name of the parameter

* 🔧 Add config check

* fix lint

* 📈 Report traffic dropped metrics to LAPI (#223)

* Initial implementation

* fix

* fixes

* Fixes

* xx

* progress

* xx

* xx

* xx

* fix linter

* Progress

* Fixes

* xx

* xx

* Remove trace logger

* Last fix

* fix lint

* fix lint

* fix lint

---------

Co-authored-by: Max Lerebourg <maxlerebourg@gmail.com>

*  Anom config

* 🍱 fix readme

* 🍱 fix lint

* 🍱 normalize

* 🍱 fix lint

* 📝 Add documentation

* 📝 Fix example and makefile and doc for wicketkeeper

* 🍱 fix last things

* 🍱 add disclaimer to use maxlerebourg docker image

* 🍱 Use official wicketpeeker image

* 🍱 revert unnecessary code

* 🍱 fix

---------

Co-authored-by: David <deivid.garcia.garcia@gmail.com>
Co-authored-by: max.lerebourg <max.lerebourg@monisnap.com>
Co-authored-by: mhx <mathieu@hanotaux.fr>
2025-09-01 19:41:45 +02:00
dependabot[bot] 734975c206 ⬆️ Bump github.com/maxlerebourg/simpleredis (#262)
Bumps [github.com/maxlerebourg/simpleredis](https://github.com/maxlerebourg/simpleredis) from 1.0.11 to 1.0.12.
- [Release notes](https://github.com/maxlerebourg/simpleredis/releases)
- [Commits](https://github.com/maxlerebourg/simpleredis/compare/v1.0.11...v1.0.12)

---
updated-dependencies:
- dependency-name: github.com/maxlerebourg/simpleredis
  dependency-version: 1.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 12:00:02 +02:00
dependabot[bot] 7397834e58 ⬆️ Bump actions/checkout from 4 to 5 (#261)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [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/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  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-08-18 11:59:31 +02:00
DavidandMax Lerebourg 84a5674b14 📈 Report traffic dropped metrics to LAPI (#223)
* Initial implementation

* fix

* fixes

* Fixes

* xx

* progress

* xx

* xx

* xx

* fix linter

* Progress

* Fixes

* xx

* xx

* Remove trace logger

* Last fix

* fix lint

* fix lint

* fix lint

---------

Co-authored-by: Max Lerebourg <maxlerebourg@gmail.com>
2025-07-02 11:36:09 +02:00
maxlerebourg de7e382fde Add env for RemediationStatusCode (#250)
*  Add env for defaultStatusCode

* 📝 doc

* change name of the parameter

* 🔧 Add config check

* fix lint
2025-07-01 21:59:12 +02:00
gptlang abae855d9e Set traefik logs to json so it can actually be parsed (#235) 2025-05-07 11:22:27 +02:00
Lluís D. 4bb6e830dc Fix query of the GET /decisions endpoint (#236)
- banned=true query parameter doesn't exist based on LAPI
  dcoumentation, remove it
2025-04-28 16:12:54 +02:00
mathieuHa 78869ecf77 🔧 Add support for logLevel in any case (#231) 2025-04-06 10:41:16 +02:00
maxlerebourg 7f99266f99 📝 doc: rename RedisUnreachableBlock to RedisCacheUnreachableBlock (#230) 2025-04-05 11:34:06 +02:00
mathieuHa 5418d35feb feat(logs) add supports write logs to files (#217)
*  feat(logs) add supports write logs to files

* fix(lint) 🚨 fix go lint

* 🐛 fix(bug) check path is done only if provided

* 📝 doc(vars) add LogFilePath to vars

* 🦺 chore(review) update doc, configuration check and logger
2025-03-31 20:19:44 +02:00
maxlerebourg a184ae6db9 💄 Center recaptcha div (#225) 2025-03-25 09:34:06 +01:00
blotus e4c84409e7 🔨 always set custom remediation header if configured for bans (#218) 2025-02-17 14:02:21 +01:00
maxlerebourg 4708d76854 Add variable to not block if redis is unreachable (#214)
*  Add variable to not block if redis is unreachable

* 🚨 fix lint

* 📝 Update README.md
2025-02-10 20:10:29 +01:00
maxlerebourg c34d7f4617 🍱 Fix lint (#211) 2025-01-29 08:14:15 +01:00
mathieuHaandMax Lerebourg 0e9620bfe9 👷 chore(ci) bump CI and automate dep updates (#210)
* 👷 chore(ci) bump CI and automate dep updates

* 🚨 chore(go) fix golang lint

* 🍱 fix lint

---------

Co-authored-by: Max Lerebourg <maxlerebourg@gmail.com>
2025-01-29 08:07:04 +01:00
maxlerebourg 92f05b0ba5 [BREAKING-CHANGE] Add CrowdsecAppsecBodyLimit (#208)
*  Add CrowdsecAppsecBodyLimit

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix error on main
2025-01-24 21:04:45 +01:00
980a7dd05e Add AppSec Path Variable (#202)
* Added Appsec Path config Variable

*  Add path env var for lapi and appsec

* 🍱 Update README.md

---------

Co-authored-by: Tobias Heinze <tobias.heinze@telekom.de>
Co-authored-by: Max Lerebourg <maxlerebourg@gmail.com>
2025-01-24 20:12:30 +01:00
60 changed files with 3149 additions and 433 deletions
+24
View File
@@ -0,0 +1,24 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
# Maintain dependencies for Go
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
# Maintain dependencies for build tools
- package-ecosystem: "gomod"
directory: "/tools"
schedule:
interval: "weekly"
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+8 -5
View File
@@ -1,5 +1,8 @@
name: Main name: Main
permissions:
contents: read
on: on:
push: push:
branches: branches:
@@ -12,8 +15,8 @@ jobs:
name: Main Process name: Main Process
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
GO_VERSION: 1.22 GO_VERSION: 1.23
GOLANGCI_LINT_VERSION: v1.57.2 GOLANGCI_LINT_VERSION: v1.63.4
YAEGI_VERSION: v0.16.1 YAEGI_VERSION: v0.16.1
CGO_ENABLED: 0 CGO_ENABLED: 0
defaults: defaults:
@@ -24,20 +27,20 @@ jobs:
# https://github.com/marketplace/actions/setup-go-environment # https://github.com/marketplace/actions/setup-go-environment
- name: Set up Go ${{ env.GO_VERSION }} - name: Set up Go ${{ env.GO_VERSION }}
uses: actions/setup-go@v2 uses: actions/setup-go@v6
with: with:
go-version: ${{ env.GO_VERSION }} go-version: ${{ env.GO_VERSION }}
# https://github.com/marketplace/actions/checkout # https://github.com/marketplace/actions/checkout
- name: Check out code - name: Check out code
uses: actions/checkout@v2 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@v2 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 }}"
+22 -34
View File
@@ -15,7 +15,7 @@ linters-settings:
locale: US locale: US
funlen: funlen:
lines: -1 lines: -1
statements: 50 statements: 60
godox: godox:
keywords: keywords:
- FIXME - FIXME
@@ -25,45 +25,33 @@ linters-settings:
rules: rules:
Main: Main:
files: files:
- $all - $all
- "!$test" - "!$test"
allow: allow:
- $gostd - $gostd
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
- github.com/leprosus/golang-ttl-map - github.com/leprosus/golang-ttl-map
- github.com/maxlerebourg/simpleredis - github.com/maxlerebourg/simpleredis
Test: Test:
files: files:
- $test - $test
allow: allow:
- $gostd - $gostd
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
linters: linters:
enable-all: true enable-all: true
disable: disable:
- deadcode # deprecated
- exhaustivestruct # deprecated
- golint # deprecated
- ifshort # deprecated
- interfacer # deprecated
- maligned # deprecated
- nosnakecase # deprecated
- scopelint # deprecated
- scopelint # deprecated
- structcheck # deprecated
- varcheck # deprecated
- sqlclosecheck # not relevant (SQL) - sqlclosecheck # not relevant (SQL)
- rowserrcheck # not relevant (SQL) - rowserrcheck # not relevant (SQL)
- execinquery # not relevant (SQL)
- cyclop # duplicate of gocyclo - cyclop # duplicate of gocyclo
- bodyclose # Too many false positives: https://github.com/timakin/bodyclose/issues/30 - bodyclose # Too many false positives: https://github.com/timakin/bodyclose/issues/30
- dupl - dupl
@@ -74,18 +62,18 @@ linters:
- wsl - wsl
- exhaustive - exhaustive
- exhaustruct - exhaustruct
- goerr113 - err113
- wrapcheck - wrapcheck
- ifshort
- noctx - noctx
- lll - lll
- gomnd
- forbidigo - forbidigo
- varnamelen - varnamelen
- wastedassign # is disabled because of generics - wastedassign # is disabled because of generics
- gofumpt - gofumpt
- gci - gci
- mnd
- exportloopref
- contextcheck
issues: issues:
exclude-use-default: false exclude-use-default: false
max-same-issues: 0 max-same-issues: 0
+15 -4
View File
@@ -1,7 +1,9 @@
.PHONY: lint test vendor clean .PHONY: lint test vendor clean e2e
export GO111MODULE=on export GO111MODULE=on
E2E_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec
default: lint test default: lint test
lint: lint:
@@ -13,6 +15,11 @@ test:
yaegi_test: yaegi_test:
yaegi test -v . yaegi test -v .
e2e: $(addprefix e2e_,$(E2E_SCENARIOS))
e2e_%:
./tests/e2e/scenarios/$*/run.sh
vendor: vendor:
go mod vendor go mod vendor
@@ -41,13 +48,16 @@ run_tlsauth:
docker compose -f examples/tls-auth/docker-compose.yml up -d --remove-orphans docker compose -f examples/tls-auth/docker-compose.yml up -d --remove-orphans
run_appsec: run_appsec:
docker compose -f examples/appsec-enabled/docker-compose.yml up -d docker compose -f examples/appsec-enabled/docker-compose.yml up -d --remove-orphans
run_custom_captcha:
docker compose -f examples/custom-captcha/docker-compose.yml up -d --remove-orphans
run_captcha: run_captcha:
docker compose -f examples/captcha/docker-compose.yml up -d docker compose -f examples/captcha/docker-compose.yml up -d --remove-orphans
run_custom_ban_page: run_custom_ban_page:
docker compose -f examples/custom-ban-page/docker-compose.yml up -d docker compose -f examples/custom-ban-page/docker-compose.yml up -d --remove-orphans
run: run:
docker compose -f docker-compose.yml up -d --remove-orphans docker compose -f docker-compose.yml up -d --remove-orphans
@@ -98,6 +108,7 @@ clean_all_docker:
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.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-ban-page/docker-compose.yml down --remove-orphans docker compose -f examples/custom-ban-page/docker-compose.yml down --remove-orphans
docker compose -f docker-compose.local.yml down --remove-orphans docker compose -f docker-compose.local.yml down --remove-orphans
docker compose -f docker-compose.yml down --remove-orphans docker compose -f docker-compose.yml down --remove-orphans
+165 -29
View File
@@ -26,6 +26,7 @@ The AppSec Component offers:
- Low-effort virtual patching capabilities. - Low-effort virtual patching capabilities.
- Support for your legacy ModSecurity rules. - Support for your legacy ModSecurity rules.
- Combining classic WAF benefits with advanced CrowdSec features for otherwise difficult advanced behavior detection. - Combining classic WAF benefits with advanced CrowdSec features for otherwise difficult advanced behavior detection.
More information on appsec in the [Crowdsec Documentation](https://doc.crowdsec.net/docs/next/appsec/intro/). More information on appsec in the [Crowdsec Documentation](https://doc.crowdsec.net/docs/next/appsec/intro/).
Remediation offered by [Crowdsec](https://docs.crowdsec.net/u/bouncers/intro) and supported by the plugin can be either `ban` or `captcha`. Remediation offered by [Crowdsec](https://docs.crowdsec.net/u/bouncers/intro) and supported by the plugin can be either `ban` or `captcha`.
@@ -34,20 +35,21 @@ For the `captcha` remediation, the user will be redirected to a page to complete
On successfull completion, he will be cleaned for a specified period of time before a new resolution challenge is expected if Crowdsec still has a decision to verify the user behavior. See the example captcha for more informations and configuration intructions. On successfull completion, he will be cleaned for a specified period of time before a new resolution challenge is expected if Crowdsec still has a decision to verify the user behavior. See the example captcha for more informations and configuration intructions.
The following captcha providers are supported now: The following captcha providers are supported now:
- [hcaptcha](https://www.hcaptcha.com/)
- [recaptcha](https://www.google.com/recaptcha/about/)
- [turnstile](https://www.cloudflare.com/products/turnstile/)
- [hcaptcha](https://www.hcaptcha.com/)
- [recaptcha](https://www.google.com/recaptcha/about/)
- [turnstile](https://www.cloudflare.com/products/turnstile/)
- [custom/wicketkeeper](https://github.com/a-ve/wicketkeeper)
There are 5 operating modes (CrowdsecMode) for this plugin: There are 5 operating modes (CrowdsecMode) for this plugin:
| Mode | Description | | Mode | Description |
|------|------| | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| none | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. All request call the Crowdsec LAPI | | none | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. All request call the Crowdsec LAPI |
| live | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. The bouncer can leverage use of a local cache in order to reduce the number of requests made to the Crowdsec LAPI. It will keep in cache the status for each IP that makes queries. | | live | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. The bouncer can leverage use of a local cache in order to reduce the number of requests made to the Crowdsec LAPI. It will keep in cache the status for each IP that makes queries. |
| stream | Stream Streaming mode allows you to keep in the local cache only the Banned IPs, every requests that does not hit the cache is authorized. Every minute, the cache is updated with news from the Crowdsec LAPI. | | stream | Stream Streaming mode allows you to keep in the local cache only the Banned IPs, every requests that does not hit the cache is authorized. Every minute, the cache is updated with news from the Crowdsec LAPI. |
| alone | Standalone mode, similar to the streaming mode but the blacklisted IPs are fetched on the CAPI. Every 2 hours, the cache is updated with news from the Crowdsec CAPI. It does not include any locally banned IP, but can work without a crowdsec service. | | alone | Standalone mode, similar to the streaming mode but the blacklisted IPs are fetched on the CAPI. Every 2 hours, the cache is updated with news from the Crowdsec CAPI. It does not include any locally banned IP, but can work without a crowdsec service. |
| appsec | Disable Crowdsec IP checking but apply Crowdsec Appsec checking. This mode is intended to be used when Crowdsec IP checking is applied at the Firewall Level. | | appsec | Disable Crowdsec IP checking but apply Crowdsec Appsec checking. This mode is intended to be used when Crowdsec IP checking is applied at the Firewall Level. |
The `streaming mode` is recommended for performance, decisions are updated every 60 sec by default and that's the only communication between Traefik and Crowdsec. Every request that happens hits the cache for quick decisions. The `streaming mode` is recommended for performance, decisions are updated every 60 sec by default and that's the only communication between Traefik and Crowdsec. Every request that happens hits the cache for quick decisions.
@@ -300,24 +302,48 @@ sequenceDiagram
To get started, use the `docker-compose.yml` file. To get started, use the `docker-compose.yml` file.
You can run it with: You can run it with:
```bash ```bash
make run make run
``` ```
### Note ### Note
**/!\ Cache is shared by all services** > [!IMPORTANT]
*This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP* > Some of the behaviours and configuration parameters are shared globally across _all_ crowdsec middlewares even if you declare different middlewares with different settings.
Only one instance of the plugin is *possible*. >
> **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.
>
> 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]
> **Appsec maximum body limit is defaulted to 10MB** > _Be careful when you upgrade to >1.4.x_
### Variables ### Variables
- Enabled - Enabled
- bool - bool
- default: false - default: false
- Enable the plugin - Enable the plugin
- LogLevel - LogLevel
- string - string
- default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR`, log are written to `stdout` / `stderr` - default: `INFO`, expected values are: `DEBUG`, `INFO`, `WARN`, `ERROR`
- Log are written to `stdout` / `stderr` or file if LogFilePath is provided
- LogFormat
- string
- default: `common`, expected values are: `common`, `json`
- Log format: `common` for traditional text logs, `json` for structured JSON logs
- LogFilePath
- string
- default: ""
- File Path to write logs, must be writable by Traefik, Log rotation may require a restart of traefik
- MetricsUpdateIntervalSeconds
- int64
- default: 600
- Interval in seconds between metrics updates to Crowdsec
- If set to zero or less, metrics collection is disabled
- CrowdsecMode - CrowdsecMode
- string - string
- default: `live`, expected values are: `none`, `live`, `stream`, `alone`, `appsec` - default: `live`, expected values are: `none`, `live`, `stream`, `alone`, `appsec`
@@ -328,7 +354,22 @@ Only one instance of the plugin is *possible*.
- 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
- string
- default: "/"
- Crowdsec Appsec Server available on this path. Will be appended to CrowdsecAppsecHost. Need to finish with "/".
- CrowdsecAppsecFailureBlock - CrowdsecAppsecFailureBlock
- bool - bool
- default: true - default: true
@@ -337,6 +378,14 @@ Only one instance of the plugin is *possible*.
- bool - bool
- default: true - default: true
- Block request when Crowdsec Appsec Server is unreachable. - Block request when Crowdsec Appsec Server is unreachable.
- CrowdsecAppsecBodyLimit
- int64
- default: 10485760 (= 10MB)
- 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`
@@ -344,6 +393,10 @@ Only one instance of the plugin is *possible*.
- string - string
- default: "crowdsec:8080" - default: "crowdsec:8080"
- Crowdsec LAPI available on which host and port. - Crowdsec LAPI available on which host and port.
- CrowdsecLapiPath
- string
- default: "/"
- Crowdsec LAPI Server available on this path. Will be appended to CrowdsecLapiHost. Need to finish with "/".
- CrowdsecLapiKey - CrowdsecLapiKey
- string - string
- default: "" - default: ""
@@ -371,7 +424,7 @@ Only one instance of the plugin is *possible*.
- 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"
@@ -396,6 +449,10 @@ Only one instance of the plugin is *possible*.
- string - string
- default: "" - default: ""
- Database selection for the Redis service - Database selection for the Redis service
- RedisCacheUnreachableBlock
- bool
- default: true
- Block request when Redis is unreachable (if Redis is unreachable, 1-second delay is added to each request)
- HTTPTimeoutSeconds - HTTPTimeoutSeconds
- int64 - int64
- default: 10 - default: 10
@@ -408,10 +465,20 @@ Only one instance of the plugin is *possible*.
- 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
- Used only in `live` mode, maximum decision duration - Used only in `live` mode, maximum decision duration
- RemediationStatusCode
- int
- default: 403
- HTTP status code for banned user (not captcha)
- CrowdsecCapiMachineId - CrowdsecCapiMachineId
- string - string
- Used only in `alone` mode, login for Crowdsec CAPI - Used only in `alone` mode, login for Crowdsec CAPI
@@ -423,7 +490,19 @@ Only one instance of the plugin is *possible*.
- Used only in `alone` mode, scenarios for Crowdsec CAPI - Used only in `alone` mode, scenarios for Crowdsec CAPI
- CaptchaProvider - CaptchaProvider
- string - string
- Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile` - Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile` or `custom`
- CaptchaCustomJsURL
- string
- If CaptchaProvider is `custom`, URL used to load the challenge in the HTML (in case of hcaptcha: `https://hcaptcha.com/1/api.js`)
- CaptchaCustomValidateURL
- string
- If CaptchaProvider is `custom`, URL used to validate the challenge (in case of hcaptcha: `https://api.hcaptcha.com/siteverify`)
- CaptchaCustomKey
- string
- If CaptchaProvider is `custom`, used to set class name of the div used by captcha provider (in case of hcaptcha: `h-captcha`)
- CaptchaCustomResponse
- string
- If CaptchaProvider is `custom`, used to set the field in the POST body from the captcha.html to Traefik (in case of hcaptcha: `h-captcha-response`)
- CaptchaSiteKey - CaptchaSiteKey
- string - string
- Site key for the captcha provider - Site key for the captcha provider
@@ -442,12 +521,17 @@ Only one instance of the plugin is *possible*.
- 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
For each plugin, the Traefik static configuration must define the module name (as is usual for Go packages). For each plugin, the Traefik static configuration must define the module name (as is usual for Go packages).
The following declaration (given here in YAML) defines a plugin: The following declaration (given here in YAML) defines a plugin:
> Note that you don't need to copy all thoses settings but only the ones you want to use. > Note that you don't need to copy all thoses settings but only the ones you want to use.
> See the examples for advanced usage. > See the examples for advanced usage.
@@ -462,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:
@@ -486,19 +600,26 @@ http:
bouncer: bouncer:
enabled: false enabled: false
logLevel: DEBUG logLevel: DEBUG
logFormat: common
LogFilePath: ""
updateIntervalSeconds: 60 updateIntervalSeconds: 60
updateMaxFailure: 0 updateMaxFailure: 0
streamStartupBlock: true
defaultDecisionSeconds: 60 defaultDecisionSeconds: 60
remediationStatusCode: 403
httpTimeoutSeconds: 10 httpTimeoutSeconds: 10
crowdsecMode: live crowdsecMode: live
crowdsecAppsecEnabled: false crowdsecAppsecEnabled: false
crowdsecAppsecScheme: ""
crowdsecAppsecHost: crowdsec:7422 crowdsecAppsecHost: crowdsec:7422
crowdsecAppsecPath: "/"
crowdsecAppsecFailureBlock: true crowdsecAppsecFailureBlock: true
crowdsecAppsecUnreachableBlock: true crowdsecAppsecUnreachableBlock: true
crowdsecAppsecBodyLimit: 10485760
crowdsecLapiKey: privateKey-foo crowdsecLapiKey: privateKey-foo
crowdsecLapiKeyFile: /etc/traefik/cs-privateKey-foo
crowdsecLapiHost: crowdsec:8080
crowdsecLapiScheme: http crowdsecLapiScheme: http
crowdsecLapiHost: crowdsec:8080
crowdsecLapiPath: "/"
crowdsecLapiTLSInsecureVerify: false crowdsecLapiTLSInsecureVerify: false
crowdsecCapiMachineId: login crowdsecCapiMachineId: login
crowdsecCapiPassword: password crowdsecCapiPassword: password
@@ -517,13 +638,13 @@ http:
redisCacheHost: "redis:6379" redisCacheHost: "redis:6379"
redisCachePassword: password redisCachePassword: password
redisCacheDatabase: "5" redisCacheDatabase: "5"
redisCacheUnreachableBlock: true
crowdsecLapiTLSCertificateAuthority: |- crowdsecLapiTLSCertificateAuthority: |-
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
... ...
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
-----END CERTIFICATE----- -----END CERTIFICATE-----
crowdsecLapiTLSCertificateAuthorityFile: /etc/traefik/crowdsec-certs/ca.pem
crowdsecLapiTLSCertificateBouncer: |- crowdsecLapiTLSCertificateBouncer: |-
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIEHjCCAwagAwIBAgIUOBTs1eqkaAUcPplztUr2xRapvNAwDQYJKoZIhvcNAQEL MIIEHjCCAwagAwIBAgIUOBTs1eqkaAUcPplztUr2xRapvNAwDQYJKoZIhvcNAQEL
@@ -531,30 +652,31 @@ http:
RaXAnYYUVRblS1jmePemh388hFxbmrpG2pITx8B5FMULqHoj11o2Rl0gSV6tHIHz RaXAnYYUVRblS1jmePemh388hFxbmrpG2pITx8B5FMULqHoj11o2Rl0gSV6tHIHz
N2U= N2U=
-----END CERTIFICATE----- -----END CERTIFICATE-----
crowdsecLapiTLSCertificateBouncerFile: /etc/traefik/crowdsec-certs/bouncer.pem
crowdsecLapiTLSCertificateBouncerKey: |- crowdsecLapiTLSCertificateBouncerKey: |-
-----BEGIN RSA PRIVATE KEY----- -----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAtYQnbJqifH+ZymePylDxGGLIuxzcAUU4/ajNj+qRAdI/Ux3d MIIEogIBAAKCAQEAtYQnbJqifH+ZymePylDxGGLIuxzcAUU4/ajNj+qRAdI/Ux3d
... ...
ic5cDRo6/VD3CS3MYzyBcibaGaV34nr0G/pI+KEqkYChzk/PZRA= ic5cDRo6/VD3CS3MYzyBcibaGaV34nr0G/pI+KEqkYChzk/PZRA=
-----END RSA PRIVATE KEY----- -----END RSA PRIVATE KEY-----
crowdsecLapiTLSCertificateBouncerKeyFile: /etc/traefik/crowdsec-certs/bouncer-key.pem
captchaProvider: hcaptcha captchaProvider: hcaptcha
captchaSiteKey: FIXME captchaSiteKey: FIXME
captchaSecretKey: FIXME captchaSecretKey: FIXME
captchaGracePeriodSeconds: 1800 captchaGracePeriodSeconds: 1800
captchaHTMLFilePath: /captcha.html captchaHTMLFilePath: /captcha.html
banHTMLFilePath: /ban.html banHTMLFilePath: /ban.html
traceHeadersCustomName: X-Request-ID
metricsUpdateIntervalSeconds: 600
``` ```
#### Fill variable with value of file #### Fill variable with value of file
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey` and `CaptchaSecretKey` 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:
- Content: VariableName: XXX - Content: VariableName: XXX
- File : VariableNameFile: /path - File : VariableNameFile: /path
#### Authenticate with LAPI #### Authenticate with LAPI
@@ -562,6 +684,7 @@ You can authenticate to the LAPI either with LAPIKEY or by using client certific
Please see below for more details on each option. Please see below for more details on each option.
#### Generate LAPI KEY #### Generate LAPI KEY
You can generate a crowdsec API key for the LAPI. You can generate a crowdsec API key for the LAPI.
You can follow the documentation here: [docs.crowdsec.net/docs/user_guides/lapi_mgmt](https://docs.crowdsec.net/docs/user_guides/lapi_mgmt) You can follow the documentation here: [docs.crowdsec.net/docs/user_guides/lapi_mgmt](https://docs.crowdsec.net/docs/user_guides/lapi_mgmt)
@@ -571,24 +694,26 @@ docker exec crowdsec cscli bouncers add crowdsecBouncer
``` ```
This LAPI key must be set where is noted FIXME-LAPI-KEY in the docker-compose.yml This LAPI key must be set where is noted FIXME-LAPI-KEY in the docker-compose.yml
```yaml ```yaml
... ..
whoami: whoami:
labels: labels:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=FIXME-LAPI-KEY" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=FIXME-LAPI-KEY"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=http" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=http"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapihost=crowdsec:8080" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapihost=crowdsec:8080"
... ..
crowdsec: crowdsec:
environment: environment:
BOUNCER_KEY_TRAEFIK: FIXME-LAPI-KEY BOUNCER_KEY_TRAEFIK: FIXME-LAPI-KEY
...
``` ```
Note: Note:
> Crowdsec does not require a specific format for la LAPI-key, you may use something like FIXME-LAPI-KEY but that is not recommanded for obvious reasons > Crowdsec does not require a specific format for la LAPI-key, you may use something like FIXME-LAPI-KEY but that is not recommanded for obvious reasons
You can then run all the containers: You can then run all the containers:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
@@ -608,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
@@ -640,6 +772,8 @@ docker exec crowdsec cscli decisions remove --ip 10.0.0.10 -t captcha
#### 10. Using Traefik with Custom Ban HTML Page [examples/custom-ban-page/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/custom-ban-page/README.md) #### 10. Using Traefik with Custom Ban HTML Page [examples/custom-ban-page/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/custom-ban-page/README.md)
#### 11. Using Traefik with Custom Captcha Whiketkeeper[examples/custom-captcha/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/custom-captcha/README.md)
### Local Mode ### Local Mode
Traefik also offers a developer mode that can be used for temporary testing of plugins not hosted on GitHub. Traefik also offers a developer mode that can be used for temporary testing of plugins not hosted on GitHub.
@@ -665,12 +799,14 @@ The source code of the plugin should be organized as follows:
``` ```
For local development, a `docker-compose.local.yml` is provided which reproduces the directory layout needed by Traefik. For local development, a `docker-compose.local.yml` is provided which reproduces the directory layout needed by Traefik.
This works once you have generated and filled your *LAPI-KEY* (crowdsecLapiKey), if not read above for informations. This works once you have generated and filled your _LAPI-KEY_ (crowdsecLapiKey), if not read above for informations.
```bash ```bash
docker compose -f docker-compose.local.yml up -d docker compose -f docker-compose.local.yml up -d
``` ```
Equivalent to Equivalent to
```bash ```bash
make run_local make run_local
``` ```
+289 -91
View File
@@ -9,10 +9,14 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
htmltemplate "html/template"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"sync/atomic"
"text/template" "text/template"
"time" "time"
@@ -33,6 +37,7 @@ const (
crowdsecLapiHeader = "X-Api-Key" crowdsecLapiHeader = "X-Api-Key"
crowdsecLapiRoute = "v1/decisions" crowdsecLapiRoute = "v1/decisions"
crowdsecLapiStreamRoute = "v1/decisions/stream" crowdsecLapiStreamRoute = "v1/decisions/stream"
crowdsecLapiMetricsRoute = "v1/usage-metrics"
crowdsecCapiHost = "api.crowdsec.net" crowdsecCapiHost = "api.crowdsec.net"
crowdsecCapiHeader = "Authorization" crowdsecCapiHeader = "Authorization"
crowdsecCapiLoginRoute = "v2/watchers/login" crowdsecCapiLoginRoute = "v2/watchers/login"
@@ -40,12 +45,32 @@ const (
cacheTimeoutKey = "updated" cacheTimeoutKey = "updated"
) )
// ##############################################################
// Important: traefik creates an instance of the bouncer per route.
// We rely on globals (both here and in the memory cache) to share info between
// routes. This means that some of the plugins parameters will only work "once"
// and will take the values of the first middleware that was instantiated even
// if you have different middlewares with different parameters. This design
// makes it impossible to have multiple crowdsec implementations per cluster (unless you have multiple traefik deployments in it)
// - updateInterval
// - updateMaxFailure
// - defaultDecisionTimeout
// - redisUnreachableBlock
// - appsecEnabled
// - appsecHost
// - metricsUpdateIntervalSeconds
// - others...
// ###################################
//nolint:gochecknoglobals //nolint:gochecknoglobals
var ( var (
isStartup = true isCrowdsecStreamStartup = true
isCrowdsecStreamHealthy = true isCrowdsecStreamHealthy = true
updateFailure = 0 updateFailure int64
ticker chan bool streamTicker chan bool
metricsTicker chan bool
lastMetricsPush time.Time
blockedRequests int64
) )
// CreateConfig creates the default plugin configuration. // CreateConfig creates the default plugin configuration.
@@ -61,36 +86,48 @@ type Bouncer struct {
enabled bool enabled bool
appsecEnabled bool appsecEnabled bool
appsecScheme string
appsecHost string appsecHost string
appsecPath string
appsecKey string
appsecFailureBlock bool appsecFailureBlock bool
appsecUnreachableBlock bool appsecUnreachableBlock bool
appsecBodyLimit int64
crowdsecScheme string crowdsecScheme string
crowdsecHost string crowdsecHost string
crowdsecPath string
crowdsecKey string crowdsecKey string
crowdsecMode string crowdsecMode string
crowdsecMachineID string crowdsecMachineID string
crowdsecPassword string crowdsecPassword string
crowdsecScenarios []string crowdsecScenarios []string
updateInterval int64 updateInterval int64
updateMaxFailure int updateMaxFailure int64
defaultDecisionTimeout int64 defaultDecisionTimeout int64
remediationStatusCode int
remediationCustomHeader string remediationCustomHeader string
forwardedCustomHeader string forwardedCustomHeader string
crowdsecStreamRoute string crowdsecStreamRoute string
crowdsecHeader string crowdsecHeader string
banTemplateString string redisUnreachableBlock bool
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: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) {
log := logger.New(config.LogLevel) config.LogLevel = strings.ToUpper(config.LogLevel)
err := configuration.ValidateParams(config) log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
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
@@ -99,43 +136,57 @@ 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 := ""
if config.CrowdsecMode == configuration.AloneMode { if config.CrowdsecMode == configuration.AloneMode {
config.CrowdsecCapiMachineID, _ = configuration.GetVariable(config, "CrowdsecCapiMachineID") config.CrowdsecCapiMachineID, _ = configuration.GetVariable(config, "CrowdsecCapiMachineID")
config.CrowdsecCapiPassword, _ = configuration.GetVariable(config, "CrowdsecCapiPassword") config.CrowdsecCapiPassword, _ = configuration.GetVariable(config, "CrowdsecCapiPassword")
config.CrowdsecLapiScheme = configuration.HTTPS
config.CrowdsecLapiHost = crowdsecCapiHost config.CrowdsecLapiHost = crowdsecCapiHost
config.CrowdsecLapiScheme = "https" config.CrowdsecLapiPath = "/"
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, err return nil, errKey
} }
config.CrowdsecLapiKey = apiKey config.CrowdsecLapiKey = apiKey
if config.CrowdsecAppsecKey == "" {
config.CrowdsecAppsecKey = apiKey
}
} }
var banTemplateString string var banTemplate *htmltemplate.Template
if config.BanHTMLFilePath != "" { if config.BanHTMLFilePath != "" {
var buf bytes.Buffer banTemplate, _ = configuration.GetHTMLTemplate(config.BanHTMLFilePath)
banTemplate, _ := configuration.GetHTMLTemplate(config.BanHTMLFilePath)
err = banTemplate.Execute(&buf, nil)
if err != nil {
log.Error("New:banTemplate is bad formatted " + err.Error())
return nil, err
}
banTemplateString = buf.String()
} }
bouncer := &Bouncer{ bouncer := &Bouncer{
@@ -146,11 +197,16 @@ 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,
appsecKey: config.CrowdsecAppsecKey,
appsecFailureBlock: config.CrowdsecAppsecFailureBlock, appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock, appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
crowdsecScheme: config.CrowdsecLapiScheme, crowdsecScheme: config.CrowdsecLapiScheme,
crowdsecHost: config.CrowdsecLapiHost, crowdsecHost: config.CrowdsecLapiHost,
crowdsecPath: config.CrowdsecLapiPath,
crowdsecKey: config.CrowdsecLapiKey, crowdsecKey: config.CrowdsecLapiKey,
crowdsecMachineID: config.CrowdsecCapiMachineID, crowdsecMachineID: config.CrowdsecCapiMachineID,
crowdsecPassword: config.CrowdsecCapiPassword, crowdsecPassword: config.CrowdsecCapiPassword,
@@ -160,7 +216,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
remediationCustomHeader: config.RemediationHeadersCustomName, remediationCustomHeader: config.RemediationHeadersCustomName,
forwardedCustomHeader: config.ForwardedHeadersCustomName, forwardedCustomHeader: config.ForwardedHeadersCustomName,
defaultDecisionTimeout: config.DefaultDecisionSeconds, defaultDecisionTimeout: config.DefaultDecisionSeconds,
banTemplateString: banTemplateString, remediationStatusCode: config.RemediationStatusCode,
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
banTemplate: banTemplate,
traceCustomHeader: config.TraceHeadersCustomName,
crowdsecStreamRoute: crowdsecStreamRoute, crowdsecStreamRoute: crowdsecStreamRoute,
crowdsecHeader: crowdsecHeader, crowdsecHeader: crowdsecHeader,
log: log, log: log,
@@ -178,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{},
} }
@@ -202,6 +269,10 @@ 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,
}, },
config.CaptchaProvider, config.CaptchaProvider,
config.CaptchaCustomJsURL,
config.CaptchaCustomKey,
config.CaptchaCustomResponse,
config.CaptchaCustomValidateURL,
config.CaptchaSiteKey, config.CaptchaSiteKey,
config.CaptchaSecretKey, config.CaptchaSecretKey,
config.RemediationHeadersCustomName, config.RemediationHeadersCustomName,
@@ -209,22 +280,36 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
config.CaptchaGracePeriodSeconds, config.CaptchaGracePeriodSeconds,
) )
if err != nil { if err != nil {
log.Error("CaptchaClient not valid " + err.Error())
return nil, err return nil, err
} }
if (config.CrowdsecMode == configuration.StreamMode || config.CrowdsecMode == configuration.AloneMode) && ticker == nil { if (config.CrowdsecMode == configuration.StreamMode || config.CrowdsecMode == configuration.AloneMode) && streamTicker == nil {
if config.CrowdsecMode == configuration.AloneMode { if config.CrowdsecMode == configuration.AloneMode {
if err := getToken(bouncer); err != nil { if err := getToken(bouncer); err != nil {
bouncer.log.Error("New:getToken " + err.Error()) bouncer.log.Error("New:getToken " + err.Error())
return nil, err return nil, err
} }
} }
handleStreamTicker(bouncer) if config.StreamStartupBlock {
isStartup = false handleStreamTicker(bouncer)
ticker = startTicker(config, log, func() { } else {
go handleStreamTicker(bouncer)
}
streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() {
handleStreamTicker(bouncer) handleStreamTicker(bouncer)
}) })
} }
// Start metrics ticker if not already running
if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 {
lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker
go handleMetricsTicker(bouncer)
metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() {
handleMetricsTicker(bouncer)
})
}
bouncer.log.Debug("New initialized mode:" + config.CrowdsecMode) bouncer.log.Debug("New initialized mode:" + config.CrowdsecMode)
return bouncer, nil return bouncer, nil
@@ -232,7 +317,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
// ServeHTTP principal function of plugin. // ServeHTTP principal function of plugin.
// //
//nolint:nestif //nolint:nestif,gocyclo
func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if !bouncer.enabled { if !bouncer.enabled {
bouncer.next.ServeHTTP(rw, req) bouncer.next.ServeHTTP(rw, req)
@@ -243,13 +328,13 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.forwardedCustomHeader) remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.forwardedCustomHeader)
if err != nil { if err != nil {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error())) bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error()))
handleBanServeHTTP(bouncer, rw) bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
return return
} }
isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP) isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP)
if err != nil { if err != nil {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error())) bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error()))
handleBanServeHTTP(bouncer, rw) bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
return return
} }
// if our IP is in the trusted list we bypass the next checks // if our IP is in the trusted list we bypass the next checks
@@ -260,7 +345,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
} }
if bouncer.crowdsecMode == configuration.AppsecMode { if bouncer.crowdsecMode == configuration.AppsecMode {
handleNextServeHTTP(bouncer, remoteIP, rw, req) bouncer.handleNextServeHTTP(rw, req, remoteIP)
return return
} }
@@ -270,17 +355,22 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if cacheErr != nil { if cacheErr != nil {
cacheErrString := cacheErr.Error() cacheErrString := cacheErr.Error()
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString)) bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString))
if !bouncer.redisUnreachableBlock && cacheErrString == cache.CacheUnreachable {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s redisUnreachable=true", remoteIP))
bouncer.handleNextServeHTTP(rw, req, remoteIP)
return
}
if cacheErrString != cache.CacheMiss { if cacheErrString != cache.CacheMiss {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString)) bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
handleBanServeHTTP(bouncer, rw) bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
return return
} }
} else { } else {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value)) bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
if value == cache.NoBannedValue { if value == cache.NoBannedValue {
handleNextServeHTTP(bouncer, remoteIP, rw, req) bouncer.handleNextServeHTTP(rw, req, remoteIP)
} else { } else {
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req) bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
} }
return return
} }
@@ -289,18 +379,21 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// Right here if we cannot join the stream we forbid the request to go on. // Right here if we cannot join the stream we forbid the request to go on.
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode { if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
if isCrowdsecStreamHealthy { if isCrowdsecStreamHealthy {
handleNextServeHTTP(bouncer, remoteIP, rw, req) bouncer.handleNextServeHTTP(rw, req, remoteIP)
} else { } else {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure)) bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure))
handleBanServeHTTP(bouncer, rw) bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
} }
} 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 {
handleNextServeHTTP(bouncer, remoteIP, rw, req) bouncer.handleNextServeHTTP(rw, req, remoteIP)
} else { } else {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error())) bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error()))
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req) bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
} }
} }
} }
@@ -334,37 +427,61 @@ type Login struct {
} }
// To append Headers we need to call rw.WriteHeader after set any header. // To append Headers we need to call rw.WriteHeader after set any header.
func handleBanServeHTTP(bouncer *Bouncer, rw http.ResponseWriter) { func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP, reason string) {
if bouncer.banTemplateString == "" { atomic.AddInt64(&blockedRequests, 1)
rw.WriteHeader(http.StatusForbidden)
return
}
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
if bouncer.remediationCustomHeader != "" { if bouncer.remediationCustomHeader != "" {
rw.Header().Set(bouncer.remediationCustomHeader, "ban") rw.Header().Set(bouncer.remediationCustomHeader, "ban")
} }
rw.WriteHeader(http.StatusForbidden) if bouncer.banTemplate == nil {
fmt.Fprint(rw, bouncer.banTemplateString) rw.WriteHeader(bouncer.remediationStatusCode)
return
}
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(bouncer.remediationStatusCode)
if req.Method == http.MethodHead {
return
}
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 {
bouncer.log.Warn("handleBanServeHTTP could not write template to ResponseWriter: " + err.Error())
}
} }
func handleRemediationServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) { func (bouncer *Bouncer) handleRemediationServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP, remediation string) {
bouncer.log.Debug(fmt.Sprintf("handleRemediationServeHTTP ip:%s remediation:%s", remoteIP, remediation)) bouncer.log.Debug(fmt.Sprintf("handleRemediationServeHTTP ip:%s remediation:%s", remoteIP, remediation))
if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue { if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue && req.Method != http.MethodHead {
if bouncer.captchaClient.Check(remoteIP) { if bouncer.captchaClient.Check(remoteIP) {
handleNextServeHTTP(bouncer, remoteIP, rw, req) bouncer.handleNextServeHTTP(rw, req, remoteIP)
return return
} }
atomic.AddInt64(&blockedRequests, 1) // If we serve a captcha that should count as a dropped request.
bouncer.captchaClient.ServeHTTP(rw, req, remoteIP) bouncer.captchaClient.ServeHTTP(rw, req, remoteIP)
return return
} }
handleBanServeHTTP(bouncer, rw) bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonLAPI)
} }
func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWriter, req *http.Request) { func (bouncer *Bouncer) handleNextServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP string) {
if bouncer.appsecEnabled { if bouncer.appsecEnabled {
if err := appsecQuery(bouncer, remoteIP, req); err != nil { if err := appsecQuery(bouncer, remoteIP, req); err != nil {
bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error())) bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error()))
handleBanServeHTTP(bouncer, rw) bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonAPPSEC)
return return
} }
} }
@@ -373,7 +490,7 @@ func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWrit
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()))
@@ -385,11 +502,17 @@ func handleStreamTicker(bouncer *Bouncer) {
} }
} }
func startTicker(config *configuration.Config, log *logger.Log, work func()) chan bool { func handleMetricsTicker(bouncer *Bouncer) {
ticker := time.NewTicker(time.Duration(config.UpdateIntervalSeconds) * time.Second) if err := reportMetrics(bouncer); err != nil {
bouncer.log.Error("handleMetricsTicker:reportMetrics " + err.Error())
}
}
func startTicker(name string, updateInterval int64, log *slog.Logger, work func()) chan bool {
ticker := time.NewTicker(time.Duration(updateInterval) * time.Second)
stop := make(chan bool, 1) stop := make(chan bool, 1)
go func() { go func() {
defer log.Debug("ticker:stopped") defer log.Debug(name + "_ticker:stopped")
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
@@ -408,10 +531,10 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
routeURL := url.URL{ routeURL := url.URL{
Scheme: bouncer.crowdsecScheme, Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost, Host: bouncer.crowdsecHost,
Path: crowdsecLapiRoute, Path: bouncer.crowdsecPath + crowdsecLapiRoute,
RawQuery: fmt.Sprintf("ip=%v&banned=true", remoteIP), RawQuery: fmt.Sprintf("ip=%v", remoteIP),
} }
body, err := crowdsecQuery(bouncer, routeURL.String(), false) body, err := crowdsecQuery(bouncer, routeURL.String(), nil)
if err != nil { if err != nil {
return cache.BannedValue, err return cache.BannedValue, err
} }
@@ -452,9 +575,9 @@ 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 { if isLiveMode && bouncer.defaultDecisionTimeout > 0 {
durationSecond := int64(duration.Seconds()) durationSecond := int64(duration.Seconds())
if bouncer.defaultDecisionTimeout < durationSecond { if bouncer.defaultDecisionTimeout < durationSecond {
durationSecond = bouncer.defaultDecisionTimeout durationSecond = bouncer.defaultDecisionTimeout
@@ -470,7 +593,16 @@ func getToken(bouncer *Bouncer) error {
Host: bouncer.crowdsecHost, Host: bouncer.crowdsecHost,
Path: crowdsecCapiLoginRoute, Path: crowdsecCapiLoginRoute,
} }
body, err := crowdsecQuery(bouncer, loginURL.String(), true)
// Move the login-specific payload here
loginData := []byte(fmt.Sprintf(
`{"machine_id": "%v","password": "%v","scenarios": ["%v"]}`,
bouncer.crowdsecMachineID,
bouncer.crowdsecPassword,
strings.Join(bouncer.crowdsecScenarios, `","`),
))
body, err := crowdsecQuery(bouncer, loginURL.String(), loginData)
if err != nil { if err != nil {
return err return err
} }
@@ -479,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)
} }
@@ -495,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 {
@@ -504,10 +637,10 @@ func handleStreamCache(bouncer *Bouncer) error {
streamRouteURL := url.URL{ streamRouteURL := url.URL{
Scheme: bouncer.crowdsecScheme, Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost, Host: bouncer.crowdsecHost,
Path: 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(), false) body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil)
if err != nil { if err != nil {
return err return err
} }
@@ -526,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()))
} }
@@ -535,24 +668,19 @@ 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
} }
func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, error) { func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, error) {
var req *http.Request var req *http.Request
if isPost { if len(data) > 0 {
data := []byte(fmt.Sprintf(
`{"machine_id": "%v","password": "%v","scenarios": ["%v"]}`,
bouncer.crowdsecMachineID,
bouncer.crowdsecPassword,
strings.Join(bouncer.crowdsecScenarios, `","`),
))
req, _ = http.NewRequest(http.MethodPost, stringURL, bytes.NewBuffer(data)) req, _ = http.NewRequest(http.MethodPost, stringURL, bytes.NewBuffer(data))
} 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 {
@@ -567,13 +695,16 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, err
if errToken := getToken(bouncer); errToken != nil { if errToken := getToken(bouncer); errToken != nil {
return nil, fmt.Errorf("crowdsecQuery:renewToken url:%s %w", stringURL, errToken) return nil, fmt.Errorf("crowdsecQuery:renewToken url:%s %w", stringURL, errToken)
} }
return crowdsecQuery(bouncer, stringURL, false) return crowdsecQuery(bouncer, stringURL, nil)
} }
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("crowdsecQuery url:%s, statusCode:%d", stringURL, res.StatusCode)
}
body, err := io.ReadAll(res.Body)
// Check if the status code starts with 2
statusStr := strconv.Itoa(res.StatusCode)
if len(statusStr) < 1 || statusStr[0] != '2' {
return nil, fmt.Errorf("crowdsecQuery method:%s url:%s, statusCode:%d (expected: 2xx)", req.Method, stringURL, res.StatusCode)
}
body, err := io.ReadAll(res.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("crowdsecQuery:readBody %w", err) return nil, fmt.Errorf("crowdsecQuery:readBody %w", err)
} }
@@ -582,17 +713,21 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]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: "/", Path: bouncer.appsecPath,
} }
var req *http.Request var req *http.Request
if httpReq.Body != nil && httpReq.ContentLength > 0 { if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil {
bodyBytes, err := io.ReadAll(httpReq.Body) var bodyBuffer bytes.Buffer
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
bodyBytes, err := io.ReadAll(teeReader)
if err != nil { if err != nil {
return fmt.Errorf("appsecQuery:GetBody %w", err) return fmt.Errorf("appsecQuery:GetBody %w", err)
} }
httpReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Conserve body intact after reading it for other middlewares and service
httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body))
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes)) req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
} else { } else {
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil) req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
@@ -603,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 {
@@ -639,3 +775,65 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
} }
return nil return nil
} }
func reportMetrics(bouncer *Bouncer) error {
now := time.Now()
currentCount := atomic.LoadInt64(&blockedRequests)
windowSizeSeconds := int(now.Sub(lastMetricsPush).Seconds())
bouncer.log.Debug(fmt.Sprintf("reportMetrics: blocked_requests=%d window_size=%ds", currentCount, windowSizeSeconds))
metrics := map[string]interface{}{
"remediation_components": []map[string]interface{}{
{
"version": pluginVersion,
"type": "bouncer",
"name": "traefik_plugin",
"metrics": []map[string]interface{}{
{
"items": []map[string]interface{}{
{
"name": "dropped",
"value": currentCount,
"unit": "request",
"labels": map[string]string{
"type": "traefik_plugin",
},
},
},
"meta": map[string]interface{}{
"window_size_seconds": windowSizeSeconds,
"utc_now_timestamp": now.Unix(),
},
},
},
"utc_startup_timestamp": time.Now().Unix(),
"feature_flags": []string{},
"os": map[string]string{
"name": "unknown",
"version": "unknown",
},
},
},
}
data, err := json.Marshal(metrics)
if err != nil {
return fmt.Errorf("reportMetrics:marshal %w", err)
}
metricsURL := url.URL{
Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost,
Path: bouncer.crowdsecPath + crowdsecLapiMetricsRoute,
}
_, err = crowdsecQuery(bouncer, metricsURL.String(), data)
if err != nil {
return fmt.Errorf("reportMetrics:query %w", err)
}
atomic.StoreInt64(&blockedRequests, 0)
lastMetricsPush = now
return nil
}
+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))
}
+148 -2
View File
@@ -2,6 +2,7 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
import ( import (
"context" "context"
htmltemplate "html/template"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"reflect" "reflect"
@@ -163,7 +164,7 @@ func Test_crowdsecQuery(t *testing.T) {
type args struct { type args struct {
bouncer *Bouncer bouncer *Bouncer
stringURL string stringURL string
isPost bool data []byte
} }
tests := []struct { tests := []struct {
name string name string
@@ -175,7 +176,7 @@ func Test_crowdsecQuery(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 := crowdsecQuery(tt.args.bouncer, tt.args.stringURL, tt.args.isPost) got, err := crowdsecQuery(tt.args.bouncer, tt.args.stringURL, tt.args.data)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("crowdsecQuery() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("crowdsecQuery() error = %v, wantErr %v", err, tt.wantErr)
return return
@@ -186,3 +187,148 @@ func Test_crowdsecQuery(t *testing.T) {
}) })
} }
} }
func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
html := "<html>You are banned</html>"
banTemplate, _ := htmltemplate.New("html").Parse(html)
tests := []struct {
name string
method string
banTemplate *htmltemplate.Template
expectBodyContent bool
}{
{
name: "GET request should have body with template",
method: http.MethodGet,
banTemplate: banTemplate,
expectBodyContent: true,
},
{
name: "HEAD request should NOT have body even with template",
method: http.MethodHead,
banTemplate: banTemplate,
expectBodyContent: false,
},
{
name: "POST request should have body with template",
method: http.MethodPost,
banTemplate: banTemplate,
expectBodyContent: true,
},
{
name: "PUT request should have body with template",
method: http.MethodPut,
banTemplate: banTemplate,
expectBodyContent: true,
},
{
name: "DELETE request should have body with template",
method: http.MethodDelete,
banTemplate: banTemplate,
expectBodyContent: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bouncer := &Bouncer{
remediationStatusCode: http.StatusForbidden,
remediationCustomHeader: "X-Test-Remediation",
banTemplate: tt.banTemplate,
}
rw := httptest.NewRecorder()
req := &http.Request{Method: tt.method}
bouncer.handleBanServeHTTP(rw, req, "0.0.0.0", "TEST")
// Check status code
if rw.Code != http.StatusForbidden {
t.Errorf("Expected status code 403, got %d", rw.Code)
}
// Check custom header
headerValue := rw.Header().Get("X-Test-Remediation")
if headerValue != "ban" {
t.Errorf("Expected header X-Test-Remediation to be 'ban', got %s", headerValue)
}
// Check body content
body := rw.Body.String()
hasBodyContent := len(body) > 0
if hasBodyContent != tt.expectBodyContent {
t.Errorf("Method %s: expected body content: %v, got body content: %v (body: %q)",
tt.method, tt.expectBodyContent, hasBodyContent, body)
}
// If we expect body content, verify it matches template
if tt.expectBodyContent && body != html {
t.Errorf("Expected body %q, got %q", html, body)
}
})
}
}
func TestCaptchaMethodBasedLogic(t *testing.T) {
tests := []struct {
name string
method string
remediation string
expectBanFallback bool
}{
{
name: "GET with captcha remediation should allow captcha",
method: http.MethodGet,
remediation: cache.CaptchaValue,
expectBanFallback: false,
},
{
name: "HEAD with captcha remediation should fallback to ban",
method: http.MethodHead,
remediation: cache.CaptchaValue,
expectBanFallback: true,
},
{
name: "POST with captcha remediation should allow captcha",
method: http.MethodPost,
remediation: cache.CaptchaValue,
expectBanFallback: false,
},
{
name: "PUT with captcha remediation should allow captcha",
method: http.MethodPut,
remediation: cache.CaptchaValue,
expectBanFallback: false,
},
{
name: "DELETE with captcha remediation should allow captcha",
method: http.MethodDelete,
remediation: cache.CaptchaValue,
expectBanFallback: false,
},
{
name: "PATCH with captcha remediation should allow captcha",
method: http.MethodPatch,
remediation: cache.CaptchaValue,
expectBanFallback: false,
},
{
name: "OPTIONS with captcha remediation should allow captcha",
method: http.MethodOptions,
remediation: cache.CaptchaValue,
expectBanFallback: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test the core logic: captcha is served for all methods except HEAD
shouldUseCaptcha := tt.remediation == cache.CaptchaValue && tt.method != http.MethodHead
if shouldUseCaptcha == tt.expectBanFallback {
t.Errorf("Method %s with %s remediation: expected ban fallback %v, but logic would use captcha %v",
tt.method, tt.remediation, tt.expectBanFallback, shouldUseCaptcha)
}
})
}
}
+1 -1
View File
@@ -293,7 +293,7 @@
</svg> </svg>
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1> <h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
</div> </div>
<form action="" method="POST" class="flex flex-col space-y-1" id="captcha-form"> <form action="" method="POST" class="flex flex-col items-center space-y-1" id="captcha-form">
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback"> <div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
</div> </div>
</form> </form>
+32 -6
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
@@ -36,7 +36,7 @@ services:
- "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"
whoami2: bar:
image: traefik/whoami image: traefik/whoami
container_name: "simple-service-bar" container_name: "simple-service-bar"
restart: unless-stopped restart: unless-stopped
@@ -48,12 +48,39 @@ services:
- "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.metricsupdateintervalseconds=15"
- "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:
image: traefik/whoami
container_name: "simple-service-bar2"
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.router-bar2.rule=PathPrefix(`/bar2`)"
- "traefik.http.routers.router-bar2.entrypoints=web"
- "traefik.http.routers.router-bar2.middlewares=crowdsec2@docker"
- "traefik.http.services.service-bar2.loadbalancer.server.port=80"
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.loglevel=DEBUG"
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.crowdsecmode=stream"
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.updateintervalseconds=10"
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.updatemaxfailure=-1"
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
bar3:
image: traefik/whoami
container_name: "simple-service-bar3"
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.router-bar3.rule=PathPrefix(`/bar3`)"
- "traefik.http.routers.router-bar3.entrypoints=web"
- "traefik.http.routers.router-bar3.middlewares=crowdsec2@docker"
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:v1.6.1-2 image: crowdsecurity/crowdsec:v1.6.8
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -67,7 +94,6 @@ services:
- crowdsec-config-local:/etc/crowdsec/ - crowdsec-config-local:/etc/crowdsec/
labels: labels:
- "traefik.enable=false" - "traefik.enable=false"
volumes: volumes:
logs-local: logs-local:
crowdsec-db-local: crowdsec-db-local:
+1 -1
View File
@@ -59,7 +59,7 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5" - "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:v1.6.1-2 image: crowdsecurity/crowdsec:v1.6.8
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -1,8 +1,6 @@
version: "3.8"
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:
@@ -15,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
@@ -48,8 +46,6 @@ services:
# Define AppSec host and port informations # Define AppSec host and port informations
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:v1.6.1-2 image: crowdsecurity/crowdsec:v1.6.1-2
container_name: "crowdsec" container_name: "crowdsec"
+2 -2
View File
@@ -20,7 +20,7 @@ For now 3 captcha providers are supported:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSiteKey=FIXME" - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSiteKey=FIXME"
# Define captcha secret key # Define captcha secret key
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSecretKey=FIXME" - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSecretKey=FIXME"
# Define captcha grade period seconds # Define captcha grace period seconds
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800" - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800"
# Define captcha HTML file path # Define captcha HTML file path
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html" - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
@@ -47,7 +47,7 @@ To instruct Crowdsec to use captcha remediation, change the `/etc/crowdsec/profi
- Return a captcha decision the first X times and then a ban decision. - Return a captcha decision the first X times and then a ban decision.
The second mode could be used to prevent repeated malicious activity. The second mode could be used to prevent repeated malicious activity.
More information is available on configuring Crowdsec in the [official documentation](https://docs.crowdsec.net/docs/next/profiles/captcha_profile/). More information is available on configuring Crowdsec in the [official documentation](https://docs.crowdsec.net/docs/next/local_api/profiles/captcha_profile/).
```yaml ```yaml
... ...
+1 -1
View File
@@ -293,7 +293,7 @@
</svg> </svg>
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1> <h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
</div> </div>
<form action="" method="POST" class="flex flex-col space-y-1" id="captcha-form"> <form action="" method="POST" class="flex flex-col items-center space-y-1" id="captcha-form">
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback"> <div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
</div> </div>
</form> </form>
+12
View File
@@ -45,3 +45,15 @@ To play the demo environment run:
```bash ```bash
make run_custom_ban_page make run_custom_ban_page
``` ```
## Another thing to note
In the html of the ban page, you can use:
- {{ .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.
- {{ .CustomHeader }} value of the specified Request Header (for example X-Request-ID)
```
<script>var remediation = "{{ .RemediationReason }}"</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.
+79
View File
@@ -0,0 +1,79 @@
# Example
Read the example captcha before this, to better understand what is done here.
### Traefik configuration
The minimal configuration is defined below to implement custom captcha.
This documentation use https://github.com/a-ve/wicketkeeper, a self-hosted captcha provider that have a similar API than big providers.
Minimal API requirement:
- the JS file URL to load the captcha on the served `captcha.html`
- the HTML className to tell to the JS where to display the challenge
- the verify URL endpoint to send the field `response` from the captcha with `content-type: application/x-www-form-urlencoded`
- the name of the field when you POST the resolved captcha to Traefik
- the JS file need to respect the `data-callback` on the div that contains the captcha if you use our template, but you can customize it by your side
```yaml
traefik:
...
labels:
# Choose captcha provider
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaProvider=custom"
# Define captcha grace period seconds
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaCustomJsURL=http://captcha.localhost:8000/fast.js"
# Inside Traefik container the plugin must be able to reach wicketkeeper service so we can go through a Traefik localhost
# domain which would resolve traefik itself and the port for the dashboard
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomValidateURL=http://wicketkeeper:8080/v0/siteverify"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomKey=wicketkeeper"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomResponse=wicketkeeper_solution"
# Define captcha HTML file path
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
```
```yaml
wicketkeeper:
image: ghcr.io/a-ve/wicketkeeper:latest
user: root
ports:
- "8080:8080"
environment:
- LISTEN_PORT=8080
- REDIS_ADDR=redis:6379
- DIFFICULTY=4
- ALLOWED_ORIGINS=*
- PRIVATE_KEY_PATH=/data/wicketkeeper.key
volumes:
- ./data:/data
depends_on:
- redis
redis:
image: redis/redis-stack-server:latest
```
```html
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback" data-challenge-url="http://captcha.localhost:8000/v0/challenge">
```
## Exemple navigation
We can try to query normally the whoami server:
```bash
curl http://localhost:8000/foo
```
We can try to ban ourself and retry.
```bash
docker exec crowdsec cscli decisions add --ip 10.0.0.20 -d 10m --type captcha
```
To play the demo environment run:
```bash
make run_custom_captcha
```
+4
View File
@@ -0,0 +1,4 @@
filenames:
- /var/log/traefik/access.log
labels:
type: traefik
+338
View File
@@ -0,0 +1,338 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>CrowdSec Captcha</title>
<meta content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
/*! tailwindcss v3.2.7 | MIT License | https://tailwindcss.com*/
*,
:after,
:before {
border: 0 solid #e5e7eb;
box-sizing: border-box
}
:after,
:before {
--tw-content: ""
}
html {
-webkit-text-size-adjust: 100%;
font-feature-settings: normal;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4
}
body {
line-height: inherit;
margin: 0
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit
}
a {
color: inherit;
text-decoration: inherit
}
h1,
h2,
h3,
h4,
h5,
h6,
hr,
p,
pre {
margin: 0
}
*,
::backdrop,
:after,
:before {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: #3b82f680;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia:
}
.flex {
display: flex
}
.flex-wrap {
flex-wrap: wrap
}
.inline-flex {
display: inline-flex
}
.h-24 {
height: 6rem
}
.h-6 {
height: 1.5rem
}
.h-full {
height: 100%
}
.h-screen {
height: 100vh
}
.text-center {
text-align: center
}
.w-24 {
width: 6rem
}
.w-6 {
width: 1.5rem
}
.w-full {
width: 100%
}
.w-screen {
width: 100vw
}
.my-3 {
margin-top: 0.75rem;
margin-bottom: 0.75rem
}
.flex-col {
flex-direction: column
}
.items-center {
align-items: center
}
.justify-center {
justify-content: center
}
.justify-between {
justify-content: space-between
}
.space-y-1>:not([hidden])~:not([hidden]) {
--tw-space-y-reverse: 0;
margin-bottom: calc(.25rem*var(--tw-space-y-reverse));
margin-top: calc(.25rem*(1 - var(--tw-space-y-reverse)))
}
.space-y-4>:not([hidden])~:not([hidden]) {
--tw-space-y-reverse: 0;
margin-bottom: calc(1rem*var(--tw-space-y-reverse));
margin-top: calc(1rem*(1 - var(--tw-space-y-reverse)))
}
.rounded-xl {
border-radius: .75rem
}
.border-2 {
border-width: 2px
}
.border-black {
--tw-border-opacity: 1;
border-color: rgb(0 0 0/var(--tw-border-opacity))
}
.p-4 {
padding: 1rem
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem
}
.py-2 {
padding-bottom: .5rem;
padding-top: .5rem
}
.text-2xl {
font-size: 1.5rem;
line-height: 2rem
}
.text-sm {
font-size: .875rem;
line-height: 1.25rem
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem
}
.font-bold {
font-weight: 700
}
.text-white {
--tw-text-opacity: 1;
color: rgb(255 255 255/var(--tw-text-opacity))
}
@media (min-width:640px) {
.sm\:w-2\/3 {
width: 66.666667%
}
}
@media (min-width:768px) {
.md\:flex-row {
flex-direction: row
}
}
@media (min-width:1024px) {
.lg\:w-1\/2 {
width: 50%
}
.lg\:text-3xl {
font-size: 1.875rem;
line-height: 2.25rem
}
.lg\:text-xl {
font-size: 1.25rem;
line-height: 1.75rem
}
}
@media (min-width:1280px) {
.xl\:text-4xl {
font-size: 2.25rem;
line-height: 2.5rem
}
}
</style>
<script src="{{ .FrontendJS }}" async defer></script>
</head>
<body class="h-screen w-screen p-4">
<div class="h-full w-full flex flex-col justify-center items-center">
<div class="border-2 border-black rounded-xl p-4 text-center w-full sm:w-2/3 lg:w-1/2">
<div class="flex flex-col items-center space-y-4">
<svg fill="black" class="h-24 w-24" aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="exclamation-triangle" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"
class="warning">
<path
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z">
</path>
</svg>
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
</div>
<form action="" method="POST" class="flex flex-col items-center space-y-1" id="captcha-form">
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback" data-challenge-url="http://captcha.localhost:8000/v0/challenge">
</div>
</form>
<div class="flex justify-center flex-wrap">
<p class="my-3">This security check has been powered by</p>
<a href="https://crowdsec.net/" target="_blank" rel="noopener" class="inline-flex flex-col items-center">
<svg fill="black" width="33.92" height="33.76" viewBox="0 0 254.4 253.2">
<defs>
<clipPath id="a">
<path d="M0 52h84v201.2H0zm0 0" />
</clipPath>
<clipPath id="b">
<path d="M170 52h84.4v201.2H170zm0 0" />
</clipPath>
</defs>
<path
d="M59.3 128.4c1.4 2.3 2.5 4.6 3.4 7-1-4.1-2.3-8.1-4.3-12-3.1-6-7.8-5.8-10.7 0-2 4-3.2 8-4.3 12.1 1-2.4 2-4.8 3.4-7.1 3.4-5.8 8.8-6 12.5 0M207.8 128.4a42.9 42.9 0 013.4 7c-1-4.1-2.3-8.1-4.3-12-3.2-6-7.8-5.8-10.7 0-2 4-3.3 8-4.3 12.1.9-2.4 2-4.8 3.4-7.1 3.4-5.8 8.8-6 12.5 0M134.6 92.9c2 3.5 3.6 7 4.8 10.7-1.3-5.4-3-10.6-5.6-15.7-4-7.5-9.7-7.2-13.3 0a75.4 75.4 0 00-5.6 16c1.2-3.8 2.7-7.4 4.7-11 4.1-7.2 10.6-7.5 15 0M43.8 136.8c.9 4.6 3.7 8.3 7.3 9.2 0 2.7 0 5.5.2 8.2.3 3.3.4 6.6 1 9.6.3 2.3 1 2.2 1.3 0 .5-3 .6-6.3 1-9.6l.2-8.2c3.5-1 6.4-4.6 7.2-9.2a17.8 17.8 0 01-9 2.4c-3.5 0-6.6-1-9.2-2.4M192.4 136.8c.8 4.6 3.7 8.3 7.2 9.2 0 2.7 0 5.5.3 8.2.3 3.3.4 6.6 1 9.6.3 2.3.9 2.2 1.2 0 .6-3 .7-6.3 1-9.6.2-2.7.3-5.5.2-8.2 3.6-1 6.4-4.6 7.3-9.2a17.8 17.8 0 01-9.1 2.4c-3.4 0-6.6-1-9.1-2.4M138.3 104.6c-3.1 1.9-7 3-11.3 3-4.3 0-8.2-1.1-11.3-3 1 5.8 4.5 10.3 9 11.5 0 3.4 0 6.8.3 10.2.4 4.1.5 8.2 1.2 12 .4 2.9 1.2 2.7 1.6 0 .7-3.8.8-7.9 1.2-12 .3-3.4.3-6.8.3-10.2 4.5-1.2 8-5.7 9-11.5" />
<path
d="M51 146c0 2.7.1 5.5.3 8.2.3 3.3.4 6.6 1 9.6.3 2.3 1 2.2 1.3 0 .5-3 .6-6.3 1-9.6l.2-8.2c3.5-1 6.4-4.6 7.2-9.2a17.8 17.8 0 01-9 2.4c-3.5 0-6.6-1-9.2-2.4.9 4.6 3.7 8.3 7.3 9.2M143.9 105c-1.9-.4-3.5-1.2-4.9-2.3 1.4 5.6 2.5 11.3 4 17 1.2 5 2 10 2.4 15 .6 7.8-4.5 14.5-10.9 14.5h-15c-6.4 0-11.5-6.7-11-14.5.5-5 1.3-10 2.6-15 1.3-5.3 2.3-10.5 3.6-15.7-2.2 1.2-4.8 1.9-7.7 2-4.7.1-9.4-.3-14-1-4-.4-6.7-3-8-6.7-1.3-3.4-2-7-3.3-10.4-.5-1.5-1.6-2.8-2.4-4.2-.4-.6-.8-1.2-.9-1.8v-7.8a77 77 0 0124.5-3c6.1 0 12 1 17.8 3.2 4.7 1.7 9.7 1.8 14.4 0 9-3.4 18.2-3.8 27.5-3 4.9.5 9.8 1.6 14.8 2.4v8.2c0 .6-.3 1.5-.7 1.7-2 .9-2.2 2.7-2.7 4.5-.9 3.2-1.8 6.4-2.9 9.5a11 11 0 01-8.8 7.7 40.6 40.6 0 01-18.4-.2m29.4 80.6c-3.2-26.8-6.4-50-8.9-60.7a14.3 14.3 0 0014.1-14h.4a9 9 0 005.6-16.5 14.3 14.3 0 00-3.7-27.2 9 9 0 00-6.9-14.6c2.4-1.1 4.5-3 5.8-5 3.4-5.3 4-29-8-44.4-5-6.3-9.8-2.5-10 1.8-1 13.2-1.1 23-4.5 34.3a9 9 0 00-16-4.1 14.3 14.3 0 00-28.4 0 9 9 0 00-16 4.1c-3.4-11.2-3.5-21.1-4.4-34.3-.3-4.3-5.2-8-10-1.8-12 15.3-11.5 39-8.1 44.4 1.3 2 3.4 3.9 5.8 5a9 9 0 00-7 14.6 14.3 14.3 0 00-3.6 27.2A9 9 0 0075 111h.5a14.5 14.5 0 0014.3 14c-4 17.2-10 66.3-15 111.3l-1.3 13.4a1656.4 1656.4 0 01106.6 0l-1.4-12.7-5.4-51.3" />
<g clip-path="url(#a)">
<path
d="M83.5 136.6l-2.3.7c-5 1-9.8 1-14.8-.2-1.4-.3-2.7-1-3.8-1.9l3.1 13.7c1 4 1.7 8 2 12 .5 6.3-3.6 11.6-8.7 11.6H46.9c-5.1 0-9.2-5.3-8.7-11.6.3-4 1-8 2-12 1-4.2 1.8-8.5 2.9-12.6-1.8 1-3.9 1.5-6.3 1.6a71 71 0 01-11.1-.7 7.7 7.7 0 01-6.5-5.5c-1-2.7-1.6-5.6-2.6-8.3-.4-1.2-1.3-2.3-2-3.4-.2-.4-.6-1-.6-1.4v-6.3c6.4-2 13-2.6 19.6-2.5 4.9.1 9.6 1 14.2 2.6 3.9 1.4 7.9 1.5 11.7 0 1.8-.7 3.6-1.2 5.5-1.6a13 13 0 01-1.6-15.5A18.3 18.3 0 0159 73.1a11.5 11.5 0 00-17.4 8.1 7.2 7.2 0 00-12.9 3.3c-2.7-9-2.8-17-3.6-27.5-.2-3.4-4-6.5-8-1.4C7.5 67.8 7.9 86.9 10.6 91c1.1 1.7 2.8 3.1 4.7 4a7.2 7.2 0 00-5.6 11.7 11.5 11.5 0 00-2.9 21.9 7.2 7.2 0 004.5 13.2h.3c0 .6 0 1.1.2 1.7.9 5.4 5.6 9.5 11.3 9.5A1177.2 1177.2 0 0010 253.2c18.1-1.5 38.1-2.6 59.5-3.4.4-4.6.8-9.3 1.4-14 1.2-11.6 3.3-30.5 5.7-49.7 2.2-18 4.7-36.3 7-49.5" />
</g>
<g clip-path="url(#b)">
<path
d="M254.4 118.2c0-5.8-4.2-10.5-9.7-11.4a7.2 7.2 0 00-5.6-11.7c2-.9 3.6-2.3 4.7-4 2.7-4.2 3.1-23.3-6.5-35.5-4-5.1-7.8-2-8 1.4-.8 10.5-.9 18.5-3.6 27.5a7.2 7.2 0 00-12.8-3.3 11.5 11.5 0 00-17.8-7.9 18.4 18.4 0 01-4.5 22 13 13 0 01-1.3 15.2c2.4.5 4.8 1 7.1 2 3.8 1.3 7.8 1.4 11.6 0 7.2-2.8 14.6-3 22-2.4 4 .4 7.9 1.2 12 1.9l-.1 6.6c0 .5-.2 1.2-.5 1.3-1.7.7-1.8 2.2-2.2 3.7l-2.3 7.6a8.8 8.8 0 01-7 6.1c-5 1-10 1-14.9-.2-1.5-.3-2.8-1-3.9-1.9 1.2 4.5 2 9.1 3.2 13.7 1 4 1.6 8 2 12 .4 6.3-3.6 11.6-8.8 11.6h-12c-5.2 0-9.3-5.3-8.8-11.6.4-4 1-8 2-12 1-4.2 1.9-8.5 3-12.6-1.8 1-4 1.5-6.3 1.6-3.7 0-7.5-.3-11.2-.7a7.7 7.7 0 01-3.7-1.5c3.1 18.4 7.1 51.2 12.5 100.9l.6 5.3.8 7.9c21.4.7 41.5 1.9 59.7 3.4L243 243l-4.4-41.2a606 606 0 00-7-48.7 11.5 11.5 0 0011.2-11.2h.4a7.2 7.2 0 004.4-13.2c4-1.8 6.8-5.8 6.8-10.5" />
</g>
<path
d="M180 249.6h.4a6946 6946 0 00-7.1-63.9l5.4 51.3 1.4 12.6M164.4 125c2.5 10.7 5.7 33.9 8.9 60.7a570.9 570.9 0 00-8.9-60.7M74.8 236.3l-1.4 13.4 1.4-13.4" />
</svg>
<span>CrowdSec</span>
</a>
</div>
</div>
</div>
<script>
function captchaCallback() {
setTimeout(() => document.querySelector('#captcha-form').submit(), 500);
}
</script>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
services:
traefik:
image: "traefik:v3.5.0"
container_name: "traefik"
restart: unless-stopped
command:
# - "--log.level=DEBUG"
- "--accesslog"
- "--accesslog.filepath=/var/log/traefik/access.log"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.forwardedheaders.trustedips=172.18.0.0/24"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.4.5"
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- logs-custom-captcha-enabled:/var/log/traefik
- "./captcha.html:/captcha.html"
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
ports:
- 8000:80
- 8080:8080
depends_on:
- crowdsec
whoami-foo:
image: traefik/whoami
container_name: "simple-service-custom-captcha-foo"
restart: unless-stopped
labels:
- "traefik.enable=true"
# Definition of the router
- "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.router-foo.entrypoints=web"
- "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
# Definition of the service
- "traefik.http.services.service-foo.loadbalancer.server.port=80"
# Definition of the middleware
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=none"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
# Choose captcha provider
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaProvider=custom"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaCustomJsURL=http://captcha.localhost:8000/fast.js"
# Inside Traefik container the plugin must be able to reach wicketkeeper service so we can go through a Traefik localhost
# domain which would resolve traefik itself and the port for the dashboard
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomValidateURL=http://wicketkeeper:8080/v0/siteverify"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomKey=wicketkeeper"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomResponse=wicketkeeper_solution"
# Define captcha grade period seconds
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=20"
# Define captcha HTML file path
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
crowdsec:
image: crowdsecurity/crowdsec:v1.6.1-2
container_name: "crowdsec"
restart: unless-stopped
environment:
COLLECTIONS: crowdsecurity/traefik
CUSTOM_HOSTNAME: crowdsec
BOUNCER_KEY_TRAEFIK_DEV: 40796d93c2958f9e58345514e67740e5
volumes:
# For captcha and ban mixed decision
- "./profiles.yaml:/etc/crowdsec/profiles.yaml:ro"
# For captcha only remediation
# - './profiles_captcha_only.yaml:/etc/crowdsec/profiles.yaml:ro'
- "./acquis.yaml:/etc/crowdsec/acquis.yaml:ro"
- logs-custom-captcha-enabled:/var/log/traefik:ro
- crowdsec-db-custom-captcha-enabled:/var/lib/crowdsec/data/
- crowdsec-config-custom-captcha-enabled:/etc/crowdsec/
labels:
- "traefik.enable=false"
wicketkeeper:
image: ghcr.io/a-ve/wicketkeeper:latest
container_name: "wicketkeeper"
environment:
- LISTEN_PORT=8080
- REDIS_ADDR=redis:6379
- DIFFICULTY=4
- ALLOWED_ORIGINS=*
- PRIVATE_KEY_PATH=/data/wicketkeeper.key # To override in production environment
volumes:
- wicketkeeper-custom-captcha-enabled:/data
user: root
labels:
- "traefik.enable=true"
# Definition of the router
- "traefik.http.routers.router-wicketkeeper.rule=Host(`captcha.localhost`)"
- "traefik.http.routers.router-wicketkeeper.entrypoints=web"
# Definition of the service
- "traefik.http.services.service-wicketkeeper.loadbalancer.server.port=8080"
depends_on:
- redis
redis:
image: redis/redis-stack-server:latest
volumes:
logs-custom-captcha-enabled:
wicketkeeper-custom-captcha-enabled:
crowdsec-db-custom-captcha-enabled:
crowdsec-config-custom-captcha-enabled:
+17
View File
@@ -0,0 +1,17 @@
name: captcha_remediation
filters:
- Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http" && GetDecisionsSinceCount(Alert.GetValue(), "24h") <= 3
## Same as above but only 3 captcha decision per 24 hours before ban
decisions:
- type: captcha
duration: 4h
on_success: break
---
name: default_ip_remediation
filters:
- Alert.Remediation == true && Alert.GetScope() == "Ip"
decisions:
- type: ban
duration: 4h
#duration_expr: "Sprintf('%dh', (GetDecisionsCount(Alert.GetValue()) + 1) * 4)"
on_success: break
+1
View File
@@ -6,6 +6,7 @@ logs:
level: DEBUG level: DEBUG
access: access:
enabled: true enabled: true
format: json
fields: fields:
headers: headers:
defaultmode: keep defaultmode: keep
+3 -1
View File
@@ -3,7 +3,9 @@ You need to create a crowdsec API credentials for the CAPI.
You can follow the documentation here: https://docs.crowdsec.net/docs/central_api/intro You can follow the documentation here: https://docs.crowdsec.net/docs/central_api/intro
```bash ```bash
curl -X POST "https://api.crowdsec.net/v2/watchers" -H "accept: application/json" -H "Content-Type: application/json" -d "{ \"password\": \"PASSWORD\", \"machine_id\": \"LOGIN\"}" LOGIN=...
PASSWORD=...
curl -X POST "https://api.crowdsec.net/v2/watchers" -H "accept: application/json" -H "Content-Type: application/json" -d "{ \"password\": \"$PASSWORD\", \"machine_id\": \"$LOGIN\"}"
``` ```
These CAPI credentials must be set in your docker-compose.yml or in your config files These CAPI credentials must be set in your docker-compose.yml or in your config files
+1 -2
View File
@@ -35,8 +35,7 @@ services:
- "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=alone" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecMode=alone"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiMachineId=FIXME" - "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiMachineId=FIXME"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiPassword=FIXME" - "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiPassword=FIXME"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseccapiscenarios=crowdsecurity/sshd,crowdsecurity/asterisk_bf,crowdsecurity/asterisk_user_enum,crowdsecurity/base-http-scenarios" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseccapiscenarios=crowdsecurity/sshd,crowdsecurity/asterisk_bf,crowdsecurity/asterisk_user_enum,crowdsecurity/base-http-scenarios"
+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
+17 -11
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"
@@ -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
+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" && \
+1 -1
View File
@@ -4,5 +4,5 @@ go 1.22
require ( require (
github.com/leprosus/golang-ttl-map v1.1.7 github.com/leprosus/golang-ttl-map v1.1.7
github.com/maxlerebourg/simpleredis v1.0.11 github.com/maxlerebourg/simpleredis v1.0.12
) )
+2 -2
View File
@@ -1,4 +1,4 @@
github.com/leprosus/golang-ttl-map v1.1.7 h1:cF4AAFDDnJTFSV+/42sKLhmMluvLdRlCGS2UaifH6UM= github.com/leprosus/golang-ttl-map v1.1.7 h1:cF4AAFDDnJTFSV+/42sKLhmMluvLdRlCGS2UaifH6UM=
github.com/leprosus/golang-ttl-map v1.1.7/go.mod h1:4QWHJPeVBbrkhOhXdhCv9IEiyj/YzkO04/iexy4vSe0= github.com/leprosus/golang-ttl-map v1.1.7/go.mod h1:4QWHJPeVBbrkhOhXdhCv9IEiyj/YzkO04/iexy4vSe0=
github.com/maxlerebourg/simpleredis v1.0.11 h1:B33TUeIrHtJH2/Qj2bRdU+UZ1BvZwFyP55JWMxHirWg= github.com/maxlerebourg/simpleredis v1.0.12 h1:VsJpk2l8U9QqxOWbYnEXbrs7iSNZFm2fNmFvlxYlQNk=
github.com/maxlerebourg/simpleredis v1.0.11/go.mod h1:lT4LX02SOsE9PxUcSrz1QW5ZnO86gPbaiYBxmtcXEls= github.com/maxlerebourg/simpleredis v1.0.12/go.mod h1:lT4LX02SOsE9PxUcSrz1QW5ZnO86gPbaiYBxmtcXEls=
+11 -6
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 (
@@ -23,6 +22,8 @@ const (
CaptchaDoneValue = "d" CaptchaDoneValue = "d"
// CacheMiss error string when cache is miss. // CacheMiss error string when cache is miss.
CacheMiss = "cache:miss" CacheMiss = "cache:miss"
// CacheUnreachable error string when cache is unreachable.
CacheUnreachable = "cache:unreachable"
) )
//nolint:gochecknoglobals //nolint:gochecknoglobals
@@ -51,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) {
@@ -60,9 +61,13 @@ func (redisCache) get(key string) (string, error) {
if err == nil && len(valueString) > 0 { if err == nil && len(valueString) > 0 {
return valueString, nil return valueString, nil
} }
if err.Error() == simpleredis.RedisMiss { errRedisMessage := err.Error()
if errRedisMessage == simpleredis.RedisMiss {
return "", errors.New(CacheMiss) return "", errors.New(CacheMiss)
} }
if errRedisMessage == simpleredis.RedisUnreachable {
return "", errors.New(CacheUnreachable)
}
return "", err return "", err
} }
@@ -87,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)
+3 -3
View File
@@ -11,7 +11,7 @@ import (
func Test_Get(t *testing.T) { func Test_Get(t *testing.T) {
IPInCache := "10.0.0.10" IPInCache := "10.0.0.10"
IPNotInCache := "10.0.0.20" IPNotInCache := "10.0.0.20"
client := &Client{cache: &localCache{}, log: logger.New("INFO")} client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
client.Set(IPInCache, BannedValue, 10) client.Set(IPInCache, BannedValue, 10)
type args struct { type args struct {
clientIP string clientIP string
@@ -47,7 +47,7 @@ func Test_Get(t *testing.T) {
} }
func Test_Set(t *testing.T) { func Test_Set(t *testing.T) {
client := &Client{cache: &localCache{}, log: logger.New("INFO")} client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
IPInCache := "10.0.0.11" IPInCache := "10.0.0.11"
type args struct { type args struct {
clientIP string clientIP string
@@ -88,7 +88,7 @@ func Test_Set(t *testing.T) {
func Test_Delete(t *testing.T) { func Test_Delete(t *testing.T) {
IPInCache := "10.0.0.12" IPInCache := "10.0.0.12"
IPNotInCache := "10.0.0.22" IPNotInCache := "10.0.0.22"
client := &Client{cache: &localCache{}, log: logger.New("INFO")} client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
client.Set(IPInCache, BannedValue, 10) client.Set(IPInCache, BannedValue, 10)
type args struct { type args struct {
clientIP string clientIP string
+42 -30
View File
@@ -5,19 +5,18 @@ 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.
type Client struct { type Client struct {
Valid bool Valid bool
provider string
siteKey string siteKey string
secretKey string secretKey string
remediationCustomHeader string remediationCustomHeader string
@@ -25,45 +24,55 @@ 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
} }
// Information for self-hosted provider.
type infoProvider struct { type infoProvider struct {
js string js string
key string key string
response string
validate string validate string
} }
var ( //nolint:gochecknoglobals
//nolint:gochecknoglobals var infoProviders = map[string]*infoProvider{
captcha = map[string]infoProvider{ configuration.HcaptchaProvider: {
configuration.HcaptchaProvider: { js: "https://hcaptcha.com/1/api.js",
js: "https://hcaptcha.com/1/api.js", key: "h-captcha",
key: "h-captcha", response: "h-captcha-response",
validate: "https://api.hcaptcha.com/siteverify", validate: "https://api.hcaptcha.com/siteverify",
}, },
configuration.RecaptchaProvider: { configuration.RecaptchaProvider: {
js: "https://www.google.com/recaptcha/api.js", js: "https://www.google.com/recaptcha/api.js",
key: "g-recaptcha", key: "g-recaptcha",
validate: "https://www.google.com/recaptcha/api/siteverify", response: "g-recaptcha-response",
}, validate: "https://www.google.com/recaptcha/api/siteverify",
configuration.TurnstileProvider: { },
js: "https://challenges.cloudflare.com/turnstile/v0/api.js", configuration.TurnstileProvider: {
key: "cf-turnstile", js: "https://challenges.cloudflare.com/turnstile/v0/api.js",
validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify", key: "cf-turnstile",
}, response: "cf-turnstile-response",
} validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
) },
}
// New Initialize captcha client. // New Initialize captcha client.
func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, 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
} }
var info *infoProvider
if provider == configuration.CustomProvider {
info = &infoProvider{js: js, key: key, response: response, validate: validate}
} else {
info = infoProviders[provider]
}
c.infoProvider = info
c.siteKey = siteKey c.siteKey = siteKey
c.secretKey = secretKey c.secretKey = secretKey
c.provider = provider
c.remediationCustomHeader = remediationCustomHeader c.remediationCustomHeader = remediationCustomHeader
html, _ := configuration.GetHTMLTemplate(captchaTemplatePath) html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
c.captchaTemplate = html c.captchaTemplate = html
@@ -85,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
} }
@@ -95,8 +107,8 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
err = c.captchaTemplate.Execute(rw, map[string]string{ err = c.captchaTemplate.Execute(rw, map[string]string{
"SiteKey": c.siteKey, "SiteKey": c.siteKey,
"FrontendJS": captcha[c.provider].js, "FrontendJS": c.infoProvider.js,
"FrontendKey": captcha[c.provider].key, "FrontendKey": c.infoProvider.key,
}) })
if err != nil { if err != nil {
c.log.Info("captcha:ServeHTTP captchaTemplateServe " + err.Error()) c.log.Info("captcha:ServeHTTP captchaTemplateServe " + err.Error())
@@ -121,7 +133,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
c.log.Debug("captcha:Validate invalid method: " + r.Method) c.log.Debug("captcha:Validate invalid method: " + r.Method)
return false, nil return false, nil
} }
var response = r.FormValue(captcha[c.provider].key + "-response") var response = r.FormValue(c.infoProvider.response)
if response == "" { if response == "" {
c.log.Debug("captcha:Validate no captcha response found in request") c.log.Debug("captcha:Validate no captcha response found in request")
return false, nil return false, nil
@@ -129,7 +141,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
var body = url.Values{} var body = url.Values{}
body.Add("secret", c.secretKey) body.Add("secret", c.secretKey)
body.Add("response", response) body.Add("response", response)
res, err := c.httpClient.PostForm(captcha[c.provider].validate, body) res, err := c.httpClient.PostForm(c.infoProvider.validate, body)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -138,7 +150,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
c.log.Error("captcha:Validate " + err.Error()) c.log.Error("captcha:Validate " + err.Error())
} }
}() }()
if !strings.Contains(res.Header.Get("content-type"), "application/json") { if !strings.Contains(res.Header.Get("Content-Type"), "application/json") {
c.log.Debug("captcha:Validate responseType:noJson") c.log.Debug("captcha:Validate responseType:noJson")
return false, nil return false, nil
} }
+229 -111
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.
@@ -28,57 +28,89 @@ const (
AppsecMode = "appsec" AppsecMode = "appsec"
HTTPS = "https" HTTPS = "https"
HTTP = "http" HTTP = "http"
LogDEBUG = "DEBUG"
LogINFO = "INFO"
LogWARN = "WARN"
LogERROR = "ERROR"
ReasonTECH = "TECHNICAL_ISSUE"
ReasonLAPI = "LAPI"
ReasonAPPSEC = "APPSEC"
HcaptchaProvider = "hcaptcha" HcaptchaProvider = "hcaptcha"
RecaptchaProvider = "recaptcha" RecaptchaProvider = "recaptcha"
TurnstileProvider = "turnstile" TurnstileProvider = "turnstile"
CustomProvider = "custom"
) )
// 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"`
CrowdsecMode string `json:"crowdsecMode,omitempty"` LogFormat string `json:"logFormat,omitempty"`
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"` LogFilePath string `json:"logFilePath,omitempty"`
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"` CrowdsecMode string `json:"crowdsecMode,omitempty"`
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"` CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"` CrowdsecAppsecScheme string `json:"crowdsecAppsecScheme,omitempty"`
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"` CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"` CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"`
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"` CrowdsecAppsecKey string `json:"crowdsecAppsecKey,omitempty"`
CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"` CrowdsecAppsecKeyFile string `json:"crowdsecAppsecKeyFile,omitempty"`
CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"` CrowdsecAppsecTLSInsecureVerify bool `json:"crowdsecAppsecTlsInsecureVerify,omitempty"`
CrowdsecLapiTLSCertificateAuthority string `json:"crowdsecLapiTlsCertificateAuthority,omitempty"` CrowdsecAppsecTLSCertificateAuthority string `json:"crowdsecAppsecTlsCertificateAuthority,omitempty"`
CrowdsecLapiTLSCertificateAuthorityFile string `json:"crowdsecLapiTlsCertificateAuthorityFile,omitempty"` CrowdsecAppsecTLSCertificateAuthorityFile string `json:"crowdsecAppsecTlsCertificateAuthorityFile,omitempty"`
CrowdsecLapiTLSCertificateBouncer string `json:"crowdsecLapiTlsCertificateBouncer,omitempty"` CrowdsecAppsecTLSCertificateBouncer string `json:"crowdsecAppsecTlsCertificateBouncer,omitempty"`
CrowdsecLapiTLSCertificateBouncerFile string `json:"crowdsecLapiTlsCertificateBouncerFile,omitempty"` CrowdsecAppsecTLSCertificateBouncerFile string `json:"crowdsecAppsecTlsCertificateBouncerFile,omitempty"`
CrowdsecLapiTLSCertificateBouncerKey string `json:"crowdsecLapiTlsCertificateBouncerKey,omitempty"` CrowdsecAppsecTLSCertificateBouncerKey string `json:"crowdsecAppsecTlsCertificateBouncerKey,omitempty"`
CrowdsecLapiTLSCertificateBouncerKeyFile string `json:"crowdsecLapiTlsCertificateBouncerKeyFile,omitempty"` CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
CrowdsecCapiMachineID string `json:"crowdsecCapiMachineId,omitempty"` CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
CrowdsecCapiMachineIDFile string `json:"crowdsecCapiMachineIdFile,omitempty"` CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
CrowdsecCapiPassword string `json:"crowdsecCapiPassword,omitempty"` CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"` CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"` CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"` CrowdsecLapiPath string `json:"crowdsecLapiPath,omitempty"`
UpdateMaxFailure int `json:"updateMaxFailure,omitempty"` CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"` CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"`
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"` CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"`
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"` CrowdsecLapiTLSCertificateAuthority string `json:"crowdsecLapiTlsCertificateAuthority,omitempty"`
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"` CrowdsecLapiTLSCertificateAuthorityFile string `json:"crowdsecLapiTlsCertificateAuthorityFile,omitempty"`
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"` CrowdsecLapiTLSCertificateBouncer string `json:"crowdsecLapiTlsCertificateBouncer,omitempty"`
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"` CrowdsecLapiTLSCertificateBouncerFile string `json:"crowdsecLapiTlsCertificateBouncerFile,omitempty"`
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"` CrowdsecLapiTLSCertificateBouncerKey string `json:"crowdsecLapiTlsCertificateBouncerKey,omitempty"`
RedisCacheHost string `json:"redisCacheHost,omitempty"` CrowdsecLapiTLSCertificateBouncerKeyFile string `json:"crowdsecLapiTlsCertificateBouncerKeyFile,omitempty"`
RedisCachePassword string `json:"redisCachePassword,omitempty"` CrowdsecCapiMachineID string `json:"crowdsecCapiMachineId,omitempty"`
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"` CrowdsecCapiMachineIDFile string `json:"crowdsecCapiMachineIdFile,omitempty"`
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"` CrowdsecCapiPassword string `json:"crowdsecCapiPassword,omitempty"`
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"`
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"`
CaptchaProvider string `json:"captchaProvider,omitempty"` UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"` MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"` UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"` StreamStartupBlock bool `json:"streamStartupBlock,omitempty"`
CaptchaSecretKeyFile string `json:"captchaSecretKeyFile,omitempty"` DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
CaptchaGracePeriodSeconds int64 `json:"captchaGracePeriodSeconds,omitempty"` RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
TraceHeadersCustomName string `json:"traceHeadersCustomName,omitempty"`
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"`
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
RedisCacheHost string `json:"redisCacheHost,omitempty"`
RedisCachePassword string `json:"redisCachePassword,omitempty"`
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
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 {
@@ -93,35 +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: "INFO", LogLevel: LogINFO,
CrowdsecMode: LiveMode, LogFormat: "common",
CrowdsecAppsecEnabled: false, LogFilePath: "",
CrowdsecAppsecHost: "crowdsec:7422", CrowdsecMode: LiveMode,
CrowdsecAppsecFailureBlock: true, CrowdsecAppsecEnabled: false,
CrowdsecAppsecUnreachableBlock: true, CrowdsecAppsecFailureBlock: true,
CrowdsecLapiScheme: HTTP, CrowdsecAppsecUnreachableBlock: true,
CrowdsecLapiHost: "crowdsec:8080", CrowdsecAppsecBodyLimit: 10485760,
CrowdsecLapiKey: "", CrowdsecAppsecScheme: "",
CrowdsecLapiTLSInsecureVerify: false, CrowdsecAppsecHost: "crowdsec:7422",
UpdateIntervalSeconds: 60, CrowdsecAppsecPath: "/",
UpdateMaxFailure: 0, CrowdsecAppsecKey: "",
DefaultDecisionSeconds: 60, CrowdsecAppsecTLSInsecureVerify: false,
HTTPTimeoutSeconds: 10, CrowdsecLapiScheme: HTTP,
CaptchaProvider: "", CrowdsecLapiHost: "crowdsec:8080",
CaptchaSiteKey: "", CrowdsecLapiPath: "/",
CaptchaSecretKey: "", CrowdsecLapiKey: "",
CaptchaGracePeriodSeconds: 1800, CrowdsecLapiTLSInsecureVerify: false,
CaptchaHTMLFilePath: "/captcha.html", UpdateIntervalSeconds: 60,
BanHTMLFilePath: "", MetricsUpdateIntervalSeconds: 600,
RemediationHeadersCustomName: "", UpdateMaxFailure: 0,
ForwardedHeadersCustomName: "X-Forwarded-For", StreamStartupBlock: true,
ForwardedHeadersTrustedIPs: []string{}, DefaultDecisionSeconds: 60,
ClientTrustedIPs: []string{}, RemediationStatusCode: http.StatusForbidden,
RedisCacheEnabled: false, HTTPTimeoutSeconds: 10,
RedisCacheHost: "redis:6379", CaptchaProvider: "",
RedisCachePassword: "", CaptchaCustomJsURL: "",
RedisCacheDatabase: "", CaptchaCustomValidateURL: "",
CaptchaCustomKey: "",
CaptchaCustomResponse: "",
CaptchaSiteKey: "",
CaptchaSecretKey: "",
CaptchaGracePeriodSeconds: 1800,
CaptchaHTMLFilePath: "/captcha.html",
BanHTMLFilePath: "",
TraceHeadersCustomName: "",
RemediationHeadersCustomName: "",
ForwardedHeadersCustomName: "X-Forwarded-For",
ForwardedHeadersTrustedIPs: []string{},
ClientTrustedIPs: []string{},
RedisCacheEnabled: false,
RedisCacheHost: "redis:6379",
RedisCachePassword: "",
RedisCacheDatabase: "",
RedisCacheUnreachableBlock: true,
} }
} }
@@ -174,15 +223,19 @@ 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
} }
if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil { if err := validateCaptcha(config); err != nil {
return err return err
} }
if err := validateParamsIPs(config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
if err := validateParamsIPs(log, config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
return err
}
if err := validateParamsIPs(log, config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
return err return err
} }
@@ -217,11 +270,11 @@ func ValidateParams(config *Config) error {
} }
} }
if err := validateURL("CrowdsecLapi", config.CrowdsecLapiScheme, config.CrowdsecLapiHost); err != nil { if err := validateURL("CrowdsecLapi", config.CrowdsecLapiScheme, config.CrowdsecLapiHost, config.CrowdsecLapiPath); err != nil {
return err return err
} }
if err := validateURL("CrowdsecAppsec", config.CrowdsecLapiScheme, config.CrowdsecAppsecHost); err != nil { if err := validateURL("CrowdsecAppsec", config.CrowdsecLapiScheme, config.CrowdsecAppsecHost, config.CrowdsecAppsecPath); err != nil {
return err return err
} }
@@ -229,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
@@ -237,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
} }
} }
@@ -254,14 +320,25 @@ func ValidateParams(config *Config) error {
} }
} }
// Check logging configuration
// to upper allow of anycase of log level
if !contains([]string{LogDEBUG, LogINFO, LogWARN, LogERROR}, strings.ToUpper(config.LogLevel)) {
return fmt.Errorf("LogLevel should be one of (%s,%s,%s,%s)", LogDEBUG, LogINFO, LogWARN, LogERROR)
}
if config.LogFilePath != "" {
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("LogFilePath is not writable %w", err)
}
}
return nil return nil
} }
func validateURL(variable, scheme, host string) error { func validateURL(variable, scheme, host, path string) error {
// This only check that the format of the URL scheme://host is correct and do not make requests // This only check that the format of the URL scheme://host/path is correct and do not make requests
testURL := url.URL{Scheme: scheme, Host: host} testURL := url.URL{Scheme: scheme, Host: host, Path: path}
if _, err := http.NewRequest(http.MethodGet, testURL.String(), nil); err != nil { if _, err := http.NewRequest(http.MethodGet, testURL.String(), nil); err != nil {
return fmt.Errorf("CrowdsecLapiScheme://%sHost: '%v://%v' must be an URL", variable, scheme, host) return fmt.Errorf("CrowdsecLapiScheme://%sHost: '%v://%v%v' must be a valid URL", variable, scheme, host, path)
} }
return nil return nil
} }
@@ -270,10 +347,10 @@ func validateURL(variable, scheme, host 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
} }
@@ -294,15 +371,33 @@ 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("INFO"), 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)
} }
} }
return nil return nil
} }
func validateCaptcha(config *Config) error {
if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider}, config.CaptchaProvider) {
return fmt.Errorf("CaptchaProvider: must be one of '%s', '%s', '%s' or '%s'", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider)
}
if config.CaptchaProvider == CustomProvider {
if config.CaptchaCustomKey == "" || config.CaptchaCustomResponse == "" || config.CaptchaCustomValidateURL == "" || config.CaptchaCustomJsURL == "" {
return fmt.Errorf(
"CaptchaProvider: provider is custom, captchaCustom variables must be filled: CaptchaCustomKey:%s, CaptchaCustomResponse:%s, CaptchaCustomValidateURL:%s, CaptchaCustomJsURL:%s",
config.CaptchaCustomKey,
config.CaptchaCustomResponse,
config.CaptchaCustomValidateURL,
config.CaptchaCustomJsURL,
)
}
}
return nil
}
func validateParamsRequired(config *Config) error { func validateParamsRequired(config *Config) error {
requiredStrings := map[string]string{ requiredStrings := map[string]string{
"CrowdsecLapiScheme": config.CrowdsecLapiScheme, "CrowdsecLapiScheme": config.CrowdsecLapiScheme,
@@ -311,23 +406,38 @@ func validateParamsRequired(config *Config) error {
} }
for key, val := range requiredStrings { for key, val := range requiredStrings {
if len(val) == 0 { if len(val) == 0 {
return fmt.Errorf("%v: cannot be empty", key) return errors.New(key + ": cannot be empty")
} }
} }
requiredInt := map[string]int64{ requiredInt0 := map[string]int64{
"CrowdsecAppsecBodyLimit": config.CrowdsecAppsecBodyLimit,
"MetricsUpdateIntervalSeconds": config.MetricsUpdateIntervalSeconds,
}
for key, val := range requiredInt0 {
if val < 0 {
return errors.New(key + ": cannot be less than 0")
}
}
requiredInt1 := map[string]int64{
"UpdateIntervalSeconds": config.UpdateIntervalSeconds, "UpdateIntervalSeconds": config.UpdateIntervalSeconds,
"DefaultDecisionSeconds": config.DefaultDecisionSeconds, "DefaultDecisionSeconds": config.DefaultDecisionSeconds,
"HTTPTimeoutSeconds": config.HTTPTimeoutSeconds, "HTTPTimeoutSeconds": config.HTTPTimeoutSeconds,
"CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds, "CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds,
} }
for key, val := range requiredInt { for key, val := range requiredInt1 {
if val < 1 { if val < 1 {
return fmt.Errorf("%v: cannot be less than 1", key) return errors.New(key + ": cannot be less than 1")
} }
} }
if config.UpdateMaxFailure < -1 { if config.UpdateMaxFailure < -1 {
return errors.New("UpdateMaxFailure: cannot be less than -1") return errors.New("UpdateMaxFailure: cannot be less than -1")
} }
if config.CrowdsecAppsecBodyLimit < 0 {
return errors.New("CrowdsecAppsecBodyLimit: cannot be less than 0")
}
if config.RemediationStatusCode < 100 || config.RemediationStatusCode >= 600 {
return errors.New("RemediationStatusCode: cannot be less than 100 and more than 600")
}
if !contains([]string{NoneMode, LiveMode, StreamMode, AloneMode, AppsecMode}, config.CrowdsecMode) { if !contains([]string{NoneMode, LiveMode, StreamMode, AloneMode, AppsecMode}, config.CrowdsecMode) {
return errors.New("CrowdsecMode: must be one of 'none', 'live', 'stream', 'alone' or 'appsec'") return errors.New("CrowdsecMode: must be one of 'none', 'live', 'stream', 'alone' or 'appsec'")
@@ -335,29 +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{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider}, config.CaptchaProvider) { if !contains([]string{HTTP, HTTPS, ""}, config.CrowdsecAppsecScheme) {
return errors.New("CaptchaProvider: must be one of 'hcaptcha', 'recaptcha' or 'turnstile'") 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
} }
@@ -365,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
} }
@@ -390,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)
}
+26 -13
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()
@@ -85,8 +86,14 @@ func Test_ValidateParams(t *testing.T) {
cfg6 := getMinimalConfig() cfg6 := getMinimalConfig()
cfg6.CrowdsecLapiScheme = HTTPS cfg6.CrowdsecLapiScheme = HTTPS
cfg6.CrowdsecLapiTLSInsecureVerify = true cfg6.CrowdsecLapiTLSInsecureVerify = true
cfg7 := getMinimalConfig()
cfg7.CrowdsecLapiScheme = HTTPS
cfg8 := getMinimalConfig() cfg8 := getMinimalConfig()
cfg8.CrowdsecLapiScheme = HTTPS cfg8.LogLevel = LogINFO
cfg9 := getMinimalConfig()
cfg9.LogLevel = "info"
cfg10 := getMinimalConfig()
cfg10.LogLevel = "Warning"
type args struct { type args struct {
config *Config config *Config
} }
@@ -104,11 +111,14 @@ func Test_ValidateParams(t *testing.T) {
{name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true}, {name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true},
// HTTPS enabled // HTTPS enabled
{name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false}, {name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false},
{name: "Not validate https without cert authority", args: args{config: cfg8}, wantErr: true}, {name: "Not validate https without cert authority", args: args{config: cfg7}, wantErr: true},
{name: "Valid log level uppercase INFO", args: args{config: cfg8}, wantErr: false},
{name: "Valid log level lowercase info", args: args{config: cfg9}, wantErr: false},
{name: "Invalid log level Warning", args: args{config: cfg10}, wantErr: true},
} }
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)
} }
}) })
@@ -136,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
@@ -155,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)
} }
}) })
@@ -196,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)
} }
}) })
@@ -220,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
} }
@@ -233,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 {
+77 -33
View File
@@ -1,48 +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 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 (
"io" "log/slog"
"log"
"os" "os"
"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) *Log { func NewWithFormat(logLevel, logFilePath, logFormat string) *slog.Logger {
logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime) // Determine log level
logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime) var level slog.Level
logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime) switch logLevel {
logError.SetOutput(os.Stderr) case "ERROR":
logInfo.SetOutput(os.Stdout) level = LevelError
if logLevel == "DEBUG" { case "WARN":
logDebug.SetOutput(os.Stdout) level = LevelWarn
case "INFO":
level = LevelInfo
case "DEBUG":
level = LevelDebug
default:
// Default to INFO level
level = LevelInfo
} }
return &Log{
logError: logError, // Set output destination
logInfo: logInfo, var output *os.File
logDebug: logDebug, if logFilePath != "" {
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err == nil {
output = logFile
} else {
// 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
} }
}
// Info log to Stdout. // Create handler based on format with custom level names
func (l *Log) Info(str string) { var handler slog.Handler
l.logInfo.Printf(str) opts := &slog.HandlerOptions{
} Level: level,
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
// Customize level names to match our expected format
if a.Key == slog.LevelKey {
lvl, ok := a.Value.Any().(slog.Level)
if !ok {
return a
}
switch {
case lvl < LevelInfo:
a.Value = slog.StringValue("DEBUG")
case lvl < LevelWarn:
a.Value = slog.StringValue("INFO")
case lvl < LevelError:
a.Value = slog.StringValue("WARN")
default:
a.Value = slog.StringValue("ERROR")
}
}
return a
},
}
// Debug log to Stdout. if logFormat == "json" {
func (l *Log) Debug(str string) { handler = slog.NewJSONHandler(output, opts)
l.logDebug.Printf(str) } else {
} // Common format (default)
handler = slog.NewTextHandler(output, opts)
}
// Error log to Stderr. // Create logger with component attribute
func (l *Log) Error(str string) { return slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
l.logError.Printf(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")
}
+55
View File
@@ -0,0 +1,55 @@
# End-to-end test suite
These tests spin up real Traefik + Crowdsec containers and exercise the
plugin in the same conditions Traefik uses in production: loaded from a
local path, no module download, no mocking.
Each scenario lives in its own directory under `scenarios/` and owns:
- `docker-compose.yml` — the stack to spin up
- `run.sh` — orchestration + assertions
- optional fixtures (`acquis.yaml`, `ban.html`, ...)
## Running locally
Prerequisites: `docker`, `docker compose`, `curl`, `bash`.
Run a single scenario:
```bash
./tests/e2e/scenarios/stream-mode/run.sh
# or
make e2e_stream-mode
```
Run everything:
```bash
make e2e
```
Scenarios run **sequentially** on a single host: they share the canonical
`crowdsec` container name (so `cscli` commands work uniformly) and the same
`8000:80` port. Each scenario uses its own Docker Compose project
(`-p e2e-<scenario>`) and tears its stack down on exit, so the next one
starts clean. `make e2e` runs them one after another; Docker reuses the
images pulled by the first scenario, so the Traefik / Crowdsec / whoami
images are downloaded only once for the whole suite.
## Writing a new scenario
1. Copy `scenarios/stream-mode/` as a template.
2. Rename `container_name`s (keep `crowdsec` for the LAPI container).
3. Edit `run.sh` to express the behavior under test.
4. Add the scenario name to `E2E_SCENARIOS` in the `Makefile`.
## CI
This Docker suite is **local-only** and intentionally not run in CI: it boots a
real Crowdsec (and downloads the AppSec collections for that scenario), which is
heavier and less deterministic than CI needs. CI instead runs a lighter
binary + mock-LAPI suite that exercises the plugin without Docker or a real
Crowdsec.
Run this suite locally with `make e2e` (all scenarios) or
`make e2e_<scenario>` (one scenario).
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Shared helpers for end-to-end scenarios.
# Dependencies: bash, curl, docker.
# Wait until the Crowdsec LAPI reports ready, or fail after timeout.
# Usage: wait_crowdsec_ready [container_name] [timeout_seconds]
wait_crowdsec_ready() {
local container="${1:-crowdsec}"
local timeout="${2:-90}"
local elapsed=0
while (( elapsed < timeout )); do
if docker exec "$container" cscli lapi status >/dev/null 2>&1; then
return 0
fi
sleep 1
((elapsed++))
done
echo "wait_crowdsec_ready: timed out after ${timeout}s waiting for $container" >&2
return 1
}
# Poll URL until it returns the expected status code, or fail.
# Usage: wait_for_status URL CODE [TIMEOUT_SECONDS] [curl args...]
wait_for_status() {
local url="$1" expected="$2" timeout="${3:-30}"
shift 3 || true
local elapsed=0
local got=""
while (( elapsed < timeout )); do
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url" || true)
if [[ "$got" == "$expected" ]]; then
return 0
fi
sleep 1
((elapsed++))
done
echo "wait_for_status: $url expected $expected, last seen ${got:-<none>}" >&2
return 1
}
# Assert a single curl returns the expected status code.
# Usage: assert_status URL CODE [curl args...]
assert_status() {
local url="$1" expected="$2"
shift 2 || true
local got
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url")
if [[ "$got" != "$expected" ]]; then
echo "assert_status: $url expected $expected, got $got" >&2
return 1
fi
}
# Assert a response header matches a value (case-insensitive name).
# Usage: assert_header URL HEADER VALUE [curl args...]
assert_header() {
local url="$1" header="$2" expected="$3"
shift 3 || true
local got
got=$(curl -s -D - -o /dev/null "$@" "$url" | tr -d '\r' \
| awk -v h="${header,,}" -F': ' 'tolower($1) == h { print $2; exit }')
if [[ "$got" != "$expected" ]]; then
echo "assert_header: $url header $header expected \"$expected\", got \"$got\"" >&2
return 1
fi
}
# Dump diagnostic info for a compose project; called on failure.
# Usage: dump_diagnostics PROJECT COMPOSE_FILE
dump_diagnostics() {
local project="$1" compose_file="$2"
echo "=== docker compose ps ==="
docker compose -p "$project" -f "$compose_file" ps || true
echo "=== docker compose logs ==="
docker compose -p "$project" -f "$compose_file" logs --no-color || true
echo "=== cscli decisions list ==="
docker exec crowdsec cscli decisions list 2>/dev/null || true
}
+6
View File
@@ -0,0 +1,6 @@
listen_addr: 0.0.0.0:7422
appsec_config: crowdsecurity/crs-inband
name: e2eAppSec
source: appsec
labels:
type: appsec
@@ -0,0 +1,55 @@
services:
traefik:
image: traefik:v3.7.1
container_name: e2e-appsec-traefik
command:
- "--log.level=INFO"
- "--accesslog"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure=true"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
ports:
- "8000:80"
depends_on:
crowdsec:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: e2e-appsec-whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.r.entrypoints=web"
- "traefik.http.routers.r.middlewares=bouncer@docker"
- "traefik.http.services.s.loadbalancer.server.port=80"
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
# IP bouncing disabled — we only test AppSec body inspection here.
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=none"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecappsecenabled=true"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
container_name: crowdsec
environment:
COLLECTIONS: "crowdsecurity/appsec-crs-inband"
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
CUSTOM_HOSTNAME: crowdsec
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
volumes:
- ./acquis.yaml:/etc/crowdsec/acquis.yaml:ro
healthcheck:
test: ["CMD", "cscli", "lapi", "status"]
interval: 2s
timeout: 3s
retries: 60
start_period: 15s
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=appsec
PROJECT="e2e-${SCENARIO}"
COMPOSE_FILE="$HERE/docker-compose.yml"
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
echo "Scenario failed. Logs written to $LOG_FILE"
fi
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] starting stack (AppSec collections download — may take ~30s on first boot)..."
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
echo "[$SCENARIO] waiting for crowdsec readiness..."
wait_crowdsec_ready crowdsec 180
echo "[$SCENARIO] waiting for traefik readiness..."
wait_for_status http://localhost:8000/foo 200 30
echo "[$SCENARIO] benign request must pass"
assert_status http://localhost:8000/foo 200
echo "[$SCENARIO] SQL-injection-like query string must be blocked by OWASP CRS (inband)"
# Classic SQLi probe: ?id=1' OR '1'='1 — caught by CRS rule 942100/942130 (paranoia 1).
assert_status "http://localhost:8000/foo?id=1%27%20OR%20%271%27%3D%271" 403
echo "[$SCENARIO] OK"
+9
View File
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>E2E captcha marker</title></head>
<body>
<h1 id="e2e-captcha-marker">E2E_CAPTCHA_PAGE_MARKER</h1>
<script src="{{ .FrontendJS }}"></script>
<div class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}"></div>
</body>
</html>
@@ -0,0 +1,59 @@
services:
traefik:
image: traefik:v3.7.1
container_name: e2e-captcha-traefik
command:
- "--log.level=INFO"
- "--accesslog"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure=true"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
- ./captcha.html:/captcha.html:ro
ports:
- "8000:80"
depends_on:
crowdsec:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: e2e-captcha-whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.r.entrypoints=web"
- "traefik.http.routers.r.middlewares=bouncer@docker"
- "traefik.http.services.s.loadbalancer.server.port=80"
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchaprovider=turnstile"
# Cloudflare Turnstile public test keys (always pass / always fail variants exist).
# These render a valid widget without contacting any real API key.
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchasitekey=1x00000000000000000000AA"
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchasecretkey=1x0000000000000000000000000000000AA"
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchahtmlfilepath=/captcha.html"
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchagraceperiodseconds=10"
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
container_name: crowdsec
environment:
DISABLE_ONLINE_API: "true"
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
CUSTOM_HOSTNAME: crowdsec
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
healthcheck:
test: ["CMD", "cscli", "lapi", "status"]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=captcha
PROJECT="e2e-${SCENARIO}"
COMPOSE_FILE="$HERE/docker-compose.yml"
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
echo "Scenario failed. Logs written to $LOG_FILE"
fi
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] starting stack..."
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
echo "[$SCENARIO] waiting for crowdsec readiness..."
wait_crowdsec_ready
echo "[$SCENARIO] waiting for traefik readiness..."
wait_for_status http://localhost:8000/foo 200 30
echo "[$SCENARIO] adding captcha decision for 1.2.3.4"
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type captcha --duration 5m
echo "[$SCENARIO] waiting one stream tick + buffer..."
sleep 6
echo "[$SCENARIO] captcha response must be HTTP 200 (the captcha page itself, not a 403)"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] captcha response body must contain the captcha template marker"
body=$(curl -s http://localhost:8000/foo -H "X-Forwarded-For: 1.2.3.4")
if ! grep -q "E2E_CAPTCHA_PAGE_MARKER" <<<"$body"; then
echo "Expected response body to contain E2E_CAPTCHA_PAGE_MARKER, got:" >&2
echo "$body" >&2
exit 1
fi
echo "[$SCENARIO] non-flagged IP must still pass through to the backend"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8"
echo "[$SCENARIO] OK"
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>E2E ban marker</title></head>
<body>
<h1 id="e2e-ban-marker">E2E_CUSTOM_BAN_PAGE_MARKER</h1>
<p>IP: {{ .ClientIP }} reason: {{ .RemediationReason }}</p>
</body>
</html>
@@ -0,0 +1,53 @@
services:
traefik:
image: traefik:v3.7.1
container_name: e2e-banpage-traefik
command:
- "--log.level=INFO"
- "--accesslog"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure=true"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
- ./ban.html:/ban.html:ro
ports:
- "8000:80"
depends_on:
crowdsec:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: e2e-banpage-whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.r.entrypoints=web"
- "traefik.http.routers.r.middlewares=bouncer@docker"
- "traefik.http.services.s.loadbalancer.server.port=80"
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
- "traefik.http.middlewares.bouncer.plugin.bouncer.banhtmlfilepath=/ban.html"
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
container_name: crowdsec
environment:
DISABLE_ONLINE_API: "true"
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
CUSTOM_HOSTNAME: crowdsec
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
healthcheck:
test: ["CMD", "cscli", "lapi", "status"]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=custom-ban-page
PROJECT="e2e-${SCENARIO}"
COMPOSE_FILE="$HERE/docker-compose.yml"
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
echo "Scenario failed. Logs written to $LOG_FILE"
fi
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] starting stack..."
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
echo "[$SCENARIO] waiting for crowdsec readiness..."
wait_crowdsec_ready
echo "[$SCENARIO] waiting for traefik readiness..."
wait_for_status http://localhost:8000/foo 200 30
echo "[$SCENARIO] adding ban decision"
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
echo "[$SCENARIO] waiting one stream tick + buffer..."
sleep 6
echo "[$SCENARIO] banned response status is 403"
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] banned response Content-Type is HTML"
assert_header http://localhost:8000/foo Content-Type "text/html; charset=utf-8" -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] banned response body contains the custom marker"
body=$(curl -s http://localhost:8000/foo -H "X-Forwarded-For: 1.2.3.4")
if ! grep -q "E2E_CUSTOM_BAN_PAGE_MARKER" <<<"$body"; then
echo "Expected response body to contain E2E_CUSTOM_BAN_PAGE_MARKER, got:" >&2
echo "$body" >&2
exit 1
fi
echo "[$SCENARIO] OK"
@@ -0,0 +1,51 @@
services:
traefik:
image: traefik:v3.7.1
container_name: e2e-live-traefik
command:
- "--log.level=INFO"
- "--accesslog"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure=true"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
ports:
- "8000:80"
depends_on:
crowdsec:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: e2e-live-whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.r.entrypoints=web"
- "traefik.http.routers.r.middlewares=bouncer@docker"
- "traefik.http.services.s.loadbalancer.server.port=80"
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=live"
- "traefik.http.middlewares.bouncer.plugin.bouncer.defaultdecisionseconds=2"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
container_name: crowdsec
environment:
DISABLE_ONLINE_API: "true"
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
CUSTOM_HOSTNAME: crowdsec
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
healthcheck:
test: ["CMD", "cscli", "lapi", "status"]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=live-mode
PROJECT="e2e-${SCENARIO}"
COMPOSE_FILE="$HERE/docker-compose.yml"
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
echo "Scenario failed. Logs written to $LOG_FILE"
fi
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] starting stack..."
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
echo "[$SCENARIO] waiting for crowdsec readiness..."
wait_crowdsec_ready
echo "[$SCENARIO] waiting for traefik readiness..."
wait_for_status http://localhost:8000/foo 200 30
echo "[$SCENARIO] no decision -> first hit queries LAPI, returns 200, caches 'allowed' for 2s"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
echo "[$SCENARIO] waiting for defaultDecisionSeconds cache to expire..."
sleep 3
echo "[$SCENARIO] next hit must re-query LAPI and now see the ban"
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] another non-banned IP must still pass"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8"
echo "[$SCENARIO] OK"
@@ -0,0 +1,50 @@
services:
traefik:
image: traefik:v3.7.1
container_name: e2e-none-traefik
command:
- "--log.level=INFO"
- "--accesslog"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure=true"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
ports:
- "8000:80"
depends_on:
crowdsec:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: e2e-none-whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.r.entrypoints=web"
- "traefik.http.routers.r.middlewares=bouncer@docker"
- "traefik.http.services.s.loadbalancer.server.port=80"
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=none"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
container_name: crowdsec
environment:
DISABLE_ONLINE_API: "true"
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
CUSTOM_HOSTNAME: crowdsec
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
healthcheck:
test: ["CMD", "cscli", "lapi", "status"]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=none-mode
PROJECT="e2e-${SCENARIO}"
COMPOSE_FILE="$HERE/docker-compose.yml"
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
echo "Scenario failed. Logs written to $LOG_FILE"
fi
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] starting stack..."
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
echo "[$SCENARIO] waiting for crowdsec readiness..."
wait_crowdsec_ready
echo "[$SCENARIO] waiting for traefik readiness..."
wait_for_status http://localhost:8000/foo 200 30
echo "[$SCENARIO] no decision -> request passes (LAPI queried per request)"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision"
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
echo "[$SCENARIO] none mode has no cache -> next request must be blocked immediately"
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] deleting decision"
docker exec crowdsec cscli decisions delete --ip 1.2.3.4
echo "[$SCENARIO] previously banned IP must pass again immediately"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] OK"
@@ -0,0 +1,51 @@
services:
traefik:
image: traefik:v3.7.1
container_name: e2e-stream-traefik
command:
- "--log.level=INFO"
- "--accesslog"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure=true"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
ports:
- "8000:80"
depends_on:
crowdsec:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: e2e-stream-whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.r.entrypoints=web"
- "traefik.http.routers.r.middlewares=bouncer@docker"
- "traefik.http.services.s.loadbalancer.server.port=80"
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
container_name: crowdsec
environment:
DISABLE_ONLINE_API: "true"
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
CUSTOM_HOSTNAME: crowdsec
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
healthcheck:
test: ["CMD", "cscli", "lapi", "status"]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=stream-mode
PROJECT="e2e-${SCENARIO}"
COMPOSE_FILE="$HERE/docker-compose.yml"
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
echo "Scenario failed. Logs written to $LOG_FILE"
fi
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] starting stack..."
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
echo "[$SCENARIO] waiting for crowdsec readiness..."
wait_crowdsec_ready
echo "[$SCENARIO] waiting for traefik readiness..."
wait_for_status http://localhost:8000/foo 200 30
echo "[$SCENARIO] no decision yet -> request allowed"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
echo "[$SCENARIO] waiting one stream tick + buffer..."
sleep 6
echo "[$SCENARIO] banned IP must be blocked (HTTP 403)"
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] non-banned IP must still pass (HTTP 200)"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8"
echo "[$SCENARIO] deleting ban decision"
docker exec crowdsec cscli decisions delete --ip 1.2.3.4
echo "[$SCENARIO] waiting one stream tick + buffer..."
sleep 6
echo "[$SCENARIO] previously banned IP must pass again"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] OK"
@@ -0,0 +1,52 @@
services:
traefik:
image: traefik:v3.7.1
container_name: e2e-trusted-traefik
command:
- "--log.level=INFO"
- "--accesslog"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.forwardedHeaders.insecure=true"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
ports:
- "8000:80"
depends_on:
crowdsec:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: e2e-trusted-whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.r.entrypoints=web"
- "traefik.http.routers.r.middlewares=bouncer@docker"
- "traefik.http.services.s.loadbalancer.server.port=80"
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
- "traefik.http.middlewares.bouncer.plugin.bouncer.clienttrustedips=1.2.3.4/32"
crowdsec:
image: crowdsecurity/crowdsec:v1.7.8
container_name: crowdsec
environment:
DISABLE_ONLINE_API: "true"
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
CUSTOM_HOSTNAME: crowdsec
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
healthcheck:
test: ["CMD", "cscli", "lapi", "status"]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=trusted-ips
PROJECT="e2e-${SCENARIO}"
COMPOSE_FILE="$HERE/docker-compose.yml"
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
echo "Scenario failed. Logs written to $LOG_FILE"
fi
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] starting stack..."
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
echo "[$SCENARIO] waiting for crowdsec readiness..."
wait_crowdsec_ready
echo "[$SCENARIO] waiting for traefik readiness..."
wait_for_status http://localhost:8000/foo 200 30
echo "[$SCENARIO] banning the trusted IP 1.2.3.4 and an untrusted IP 5.6.7.8"
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
docker exec crowdsec cscli decisions add --ip 5.6.7.8 --type ban --duration 5m
echo "[$SCENARIO] waiting one stream tick + buffer..."
sleep 6
echo "[$SCENARIO] trusted IP must bypass the bouncer even though it is banned"
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] untrusted banned IP must be blocked (control: proves the bouncer is active)"
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 5.6.7.8"
echo "[$SCENARIO] OK"
+1 -1
View File
@@ -117,7 +117,7 @@ func (sr *SimpleRedis) askRedis(cmd redisCmd, channel chan redisCmd) redisCmd {
str := string(read) str := string(read)
if strings.Contains(str, "-NOAUTH") { if strings.Contains(str, "-NOAUTH") {
return redisCmd{Error: fmt.Errorf(RedisNoAuth)} return redisCmd{Error: fmt.Errorf(RedisNoAuth)}
} else if str != "$1" { } else if str == "$-1" {
return redisCmd{Error: fmt.Errorf(RedisMiss)} return redisCmd{Error: fmt.Errorf(RedisMiss)}
} }
read, _ = reader.ReadLineBytes() read, _ = reader.ReadLineBytes()
+1 -1
View File
@@ -1,6 +1,6 @@
# github.com/leprosus/golang-ttl-map v1.1.7 # github.com/leprosus/golang-ttl-map v1.1.7
## explicit; go 1.15 ## explicit; go 1.15
github.com/leprosus/golang-ttl-map github.com/leprosus/golang-ttl-map
# github.com/maxlerebourg/simpleredis v1.0.11 # github.com/maxlerebourg/simpleredis v1.0.12
## explicit; go 1.22 ## explicit; go 1.22
github.com/maxlerebourg/simpleredis github.com/maxlerebourg/simpleredis
+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