Compare commits

..
1 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
81 changed files with 1242 additions and 2235 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"
-42
View File
@@ -1,42 +0,0 @@
name: E2E
on:
push:
branches: [main]
pull_request:
concurrency:
group: e2e-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
e2e:
name: e2e (binary + mock LAPI)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
# Track go.mod (Go 1.22) — the plugin's yaegi-bound floor. Keeps the
# single source of truth and builds the mock on the supported version.
go-version-file: go.mod
# CI runs the binary/mock suite only: Traefik as a downloaded binary +
# a small LAPI mock (no Docker, no real Crowdsec). It validates the
# plugin's own behaviour. Crowdsec / AppSec correctness is upstream's
# responsibility, so those are intentionally out of scope here. The
# Docker suite (tests/e2e/scenarios) stays available for local debugging.
# `-k` keeps going after a failing scenario so the logs cover all of
# them, while make still exits non-zero if any scenario failed.
- name: Run mock scenarios
run: make -k e2e_mock
- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: e2e-logs
path: /tmp/e2e-mock-*.log
if-no-files-found: ignore
+4 -9
View File
@@ -15,13 +15,8 @@ jobs:
name: Main Process name: Main Process
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
# Keep in sync with go.mod. Capped at 1.22 because the plugin is run by GO_VERSION: 1.23
# yaegi (bundled in Traefik) and even Traefik v3.7.1 ships yaegi v0.16.1,
# which only supports Go 1.22. Building on the floor makes go build / go
# test reject newer stdlib before yaegi_test does.
GO_VERSION: 1.22
GOLANGCI_LINT_VERSION: v1.63.4 GOLANGCI_LINT_VERSION: v1.63.4
# yaegi_test guard — pin to the version current Traefik bundles.
YAEGI_VERSION: v0.16.1 YAEGI_VERSION: v0.16.1
CGO_ENABLED: 0 CGO_ENABLED: 0
defaults: defaults:
@@ -32,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@v7 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@v7 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@v6 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') }}
-83
View File
@@ -1,83 +0,0 @@
name: Release (1/2) Prepare
# Step 1 of the release process: bump pluginVersion *before* the tag exists.
#
# The version reported to the Crowdsec LAPI lives in version.go, so it has to
# be correct in the very commit the tag points at. Anything that patches
# version.go after the release is published is too late: Traefik's plugin
# service caches the plugin archive per module+version, so users keep the
# source that was there when the tag was first resolved (see #322, #363).
#
# This workflow opens a "release" PR containing only that bump. Merging it
# triggers Release (2/2) Publish, which creates the tag and the GitHub release
# on the merged commit.
on:
workflow_dispatch:
inputs:
version:
description: "Version to release, e.g. v1.7.1 or v1.8.0-alpha"
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
prepare:
name: Open release PR for ${{ inputs.version }}
runs-on: ubuntu-latest
steps:
- name: Check out main
uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
- name: Validate version
env:
VERSION: ${{ inputs.version }}
run: |
if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
echo "::error::'$VERSION' is not a vX.Y.Z / vX.Y.Z-suffix version"
exit 1
fi
if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
echo "::error::tag $VERSION already exists"
exit 1
fi
- name: Bump version.go
env:
VERSION: ${{ inputs.version }}
run: |
sed -i 's/pluginVersion = "[^"]*"/pluginVersion = "'"$VERSION"'"/' version.go
cat version.go
if git diff --quiet -- version.go; then
echo "::error::version.go already reads $VERSION, nothing to release"
exit 1
fi
- name: Push release branch and open PR
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git switch -c "release/$VERSION"
git commit -am "🔖 release $VERSION"
git push -u origin "release/$VERSION"
cat > /tmp/pr-body.md <<EOF
Bumps \`pluginVersion\` to \`$VERSION\` so the tag carries the version
the plugin reports to the Crowdsec LAPI.
Merging this PR tags \`$VERSION\` on the resulting commit and publishes
the GitHub release automatically.
> Keep the PR title as-is: **Release (2/2) Publish** matches on it.
EOF
gh pr create --base main --head "release/$VERSION" --title "🔖 release $VERSION" --body-file /tmp/pr-body.md
-53
View File
@@ -1,53 +0,0 @@
name: Release (2/2) Publish
# Step 2 of the release process: tag and publish the commit prepared by
# Release (1/2) Prepare.
#
# Triggered by the release PR landing on main. The tag is created on that
# commit, so version.go inside the released source always matches the tag —
# no post-release patching, no force-moved tags.
on:
push:
branches: [main]
paths: ["version.go"]
permissions:
contents: write
jobs:
publish:
name: Tag and publish
runs-on: ubuntu-latest
steps:
- name: Check out the pushed commit
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Resolve release version
id: resolve
run: |
version="$(git log -1 --format='%B' | grep -oP '🔖 release \Kv[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?' || true)"
[ -z "$version" ] && { echo "version.go changed outside a release commit, nothing to do"; echo "release=false" >> "$GITHUB_OUTPUT"; exit 0; }
in_source="$(sed -n 's/.*pluginVersion = "\([^"]*\)".*/\1/p' version.go)"
[ "$in_source" != "$version" ] && { echo "::error::commit says $version but version.go reads $in_source"; exit 1; }
git rev-parse -q --verify "refs/tags/$version" >/dev/null && { echo "::error::tag $version already exists"; exit 1; }
echo "release=true" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "prerelease=$([[ "$version" == *-* ]] && echo '--prerelease')" >> "$GITHUB_OUTPUT"
- name: Tag and create the GitHub release
if: steps.resolve.outputs.release == 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.resolve.outputs.version }}
PRERELEASE: ${{ steps.resolve.outputs.prerelease }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$VERSION" -m "$VERSION"
git push origin "$VERSION"
gh release create "$VERSION" --title "$VERSION" --generate-notes $PRERELEASE
+46
View File
@@ -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 }}"
-41
View File
@@ -1,41 +0,0 @@
name: Renovate
# Self-hosted Renovate: opens dependency-update PRs on a daily schedule.
# Config lives in /renovate.json. Requires a repo/org secret RENOVATE_TOKEN
# (a PAT with `repo` + `workflow` scope, or a fine-grained token with
# contents:write + pull-requests:write) so Renovate can push branches and open
# PRs. Trigger manually from the Actions tab via "Run workflow" to test.
on:
schedule:
- cron: "0 4 * * *" # every day at 04:00 UTC
workflow_dispatch:
inputs:
logLevel:
description: "Renovate log level"
required: false
default: "info"
permissions:
contents: read
concurrency:
group: renovate
cancel-in-progress: false
jobs:
renovate:
runs-on: ubuntu-latest
steps:
- name: Run Renovate
uses: renovatebot/github-action@v46.2.2
with:
token: ${{ secrets.RENOVATE_TOKEN }}
env:
RENOVATE_REPOSITORIES: ${{ github.repository }}
RENOVATE_ONBOARDING: "false"
RENOVATE_REQUIRE_CONFIG: "required"
# The grouped "all" branch holds many upgrades; changelog/PR-body
# rendering for it blew the default 4GB V8 heap (exit 134 OOM).
NODE_OPTIONS: "--max-old-space-size=8192"
LOG_LEVEL: ${{ github.event.inputs.logLevel || 'info' }}
-4
View File
@@ -5,7 +5,3 @@ conf
db db
logs logs
docker-compose.dev.yml docker-compose.dev.yml
# Binary e2e suite working cache: Traefik binary + compiled mock. Persisted
# across local runs; CI runs on fresh runners so it is recreated every time.
tests/e2e/mock/.cache/
+1 -2
View File
@@ -7,7 +7,7 @@ linters-settings:
disable: disable:
- fieldalignment - fieldalignment
gocyclo: gocyclo:
min-complexity: 20 min-complexity: 15
goconst: goconst:
min-len: 5 min-len: 5
min-occurrences: 4 min-occurrences: 4
@@ -41,7 +41,6 @@ linters-settings:
- $test - $test
allow: allow:
- $gostd - $gostd
- github.com/maxlerebourg/simpleredis
- 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
+6 -7
View File
@@ -1,10 +1,8 @@
.PHONY: lint test vendor clean e2e_mock .PHONY: lint test vendor clean e2e
export GO111MODULE=on export GO111MODULE=on
# Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs. E2E_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec
# The local Docker suite (make e2e) lives in a separate PR/branch.
E2E_MOCK_SCENARIOS := $(notdir $(wildcard tests/e2e/mock/scenarios/*))
default: lint test default: lint test
@@ -17,10 +15,10 @@ test:
yaegi_test: yaegi_test:
yaegi test -v . yaegi test -v .
e2e_mock: $(addprefix e2e_mock_,$(E2E_MOCK_SCENARIOS)) e2e: $(addprefix e2e_,$(E2E_SCENARIOS))
e2e_mock_%: e2e_%:
bash ./tests/e2e/mock/scenarios/$*/run.sh ./tests/e2e/scenarios/$*/run.sh
vendor: vendor:
go mod vendor go mod vendor
@@ -124,3 +122,4 @@ show_metrics:
show_decisions: show_decisions:
docker exec crowdsec cscli decisions list docker exec crowdsec cscli decisions list
+38 -53
View File
@@ -68,7 +68,7 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage User->>TraefikPlugin: Can I access that webpage
create participant CrowdsecLAPI create participant CrowdsecLAPI
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a Crowdsec Decision ? TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a Crowdsec Decision ?
destroy CrowdsecLAPI Destroy CrowdsecLAPI
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
TraefikPlugin->>User: No, HTTP 403 TraefikPlugin->>User: No, HTTP 403
``` ```
@@ -82,9 +82,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage User->>TraefikPlugin: Can I access that webpage
create participant CrowdsecLAPI create participant CrowdsecLAPI
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ? TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
destroy CrowdsecLAPI Destroy CrowdsecLAPI
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good! CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
destroy TraefikPlugin Destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response Webserver->>User: HTTP Response
``` ```
@@ -105,10 +105,10 @@ sequenceDiagram
PluginCache-->>TraefikPlugin: Nothing, all good! PluginCache-->>TraefikPlugin: Nothing, all good!
create participant CrowdsecLAPI create participant CrowdsecLAPI
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ? TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
destroy CrowdsecLAPI Destroy CrowdsecLAPI
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
destroy PluginCache Destroy PluginCache
PluginCache-->>TraefikPlugin: Done PluginCache-->>TraefikPlugin: Done
TraefikPlugin->>User: No, HTTP 403 TraefikPlugin->>User: No, HTTP 403
``` ```
@@ -125,10 +125,10 @@ sequenceDiagram
PluginCache-->>TraefikPlugin: Nothing, all good! PluginCache-->>TraefikPlugin: Nothing, all good!
create participant CrowdsecLAPI create participant CrowdsecLAPI
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ? TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
destroy CrowdsecLAPI Destroy CrowdsecLAPI
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good! CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
destroy PluginCache Destroy PluginCache
PluginCache-->>TraefikPlugin: Done PluginCache-->>TraefikPlugin: Done
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response Webserver->>User: HTTP Response
@@ -145,11 +145,11 @@ sequenceDiagram
participant TraefikPlugin participant TraefikPlugin
participant CrowdsecLAPI participant CrowdsecLAPI
TraefikPlugin->>CrowdsecLAPI: What are the current decisions TraefikPlugin->>CrowdsecLAPI: What are the current decisions
destroy CrowdsecLAPI Destroy CrowdsecLAPI
CrowdsecLAPI->>TraefikPlugin: Here is the list CrowdsecLAPI->>TraefikPlugin: Here is the list
create participant PluginCache create participant PluginCache
TraefikPlugin-->>PluginCache: Store this list TraefikPlugin-->>PluginCache: Store this list
destroy PluginCache Destroy PluginCache
PluginCache-->>TraefikPlugin: Done PluginCache-->>TraefikPlugin: Done
``` ```
@@ -162,9 +162,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage User->>TraefikPlugin: Can I access that webpage
create participant PluginCache create participant PluginCache
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ? TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
destroy PluginCache Destroy PluginCache
PluginCache-->>TraefikPlugin: Yes a ban decision PluginCache-->>TraefikPlugin: Yes a ban decision
destroy TraefikPlugin Destroy TraefikPlugin
TraefikPlugin->>User: No, HTTP 403 TraefikPlugin->>User: No, HTTP 403
``` ```
@@ -177,9 +177,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage User->>TraefikPlugin: Can I access that webpage
create participant PluginCache create participant PluginCache
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ? TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
destroy PluginCache Destroy PluginCache
PluginCache-->>TraefikPlugin: Nothing, all good! PluginCache-->>TraefikPlugin: Nothing, all good!
destroy TraefikPlugin Destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response Webserver->>User: HTTP Response
``` ```
@@ -195,11 +195,11 @@ sequenceDiagram
participant TraefikPlugin participant TraefikPlugin
participant CrowdsecCAPI participant CrowdsecCAPI
TraefikPlugin->>CrowdsecCAPI: What are the current decisions from CAPI TraefikPlugin->>CrowdsecCAPI: What are the current decisions from CAPI
destroy CrowdsecCAPI Destroy CrowdsecCAPI
CrowdsecCAPI->>TraefikPlugin: Here is the list CrowdsecCAPI->>TraefikPlugin: Here is the list
create participant PluginCache create participant PluginCache
TraefikPlugin-->>PluginCache: Store this list TraefikPlugin-->>PluginCache: Store this list
destroy PluginCache Destroy PluginCache
PluginCache-->>TraefikPlugin: Done PluginCache-->>TraefikPlugin: Done
``` ```
@@ -212,9 +212,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage User->>TraefikPlugin: Can I access that webpage
create participant PluginCache create participant PluginCache
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ? TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
destroy PluginCache Destroy PluginCache
PluginCache-->>TraefikPlugin: Yes a ban decision PluginCache-->>TraefikPlugin: Yes a ban decision
destroy TraefikPlugin Destroy TraefikPlugin
TraefikPlugin->>User: No, HTTP 403 TraefikPlugin->>User: No, HTTP 403
``` ```
@@ -227,9 +227,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage User->>TraefikPlugin: Can I access that webpage
create participant PluginCache create participant PluginCache
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ? TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
destroy PluginCache Destroy PluginCache
PluginCache-->>TraefikPlugin: Nothing, all good! PluginCache-->>TraefikPlugin: Nothing, all good!
destroy TraefikPlugin Destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response Webserver->>User: HTTP Response
``` ```
@@ -247,9 +247,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage User->>TraefikPlugin: Can I access that webpage
create participant CrowdsecAppSec create participant CrowdsecAppSec
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ? TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
destroy CrowdsecAppSec Destroy CrowdsecAppSec
CrowdsecAppSec-->>TraefikPlugin: Yes I think so CrowdsecAppSec-->>TraefikPlugin: Yes I think so
destroy TraefikPlugin Destroy TraefikPlugin
TraefikPlugin->>User: No, HTTP 403 TraefikPlugin->>User: No, HTTP 403
``` ```
@@ -262,9 +262,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage User->>TraefikPlugin: Can I access that webpage
create participant CrowdsecAppSec create participant CrowdsecAppSec
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ? TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
destroy CrowdsecAppSec Destroy CrowdsecAppSec
CrowdsecAppSec-->>TraefikPlugin: No I don't think so CrowdsecAppSec-->>TraefikPlugin: No I don't think so
destroy TraefikPlugin Destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response Webserver->>User: HTTP Response
``` ```
@@ -285,12 +285,12 @@ sequenceDiagram
User->>TraefikPlugin: Fine, done! User->>TraefikPlugin: Fine, done!
create participant ProviderCaptcha create participant ProviderCaptcha
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ? TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
destroy ProviderCaptcha Destroy ProviderCaptcha
ProviderCaptcha-->>TraefikPlugin: Yes ProviderCaptcha-->>TraefikPlugin: Yes
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
destroy PluginCache Destroy PluginCache
PluginCache-->>TraefikPlugin: Done PluginCache-->>TraefikPlugin: Done
destroy TraefikPlugin Destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response Webserver->>User: HTTP Response
``` ```
@@ -362,7 +362,7 @@ make run
- CrowdsecAppsecTlsCertificateAuthority - CrowdsecAppsecTlsCertificateAuthority
- string - string
- default: "" - default: ""
- PEM-encoded Certificate Authority used to verify Appsec's server certificate. When empty (and `crowdsecAppsecTlsInsecureVerify` is `false`), the host's system trust store is used. - PEM-encoded Certificate Authority of Appsec
- CrowdsecAppsecScheme - CrowdsecAppsecScheme
- string - string
- default: value of `CrowdsecLapiScheme`, expected values are: `http`, `https` - default: value of `CrowdsecLapiScheme`, expected values are: `http`, `https`
@@ -382,10 +382,6 @@ make run
- int64 - int64
- default: 10485760 (= 10MB) - default: 10485760 (= 10MB)
- Transmit only the first number of bytes to Crowdsec Appsec Server. - Transmit only the first number of bytes to Crowdsec Appsec Server.
- CrowdsecAppsecUnreadableBodyBlock
- bool
- default: true
- Behaviour when the request body cannot be buffered for inspection (HTTP/2 or HTTP/3 request without a `Content-Length`, typically a bidirectional gRPC stream). When `false` the request is forwarded to the Appsec Server with headers only (the body is left to stream through untouched). When `true` the request is blocked outright. Mirrors the reference bouncers' `APPSEC_DROP_UNREADABLE_BODY` option.
- CrowdsecAppsecKey - CrowdsecAppsecKey
- string - string
- default: value of `CrowdsecLapiKey` - default: value of `CrowdsecLapiKey`
@@ -412,7 +408,7 @@ make run
- CrowdsecLapiTlsCertificateAuthority - CrowdsecLapiTlsCertificateAuthority
- string - string
- default: "" - default: ""
- PEM-encoded Certificate Authority used to verify the LAPI's server certificate. When empty (and `crowdsecLapiTlsInsecureVerify` is `false`), the host's system trust store is used. - PEM-encoded Certificate Authority of the Crowdsec LAPI
- CrowdsecLapiTlsCertificateBouncer - CrowdsecLapiTlsCertificateBouncer
- string - string
- default: "" - default: ""
@@ -444,12 +440,7 @@ make run
- RedisCacheHost - RedisCacheHost
- string - string
- default: "redis:6379" - default: "redis:6379"
- hostname and port for the Redis write host (primary) - hostname and port for the Redis service
- RedisCacheReadHosts
- []string
- default: []
- List of Redis replica hostnames (host:port) to use for read operations. Reads are distributed round-robin across replicas. Falls back to RedisCacheHost when empty.
- Note: when set, reads are not retried against RedisCacheHost (the primary) if the replicas are unreachable. With RedisCacheUnreachableBlock at its default (true), a replica outage will therefore block/delay requests even though the primary is healthy.
- RedisCachePassword - RedisCachePassword
- string - string
- default: "" - default: ""
@@ -522,14 +513,14 @@ make run
- int64 - int64
- default: 1800 (= 30 minutes) - default: 1800 (= 30 minutes)
- Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid - Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid
- CaptchaFilePath - CaptchaHTMLFilePath
- string - string
- default: /captcha.html - default: /captcha.html
- Path where the captcha template is stored. The Content-Type header is automatically inferred from the file extension. - Path where the captcha template is stored
- BanFilePath - BanHTMLFilePath
- string - string
- default: "" - default: ""
- Path where the ban file is stored (default empty ""=disabled). The Content-Type header is automatically inferred from the file extension. - Path where the ban html file is stored (default empty ""=disabled)
- TraceHeadersCustomName - TraceHeadersCustomName
- string - string
- default: "" - default: ""
@@ -625,7 +616,6 @@ http:
crowdsecAppsecFailureBlock: true crowdsecAppsecFailureBlock: true
crowdsecAppsecUnreachableBlock: true crowdsecAppsecUnreachableBlock: true
crowdsecAppsecBodyLimit: 10485760 crowdsecAppsecBodyLimit: 10485760
crowdsecAppsecUnreadableBodyBlock: false
crowdsecLapiKey: privateKey-foo crowdsecLapiKey: privateKey-foo
crowdsecLapiScheme: http crowdsecLapiScheme: http
crowdsecLapiHost: crowdsec:8080 crowdsecLapiHost: crowdsec:8080
@@ -645,10 +635,7 @@ http:
forwardedHeadersCustomName: X-Custom-Header forwardedHeadersCustomName: X-Custom-Header
remediationHeadersCustomName: cs-remediation remediationHeadersCustomName: cs-remediation
redisCacheEnabled: false redisCacheEnabled: false
redisCacheHost: "redis-primary:6379" redisCacheHost: "redis:6379"
redisCacheReadHosts:
- "redis-replica-1:6379"
- "redis-replica-2:6379"
redisCachePassword: password redisCachePassword: password
redisCacheDatabase: "5" redisCacheDatabase: "5"
redisCacheUnreachableBlock: true redisCacheUnreachableBlock: true
@@ -740,18 +727,16 @@ A script is available to generate certificates in `examples/tls-auth/gencerts.sh
#### Use HTTPS to communicate with the LAPI #### Use HTTPS to communicate with the LAPI
Set `crowdsecLapiScheme` to `https`. The plugin then validates Crowdsec's server certificate. Three options: To communicate with the LAPI in HTTPS you need to either accept any certificates by setting the `crowdsecLapiTLSInsecureVerify` to true or add the CA used by the server certificate of Crowdsec using `crowdsecLapiTLSCertificateAuthority` or `crowdsecLapiTLSCertificateAuthorityFile`.
Set the `crowdsecLapiScheme` to https.
- **Publicly trusted certificate** (e.g. Let's Encrypt behind a reverse proxy): leave `crowdsecLapiTLSCertificateAuthority` empty and `crowdsecLapiTLSInsecureVerify` `false`. The plugin falls back to the host's system trust store (the `traefik` image ships `ca-certificates`).
- **Private/self-signed CA**: set `crowdsecLapiTLSCertificateAuthority` (or `…File`) to the PEM-encoded CA that signed Crowdsec's server cert.
- **Skip verification entirely** (not recommended for production): set `crowdsecLapiTLSInsecureVerify` to `true`.
Crowdsec must be listening in HTTPS for this to work. 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 #### Use HTTPS to communicate with the Appsec
Set `crowdsecAppsecScheme` to `https`. Same three options as for the LAPI, prefixed `crowdsecAppsec…` instead of `crowdsecLapi…`: empty CA + secure verify falls back to the system trust store, a custom CA pins to your private PKI, and `crowdsecAppsecTLSInsecureVerify=true` skips verification altogether. 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. Currently AppSec does not support mTLS authentication for the AppSec Component.
+83 -129
View File
@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
htmltemplate "html/template"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -83,59 +84,49 @@ type Bouncer struct {
name string name string
template *template.Template template *template.Template
enabled bool enabled bool
appsecEnabled bool appsecEnabled bool
appsecScheme string appsecScheme string
appsecHost string appsecHost string
appsecPath string appsecPath string
appsecKey string appsecKey string
appsecFailureBlock bool appsecFailureBlock bool
appsecUnreachableBlock bool appsecUnreachableBlock bool
appsecUnreadableBodyBlock bool appsecBodyLimit int64
appsecBodyLimit int64 crowdsecScheme string
crowdsecScheme string crowdsecHost string
crowdsecHost string crowdsecPath 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 int64
updateMaxFailure int64 defaultDecisionTimeout int64
defaultDecisionTimeout int64 remediationStatusCode int
remediationStatusCode int remediationCustomHeader string
remediationCustomHeader string forwardedCustomHeader string
forwardedCustomHeader string crowdsecStreamRoute string
crowdsecStreamRoute string crowdsecHeader string
crowdsecHeader string redisUnreachableBlock bool
redisUnreachableBlock bool banTemplate *htmltemplate.Template
banTemplate *template.Template traceCustomHeader string
banTemplateContentType string clientPoolStrategy *ip.PoolStrategy
traceCustomHeader string serverPoolStrategy *ip.PoolStrategy
clientPoolStrategy *ip.PoolStrategy httpClient *http.Client
serverPoolStrategy *ip.PoolStrategy httpAppsecClient *http.Client
httpClient *http.Client cacheClient *cache.Client
httpAppsecClient *http.Client captchaClient *captcha.Client
cacheClient *cache.Client log *slog.Logger
captchaClient *captcha.Client
log *slog.Logger
} }
// New creates the crowdsec bouncer plugin. // New creates the crowdsec bouncer plugin.
// //
//nolint:nestif,gocyclo,gocognit,funlen,maintidx //nolint:nestif,gocyclo,gocognit
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) { func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
config.LogLevel = strings.ToUpper(config.LogLevel) config.LogLevel = strings.ToUpper(config.LogLevel)
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat) log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
if config.BanFilePath == "" && config.BanHTMLFilePath != "" {
config.BanFilePath = config.BanHTMLFilePath
}
if config.CaptchaHTMLFilePath != "" {
config.CaptchaFilePath = config.CaptchaHTMLFilePath
}
err := configuration.ValidateParams(config, log) err := configuration.ValidateParams(config, log)
if err != nil { if err != nil {
log.Error("New:validateParams " + err.Error()) log.Error("New:validateParams " + err.Error())
@@ -193,10 +184,9 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
} }
} }
var banTemplate *template.Template var banTemplate *htmltemplate.Template
var banTemplateContentType string if config.BanHTMLFilePath != "" {
if config.BanFilePath != "" { banTemplate, _ = configuration.GetHTMLTemplate(config.BanHTMLFilePath)
banTemplate, banTemplateContentType, _ = configuration.GetTemplate(config.BanFilePath)
} }
bouncer := &Bouncer{ bouncer := &Bouncer{
@@ -204,37 +194,35 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
name: name, name: name,
template: template.New("CrowdsecBouncer").Delims("[[", "]]"), template: template.New("CrowdsecBouncer").Delims("[[", "]]"),
enabled: config.Enabled, enabled: config.Enabled,
crowdsecMode: config.CrowdsecMode, crowdsecMode: config.CrowdsecMode,
appsecEnabled: config.CrowdsecAppsecEnabled, appsecEnabled: config.CrowdsecAppsecEnabled,
appsecScheme: config.CrowdsecAppsecScheme, appsecScheme: config.CrowdsecAppsecScheme,
appsecHost: config.CrowdsecAppsecHost, appsecHost: config.CrowdsecAppsecHost,
appsecPath: config.CrowdsecAppsecPath, appsecPath: config.CrowdsecAppsecPath,
appsecKey: config.CrowdsecAppsecKey, appsecKey: config.CrowdsecAppsecKey,
appsecFailureBlock: config.CrowdsecAppsecFailureBlock, appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock, appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
appsecUnreadableBodyBlock: config.CrowdsecAppsecUnreadableBodyBlock, appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
appsecBodyLimit: config.CrowdsecAppsecBodyLimit, crowdsecScheme: config.CrowdsecLapiScheme,
crowdsecScheme: config.CrowdsecLapiScheme, crowdsecHost: config.CrowdsecLapiHost,
crowdsecHost: config.CrowdsecLapiHost, crowdsecPath: config.CrowdsecLapiPath,
crowdsecPath: config.CrowdsecLapiPath, crowdsecKey: config.CrowdsecLapiKey,
crowdsecKey: config.CrowdsecLapiKey, crowdsecMachineID: config.CrowdsecCapiMachineID,
crowdsecMachineID: config.CrowdsecCapiMachineID, crowdsecPassword: config.CrowdsecCapiPassword,
crowdsecPassword: config.CrowdsecCapiPassword, crowdsecScenarios: config.CrowdsecCapiScenarios,
crowdsecScenarios: config.CrowdsecCapiScenarios, updateInterval: config.UpdateIntervalSeconds,
updateInterval: config.UpdateIntervalSeconds, updateMaxFailure: config.UpdateMaxFailure,
updateMaxFailure: config.UpdateMaxFailure, remediationCustomHeader: config.RemediationHeadersCustomName,
remediationCustomHeader: config.RemediationHeadersCustomName, forwardedCustomHeader: config.ForwardedHeadersCustomName,
forwardedCustomHeader: config.ForwardedHeadersCustomName, defaultDecisionTimeout: config.DefaultDecisionSeconds,
defaultDecisionTimeout: config.DefaultDecisionSeconds, remediationStatusCode: config.RemediationStatusCode,
remediationStatusCode: config.RemediationStatusCode, redisUnreachableBlock: config.RedisCacheUnreachableBlock,
redisUnreachableBlock: config.RedisCacheUnreachableBlock, banTemplate: banTemplate,
banTemplate: banTemplate, traceCustomHeader: config.TraceHeadersCustomName,
banTemplateContentType: banTemplateContentType, crowdsecStreamRoute: crowdsecStreamRoute,
traceCustomHeader: config.TraceHeadersCustomName, crowdsecHeader: crowdsecHeader,
crowdsecStreamRoute: crowdsecStreamRoute, log: log,
crowdsecHeader: crowdsecHeader,
log: log,
serverPoolStrategy: &ip.PoolStrategy{ serverPoolStrategy: &ip.PoolStrategy{
Checker: serverChecker, Checker: serverChecker,
}, },
@@ -268,7 +256,6 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
log, log,
config.RedisCacheEnabled, config.RedisCacheEnabled,
config.RedisCacheHost, config.RedisCacheHost,
config.RedisCacheReadHosts,
config.RedisCachePassword, config.RedisCachePassword,
config.RedisCacheDatabase, config.RedisCacheDatabase,
) )
@@ -289,7 +276,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
config.CaptchaSiteKey, config.CaptchaSiteKey,
config.CaptchaSecretKey, config.CaptchaSecretKey,
config.RemediationHeadersCustomName, config.RemediationHeadersCustomName,
config.CaptchaFilePath, config.CaptchaHTMLFilePath,
config.CaptchaGracePeriodSeconds, config.CaptchaGracePeriodSeconds,
) )
if err != nil { if err != nil {
@@ -330,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)
@@ -446,9 +433,14 @@ func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Req
if bouncer.remediationCustomHeader != "" { if bouncer.remediationCustomHeader != "" {
rw.Header().Set(bouncer.remediationCustomHeader, "ban") rw.Header().Set(bouncer.remediationCustomHeader, "ban")
} }
rw.Header().Set("Content-Type", bouncer.banTemplateContentType) if bouncer.banTemplate == nil {
rw.WriteHeader(bouncer.remediationStatusCode)
return
}
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(bouncer.remediationStatusCode) rw.WriteHeader(bouncer.remediationStatusCode)
if bouncer.banTemplate == nil || req.Method == http.MethodHead {
if req.Method == http.MethodHead {
return return
} }
templateData := map[string]string{ templateData := map[string]string{
@@ -641,12 +633,7 @@ func handleStreamCache(bouncer *Bouncer) error {
if err.Error() != cache.CacheMiss { if err.Error() != cache.CacheMiss {
return err return err
} }
// To avoid every instance trying to update the cache, set 1 second at least bouncer.cacheClient.Set(cacheTimeoutKey, cache.NoBannedValue, bouncer.updateInterval-1)
leaseDuration := bouncer.updateInterval - 1
if leaseDuration < 1 {
leaseDuration = 1
}
bouncer.cacheClient.Set(cacheTimeoutKey, cache.NoBannedValue, leaseDuration)
streamRouteURL := url.URL{ streamRouteURL := url.URL{
Scheme: bouncer.crowdsecScheme, Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost, Host: bouncer.crowdsecHost,
@@ -685,12 +672,6 @@ func handleStreamCache(bouncer *Bouncer) error {
return nil return nil
} }
func isReverseProxyError(statusCode int) bool {
return statusCode == http.StatusBadGateway ||
statusCode == http.StatusServiceUnavailable ||
statusCode == http.StatusGatewayTimeout
}
func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, error) { func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, error) {
var req *http.Request var req *http.Request
if len(data) > 0 { if len(data) > 0 {
@@ -702,7 +683,7 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion) 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 || isReverseProxyError(res.StatusCode) { if err != nil {
return nil, fmt.Errorf("crowdsecQuery:unreachable url:%s %w", stringURL, err) return nil, fmt.Errorf("crowdsecQuery:unreachable url:%s %w", stringURL, err)
} }
defer func() { defer func() {
@@ -730,27 +711,6 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
return body, nil return body, nil
} }
// isBodyUnreadable reports whether the request body cannot be buffered before
// forwarding it to the Appsec component. An HTTP/2 or HTTP/3 request without a
// Content-Length (typically a bidirectional gRPC stream) keeps its body open
// for the whole life of the stream and never reaches EOF, so reading it with
// io.ReadAll would block until the request times out and is wrongly turned into
// a 403. This mirrors the reference lua-cs-bouncer behavior, which refuses to
// read the body of an HTTP/2+ request that has no Content-Length.
func isBodyUnreadable(httpReq *http.Request) bool {
return httpReq.Body != nil && httpReq.Body != http.NoBody && httpReq.ProtoMajor >= 2 && httpReq.ContentLength < 0
}
// isMethodWithBody used only when isBodyUnreadable returns true but the request method can't have body.
func isMethodWithBody(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
default:
return false
}
}
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error { func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
routeURL := url.URL{ routeURL := url.URL{
Scheme: bouncer.appsecScheme, Scheme: bouncer.appsecScheme,
@@ -758,13 +718,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
Path: bouncer.appsecPath, Path: bouncer.appsecPath,
} }
var req *http.Request var req *http.Request
switch { if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil {
case isBodyUnreadable(httpReq):
if bouncer.appsecUnreadableBodyBlock && isMethodWithBody(httpReq.Method) {
return errors.New("appsecQuery:unreadableBody dropped")
}
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
case bouncer.appsecBodyLimit > 0 && httpReq.Body != nil:
var bodyBuffer bytes.Buffer var bodyBuffer bytes.Buffer
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit) limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
teeReader := io.TeeReader(limitedReader, &bodyBuffer) teeReader := io.TeeReader(limitedReader, &bodyBuffer)
@@ -775,7 +729,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
// Conserve body intact after reading it for other middlewares and service // Conserve body intact after reading it for other middlewares and service
httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body)) 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))
default: } else {
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil) req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
} }
@@ -793,7 +747,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion) req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
res, err := bouncer.httpAppsecClient.Do(req) res, err := bouncer.httpAppsecClient.Do(req)
if err != nil || isReverseProxyError(res.StatusCode) { if err != nil {
bouncer.log.Error("appsecQuery:unreachable") bouncer.log.Error("appsecQuery:unreachable")
if bouncer.appsecUnreachableBlock { if bouncer.appsecUnreachableBlock {
return fmt.Errorf("appsecQuery:unreachable %w", err) return fmt.Errorf("appsecQuery:unreachable %w", err)
+2 -2
View File
@@ -32,13 +32,13 @@ func getTestConfig() *configuration.Config {
ForwardedHeadersTrustedIPs: []string{"127.0.0.1"}, ForwardedHeadersTrustedIPs: []string{"127.0.0.1"},
ForwardedHeadersCustomName: "", ForwardedHeadersCustomName: "",
RemediationStatusCode: 403, RemediationStatusCode: 403,
BanFilePath: "", BanHTMLFilePath: "",
RemediationHeadersCustomName: "", RemediationHeadersCustomName: "",
CaptchaProvider: "", CaptchaProvider: "",
CaptchaSiteKey: "", CaptchaSiteKey: "",
CaptchaSecretKey: "", CaptchaSecretKey: "",
CaptchaGracePeriodSeconds: 1, CaptchaGracePeriodSeconds: 1,
CaptchaFilePath: "", CaptchaHTMLFilePath: "",
RedisCacheEnabled: false, RedisCacheEnabled: false,
RedisCacheHost: "", RedisCacheHost: "",
RedisCachePassword: "", RedisCachePassword: "",
+3 -226
View File
@@ -2,20 +2,16 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
import ( import (
"context" "context"
"io" htmltemplate "html/template"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"reflect" "reflect"
"strings"
"testing" "testing"
"text/template" "text/template"
"time"
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"
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"
) )
func TestServeHTTP(t *testing.T) { func TestServeHTTP(t *testing.T) {
@@ -194,11 +190,11 @@ func Test_crowdsecQuery(t *testing.T) {
func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) { func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
html := "<html>You are banned</html>" html := "<html>You are banned</html>"
banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html) banTemplate, _ := htmltemplate.New("html").Parse(html)
tests := []struct { tests := []struct {
name string name string
method string method string
banTemplate *template.Template banTemplate *htmltemplate.Template
expectBodyContent bool expectBodyContent bool
}{ }{
{ {
@@ -239,7 +235,6 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
remediationStatusCode: http.StatusForbidden, remediationStatusCode: http.StatusForbidden,
remediationCustomHeader: "X-Test-Remediation", remediationCustomHeader: "X-Test-Remediation",
banTemplate: tt.banTemplate, banTemplate: tt.banTemplate,
banTemplateContentType: "text/html; charset=utf-8",
} }
rw := httptest.NewRecorder() rw := httptest.NewRecorder()
@@ -274,50 +269,6 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
} }
} }
func TestHandleBanServeHTTPContentType(t *testing.T) {
html := "<html>You are banned</html>"
banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html)
tests := []struct {
name string
banTemplate *template.Template
banTemplateContentType string
}{
{
name: "Default HTML content type",
banTemplate: banTemplate,
banTemplateContentType: "text/html; charset=utf-8",
},
{
name: "Custom JSON content type",
banTemplate: banTemplate,
banTemplateContentType: "application/json",
},
{
name: "Content type set even when banTemplate is nil",
banTemplate: nil,
banTemplateContentType: "application/json",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bouncer := &Bouncer{
remediationStatusCode: http.StatusForbidden,
banTemplate: tt.banTemplate,
banTemplateContentType: tt.banTemplateContentType,
}
rw := httptest.NewRecorder()
req := &http.Request{Method: http.MethodGet}
bouncer.handleBanServeHTTP(rw, req, "0.0.0.0", "TEST")
if got := rw.Header().Get("Content-Type"); got != tt.banTemplateContentType {
t.Errorf("Expected Content-Type %q, got %q", tt.banTemplateContentType, got)
}
})
}
}
func TestCaptchaMethodBasedLogic(t *testing.T) { func TestCaptchaMethodBasedLogic(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -381,177 +332,3 @@ func TestCaptchaMethodBasedLogic(t *testing.T) {
}) })
} }
} }
// blockingBody simulates a request body that never reaches EOF, like a
// bidirectional gRPC stream that keeps its body open for the whole life of
// the connection. Reading from it blocks until the test is done.
type blockingBody struct {
done <-chan struct{}
}
func (b blockingBody) Read(_ []byte) (int, error) {
<-b.done
return 0, io.EOF
}
func (blockingBody) Close() error { return nil }
func Test_isBodyUnreadable(t *testing.T) {
realBody := func() io.ReadCloser { return io.NopCloser(strings.NewReader("data")) }
tests := []struct {
name string
protoMajor int
contentLength int64
body io.ReadCloser
want bool
}{
{name: "http2 grpc stream without content-length", protoMajor: 2, contentLength: -1, body: realBody(), want: true},
{name: "http3 stream without content-length", protoMajor: 3, contentLength: -1, body: realBody(), want: true},
{name: "http2 with content-length", protoMajor: 2, contentLength: 42, body: realBody(), want: false},
{name: "http1.1 chunked without content-length", protoMajor: 1, contentLength: -1, body: realBody(), want: false},
{name: "http2 without body", protoMajor: 2, contentLength: -1, body: nil, want: false},
{name: "http2 with http.NoBody", protoMajor: 2, contentLength: -1, body: http.NoBody, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, "http://localhost", nil)
req.ProtoMajor = tt.protoMajor
req.ContentLength = tt.contentLength
req.Body = tt.body
if got := isBodyUnreadable(req); got != tt.want {
t.Errorf("isBodyUnreadable() = %v, want %v", got, tt.want)
}
})
}
}
// newStreamingRequest builds an HTTP/2 request whose body never reaches EOF,
// like a bidirectional gRPC stream (issue #323).
func newStreamingRequest(done <-chan struct{}) *http.Request {
req, _ := http.NewRequest(http.MethodPost, "http://localhost/signalexchange.SignalExchange/ConnectStream", blockingBody{done: done})
req.Header.Set("Content-Type", "application/grpc")
req.ProtoMajor = 2
req.ContentLength = -1
return req
}
// Test_appsecQuery_streamingDoesNotBlock is a regression test for issue #323:
// a gRPC streaming request whose body never reaches EOF must not be buffered
// (io.ReadAll would block until timeout and wrongly produce a 403). The appsec
// query must complete promptly, inspecting headers only.
func Test_appsecQuery_streamingDoesNotBlock(t *testing.T) {
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusOK)
}))
defer appsecServer.Close()
appsecURL, _ := url.Parse(appsecServer.URL)
bouncer := &Bouncer{
appsecScheme: appsecURL.Scheme,
appsecHost: appsecURL.Host,
appsecPath: "/",
appsecBodyLimit: 10485760,
appsecUnreachableBlock: true,
appsecFailureBlock: true,
httpAppsecClient: appsecServer.Client(),
log: logger.New("INFO", ""),
}
done := make(chan struct{})
defer close(done)
finished := make(chan error, 1)
go func() {
finished <- appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done))
}()
select {
case err := <-finished:
if err != nil {
t.Errorf("appsecQuery() on streaming request returned error: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("appsecQuery() blocked on a streaming request body (issue #323 regression)")
}
}
// Test_appsecQuery_dropUnreadableBody verifies that, when configured to do so,
// a request with an unreadable body is dropped (blocked) instead of forwarded
// without its body, mirroring the reference APPSEC_DROP_UNREADABLE_BODY option.
func Test_appsecQuery_dropUnreadableBody(t *testing.T) {
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusOK)
}))
defer appsecServer.Close()
appsecURL, _ := url.Parse(appsecServer.URL)
bouncer := &Bouncer{
appsecScheme: appsecURL.Scheme,
appsecHost: appsecURL.Host,
appsecPath: "/",
appsecBodyLimit: 10485760,
appsecUnreadableBodyBlock: true,
httpAppsecClient: appsecServer.Client(),
log: logger.New("INFO", ""),
}
done := make(chan struct{})
defer close(done)
finished := make(chan error, 1)
go func() {
finished <- appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done))
}()
select {
case err := <-finished:
if err == nil {
t.Error("appsecQuery() expected an error to block the request, got nil")
}
case <-time.After(2 * time.Second):
t.Fatal("appsecQuery() blocked on a streaming request body (issue #323 regression)")
}
}
func newUnreadableGetRequest(done <-chan struct{}) *http.Request {
req, _ := http.NewRequest(http.MethodGet, "http://localhost/", blockingBody{done: done})
req.ProtoMajor = 3
req.ContentLength = -1
return req
}
// Test_appsecQuery_unreadableBodyGetNotDropped is a regression test for issue #351
func Test_appsecQuery_unreadableBodyGetNotDropped(t *testing.T) {
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusOK)
}))
defer appsecServer.Close()
appsecURL, _ := url.Parse(appsecServer.URL)
bouncer := &Bouncer{
appsecScheme: appsecURL.Scheme,
appsecHost: appsecURL.Host,
appsecPath: "/",
appsecBodyLimit: 10485760,
appsecUnreadableBodyBlock: true,
httpAppsecClient: appsecServer.Client(),
log: logger.New("INFO", ""),
}
done := make(chan struct{})
defer close(done)
finished := make(chan error, 1)
go func() {
finished <- appsecQuery(bouncer, "1.2.3.4", newUnreadableGetRequest(done))
}()
select {
case err := <-finished:
if err != nil {
t.Errorf("appsecQuery() on an HTTP/3 GET without content-length returned error: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("appsecQuery() blocked on an HTTP/3 GET request body (issue #351 regression)")
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: "traefik:v3.7.11" image: "traefik:v3.5.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -80,7 +80,7 @@ services:
- "traefik.http.routers.router-bar3.entrypoints=web" - "traefik.http.routers.router-bar3.entrypoints=web"
- "traefik.http.routers.router-bar3.middlewares=crowdsec2@docker" - "traefik.http.routers.router-bar3.middlewares=crowdsec2@docker"
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:v1.7.8 image: crowdsecurity/crowdsec:v1.6.8
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
+3 -3
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: "traefik:v3.7.11" image: "traefik:v3.0.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -12,7 +12,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.7.1" - "--experimental.plugins.bouncer.version=v1.3.0"
volumes: volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro" - "/var/run/docker.sock:/var/run/docker.sock:ro"
# - './ban.html:/ban.html:ro' # - './ban.html:/ban.html:ro'
@@ -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.7.8 image: crowdsecurity/crowdsec:v1.6.8
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
+3 -3
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: "traefik:v3.7.11" image: "traefik:v3.5.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.7.1" - "--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
@@ -47,7 +47,7 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:v1.7.8 image: crowdsecurity/crowdsec:v1.6.1-2
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
+4 -4
View File
@@ -1,6 +1,6 @@
services: services:
cloudflare: cloudflare:
image: "traefik:v3.7.11" image: "traefik:v3.0.0"
container_name: "cloudflare" container_name: "cloudflare"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -19,7 +19,7 @@ services:
- 8080:8080 - 8080:8080
traefik: traefik:
image: "traefik:v3.7.11" image: "traefik:v3.0.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -33,7 +33,7 @@ services:
- "--entrypoints.web.forwardedheaders.trustedips=172.21.0.5" - "--entrypoints.web.forwardedheaders.trustedips=172.21.0.5"
- "--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.7.1" - "--experimental.plugins.bouncer.version=v1.3.0"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- logs-traefik:/var/log/traefik - logs-traefik:/var/log/traefik
@@ -79,7 +79,7 @@ services:
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:v1.7.8 image: crowdsecurity/crowdsec:v1.6.1-2
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
+7 -7
View File
@@ -52,7 +52,7 @@ More information is available on configuring Crowdsec in the [official documenta
```yaml ```yaml
... ...
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:v1.6.8 image: crowdsecurity/crowdsec:v1.6.1-2
volumes: volumes:
# For captcha and ban mixed decision # For captcha and ban mixed decision
- './profiles.yaml:/etc/crowdsec/profiles.yaml:ro' - './profiles.yaml:/etc/crowdsec/profiles.yaml:ro'
@@ -100,9 +100,9 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage User->>TraefikPlugin: Can I access that webpage
create participant PluginCache create participant PluginCache
TraefikPlugin-->>PluginCache: Does the user IP has a crowdsec decision ? TraefikPlugin-->>PluginCache: Does the user IP has a crowdsec decision ?
destroy PluginCache Destroy PluginCache
PluginCache-->>TraefikPlugin: Nothing, all good! PluginCache-->>TraefikPlugin: Nothing, all good!
destroy TraefikPlugin Destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response Webserver->>User: HTTP Response
``` ```
@@ -121,12 +121,12 @@ sequenceDiagram
User->>TraefikPlugin: Fine, done! User->>TraefikPlugin: Fine, done!
create participant ProviderCaptcha create participant ProviderCaptcha
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ? TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
destroy ProviderCaptcha Destroy ProviderCaptcha
ProviderCaptcha-->>TraefikPlugin: Yes ProviderCaptcha-->>TraefikPlugin: Yes
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
destroy PluginCache Destroy PluginCache
PluginCache-->>TraefikPlugin: Done PluginCache-->>TraefikPlugin: Done
destroy TraefikPlugin Destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response Webserver->>User: HTTP Response
``` ```
@@ -140,7 +140,7 @@ sequenceDiagram
User->>TraefikPlugin: Can I access that webpage User->>TraefikPlugin: Can I access that webpage
create participant PluginCache create participant PluginCache
TraefikPlugin-->>PluginCache: Does the User IP has a Crowdsec Decision ? TraefikPlugin-->>PluginCache: Does the User IP has a Crowdsec Decision ?
destroy PluginCache Destroy PluginCache
PluginCache-->>TraefikPlugin: Yes a ban Decision PluginCache-->>TraefikPlugin: Yes a ban Decision
TraefikPlugin->>User: No, HTTP 403 TraefikPlugin->>User: No, HTTP 403
``` ```
+3 -3
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: "traefik:v3.7.11" image: "traefik:v3.0.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.7.1" - "--experimental.plugins.bouncer.version=v1.3.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
@@ -55,7 +55,7 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html" - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:v1.7.8 image: crowdsecurity/crowdsec:v1.6.1-2
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
+3 -3
View File
@@ -9,11 +9,11 @@ This can be usefull as some browser (Firefox for instance) return a 403 blank we
```yaml ```yaml
labels: labels:
# Define ban file path # Define ban HTML file path
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html" - "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
``` ```
The ban file must be present in the Traefik container (bind mounted or added during a custom build). The ban HTML file must be present in the Traefik container (bind mounted or added during a custom build).
It is not directly accessible from Traefik even when importing the plugin, so [download](https://raw.githubusercontent.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/master/ban.html) it locally to expose it to Traefik. It is not directly accessible from Traefik even when importing the plugin, so [download](https://raw.githubusercontent.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/master/ban.html) it locally to expose it to Traefik.
```yaml ```yaml
+5 -5
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: "traefik:v3.7.11" image: "traefik:v3.0.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.7.1" - "--experimental.plugins.bouncer.version=v1.3.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
@@ -42,11 +42,11 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG" - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
# Define ban file path # Define ban HTML file path
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html" - "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:v1.7.8 image: crowdsecurity/crowdsec:v1.6.1-2
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
+3 -3
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: "traefik:v3.7.11" image: "traefik:v3.5.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -14,7 +14,7 @@ services:
- "--entrypoints.web.forwardedheaders.trustedips=172.18.0.0/24" - "--entrypoints.web.forwardedheaders.trustedips=172.18.0.0/24"
- "--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.7.1" - "--experimental.plugins.bouncer.version=v1.4.5"
# - "--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
@@ -59,7 +59,7 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html" - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:v1.7.8 image: crowdsecurity/crowdsec:v1.6.1-2
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
+1 -1
View File
@@ -1,5 +1,5 @@
image: image:
tag: v1.7.8-2 tag: v1.6.1-2
agent: agent:
acquisition: acquisition:
+2 -2
View File
@@ -1,5 +1,5 @@
image: image:
tag: v3.7.11 tag: v3.0.0
logs: logs:
general: general:
@@ -15,4 +15,4 @@ experimental:
plugins: plugins:
bouncer: bouncer:
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
version: "v1.7.1" version: "v1.3.0"
+4 -4
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: "traefik:v3.7.11" image: "traefik:v3.0.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.7.1" - "--experimental.plugins.bouncer.version=v1.3.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
@@ -71,7 +71,7 @@ services:
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:v1.7.8 image: crowdsecurity/crowdsec:v1.6.1-2
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -87,7 +87,7 @@ services:
- "traefik.enable=false" - "traefik.enable=false"
redis-secure: redis-secure:
image: "redis:8.10.0-alpine" image: "redis:7.0.12-alpine"
container_name: "redis-secure" container_name: "redis-secure"
hostname: redis-secure hostname: redis-secure
restart: unless-stopped restart: unless-stopped
+2 -2
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: "traefik:v3.7.11" image: "traefik:v3.0.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.7.1" - "--experimental.plugins.bouncer.version=v1.3.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
+1 -1
View File
@@ -1,4 +1,4 @@
FROM ubuntu:26.04 FROM ubuntu:24.04
RUN apt-get update && apt-get install -y curl wget RUN apt-get update && apt-get install -y curl wget
RUN VERSION=$(curl --silent "https://api.github.com/repos/cloudflare/cfssl/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') && VNUMBER=${VERSION#"v"} && wget https://github.com/cloudflare/cfssl/releases/download/${VERSION}/cfssl_${VNUMBER}_linux_amd64 -O cfssl && chmod +x cfssl && mv cfssl /usr/local/bin RUN VERSION=$(curl --silent "https://api.github.com/repos/cloudflare/cfssl/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') && VNUMBER=${VERSION#"v"} && wget https://github.com/cloudflare/cfssl/releases/download/${VERSION}/cfssl_${VNUMBER}_linux_amd64 -O cfssl && chmod +x cfssl && mv cfssl /usr/local/bin
+3 -3
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: "traefik:v3.7.11" image: "traefik:v3.5.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.7.1" - "--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
@@ -71,7 +71,7 @@ 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.7.8 image: crowdsecurity/crowdsec:latest
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
+3 -3
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: "traefik:v3.7.11" image: "traefik:v3.0.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -13,7 +13,7 @@ services:
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.7.1" - "--experimental.plugins.bouncer.version=v1.3.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
@@ -65,7 +65,7 @@ services:
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:v1.7.8 image: crowdsecurity/crowdsec:v1.6.1-2
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin module github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
go 1.22.12 go 1.22
require ( require (
github.com/leprosus/golang-ttl-map v1.1.7 github.com/leprosus/golang-ttl-map v1.1.7
+24 -43
View File
@@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"sync/atomic"
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"
@@ -28,7 +27,10 @@ const (
) )
//nolint:gochecknoglobals //nolint:gochecknoglobals
var cache = ttl_map.New() var (
redis simpleredis.SimpleRedis
cache = ttl_map.New()
)
type localCache struct{} type localCache struct{}
@@ -50,48 +52,33 @@ func (localCache) delete(key string) {
} }
type redisCache struct { type redisCache struct {
log *slog.Logger log *slog.Logger
writer simpleredis.SimpleRedis
readers []simpleredis.SimpleRedis
counter atomic.Uint64
} }
func (rc *redisCache) nextReader() *simpleredis.SimpleRedis { func (redisCache) get(key string) (string, error) {
n := len(rc.readers) value, err := redis.Get(key)
if n == 0 {
return &rc.writer
}
idx := rc.counter.Add(1) % uint64(n)
return &rc.readers[idx]
}
func (rc *redisCache) get(key string) (string, error) {
value, err := rc.nextReader().Get(key)
if err != nil {
switch err.Error() {
case simpleredis.RedisMiss:
return "", errors.New(CacheMiss)
case simpleredis.RedisUnreachable:
return "", errors.New(CacheUnreachable)
default:
return "", err
}
}
valueString := string(value) valueString := string(value)
if len(valueString) > 0 { if err == nil && len(valueString) > 0 {
return valueString, nil return valueString, nil
} }
return "", errors.New(CacheMiss) errRedisMessage := err.Error()
if errRedisMessage == simpleredis.RedisMiss {
return "", errors.New(CacheMiss)
}
if errRedisMessage == simpleredis.RedisUnreachable {
return "", errors.New(CacheUnreachable)
}
return "", err
} }
func (rc *redisCache) set(key, value string, duration int64) { func (rc redisCache) set(key, value string, duration int64) {
if err := rc.writer.Set(key, []byte(value), duration); err != nil { if err := redis.Set(key, []byte(value), duration); err != nil {
rc.log.Error("cache:setDecisionRedisCache" + err.Error()) rc.log.Error("cache:setDecisionRedisCache" + err.Error())
} }
} }
func (rc *redisCache) delete(key string) { func (rc redisCache) delete(key string) {
if err := rc.writer.Del(key); err != nil { if err := redis.Del(key); err != nil {
rc.log.Error("cache:deleteDecisionRedisCache " + err.Error()) rc.log.Error("cache:deleteDecisionRedisCache " + err.Error())
} }
} }
@@ -109,21 +96,15 @@ type Client struct {
} }
// New Initialize cache client. // New Initialize cache client.
func (c *Client) New(log *slog.Logger, isRedis bool, writeHost string, readHosts []string, 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 {
rc := &redisCache{log: log} redis.Init(host, pass, database)
rc.writer.Init(writeHost, pass, database) c.cache = &redisCache{log: log}
for _, h := range readHosts {
var r simpleredis.SimpleRedis
r.Init(h, pass, database)
rc.readers = append(rc.readers, r)
}
c.cache = rc
} else { } else {
c.cache = &localCache{} c.cache = &localCache{}
} }
c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v writeHost:%v readHosts:%v", isRedis, writeHost, readHosts)) c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v", isRedis))
} }
// Delete delete decision in cache. // Delete delete decision in cache.
-38
View File
@@ -6,7 +6,6 @@ import (
"testing" "testing"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger" logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
simpleredis "github.com/maxlerebourg/simpleredis"
) )
func Test_Get(t *testing.T) { func Test_Get(t *testing.T) {
@@ -123,40 +122,3 @@ func Test_Delete(t *testing.T) {
}) })
} }
} }
// indexOfReader returns the position of r inside rc.readers, or -1 when r is the writer (the no-readers fallback).
func indexOfReader(rc *redisCache, r *simpleredis.SimpleRedis) int {
if r == &rc.writer {
return -1
}
for i := range rc.readers {
if r == &rc.readers[i] {
return i
}
}
return -2
}
func Test_nextReader(t *testing.T) {
// The counter starts at 0, so the first Add(1) yields index 1, then 2, 0, 1, ... over n readers.
tests := []struct {
name string
readers int
want []int
}{
{name: "round-robin over three readers", readers: 3, want: []int{1, 2, 0, 1, 2, 0, 1}},
{name: "single reader always selected", readers: 1, want: []int{0, 0, 0, 0, 0}},
{name: "no readers fall back to writer", readers: 0, want: []int{-1, -1, -1}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rc := &redisCache{log: logger.New("INFO", "")}
rc.readers = make([]simpleredis.SimpleRedis, tt.readers)
for call, want := range tt.want {
if got := indexOfReader(rc, rc.nextReader()); got != want {
t.Errorf("call %d: nextReader() -> reader[%d], want reader[%d]", call, got, want)
}
}
})
}
}
+6 -8
View File
@@ -4,11 +4,11 @@ package captcha
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"text/template"
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"
@@ -21,8 +21,7 @@ type Client struct {
secretKey string secretKey string
remediationCustomHeader string remediationCustomHeader string
gracePeriodSeconds int64 gracePeriodSeconds int64
templateContentType string captchaTemplate *template.Template
template *template.Template
cacheClient *cache.Client cacheClient *cache.Client
httpClient *http.Client httpClient *http.Client
log *slog.Logger log *slog.Logger
@@ -75,9 +74,8 @@ func (c *Client) New(log *slog.Logger, cacheClient *cache.Client, httpClient *ht
c.siteKey = siteKey c.siteKey = siteKey
c.secretKey = secretKey c.secretKey = secretKey
c.remediationCustomHeader = remediationCustomHeader c.remediationCustomHeader = remediationCustomHeader
template, contentType, _ := configuration.GetTemplate(captchaTemplatePath) html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
c.template = template c.captchaTemplate = html
c.templateContentType = contentType
c.gracePeriodSeconds = gracePeriodSeconds c.gracePeriodSeconds = gracePeriodSeconds
c.log = log c.log = log
c.httpClient = httpClient c.httpClient = httpClient
@@ -102,12 +100,12 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
http.Redirect(rw, r, r.URL.String(), http.StatusFound) http.Redirect(rw, r, r.URL.String(), http.StatusFound)
return return
} }
rw.Header().Set("Content-Type", c.templateContentType) rw.Header().Set("Content-Type", "text/html; charset=utf-8")
if c.remediationCustomHeader != "" { if c.remediationCustomHeader != "" {
rw.Header().Set(c.remediationCustomHeader, "captcha") rw.Header().Set(c.remediationCustomHeader, "captcha")
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
err = c.template.Execute(rw, map[string]string{ err = c.captchaTemplate.Execute(rw, map[string]string{
"SiteKey": c.siteKey, "SiteKey": c.siteKey,
"FrontendJS": c.infoProvider.js, "FrontendJS": c.infoProvider.js,
"FrontendKey": c.infoProvider.key, "FrontendKey": c.infoProvider.key,
+69 -102
View File
@@ -6,6 +6,7 @@ import (
"crypto/x509" "crypto/x509"
"errors" "errors"
"fmt" "fmt"
"html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
@@ -14,7 +15,6 @@ import (
"reflect" "reflect"
"regexp" "regexp"
"strings" "strings"
"text/template"
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip" ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
) )
@@ -63,7 +63,6 @@ type Config struct {
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"` CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"` CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"` CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
CrowdsecAppsecUnreadableBodyBlock bool `json:"crowdsecAppsecUnreadableBodyBlock,omitempty"`
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"` CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"` CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"` CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
@@ -96,15 +95,12 @@ type Config struct {
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"` ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"` RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
RedisCacheHost string `json:"redisCacheHost,omitempty"` RedisCacheHost string `json:"redisCacheHost,omitempty"`
RedisCacheReadHosts []string `json:"redisCacheReadHosts,omitempty"`
RedisCachePassword string `json:"redisCachePassword,omitempty"` RedisCachePassword string `json:"redisCachePassword,omitempty"`
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"` RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"` RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"` RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
BanFilePath string `json:"banFilePath,omitempty"` CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
CaptchaFilePath string `json:"captchaFilePath,omitempty"`
CaptchaProvider string `json:"captchaProvider,omitempty"` CaptchaProvider string `json:"captchaProvider,omitempty"`
CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"` CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"`
CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"` CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"`
@@ -129,54 +125,52 @@ func contains(source []string, target string) bool {
// New creates the default plugin configuration. // New creates the default plugin configuration.
func New() *Config { func New() *Config {
return &Config{ return &Config{
Enabled: false, Enabled: false,
LogLevel: LogINFO, LogLevel: LogINFO,
LogFormat: "common", LogFormat: "common",
LogFilePath: "", LogFilePath: "",
CrowdsecMode: LiveMode, CrowdsecMode: LiveMode,
CrowdsecAppsecEnabled: false, CrowdsecAppsecEnabled: false,
CrowdsecAppsecFailureBlock: true, CrowdsecAppsecFailureBlock: true,
CrowdsecAppsecUnreachableBlock: true, CrowdsecAppsecUnreachableBlock: true,
CrowdsecAppsecUnreadableBodyBlock: true, CrowdsecAppsecBodyLimit: 10485760,
CrowdsecAppsecBodyLimit: 10485760, CrowdsecAppsecScheme: "",
CrowdsecAppsecScheme: "", CrowdsecAppsecHost: "crowdsec:7422",
CrowdsecAppsecHost: "crowdsec:7422", CrowdsecAppsecPath: "/",
CrowdsecAppsecPath: "/", CrowdsecAppsecKey: "",
CrowdsecAppsecKey: "", CrowdsecAppsecTLSInsecureVerify: false,
CrowdsecAppsecTLSInsecureVerify: false, CrowdsecLapiScheme: HTTP,
CrowdsecLapiScheme: HTTP, CrowdsecLapiHost: "crowdsec:8080",
CrowdsecLapiHost: "crowdsec:8080", CrowdsecLapiPath: "/",
CrowdsecLapiPath: "/", CrowdsecLapiKey: "",
CrowdsecLapiKey: "", CrowdsecLapiTLSInsecureVerify: false,
CrowdsecLapiTLSInsecureVerify: false, UpdateIntervalSeconds: 60,
UpdateIntervalSeconds: 60, MetricsUpdateIntervalSeconds: 600,
MetricsUpdateIntervalSeconds: 600, UpdateMaxFailure: 0,
UpdateMaxFailure: 0, StreamStartupBlock: true,
StreamStartupBlock: true, DefaultDecisionSeconds: 60,
DefaultDecisionSeconds: 60, RemediationStatusCode: http.StatusForbidden,
RemediationStatusCode: http.StatusForbidden, HTTPTimeoutSeconds: 10,
HTTPTimeoutSeconds: 10, CaptchaProvider: "",
CaptchaProvider: "", CaptchaCustomJsURL: "",
CaptchaCustomJsURL: "", CaptchaCustomValidateURL: "",
CaptchaCustomValidateURL: "", CaptchaCustomKey: "",
CaptchaCustomKey: "", CaptchaCustomResponse: "",
CaptchaCustomResponse: "", CaptchaSiteKey: "",
CaptchaSiteKey: "", CaptchaSecretKey: "",
CaptchaSecretKey: "", CaptchaGracePeriodSeconds: 1800,
CaptchaGracePeriodSeconds: 1800, CaptchaHTMLFilePath: "/captcha.html",
CaptchaFilePath: "/captcha.html", BanHTMLFilePath: "",
BanFilePath: "", TraceHeadersCustomName: "",
TraceHeadersCustomName: "", RemediationHeadersCustomName: "",
RemediationHeadersCustomName: "", ForwardedHeadersCustomName: "X-Forwarded-For",
ForwardedHeadersCustomName: "X-Forwarded-For", ForwardedHeadersTrustedIPs: []string{},
ForwardedHeadersTrustedIPs: []string{}, ClientTrustedIPs: []string{},
ClientTrustedIPs: []string{}, RedisCacheEnabled: false,
RedisCacheEnabled: false, RedisCacheHost: "redis:6379",
RedisCacheHost: "redis:6379", RedisCachePassword: "",
RedisCacheReadHosts: []string{}, RedisCacheDatabase: "",
RedisCachePassword: "", RedisCacheUnreachableBlock: true,
RedisCacheDatabase: "",
RedisCacheUnreachableBlock: true,
} }
} }
@@ -207,50 +201,28 @@ func GetVariable(config *Config, key string) (string, error) {
return strings.TrimSpace(value), nil return strings.TrimSpace(value), nil
} }
func getContentTypeFromPath(path string) string { // GetHTMLTemplate get compiled HTML template.
func GetHTMLTemplate(path string) (*template.Template, error) {
var err error
if path == "" { if path == "" {
return "" return nil, errors.New("no html template provided")
} }
ext := strings.ToLower(filepath.Ext(path))
contentTypeMap := map[string]string{
".html": "text/html; charset=utf-8",
".htm": "text/html; charset=utf-8",
".json": "application/json",
".txt": "text/plain",
".xml": "application/xml",
".js": "application/javascript",
".css": "text/css",
}
if contentType, ok := contentTypeMap[ext]; ok {
return contentType
}
// Default to HTML for backward compatibility
return "text/html; charset=utf-8"
}
// GetTemplate get compiled template with {{ and }} delimiters.
// Uses text/template for all file types to avoid HTML escaping issues.
func GetTemplate(path string) (*template.Template, string, error) {
if path == "" {
return nil, "", errors.New("no template file provided")
}
contentType := getContentTypeFromPath(path)
//nolint:gosec //nolint:gosec
b, err := os.ReadFile(path) b, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, "", err return nil, err
} }
content := string(b) html := string(b)
compiledTemplate, err := template.New(filepath.Base(path)).Delims("{{", "}}").Parse(content) compiledTemplate, err := template.New("html").Parse(html)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("impossible to compile template %s: %w", path, err) return nil, fmt.Errorf("impossible to compile html template: %w", err)
} }
return compiledTemplate, contentType, nil return compiledTemplate, nil
} }
// ValidateParams validate all the param gave by user. // ValidateParams validate all the param gave by user.
// //
//nolint:gocyclo,gocognit,nestif //nolint:gocyclo,gocognit
func ValidateParams(config *Config, log *slog.Logger) 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
@@ -288,14 +260,12 @@ func ValidateParams(config *Config, log *slog.Logger) error {
if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil { if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil {
return err return err
} }
if config.CaptchaFilePath != "" { if _, err := GetHTMLTemplate(config.CaptchaHTMLFilePath); err != nil {
if _, _, err := GetTemplate(config.CaptchaFilePath); err != nil { return err
return err
}
} }
} }
if config.BanFilePath != "" { if config.BanHTMLFilePath != "" {
if _, _, err := GetTemplate(config.BanFilePath); err != nil { if _, err := GetHTMLTemplate(config.BanHTMLFilePath); err != nil {
return err return err
} }
} }
@@ -391,8 +361,7 @@ func validateParamsTLS(config *Config) error {
return err return err
} }
if certAuth == "" { if certAuth == "" {
// No custom CA — runtime will fall back to the system trust store. return errors.New("CrowdsecLapiTLSCertificateAuthority must be specified when CrowdsecLapiScheme='https' and CrowdsecLapiTLSInsecureVerify=false")
return nil
} }
tlsConfig := new(tls.Config) tlsConfig := new(tls.Config)
tlsConfig.RootCAs = x509.NewCertPool() tlsConfig.RootCAs = x509.NewCertPool()
@@ -484,31 +453,29 @@ func validateParamsRequired(config *Config) error {
func getTLSConfig(config *Config, log *slog.Logger, prefix, scheme string, insecureVerify bool) (*tls.Config, error) { func getTLSConfig(config *Config, log *slog.Logger, prefix, scheme string, insecureVerify bool) (*tls.Config, error) {
tlsConfig := new(tls.Config) tlsConfig := new(tls.Config)
tlsConfig.RootCAs = x509.NewCertPool()
if scheme != HTTPS { if scheme != HTTPS {
log.Debug("getTLSConfig:" + prefix + "Scheme https:no") log.Debug("getTLSConfig:" + prefix + "Scheme https:no")
return tlsConfig, nil return tlsConfig, nil
} }
// RootCAs is intentionally left nil unless a custom CA is provided:
// crypto/tls then falls back to x509.SystemCertPool(), which is what we
// want when the LAPI is exposed behind a reverse proxy with a publicly
// trusted certificate (e.g. Let's Encrypt).
//nolint:nestif //nolint:nestif
if insecureVerify { if insecureVerify {
tlsConfig.InsecureSkipVerify = true tlsConfig.InsecureSkipVerify = true
log.Debug("getTLSConfig:" + prefix + "TLSInsecureVerify tlsInsecure:true") log.Debug("getTLSConfig:" + prefix + "TLSInsecureVerify tlsInsecure:true")
// If we return here and still want to use client auth this won't work
// return tlsConfig, nil
} else { } else {
certAuthority, err := GetVariable(config, prefix+"TLSCertificateAuthority") certAuthority, err := GetVariable(config, prefix+"TLSCertificateAuthority")
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(certAuthority) > 0 { if len(certAuthority) > 0 {
tlsConfig.RootCAs = x509.NewCertPool()
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) { if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
// here we return because if CrowdsecLapiTLSInsecureVerify is false
// and CA not load, we can't communicate with https
return nil, errors.New("getTLSConfig:" + prefix + " cannot load CA and verify cert is enabled") return nil, errors.New("getTLSConfig:" + prefix + " cannot load CA and verify cert is enabled")
} }
log.Debug("getTLSConfig:" + prefix + "TLSCertificateAuthority CA added successfully") log.Debug("getTLSConfig:" + prefix + "TLSCertificateAuthority CA added successfully")
} else {
log.Debug("getTLSConfig:" + prefix + " no CA provided, using system trust store")
} }
} }
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer") certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
+21 -90
View File
@@ -1,25 +1,13 @@
package configuration package configuration
import ( import (
"crypto/tls"
"reflect"
"testing" "testing"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger" logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
) )
// validPEM is a minimal self-signed certificate accepted by AppendCertsFromPEM,
// shared by the TLS tests below.
const validPEM = `-----BEGIN CERTIFICATE-----
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
6MF9+Yw1Yy0t
-----END CERTIFICATE-----`
func getMinimalConfig() *Config { func getMinimalConfig() *Config {
cfg := New() cfg := New()
cfg.CrowdsecLapiKey = "test" cfg.CrowdsecLapiKey = "test"
@@ -123,7 +111,7 @@ 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: "Validate https without cert authority (falls back to system trust store)", args: args{config: cfg7}, wantErr: false}, {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 uppercase INFO", args: args{config: cfg8}, wantErr: false},
{name: "Valid log level lowercase info", args: args{config: cfg9}, 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}, {name: "Invalid log level Warning", args: args{config: cfg10}, wantErr: true},
@@ -138,24 +126,19 @@ func Test_ValidateParams(t *testing.T) {
} }
func Test_validateParamsTLS(t *testing.T) { func Test_validateParamsTLS(t *testing.T) {
cfgEmpty := getMinimalConfig() type args struct {
cfgValid := getMinimalConfig() config *Config
cfgValid.CrowdsecLapiTLSCertificateAuthority = validPEM }
cfgInvalidCA := getMinimalConfig()
cfgInvalidCA.CrowdsecLapiTLSCertificateAuthority = "not a pem"
tests := []struct { tests := []struct {
name string name string
config *Config args args
wantErr bool wantErr bool
}{ }{
{name: "Empty CA is accepted (system trust store used at runtime)", config: cfgEmpty, wantErr: false}, // TODO: Add test cases.
{name: "Valid PEM CA is accepted", config: cfgValid, wantErr: false},
{name: "Invalid CA is rejected", config: cfgInvalidCA, 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 := validateParamsTLS(tt.config); (err != nil) != tt.wantErr { if err := validateParamsTLS(tt.args.config); (err != nil) != tt.wantErr {
t.Errorf("validateParamsTLS() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("validateParamsTLS() error = %v, wantErr %v", err, tt.wantErr)
} }
}) })
@@ -250,78 +233,26 @@ func Test_validateParamsAPIKey(t *testing.T) {
func Test_GetTLSConfigCrowdsec(t *testing.T) { func Test_GetTLSConfigCrowdsec(t *testing.T) {
log := logger.New("INFO", "") log := logger.New("INFO", "")
type args struct {
httpCfg := getMinimalConfig() config *Config
httpCfg.CrowdsecLapiScheme = HTTP }
httpsSystemCA := getMinimalConfig()
httpsSystemCA.CrowdsecLapiScheme = HTTPS
httpsCustomCA := getMinimalConfig()
httpsCustomCA.CrowdsecLapiScheme = HTTPS
httpsCustomCA.CrowdsecLapiTLSCertificateAuthority = validPEM
httpsInsecure := getMinimalConfig()
httpsInsecure.CrowdsecLapiScheme = HTTPS
httpsInsecure.CrowdsecLapiTLSInsecureVerify = true
httpsBadCA := getMinimalConfig()
httpsBadCA.CrowdsecLapiScheme = HTTPS
httpsBadCA.CrowdsecLapiTLSCertificateAuthority = "not a pem"
tests := []struct { tests := []struct {
name string name string
config *Config args args
wantErr bool want *tls.Config
wantRootCAsNil bool wantErr bool
wantInsecureSkip bool
}{ }{
{name: "HTTP scheme returns empty tls.Config", config: httpCfg, wantRootCAsNil: true}, // TODO: Add test cases.
{name: "HTTPS without CA leaves RootCAs nil (system trust store)", config: httpsSystemCA, wantRootCAsNil: true},
{name: "HTTPS with custom CA populates RootCAs", config: httpsCustomCA, wantRootCAsNil: false},
{name: "HTTPS with insecure verify sets InsecureSkipVerify", config: httpsInsecure, wantRootCAsNil: true, wantInsecureSkip: true},
{name: "HTTPS with garbage CA is rejected", config: httpsBadCA, wantErr: true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := GetTLSConfigCrowdsec(tt.config, log, false) 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
} }
if tt.wantErr { if !reflect.DeepEqual(got, tt.want) {
return t.Errorf("getTLSConfigCrowdsec() = %v, want %v", got, tt.want)
}
if (got.RootCAs == nil) != tt.wantRootCAsNil {
t.Errorf("GetTLSConfigCrowdsec() RootCAs nil = %v, want nil = %v", got.RootCAs == nil, tt.wantRootCAsNil)
}
if got.InsecureSkipVerify != tt.wantInsecureSkip {
t.Errorf("GetTLSConfigCrowdsec() InsecureSkipVerify = %v, want %v", got.InsecureSkipVerify, tt.wantInsecureSkip)
}
})
}
}
func Test_getContentTypeFromPath(t *testing.T) {
tests := []struct {
name string
path string
expected string
}{
{name: "HTML file with .html extension", path: "/ban.html", expected: "text/html; charset=utf-8"},
{name: "JSON file", path: "/ban.json", expected: "application/json"},
{name: "Text file", path: "/ban.txt", expected: "text/plain"},
{name: "File with mixed case extension", path: "/ban.HtMl", expected: "text/html; charset=utf-8"},
{name: "Unknown extension defaults to HTML", path: "/ban.xyz", expected: "text/html; charset=utf-8"},
{name: "File without extension", path: "/ban", expected: "text/html; charset=utf-8"},
{name: "Empty path", path: "", expected: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := getContentTypeFromPath(tt.path)
if got != tt.expected {
t.Errorf("GetContentTypeFromPath(%q) = %q, want %q", tt.path, got, tt.expected)
} }
}) })
} }
-77
View File
@@ -1,77 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"gitAuthor": "Renovate Bot <22881669+maxlerebourg@users.noreply.github.com>",
"fetchChangeLogs": "off",
"labels": ["dependencies"],
"ignorePaths": ["**/vendor/**", "**/node_modules/**"],
"rangeStrategy": "bump",
"prConcurrentLimit": 1,
"branchPrefix": "renovate/",
"commitMessagePrefix": "⬆️ renovate: ",
"groupName": "all",
"dependencyDashboard": false,
"packageRules": [
{
"description": "Cap the Go version at what yaegi supports. The plugin is interpreted by yaegi (bundled in Traefik), and even Traefik v3.7.1 ships yaegi v0.16.1 = Go 1.22. A newer Go would break the plugin on every current Traefik. Raise this only once Traefik ships a yaegi supporting a newer Go.",
"matchManagers": ["gomod"],
"matchDepNames": ["go", "toolchain"],
"allowedVersions": "<1.23"
},
{
"description": "whoami is a throwaway demo backend; leave it on latest",
"matchPackageNames": ["traefik/whoami"],
"enabled": false
}
],
"customManagers": [
{
"description": "Plugin self-pin in docker-compose CLI args (--experimental.plugins.bouncer.version=vX)",
"customType": "regex",
"managerFilePatterns": ["/(^|/)docker-compose[^/]*\\.ya?ml$/"],
"matchStrings": [
"experimental\\.plugins\\.bouncer\\.version=(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)"
],
"depNameTemplate": "maxlerebourg/crowdsec-bouncer-traefik-plugin",
"datasourceTemplate": "github-tags"
},
{
"description": "Plugin self-pin in the Traefik Helm values (version: \"vX\")",
"customType": "regex",
"managerFilePatterns": ["/^examples/kubernetes/traefik/values\\.ya?ml$/"],
"matchStrings": [
"version:\\s*\"(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)\""
],
"depNameTemplate": "maxlerebourg/crowdsec-bouncer-traefik-plugin",
"datasourceTemplate": "github-tags"
},
{
"description": "Traefik image tag in the Traefik Helm values (no repository key, so match by file)",
"customType": "regex",
"managerFilePatterns": ["/^examples/kubernetes/traefik/values\\.ya?ml$/"],
"matchStrings": ["tag:\\s*(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)"],
"depNameTemplate": "traefik",
"datasourceTemplate": "docker"
},
{
"description": "Crowdsec image tag in the Crowdsec Helm values (no repository key, so match by file)",
"customType": "regex",
"managerFilePatterns": [
"/^examples/kubernetes/crowdsec/values\\.ya?ml$/"
],
"matchStrings": ["tag:\\s*(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)"],
"depNameTemplate": "crowdsecurity/crowdsec",
"datasourceTemplate": "docker"
},
{
"description": "Pinned Traefik binary in the e2e mock suite (TRAEFIK_VERSION:-vX in common.sh)",
"customType": "regex",
"managerFilePatterns": ["/^tests/e2e/mock/lib/common\\.sh$/"],
"matchStrings": [
"TRAEFIK_VERSION:-(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)"
],
"depNameTemplate": "traefik/traefik",
"datasourceTemplate": "github-releases"
}
]
}
+55
View File
@@ -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
}
-91
View File
@@ -1,91 +0,0 @@
# Binary e2e suite (Traefik binary + mock LAPI)
This suite runs **Traefik as a downloaded binary** with the plugin loaded from
the local source tree, and replaces Crowdsec with a small **HTTP mock**
([`mocklapi/`](mocklapi/main.go), a stdlib-only Go command). No Docker, no real
Crowdsec.
It is what **CI runs** (`make e2e_mock`). A separate, local-only **Docker
suite** (real Traefik + Crowdsec, under `tests/e2e/scenarios`) is kept for
high-fidelity debugging against a real Crowdsec but is not exercised in CI; it
ships in its own PR (#333).
## Scope — what this suite tests
These tests validate the **plugin's own behaviour**: the request flow through
the Traefik middleware, the live / none / stream modes, caching, trusted-IP
bypass, ban / captcha page rendering, and the AppSec request path (header
forwarding + enforcing the engine's allow/block verdict).
The mock stands in for Crowdsec, emulating the slice of the LAPI HTTP contract
the plugin consumes — including a single, deterministic AppSec rule (block any
URI containing `rpc2`, the probe from [`examples/appsec-enabled`](../../../examples/appsec-enabled)).
It is not the real WAF engine, so this suite exercises the plugin's AppSec
*wiring* rather than the detection accuracy of OWASP CRS / virtual patching —
that lives upstream in Crowdsec.
## What runs
| Component | How |
|-----------|-----|
| Traefik | Binary `v3.7.1`, downloaded into `.cache/` (reused across local runs; re-downloaded on fresh CI runners) |
| Plugin | Loaded via `experimental.localPlugins` from the repo root (symlinked into `plugins-local/`) |
| LAPI | `mocklapi` — a stdlib-only Go command (its own nested module), compiled and cached under `.cache/`, driven through `/admin` endpoints instead of `cscli`. Serves plain HTTP, or HTTPS when `--lapi-tls-cert/--lapi-tls-key` are passed (the `tls-system-ca` scenario) |
| AppSec | WAF stand-in built into the mock — blocks URIs containing `rpc2`, allows the rest |
| Backend | A plain HTTP responder built into the mock |
Fixed ports (override with env vars if needed): Traefik `8000`, LAPI `8090`,
backend `8091`, AppSec `8092`.
## Running locally
Prerequisites: `bash`, `curl`, `go`, `tar` (plus `openssl` for the
`tls-system-ca` scenario, which mints a throwaway CA at runtime). On first use
the Traefik binary is fetched and the mock is compiled into `.cache/`. That
cache is reused across local runs; CI runs on fresh runners, so both are
recreated on every CI run.
The `tls-system-ca` scenario verifies that, with no custom CA configured, the
bouncer falls back to the OS/system trust store for an HTTPS LAPI: it serves the
mock over TLS and points the Traefik process's `SSL_CERT_FILE` at the test CA
(trusted → 200) or an empty bundle (untrusted → 403, proving it still verifies).
```bash
# one scenario
make e2e_mock_stream-mode
# or directly
./tests/e2e/mock/scenarios/stream-mode/run.sh
# the whole suite
make e2e_mock
```
## Layout
```
mock/
lib/
common.sh # stack lifecycle, Traefik download, mock build, assertions, admin client
traefik.yml # static Traefik config (shared by all scenarios)
mocklapi/
go.mod # nested module — kept out of the plugin's build/lint/vendor
main.go # mock LAPI + AppSec stand-in + backend
scenarios/
<name>/
dynamic.yml # Traefik dynamic config (router + bouncer middleware + backend)
run.sh # assertions for the scenario
*.html # optional fixtures (ban / captcha templates)
```
`dynamic.yml` uses placeholders (`@@APIKEY@@`, `@@LAPI_HOST@@`,
`@@BACKEND_URL@@`, `@@SCENARIO_DIR@@`) that `common.sh` substitutes at runtime.
## Adding a scenario
1. Create `scenarios/<name>/dynamic.yml` and `run.sh` (copy `stream-mode/` as a
template).
2. In `run.sh`, define a `body` function with the assertions and call
`run_scenario "<name>" "$HERE" body`.
3. Drive decisions with `lapi_add_decision <ip> [type] [duration]` and
`lapi_delete_decision <ip>`.
4. Add `<name>` to `E2E_MOCK_SCENARIOS` in the `Makefile`.
-267
View File
@@ -1,267 +0,0 @@
#!/usr/bin/env bash
# Shared helpers for the binary (mock) e2e suite.
#
# Unlike the Docker suite under tests/e2e/scenarios, this one runs Traefik as a
# downloaded binary and replaces Crowdsec with a small HTTP mock (the mocklapi
# Go command). It validates the plugin's own behaviour (modes, cache, trusted
# IPs, ban / captcha rendering, AppSec wiring) — not the accuracy of Crowdsec's
# detection or its WAF engine, which the mock only stands in for.
#
# Dependencies: bash, curl, go, tar. The Traefik binary is downloaded and the
# mock is compiled into .cache/ on first use. That cache persists across local
# runs; CI runs on fresh runners, so both are recreated on every CI run.
set -euo pipefail
# Pinned to match the Docker suite (tests/e2e/scenarios/*/docker-compose.yml).
TRAEFIK_VERSION="${TRAEFIK_VERSION:-v3.7.11}"
WEB_PORT="${WEB_PORT:-8000}"
LAPI_PORT="${LAPI_PORT:-8090}"
BACKEND_PORT="${BACKEND_PORT:-8091}"
APPSEC_PORT="${APPSEC_PORT:-8092}"
REDIS_PORT="${REDIS_PORT:-8093}"
REDIS_READ_PORT="${REDIS_READ_PORT:-8094}"
LAPI_KEY="${LAPI_KEY:-e2e-mock-key}"
MOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$MOCK_LIB_DIR/../../../.." && pwd)"
CACHE_DIR="$MOCK_LIB_DIR/../.cache"
# Populated by start_stack / run_scenario, consumed by the EXIT trap.
WORKDIR=""
TRAEFIK_PID=""
MOCK_PID=""
SCENARIO_NAME=""
SCENARIO_LOG=""
# Resolve (and cache) the Traefik binary for this host, echoing its path.
ensure_traefik() {
local bin="$CACHE_DIR/traefik-$TRAEFIK_VERSION"
if [[ -x "$bin" ]]; then
echo "$bin"
return 0
fi
mkdir -p "$CACHE_DIR"
local os arch
case "$(uname -s)" in
Linux) os=linux ;;
Darwin) os=darwin ;;
*) echo "ensure_traefik: unsupported OS $(uname -s)" >&2; return 1 ;;
esac
case "$(uname -m)" in
x86_64 | amd64) arch=amd64 ;;
aarch64 | arm64) arch=arm64 ;;
*) echo "ensure_traefik: unsupported arch $(uname -m)" >&2; return 1 ;;
esac
local url="https://github.com/traefik/traefik/releases/download/${TRAEFIK_VERSION}/traefik_${TRAEFIK_VERSION}_${os}_${arch}.tar.gz"
echo "ensure_traefik: downloading $url" >&2
local tmp
tmp="$(mktemp -d)"
curl -sSfL "$url" -o "$tmp/traefik.tar.gz"
tar -xzf "$tmp/traefik.tar.gz" -C "$tmp" traefik
mv "$tmp/traefik" "$bin"
chmod +x "$bin"
rm -rf "$tmp"
echo "$bin"
}
# Build (and cache) the mock LAPI binary, echoing its path. Go's build cache
# makes the rebuild near-instant after the first run.
ensure_mock() {
local bin="$CACHE_DIR/mocklapi"
mkdir -p "$CACHE_DIR"
( cd "$MOCK_LIB_DIR/../mocklapi" && go build -o "$bin" . ) >&2
echo "$bin"
}
# Poll a URL until it returns the expected status code, or fail.
# Usage: wait_for_status URL CODE [TIMEOUT_SECONDS] [curl args...]
wait_for_status() {
local url="$1" expected="$2" timeout="${3:-30}"
shift 3 || true
local elapsed=0 got=""
while (( elapsed < timeout )); do
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url" || true)
if [[ "$got" == "$expected" ]]; then
return 0
fi
sleep 1
# Note: `((elapsed++))` returns exit 1 when elapsed is 0, which trips set -e.
elapsed=$((elapsed + 1))
done
echo "wait_for_status: $url expected $expected, last seen ${got:-<none>}" >&2
return 1
}
# Poll a URL until its body contains a substring, or fail. Used when the status
# code alone can't tell the states apart (e.g. captcha page vs backend, both 200).
# Usage: wait_for_body_contains URL NEEDLE [TIMEOUT_SECONDS] [curl args...]
wait_for_body_contains() {
local url="$1" needle="$2" timeout="${3:-30}"
shift 3 || true
local elapsed=0 body=""
while (( elapsed < timeout )); do
body=$(curl -s "$@" "$url" || true)
if grep -q "$needle" <<<"$body"; then
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "wait_for_body_contains: $url did not contain \"$needle\" within ${timeout}s" >&2
return 1
}
# Assert a single curl returns the expected status code.
# Usage: assert_status URL CODE [curl args...]
assert_status() {
local url="$1" expected="$2"
shift 2 || true
local got
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url")
if [[ "$got" != "$expected" ]]; then
echo "assert_status: $url expected $expected, got $got" >&2
return 1
fi
}
# Assert a response header matches a value (case-insensitive name).
# Usage: assert_header URL HEADER VALUE [curl args...]
assert_header() {
local url="$1" header="$2" expected="$3"
shift 3 || true
local got
got=$(curl -s -D - -o /dev/null "$@" "$url" | tr -d '\r' \
| awk -v h="${header,,}" -F': ' 'tolower($1) == h { print $2; exit }')
if [[ "$got" != "$expected" ]]; then
echo "assert_header: $url header $header expected \"$expected\", got \"$got\"" >&2
return 1
fi
}
# Assert a response body contains a substring.
# Usage: assert_body_contains URL NEEDLE [curl args...]
assert_body_contains() {
local url="$1" needle="$2"
shift 2 || true
local body
body=$(curl -s "$@" "$url")
if ! grep -q "$needle" <<<"$body"; then
echo "assert_body_contains: $url expected to contain \"$needle\", got:" >&2
echo "$body" >&2
return 1
fi
}
# --- mock admin client -------------------------------------------------------
lapi_add_decision() {
local ip="$1" type="${2:-ban}" duration="${3:-4h}"
curl -sS -X POST "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}&type=${type}&duration=${duration}" >/dev/null
}
lapi_delete_decision() {
local ip="$1"
curl -sS -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}" >/dev/null
}
# --- stack lifecycle ---------------------------------------------------------
# start_stack SCENARIO_DIR
# Spins up the mock + Traefik (with the scenario's dynamic.yml) and waits ready.
start_stack() {
local scenario_dir="$1"
local traefik_bin mock_bin
traefik_bin="$(ensure_traefik)"
mock_bin="$(ensure_mock)"
WORKDIR="$(mktemp -d)"
# Expose the plugin source where Traefik's localPlugins loader expects it.
mkdir -p "$WORKDIR/plugins-local/src/github.com/maxlerebourg"
ln -s "$REPO_ROOT" "$WORKDIR/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
cp "$MOCK_LIB_DIR/traefik.yml" "$WORKDIR/traefik.yml"
# Render the scenario's dynamic config with the live ports / key / paths.
sed \
-e "s|@@APIKEY@@|${LAPI_KEY}|g" \
-e "s|@@LAPI_HOST@@|127.0.0.1:${LAPI_PORT}|g" \
-e "s|@@APPSEC_HOST@@|127.0.0.1:${APPSEC_PORT}|g" \
-e "s|@@BACKEND_URL@@|http://127.0.0.1:${BACKEND_PORT}|g" \
-e "s|@@REDIS_HOST@@|127.0.0.1:${REDIS_PORT}|g" \
-e "s|@@REDIS_READ_HOST@@|127.0.0.1:${REDIS_READ_PORT}|g" \
-e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \
"$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml"
# Opt-in HTTPS LAPI: a scenario exports LAPI_TLS_CERT/LAPI_TLS_KEY to serve the
# LAPI over TLS (used by tls-system-ca). Default empty -> plaintext as before.
local mock_tls_args=() lapi_scheme=http lapi_curl=()
if [[ -n "${LAPI_TLS_CERT:-}" && -n "${LAPI_TLS_KEY:-}" ]]; then
mock_tls_args=(--lapi-tls-cert "$LAPI_TLS_CERT" --lapi-tls-key "$LAPI_TLS_KEY")
lapi_scheme=https
lapi_curl=(-k) # the readiness probe ignores trust; the bouncer's trust is what we test
fi
"$mock_bin" \
--lapi-addr "127.0.0.1:${LAPI_PORT}" \
--backend-addr "127.0.0.1:${BACKEND_PORT}" \
--appsec-addr "127.0.0.1:${APPSEC_PORT}" \
--redis-addr "127.0.0.1:${REDIS_PORT}" \
--redis-read-addr "127.0.0.1:${REDIS_READ_PORT}" \
"${mock_tls_args[@]}" >"$WORKDIR/mock.log" 2>&1 &
MOCK_PID=$!
# Opt-in trust store for the Traefik process: a scenario exports
# TRAEFIK_SSL_CERT_FILE to point Go's x509.SystemCertPool() at a specific CA
# bundle. Empty -> Go's default system store (unchanged behaviour).
( cd "$WORKDIR" && SSL_CERT_FILE="${TRAEFIK_SSL_CERT_FILE:-}" exec "$traefik_bin" --configfile=traefik.yml ) >"$WORKDIR/traefik.log" 2>&1 &
TRAEFIK_PID=$!
wait_for_status "${lapi_scheme}://127.0.0.1:${LAPI_PORT}/health" 200 30 "${lapi_curl[@]}"
# AppSec stand-in: a bare GET carries no "rpc2" URI, so it answers 200 (allow).
wait_for_status "http://127.0.0.1:${APPSEC_PORT}/" 200 30
# /ping is served by Traefik itself once it is up (plugin compilation included).
wait_for_status "http://127.0.0.1:${WEB_PORT}/ping" 200 60
}
stop_stack() {
[[ -n "$TRAEFIK_PID" ]] && kill "$TRAEFIK_PID" 2>/dev/null || true
[[ -n "$MOCK_PID" ]] && kill "$MOCK_PID" 2>/dev/null || true
[[ -n "$TRAEFIK_PID" ]] && wait "$TRAEFIK_PID" 2>/dev/null || true
[[ -n "$MOCK_PID" ]] && wait "$MOCK_PID" 2>/dev/null || true
[[ -n "$WORKDIR" && -d "$WORKDIR" ]] && rm -rf "$WORKDIR" || true
}
dump_diagnostics() {
echo "=== traefik.log ==="
cat "$WORKDIR/traefik.log" 2>/dev/null || true
echo "=== mock.log ==="
cat "$WORKDIR/mock.log" 2>/dev/null || true
}
# EXIT trap: runs after the scenario body (or after a failed assertion under
# `set -e`), so it relies only on globals, never on run_scenario's locals.
_scenario_cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics > "$SCENARIO_LOG" 2>&1 || true
echo "[$SCENARIO_NAME] failed. Logs written to $SCENARIO_LOG" >&2
fi
stop_stack
exit $rc
}
# run_scenario SCENARIO_NAME SCENARIO_DIR BODY_FN
# Wraps lifecycle + diagnostics so each run.sh stays declarative.
run_scenario() {
SCENARIO_NAME="$1"
local dir="$2" body="$3"
SCENARIO_LOG="/tmp/e2e-mock-${SCENARIO_NAME}.log"
trap _scenario_cleanup EXIT
echo "[$SCENARIO_NAME] starting binary stack (Traefik + mock LAPI)..."
start_stack "$dir"
"$body"
echo "[$SCENARIO_NAME] OK"
}
-28
View File
@@ -1,28 +0,0 @@
# Static Traefik configuration for the binary e2e suite.
# The dynamic part (router + bouncer middleware + backend service) lives in
# dynamic.yml, generated per scenario by common.sh.
entryPoints:
web:
address: ":8000"
forwardedHeaders:
# The test's curl sets X-Forwarded-For; preserve it through the proxy.
insecure: true
log:
level: INFO
accessLog: {}
# /ping on the web entrypoint is the readiness probe — no dashboard/API needed.
ping:
entryPoint: web
providers:
file:
filename: dynamic.yml
watch: false
experimental:
localPlugins:
bouncer:
moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
-6
View File
@@ -1,6 +0,0 @@
// Standalone module so this test helper stays out of the plugin module:
// it is excluded from the plugin's `go build ./...`, `go test ./...`,
// golangci-lint and `go mod vendor`. Stdlib only no dependencies.
module mocklapi
go 1.22.12
-218
View File
@@ -1,218 +0,0 @@
// Command mocklapi is a minimal Crowdsec LAPI stand-in for the binary e2e
// suite. It answers only the few LAPI routes the plugin calls — live/none
// decision lookups, the stream poll and the usage-metrics push — and lets the
// test drive decisions through /admin instead of `cscli`. It also serves the
// stub upstream that Traefik proxies allowed requests to, and a hardcoded Redis
// stand-in for exercising the redis cache path.
//
// It is NOT a Crowdsec/AppSec conformance harness — the real WAF engine (OWASP
// CRS, virtual patching) is out of scope. The AppSec endpoint here emulates a
// single deterministic rule so the suite can exercise the plugin's AppSec
// wiring (header forwarding, allow/block handling) end to end. See the README.
package main
import (
"bufio"
"encoding/json"
"flag"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
)
// Decision is the subset of a LAPI decision the plugin actually reads.
type Decision struct {
Value string `json:"value"`
Type string `json:"type"`
Duration string `json:"duration"`
}
var (
mu sync.Mutex
active = map[string]Decision{} // ip -> decision currently in force
deleted = map[string]Decision{} // ip -> decision to report in the stream "deleted" list
)
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func list(m map[string]Decision) []Decision {
out := make([]Decision, 0, len(m))
for _, d := range m {
out = append(out, d)
}
return out
}
// --- Redis mock (inline-command wire format, as spoken by simpleredis) ---
// serveRedis is a hardcoded stand-in. When verdicts is true it plays a replica
// that holds decisions: every line is scanned for known IPs, 1.2.3.4 → "f"
// (clean), 1.2.3.5 → "t" (banned); any other GET is a miss ($-1). When verdicts
// is false it plays the primary and answers every GET with a miss, so a
// scenario can prove reads are served from the replica and not the primary.
// SET, DEL, AUTH, SELECT get +OK (they don't read the response anyway).
func serveRedis(addr string, verdicts bool) {
ln, err := net.Listen("tcp", addr)
if err != nil {
log.Fatal(err)
}
defer ln.Close()
for {
conn, err := ln.Accept()
if err != nil {
continue
}
go func(conn net.Conn) {
defer conn.Close()
rd := bufio.NewReader(conn)
for {
line, _, err := rd.ReadLine()
if err != nil {
return
}
s := string(line)
switch {
case verdicts && strings.Contains(s, "1.2.3.4"):
conn.Write([]byte("$1\r\nf\r\n"))
case verdicts && strings.Contains(s, "1.2.3.5"):
conn.Write([]byte("$1\r\nt\r\n"))
case strings.HasPrefix(strings.ToUpper(s), "GET "):
conn.Write([]byte("$-1\r\n"))
default:
conn.Write([]byte("+OK\r\n"))
}
}
}(conn)
}
}
func main() {
lapiAddr := flag.String("lapi-addr", "127.0.0.1:8090", "address for the LAPI mock")
// The stub upstream Traefik proxies allowed requests to — the binary-suite
// equivalent of the traefik/whoami container. Not AppSec.
backendAddr := flag.String("backend-addr", "127.0.0.1:8091", "address for the stub upstream service")
// AppSec WAF stand-in (the real engine listens on :7422). Not a CRS engine.
appsecAddr := flag.String("appsec-addr", "127.0.0.1:8092", "address for the AppSec mock")
// Redis stand-ins on plain TCP ports, enough to exercise the plugin's redis
// cache path. The primary answers every GET with a miss; the replica serves
// the hardcoded verdicts, so a scenario pointing redisCacheReadHosts at the
// replica proves reads are offloaded to replicas.
redisAddr := flag.String("redis-addr", "127.0.0.1:8093", "address for the Redis primary mock (writes; GET always misses)")
redisReadAddr := flag.String("redis-read-addr", "127.0.0.1:8094", "address for the Redis replica mock (serves cached verdicts)")
// Optional TLS for the LAPI: when both are set the LAPI is served over HTTPS
// (cert signed by the scenario's throwaway CA) so the suite can exercise the
// bouncer's system-trust-store path. Backend and AppSec stay plaintext.
lapiTLSCert := flag.String("lapi-tls-cert", "", "PEM cert to serve the LAPI over HTTPS (optional)")
lapiTLSKey := flag.String("lapi-tls-key", "", "PEM key for --lapi-tls-cert")
flag.Parse()
go func() {
log.Fatal(http.ListenAndServe(*backendAddr, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("E2E_BACKEND_OK\n"))
})))
}()
// AppSec mock: the plugin forwards the request metadata in X-Crowdsec-Appsec-*
// headers and reads our status — 200 allows, 403 blocks. We emulate one
// deterministic virtual-patching rule (block any URI containing "rpc2", the
// exact probe from examples/appsec-enabled) so the plugin's AppSec path is
// exercised without standing up the real WAF.
go func() {
log.Fatal(http.ListenAndServe(*appsecAddr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "403") {
w.WriteHeader(http.StatusForbidden)
}
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "500") {
w.WriteHeader(http.StatusInternalServerError)
}
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "502") {
w.WriteHeader(http.StatusBadGateway)
}
// Read body
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
defer r.Body.Close()
if strings.Contains(string(body), "a=0") {
w.WriteHeader(http.StatusForbidden)
return
}
})))
}()
go serveRedis(*redisAddr, false)
go serveRedis(*redisReadAddr, true)
mux := http.NewServeMux()
// Readiness probe for the test harness (empty body, 200).
mux.HandleFunc("/health", func(http.ResponseWriter, *http.Request) {})
// live / none mode: the plugin asks about one IP and expects a decision
// array, or the literal `null` when there is none.
mux.HandleFunc("/v1/decisions", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if d, ok := active[r.URL.Query().Get("ip")]; ok {
writeJSON(w, []Decision{d})
return
}
_, _ = w.Write([]byte("null"))
})
// stream mode: report the whole active set as "new" and anything removed as
// "deleted". Re-sending the same on every poll is harmless — the plugin just
// re-adds to / re-deletes from its cache.
mux.HandleFunc("/v1/decisions/stream", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
defer mu.Unlock()
writeJSON(w, map[string][]Decision{"new": list(active), "deleted": list(deleted)})
})
// usage-metrics push: accept and ignore.
mux.HandleFunc("/v1/usage-metrics", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
})
// Test control plane: add / remove decisions instead of cscli.
mux.HandleFunc("/admin/decisions", func(_ http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
ip := q.Get("ip")
mu.Lock()
defer mu.Unlock()
switch r.Method {
case http.MethodPost:
dtype := q.Get("type")
if dtype == "" {
dtype = "ban"
}
duration := q.Get("duration")
if duration == "" {
duration = "4h"
}
active[ip] = Decision{Value: ip, Type: dtype, Duration: duration}
delete(deleted, ip)
case http.MethodDelete:
if d, ok := active[ip]; ok {
deleted[ip] = d
delete(active, ip)
}
}
})
if *lapiTLSCert != "" && *lapiTLSKey != "" {
log.Printf("mocklapi: LAPI on %s (TLS), backend on %s, appsec on %s, redis on %s (read %s)", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr, *redisReadAddr)
log.Fatal(http.ListenAndServeTLS(*lapiAddr, *lapiTLSCert, *lapiTLSKey, mux))
}
log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s, redis on %s (read %s)", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr, *redisReadAddr)
log.Fatal(http.ListenAndServe(*lapiAddr, mux))
}
@@ -1,32 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
# IP bouncing disabled — this scenario exercises AppSec only.
crowdsecMode: appsec
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
crowdsecAppsecEnabled: "true"
crowdsecAppsecFailureBlock: "true"
crowdsecAppsecBodyLimit: 4
crowdsecAppsecUnreachableBlock: "false"
crowdsecAppsecScheme: http
crowdsecAppsecHost: "@@APPSEC_HOST@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
-44
View File
@@ -1,44 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=appsec
# AppSec wiring check: the plugin forwards each request to the AppSec engine and
# enforces its verdict. The mock emulates one virtual-patching rule (block any
# URI containing "rpc2"), mirroring examples/appsec-enabled. This proves the
# plugin's AppSec path end to end (header forwarding + allow/block handling); it
# does not test the real WAF's detection accuracy.
body() {
echo "[$SCENARIO] benign request must pass (AppSec 200)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] request that return 403 must be blocked (AppSec 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo/403" 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] request that return 500 must be blocked (because CrowdsecAppsecFailureBlock = true) (AppSec 500)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo/500" 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] request that return 502 must pass (because CrowdsecAppsecUnreachableBlock = false) (Proxy error 502)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo/502" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] request that send bad body after crowdsecAppsecBodyLimit must pass (AppSec 200)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4" -X POST -d "______&a=0"
echo "[$SCENARIO] request that send bad body before crowdsecAppsecBodyLimit must pass (AppSec 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" -X POST -d "a=0&______"
echo "[$SCENARIO] request http2 that send no body GET (AppSec 200)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:"
echo "[$SCENARIO] request http2 that send unreadable body GET (AppSec 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:" -d "test"
echo "[$SCENARIO] request http2 that send unreadable body POST (AppSec 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:" -X POST -d "test"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -1,33 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: stream
updateIntervalSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
captchaProvider: turnstile
# Cloudflare Turnstile public test keys: render a valid widget without
# contacting a real API key.
captchaSiteKey: "1x00000000000000000000AA"
captchaSecretKey: "1x0000000000000000000000000000000AA"
captchaHtmlFilePath: "@@SCENARIO_DIR@@/captcha.html"
captchaGracePeriodSeconds: "10"
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=captcha
body() {
echo "[$SCENARIO] adding captcha decision for 1.2.3.4"
lapi_add_decision 1.2.3.4 captcha 5m
# Status stays 200 before/after (captcha page vs backend), so gate on the body
# marker appearing once the captcha decision has been polled.
echo "[$SCENARIO] captcha page must be served once the decision is polled (200 + marker)"
wait_for_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CAPTCHA_PAGE_MARKER" 15 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] captcha response Content-Type is HTML"
assert_header "http://127.0.0.1:${WEB_PORT}/foo" Content-Type "text/html; charset=utf-8" -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] captcha response is HTTP 200 (the captcha page itself, not a 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] non-flagged IP must still pass through to the backend"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -1,4 +0,0 @@
{
"marker": "E2E_CUSTOM_BAN_PAGE_MARKER",
"body": "IP: {{ .ClientIP }}, reason: {{ .RemediationReason }}, trace: {{ .TraceID }}"
}
@@ -1,29 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: stream
updateIntervalSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
banFilePath: "@@SCENARIO_DIR@@/ban.json"
remediationHeadersCustomName: "X-E2E-Remediation"
traceHeadersCustomName: x-trace
@@ -1,30 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=custom-ban-page
body() {
echo "[$SCENARIO] adding ban decision"
lapi_add_decision 1.2.3.4 ban 5m
echo "[$SCENARIO] banned response becomes 403 once the next stream poll lands"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] banned response Content-Type is HTML"
assert_header "http://127.0.0.1:${WEB_PORT}/foo" Content-Type "application/json" -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] banned response body contains the custom marker"
assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CUSTOM_BAN_PAGE_MARKER" -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] banned response body contains the IP and reason from templating"
assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "IP: 1.2.3.4, reason: LAPI, trace: 0123456789" -H "X-Forwarded-For: 1.2.3.4" -H "X-Trace: 0123456789"
echo "[$SCENARIO] banned response carries the custom remediation header (remediationHeadersCustomName)"
assert_header "http://127.0.0.1:${WEB_PORT}/foo" X-E2E-Remediation "ban" -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -1,26 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: live
defaultDecisionSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=live-mode
body() {
echo "[$SCENARIO] no decision -> first hit queries LAPI, returns 200, caches 'allowed' for 2s"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
lapi_add_decision 1.2.3.4 ban 5m
# Stays 200 until the cached 'allowed' (defaultDecisionSeconds) expires, then
# the re-query sees the ban — poll instead of guessing the cache TTL.
echo "[$SCENARIO] hit must turn 403 once the cached 'allowed' expires and LAPI is re-queried"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] another non-banned IP must still pass"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -1,25 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: none
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=none-mode
body() {
echo "[$SCENARIO] no decision -> request passes (LAPI queried per request)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
lapi_add_decision 1.2.3.4 ban 5m
echo "[$SCENARIO] none mode has no cache -> next request must be blocked immediately"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] deleting decision"
lapi_delete_decision 1.2.3.4
echo "[$SCENARIO] previously banned IP must pass again immediately"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -1,30 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: live
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
redisCacheEnabled: "true"
redisCacheHost: "@@REDIS_HOST@@"
redisCacheReadHosts:
- "@@REDIS_READ_HOST@@"
- "@@REDIS_HOST@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=redis
# The replica mock returns "f" (not banned) for 1.2.3.4 and "t" (banned) for 1.2.3.5.
# The primary mock always misses.
body() {
echo "[$SCENARIO] cached banned IP must not be blocked because call for primary (test rotation)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.5"
echo "[$SCENARIO] cached banned IP must be blocked"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.5"
echo "[$SCENARIO] cached clean IP must pass"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] unknown IP (redis miss) must fall through to LAPI and pass"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.6"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -1,26 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: stream
updateIntervalSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
@@ -1,30 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=stream-mode
body() {
echo "[$SCENARIO] no decision yet -> request allowed"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
lapi_add_decision 1.2.3.4 ban 5m
echo "[$SCENARIO] banned IP must be blocked once the next stream poll lands (HTTP 403)"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
echo "[$SCENARIO] non-banned IP must still pass (HTTP 200)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
echo "[$SCENARIO] deleting ban decision"
lapi_delete_decision 1.2.3.4
echo "[$SCENARIO] previously banned IP must pass again once the deletion is polled"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
@@ -1,28 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: live
defaultDecisionSeconds: "2"
# HTTPS LAPI with NO custom CA configured: the bouncer must fall back to
# the OS/system trust store (which the scenario controls via SSL_CERT_FILE).
crowdsecLapiScheme: https
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
@@ -1,66 +0,0 @@
#!/usr/bin/env bash
# Scenario: HTTPS LAPI with no custom CA configured -> the bouncer must fall back
# to the OS/system trust store (PR #331). In the binary suite the "system trust
# store" is whatever Go's x509.SystemCertPool() reads, which honours SSL_CERT_FILE
# on the Traefik process. We mint a throwaway CA, serve the mock LAPI over HTTPS
# with a cert signed by it, and run the stack twice:
#
# positive: SSL_CERT_FILE = our CA -> LAPI trusted -> 200
# negative: SSL_CERT_FILE = empty bundle -> LAPI not trusted -> 403
#
# live mode is fail-closed, so a TLS error becomes a 403. The negative run proves
# the patch still VERIFIES (it is not an insecure skip).
#
# Extra dependency vs other scenarios: openssl.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=tls-system-ca
SCENARIO_NAME="$SCENARIO"
SCENARIO_LOG="/tmp/e2e-mock-${SCENARIO}.log"
CERT_DIR="$(mktemp -d)"
cleanup() {
local rc=$?
if (( rc != 0 )); then
dump_diagnostics > "$SCENARIO_LOG" 2>&1 || true
echo "[$SCENARIO] failed. Logs written to $SCENARIO_LOG" >&2
fi
stop_stack
rm -rf "$CERT_DIR"
exit $rc
}
trap cleanup EXIT
echo "[$SCENARIO] minting throwaway CA + LAPI cert (SAN=IP:127.0.0.1)..."
openssl ecparam -name prime256v1 -genkey -noout -out "$CERT_DIR/ca.key" 2>/dev/null
openssl req -x509 -new -key "$CERT_DIR/ca.key" -sha256 -days 3650 \
-subj "/CN=crowdsec-bouncer e2e test CA" -out "$CERT_DIR/ca.crt" 2>/dev/null
openssl ecparam -name prime256v1 -genkey -noout -out "$CERT_DIR/lapi.key" 2>/dev/null
openssl req -new -key "$CERT_DIR/lapi.key" -subj "/CN=lapi" -out "$CERT_DIR/lapi.csr" 2>/dev/null
openssl x509 -req -in "$CERT_DIR/lapi.csr" -CA "$CERT_DIR/ca.crt" -CAkey "$CERT_DIR/ca.key" \
-CAcreateserial -days 3650 -sha256 -out "$CERT_DIR/lapi.crt" \
-extfile <(printf "subjectAltName=IP:127.0.0.1\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth") 2>/dev/null
: > "$CERT_DIR/empty.crt" # an empty bundle = a system store that trusts nothing
# The mock serves the same CA-signed cert in both runs; only Traefik's trust differs.
export LAPI_TLS_CERT="$CERT_DIR/lapi.crt" LAPI_TLS_KEY="$CERT_DIR/lapi.key"
echo "[$SCENARIO] === positive: CA in the system trust store ==="
export TRAEFIK_SSL_CERT_FILE="$CERT_DIR/ca.crt"
start_stack "$HERE"
echo "[$SCENARIO] HTTPS LAPI verifies via system trust store -> request passes (200)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
stop_stack
echo "[$SCENARIO] === negative: CA absent from the system trust store ==="
export TRAEFIK_SSL_CERT_FILE="$CERT_DIR/empty.crt"
start_stack "$HERE"
echo "[$SCENARIO] LAPI cert not trusted -> TLS fails, fail-closed (403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4"
stop_stack
echo "[$SCENARIO] OK"
@@ -1,28 +0,0 @@
http:
routers:
r:
rule: "PathPrefix(`/foo`)"
entryPoints:
- web
service: backend
middlewares:
- bouncer
services:
backend:
loadBalancer:
servers:
- url: "@@BACKEND_URL@@"
middlewares:
bouncer:
plugin:
bouncer:
enabled: "true"
crowdsecMode: stream
updateIntervalSeconds: "2"
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
forwardedHeadersTrustedIps:
- "127.0.0.1/32"
clientTrustedIps:
- "1.2.3.4/32"
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
SCENARIO=trusted-ips
body() {
echo "[$SCENARIO] banning the trusted IP 1.2.3.4 and an untrusted IP 5.6.7.8"
lapi_add_decision 1.2.3.4 ban 5m
lapi_add_decision 5.6.7.8 ban 5m
# The untrusted IP turning 403 is our signal that the bans have been polled;
# it also doubles as the control proving the bouncer is active.
echo "[$SCENARIO] untrusted banned IP must be blocked once the bans are polled (HTTP 403)"
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 5.6.7.8"
echo "[$SCENARIO] trusted IP must bypass the bouncer even though it is banned"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
}
run_scenario "$SCENARIO" "$HERE" body
+6
View File
@@ -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"
@@ -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"
+2 -3
View File
@@ -1,5 +1,4 @@
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
// pluginVersion is what the plugin reports to the Crowdsec LAPI. // pluginVersion is updated automatically by the release workflow.
// Do not edit by hand: the "Release (1/2) Prepare" workflow bumps it. var pluginVersion = "1.6.X" //nolint:gochecknoglobals
var pluginVersion = "v1.7.1" //nolint:gochecknoglobals