mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f8608d770 | ||
|
|
ae7481caa5 | ||
|
|
955391671c | ||
|
|
9b8d6b937c | ||
|
|
d57ead2ec7 | ||
|
|
1ba556e918 | ||
|
|
6b0518859d | ||
|
|
bef5dfaadb | ||
|
|
99cf9712f4 | ||
|
|
ed4a9e8262 | ||
|
|
f6ef95cf38 | ||
|
|
9daba9739c | ||
|
|
e98b8ed5ba | ||
|
|
bb44aef718 | ||
|
|
31874263f2 | ||
|
|
1c98c70f14 | ||
|
|
be13c49144 | ||
|
|
5a2998bc62 | ||
|
|
26ce12f7e3 | ||
|
|
d32f271195 | ||
|
|
1c1672c856 | ||
|
|
21895fbb9d | ||
|
|
7c73cb38dd | ||
|
|
f4dcd933c8 | ||
|
|
67b33dcf13 | ||
|
|
661a89ea9c | ||
|
|
14b9c47ab2 | ||
|
|
f5d580578c | ||
|
|
71d845faae | ||
|
|
0d8fd2a7a9 | ||
|
|
1f6a8991c8 | ||
|
|
7f776fe0fe | ||
|
|
e54c1d5c4f |
@@ -1,24 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain dependencies for Go
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
# Maintain dependencies for build tools
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/tools"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
# Maintain dependencies for GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -0,0 +1,42 @@
|
||||
name: E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: e2e-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: e2e (binary + mock LAPI)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@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
|
||||
@@ -15,8 +15,13 @@ jobs:
|
||||
name: Main Process
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GO_VERSION: 1.23
|
||||
# Keep in sync with go.mod. Capped at 1.22 because the plugin is run by
|
||||
# yaegi (bundled in Traefik) and even Traefik v3.7.1 ships yaegi v0.16.1,
|
||||
# which only supports Go 1.22. Building on the floor makes go build / go
|
||||
# test reject newer stdlib before yaegi_test does.
|
||||
GO_VERSION: 1.22
|
||||
GOLANGCI_LINT_VERSION: v1.63.4
|
||||
# yaegi_test guard — pin to the version current Traefik bundles.
|
||||
YAEGI_VERSION: v0.16.1
|
||||
CGO_ENABLED: 0
|
||||
defaults:
|
||||
@@ -27,20 +32,20 @@ jobs:
|
||||
|
||||
# https://github.com/marketplace/actions/setup-go-environment
|
||||
- name: Set up Go ${{ env.GO_VERSION }}
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
# https://github.com/marketplace/actions/checkout
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: go/src/github.com/${{ github.repository }}
|
||||
fetch-depth: 0
|
||||
|
||||
# https://github.com/marketplace/actions/cache
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Release (1/2) Prepare
|
||||
|
||||
# Step 1 of the release process: bump pluginVersion *before* the tag exists.
|
||||
#
|
||||
# The version reported to the Crowdsec LAPI lives in version.go, so it has to
|
||||
# be correct in the very commit the tag points at. Anything that patches
|
||||
# version.go after the release is published is too late: Traefik's plugin
|
||||
# service caches the plugin archive per module+version, so users keep the
|
||||
# source that was there when the tag was first resolved (see #322, #363).
|
||||
#
|
||||
# This workflow opens a "release" PR containing only that bump. Merging it
|
||||
# triggers Release (2/2) Publish, which creates the tag and the GitHub release
|
||||
# on the merged commit.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to release, e.g. v1.7.1 or v1.8.0-alpha"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Open release PR for ${{ inputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out main
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate version
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "::error::'$VERSION' is not a vX.Y.Z / vX.Y.Z-suffix version"
|
||||
exit 1
|
||||
fi
|
||||
if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
|
||||
echo "::error::tag $VERSION already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Bump version.go
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
sed -i 's/pluginVersion = "[^"]*"/pluginVersion = "'"$VERSION"'"/' version.go
|
||||
cat version.go
|
||||
if git diff --quiet -- version.go; then
|
||||
echo "::error::version.go already reads $VERSION, nothing to release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Push release branch and open PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git switch -c "release/$VERSION"
|
||||
git commit -am "🔖 release $VERSION"
|
||||
git push -u origin "release/$VERSION"
|
||||
|
||||
cat > /tmp/pr-body.md <<EOF
|
||||
Bumps \`pluginVersion\` to \`$VERSION\` so the tag carries the version
|
||||
the plugin reports to the Crowdsec LAPI.
|
||||
|
||||
Merging this PR tags \`$VERSION\` on the resulting commit and publishes
|
||||
the GitHub release automatically.
|
||||
|
||||
> Keep the PR title as-is: **Release (2/2) Publish** matches on it.
|
||||
EOF
|
||||
|
||||
gh pr create --base main --head "release/$VERSION" --title "🔖 release $VERSION" --body-file /tmp/pr-body.md
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Release (2/2) Publish
|
||||
|
||||
# Step 2 of the release process: tag and publish the commit prepared by
|
||||
# Release (1/2) Prepare.
|
||||
#
|
||||
# Triggered by the release PR landing on main. The tag is created on that
|
||||
# commit, so version.go inside the released source always matches the tag —
|
||||
# no post-release patching, no force-moved tags.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["version.go"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Tag and publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the pushed commit
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release version
|
||||
id: resolve
|
||||
run: |
|
||||
version="$(git log -1 --format='%B' | grep -oP '🔖 release \Kv[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?' || true)"
|
||||
[ -z "$version" ] && { echo "version.go changed outside a release commit, nothing to do"; echo "release=false" >> "$GITHUB_OUTPUT"; exit 0; }
|
||||
|
||||
in_source="$(sed -n 's/.*pluginVersion = "\([^"]*\)".*/\1/p' version.go)"
|
||||
[ "$in_source" != "$version" ] && { echo "::error::commit says $version but version.go reads $in_source"; exit 1; }
|
||||
git rev-parse -q --verify "refs/tags/$version" >/dev/null && { echo "::error::tag $version already exists"; exit 1; }
|
||||
|
||||
echo "release=true" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=$([[ "$version" == *-* ]] && echo '--prerelease')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Tag and create the GitHub release
|
||||
if: steps.resolve.outputs.release == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
PRERELEASE: ${{ steps.resolve.outputs.prerelease }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$VERSION" -m "$VERSION"
|
||||
git push origin "$VERSION"
|
||||
gh release create "$VERSION" --title "$VERSION" --generate-notes $PRERELEASE
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Release Version Update
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-version:
|
||||
name: Update version in source
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Extract version from tag
|
||||
id: get_version
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update version in version.go
|
||||
run: |
|
||||
sed -i 's/pluginVersion = "[^"]*"/pluginVersion = "'"${{ steps.get_version.outputs.version }}"'"/' version.go
|
||||
cat version.go
|
||||
|
||||
- name: Commit, push, and retag
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add version.go
|
||||
if git diff --cached --quiet; then
|
||||
echo "Version already up to date, nothing to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "⬆️ chore: bump version to ${{ steps.get_version.outputs.version }}"
|
||||
git push origin main
|
||||
# Move the release tag to include the version update
|
||||
git tag -f "${{ steps.get_version.outputs.tag }}"
|
||||
git push -f origin "${{ steps.get_version.outputs.tag }}"
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Renovate
|
||||
|
||||
# Self-hosted Renovate: opens dependency-update PRs on a daily schedule.
|
||||
# Config lives in /renovate.json. Requires a repo/org secret RENOVATE_TOKEN
|
||||
# (a PAT with `repo` + `workflow` scope, or a fine-grained token with
|
||||
# contents:write + pull-requests:write) so Renovate can push branches and open
|
||||
# PRs. Trigger manually from the Actions tab via "Run workflow" to test.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 4 * * *" # every day at 04:00 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
logLevel:
|
||||
description: "Renovate log level"
|
||||
required: false
|
||||
default: "info"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: renovate
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Run Renovate
|
||||
uses: renovatebot/github-action@v46.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' }}
|
||||
@@ -5,3 +5,7 @@ conf
|
||||
db
|
||||
logs
|
||||
docker-compose.dev.yml
|
||||
|
||||
# Binary e2e suite working cache: Traefik binary + compiled mock. Persisted
|
||||
# across local runs; CI runs on fresh runners so it is recreated every time.
|
||||
tests/e2e/mock/.cache/
|
||||
|
||||
+3
-1
@@ -7,7 +7,7 @@ linters-settings:
|
||||
disable:
|
||||
- fieldalignment
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
min-complexity: 20
|
||||
goconst:
|
||||
min-len: 5
|
||||
min-occurrences: 4
|
||||
@@ -41,6 +41,7 @@ linters-settings:
|
||||
- $test
|
||||
allow:
|
||||
- $gostd
|
||||
- github.com/maxlerebourg/simpleredis
|
||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
|
||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
|
||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
|
||||
@@ -73,6 +74,7 @@ linters:
|
||||
- gci
|
||||
- mnd
|
||||
- exportloopref
|
||||
- contextcheck
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-same-issues: 0
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
.PHONY: lint test vendor clean
|
||||
.PHONY: lint test vendor clean e2e_mock
|
||||
|
||||
export GO111MODULE=on
|
||||
|
||||
# Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs.
|
||||
# The local Docker suite (make e2e) lives in a separate PR/branch.
|
||||
E2E_MOCK_SCENARIOS := $(notdir $(wildcard tests/e2e/mock/scenarios/*))
|
||||
|
||||
default: lint test
|
||||
|
||||
lint:
|
||||
@@ -13,6 +17,11 @@ test:
|
||||
yaegi_test:
|
||||
yaegi test -v .
|
||||
|
||||
e2e_mock: $(addprefix e2e_mock_,$(E2E_MOCK_SCENARIOS))
|
||||
|
||||
e2e_mock_%:
|
||||
bash ./tests/e2e/mock/scenarios/$*/run.sh
|
||||
|
||||
vendor:
|
||||
go mod vendor
|
||||
|
||||
@@ -115,4 +124,3 @@ show_metrics:
|
||||
|
||||
show_decisions:
|
||||
docker exec crowdsec cscli decisions list
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant CrowdsecLAPI
|
||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a Crowdsec Decision ?
|
||||
Destroy CrowdsecLAPI
|
||||
destroy CrowdsecLAPI
|
||||
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
||||
TraefikPlugin->>User: No, HTTP 403
|
||||
```
|
||||
@@ -82,9 +82,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant CrowdsecLAPI
|
||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||
Destroy CrowdsecLAPI
|
||||
destroy CrowdsecLAPI
|
||||
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
```
|
||||
@@ -105,10 +105,10 @@ sequenceDiagram
|
||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||
create participant CrowdsecLAPI
|
||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||
Destroy CrowdsecLAPI
|
||||
destroy CrowdsecLAPI
|
||||
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
||||
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Done
|
||||
TraefikPlugin->>User: No, HTTP 403
|
||||
```
|
||||
@@ -125,10 +125,10 @@ sequenceDiagram
|
||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||
create participant CrowdsecLAPI
|
||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||
Destroy CrowdsecLAPI
|
||||
destroy CrowdsecLAPI
|
||||
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
||||
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Done
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
@@ -145,11 +145,11 @@ sequenceDiagram
|
||||
participant TraefikPlugin
|
||||
participant CrowdsecLAPI
|
||||
TraefikPlugin->>CrowdsecLAPI: What are the current decisions
|
||||
Destroy CrowdsecLAPI
|
||||
destroy CrowdsecLAPI
|
||||
CrowdsecLAPI->>TraefikPlugin: Here is the list
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Store this list
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Done
|
||||
```
|
||||
|
||||
@@ -162,9 +162,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Yes a ban decision
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>User: No, HTTP 403
|
||||
```
|
||||
|
||||
@@ -177,9 +177,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
```
|
||||
@@ -195,11 +195,11 @@ sequenceDiagram
|
||||
participant TraefikPlugin
|
||||
participant CrowdsecCAPI
|
||||
TraefikPlugin->>CrowdsecCAPI: What are the current decisions from CAPI
|
||||
Destroy CrowdsecCAPI
|
||||
destroy CrowdsecCAPI
|
||||
CrowdsecCAPI->>TraefikPlugin: Here is the list
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Store this list
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Done
|
||||
```
|
||||
|
||||
@@ -212,9 +212,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Yes a ban decision
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>User: No, HTTP 403
|
||||
```
|
||||
|
||||
@@ -227,9 +227,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
```
|
||||
@@ -247,9 +247,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant CrowdsecAppSec
|
||||
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
||||
Destroy CrowdsecAppSec
|
||||
destroy CrowdsecAppSec
|
||||
CrowdsecAppSec-->>TraefikPlugin: Yes I think so
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>User: No, HTTP 403
|
||||
```
|
||||
|
||||
@@ -262,9 +262,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant CrowdsecAppSec
|
||||
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
||||
Destroy CrowdsecAppSec
|
||||
destroy CrowdsecAppSec
|
||||
CrowdsecAppSec-->>TraefikPlugin: No I don't think so
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
```
|
||||
@@ -285,12 +285,12 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Fine, done!
|
||||
create participant ProviderCaptcha
|
||||
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
||||
Destroy ProviderCaptcha
|
||||
destroy ProviderCaptcha
|
||||
ProviderCaptcha-->>TraefikPlugin: Yes
|
||||
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Done
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
```
|
||||
@@ -329,8 +329,12 @@ make run
|
||||
- Enable the plugin
|
||||
- LogLevel
|
||||
- string
|
||||
- default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR`
|
||||
- default: `INFO`, expected values are: `DEBUG`, `INFO`, `WARN`, `ERROR`
|
||||
- Log are written to `stdout` / `stderr` or file if LogFilePath is provided
|
||||
- LogFormat
|
||||
- string
|
||||
- default: `common`, expected values are: `common`, `json`
|
||||
- Log format: `common` for traditional text logs, `json` for structured JSON logs
|
||||
- LogFilePath
|
||||
- string
|
||||
- default: ""
|
||||
@@ -358,7 +362,7 @@ make run
|
||||
- CrowdsecAppsecTlsCertificateAuthority
|
||||
- string
|
||||
- default: ""
|
||||
- PEM-encoded Certificate Authority of Appsec
|
||||
- PEM-encoded Certificate Authority used to verify Appsec's server certificate. When empty (and `crowdsecAppsecTlsInsecureVerify` is `false`), the host's system trust store is used.
|
||||
- CrowdsecAppsecScheme
|
||||
- string
|
||||
- default: value of `CrowdsecLapiScheme`, expected values are: `http`, `https`
|
||||
@@ -378,6 +382,10 @@ make run
|
||||
- int64
|
||||
- default: 10485760 (= 10MB)
|
||||
- Transmit only the first number of bytes to Crowdsec Appsec Server.
|
||||
- CrowdsecAppsecUnreadableBodyBlock
|
||||
- bool
|
||||
- default: true
|
||||
- Behaviour when the request body cannot be buffered for inspection (HTTP/2 or HTTP/3 request without a `Content-Length`, typically a bidirectional gRPC stream). When `false` the request is forwarded to the Appsec Server with headers only (the body is left to stream through untouched). When `true` the request is blocked outright. Mirrors the reference bouncers' `APPSEC_DROP_UNREADABLE_BODY` option.
|
||||
- CrowdsecAppsecKey
|
||||
- string
|
||||
- default: value of `CrowdsecLapiKey`
|
||||
@@ -404,7 +412,7 @@ make run
|
||||
- CrowdsecLapiTlsCertificateAuthority
|
||||
- string
|
||||
- default: ""
|
||||
- PEM-encoded Certificate Authority of the Crowdsec LAPI
|
||||
- PEM-encoded Certificate Authority used to verify the LAPI's server certificate. When empty (and `crowdsecLapiTlsInsecureVerify` is `false`), the host's system trust store is used.
|
||||
- CrowdsecLapiTlsCertificateBouncer
|
||||
- string
|
||||
- default: ""
|
||||
@@ -436,7 +444,12 @@ make run
|
||||
- RedisCacheHost
|
||||
- string
|
||||
- default: "redis:6379"
|
||||
- hostname and port for the Redis service
|
||||
- hostname and port for the Redis write host (primary)
|
||||
- RedisCacheReadHosts
|
||||
- []string
|
||||
- default: []
|
||||
- List of Redis replica hostnames (host:port) to use for read operations. Reads are distributed round-robin across replicas. Falls back to RedisCacheHost when empty.
|
||||
- Note: when set, reads are not retried against RedisCacheHost (the primary) if the replicas are unreachable. With RedisCacheUnreachableBlock at its default (true), a replica outage will therefore block/delay requests even though the primary is healthy.
|
||||
- RedisCachePassword
|
||||
- string
|
||||
- default: ""
|
||||
@@ -461,6 +474,12 @@ make run
|
||||
- int64
|
||||
- default: 0
|
||||
- Used only in `stream` and `alone` mode, the maximum number of time we can not reach Crowdsec before blocking traffic (set -1 to never block)
|
||||
- StreamStartupBlock
|
||||
- bool
|
||||
- default: true
|
||||
- Used only in `stream` and `alone` mode, controls whether the initial stream update runs synchronously or asynchronously during plugin initialization
|
||||
- When `true`, plugin initialization waits for Crowdsec to be ready before serving traffic.
|
||||
- **Warning**: When `false`, all requests bypass remediation until the first stream sync completes — banned IPs will be allowed through during this window. Only disable when startup availability is more important than blocking at startup.
|
||||
- DefaultDecisionSeconds
|
||||
- int64
|
||||
- default: 60
|
||||
@@ -503,14 +522,14 @@ make run
|
||||
- int64
|
||||
- default: 1800 (= 30 minutes)
|
||||
- Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid
|
||||
- CaptchaHTMLFilePath
|
||||
- CaptchaFilePath
|
||||
- string
|
||||
- default: /captcha.html
|
||||
- Path where the captcha template is stored
|
||||
- BanHTMLFilePath
|
||||
- Path where the captcha template is stored. The Content-Type header is automatically inferred from the file extension.
|
||||
- BanFilePath
|
||||
- string
|
||||
- default: ""
|
||||
- Path where the ban html file is stored (default empty ""=disabled)
|
||||
- Path where the ban file is stored (default empty ""=disabled). The Content-Type header is automatically inferred from the file extension.
|
||||
- TraceHeadersCustomName
|
||||
- string
|
||||
- default: ""
|
||||
@@ -536,7 +555,37 @@ experimental:
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Dynamic configuration
|
||||
# Simplified dynamic configuration
|
||||
|
||||
http:
|
||||
routers:
|
||||
my-router:
|
||||
rule: host(`whoami.localhost`)
|
||||
service: service-foo
|
||||
entryPoints:
|
||||
- web
|
||||
middlewares:
|
||||
- crowdsec
|
||||
|
||||
services:
|
||||
service-foo:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: http://127.0.0.1:5000
|
||||
|
||||
middlewares:
|
||||
crowdsec:
|
||||
plugin:
|
||||
bouncer:
|
||||
enabled: true
|
||||
logLevel: DEBUG
|
||||
crowdsecMode: live
|
||||
crowdsecLapiKey: privateKey-foo
|
||||
crowdsecLapiHost: crowdsec:8080
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Full dynamic configuration
|
||||
|
||||
http:
|
||||
routers:
|
||||
@@ -560,9 +609,11 @@ http:
|
||||
bouncer:
|
||||
enabled: false
|
||||
logLevel: DEBUG
|
||||
logFormat: common
|
||||
LogFilePath: ""
|
||||
updateIntervalSeconds: 60
|
||||
updateMaxFailure: 0
|
||||
streamStartupBlock: true
|
||||
defaultDecisionSeconds: 60
|
||||
remediationStatusCode: 403
|
||||
httpTimeoutSeconds: 10
|
||||
@@ -574,6 +625,7 @@ http:
|
||||
crowdsecAppsecFailureBlock: true
|
||||
crowdsecAppsecUnreachableBlock: true
|
||||
crowdsecAppsecBodyLimit: 10485760
|
||||
crowdsecAppsecUnreadableBodyBlock: false
|
||||
crowdsecLapiKey: privateKey-foo
|
||||
crowdsecLapiScheme: http
|
||||
crowdsecLapiHost: crowdsec:8080
|
||||
@@ -593,7 +645,10 @@ http:
|
||||
forwardedHeadersCustomName: X-Custom-Header
|
||||
remediationHeadersCustomName: cs-remediation
|
||||
redisCacheEnabled: false
|
||||
redisCacheHost: "redis:6379"
|
||||
redisCacheHost: "redis-primary:6379"
|
||||
redisCacheReadHosts:
|
||||
- "redis-replica-1:6379"
|
||||
- "redis-replica-2:6379"
|
||||
redisCachePassword: password
|
||||
redisCacheDatabase: "5"
|
||||
redisCacheUnreachableBlock: true
|
||||
@@ -685,16 +740,18 @@ A script is available to generate certificates in `examples/tls-auth/gencerts.sh
|
||||
|
||||
#### Use HTTPS to communicate with the LAPI
|
||||
|
||||
To communicate with the LAPI in HTTPS you need to either accept any certificates by setting the `crowdsecLapiTLSInsecureVerify` to true or add the CA used by the server certificate of Crowdsec using `crowdsecLapiTLSCertificateAuthority` or `crowdsecLapiTLSCertificateAuthorityFile`.
|
||||
Set the `crowdsecLapiScheme` to https.
|
||||
Set `crowdsecLapiScheme` to `https`. The plugin then validates Crowdsec's server certificate. Three options:
|
||||
|
||||
- **Publicly trusted certificate** (e.g. Let's Encrypt behind a reverse proxy): leave `crowdsecLapiTLSCertificateAuthority` empty and `crowdsecLapiTLSInsecureVerify` `false`. The plugin falls back to the host's system trust store (the `traefik` image ships `ca-certificates`).
|
||||
- **Private/self-signed CA**: set `crowdsecLapiTLSCertificateAuthority` (or `…File`) to the PEM-encoded CA that signed Crowdsec's server cert.
|
||||
- **Skip verification entirely** (not recommended for production): set `crowdsecLapiTLSInsecureVerify` to `true`.
|
||||
|
||||
Crowdsec must be listening in HTTPS for this to work.
|
||||
Please see the [tls-auth example](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/tls-auth/README.md) or the official documentation: [docs.crowdsec.net/docs/local_api/tls_auth/](https://docs.crowdsec.net/docs/local_api/tls_auth/)
|
||||
|
||||
#### Use HTTPS to communicate with the Appsec
|
||||
|
||||
To communicate with the Appsec in HTTPS you need to either accept any certificates by setting the `crowdsecAppsecTLSInsecureVerify` to true or add the CA used by the server certificate of Crowdsec using `crowdsecAppsecTLSCertificateAuthority` or `crowdsecAppsecTLSCertificateAuthorityFile`.
|
||||
Set the `crowdsecAppsecScheme` to https.
|
||||
Set `crowdsecAppsecScheme` to `https`. Same three options as for the LAPI, prefixed `crowdsecAppsec…` instead of `crowdsecLapi…`: empty CA + secure verify falls back to the system trust store, a custom CA pins to your private PKI, and `crowdsecAppsecTLSInsecureVerify=true` skips verification altogether.
|
||||
|
||||
Currently AppSec does not support mTLS authentication for the AppSec Component.
|
||||
|
||||
|
||||
+172
-101
@@ -9,8 +9,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
htmltemplate "html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -63,7 +63,7 @@ const (
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var (
|
||||
isStartup = true
|
||||
isCrowdsecStreamStartup = true
|
||||
isCrowdsecStreamHealthy = true
|
||||
updateFailure int64
|
||||
streamTicker chan bool
|
||||
@@ -83,50 +83,60 @@ type Bouncer struct {
|
||||
name string
|
||||
template *template.Template
|
||||
|
||||
enabled bool
|
||||
appsecEnabled bool
|
||||
appsecScheme string
|
||||
appsecHost string
|
||||
appsecPath string
|
||||
appsecKey string
|
||||
appsecFailureBlock bool
|
||||
appsecUnreachableBlock bool
|
||||
appsecBodyLimit int64
|
||||
crowdsecScheme string
|
||||
crowdsecHost string
|
||||
crowdsecPath string
|
||||
crowdsecKey string
|
||||
crowdsecMode string
|
||||
crowdsecMachineID string
|
||||
crowdsecPassword string
|
||||
crowdsecScenarios []string
|
||||
updateInterval int64
|
||||
updateMaxFailure int64
|
||||
defaultDecisionTimeout int64
|
||||
remediationStatusCode int
|
||||
remediationCustomHeader string
|
||||
forwardedCustomHeader string
|
||||
crowdsecStreamRoute string
|
||||
crowdsecHeader string
|
||||
redisUnreachableBlock bool
|
||||
banTemplate *htmltemplate.Template
|
||||
traceCustomHeader string
|
||||
clientPoolStrategy *ip.PoolStrategy
|
||||
serverPoolStrategy *ip.PoolStrategy
|
||||
httpClient *http.Client
|
||||
httpAppsecClient *http.Client
|
||||
cacheClient *cache.Client
|
||||
captchaClient *captcha.Client
|
||||
log *logger.Log
|
||||
enabled bool
|
||||
appsecEnabled bool
|
||||
appsecScheme string
|
||||
appsecHost string
|
||||
appsecPath string
|
||||
appsecKey string
|
||||
appsecFailureBlock bool
|
||||
appsecUnreachableBlock bool
|
||||
appsecUnreadableBodyBlock bool
|
||||
appsecBodyLimit int64
|
||||
crowdsecScheme string
|
||||
crowdsecHost string
|
||||
crowdsecPath string
|
||||
crowdsecKey string
|
||||
crowdsecMode string
|
||||
crowdsecMachineID string
|
||||
crowdsecPassword string
|
||||
crowdsecScenarios []string
|
||||
updateInterval int64
|
||||
updateMaxFailure int64
|
||||
defaultDecisionTimeout int64
|
||||
remediationStatusCode int
|
||||
remediationCustomHeader string
|
||||
forwardedCustomHeader string
|
||||
crowdsecStreamRoute string
|
||||
crowdsecHeader string
|
||||
redisUnreachableBlock bool
|
||||
banTemplate *template.Template
|
||||
banTemplateContentType string
|
||||
traceCustomHeader string
|
||||
clientPoolStrategy *ip.PoolStrategy
|
||||
serverPoolStrategy *ip.PoolStrategy
|
||||
httpClient *http.Client
|
||||
httpAppsecClient *http.Client
|
||||
cacheClient *cache.Client
|
||||
captchaClient *captcha.Client
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates the crowdsec bouncer plugin.
|
||||
//
|
||||
//nolint:gocyclo
|
||||
//nolint:nestif,gocyclo,gocognit,funlen,maintidx
|
||||
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
||||
config.LogLevel = strings.ToUpper(config.LogLevel)
|
||||
log := logger.New(config.LogLevel, config.LogFilePath)
|
||||
err := configuration.ValidateParams(config)
|
||||
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
|
||||
|
||||
if config.BanFilePath == "" && config.BanHTMLFilePath != "" {
|
||||
config.BanFilePath = config.BanHTMLFilePath
|
||||
}
|
||||
if config.CaptchaHTMLFilePath != "" {
|
||||
config.CaptchaFilePath = config.CaptchaHTMLFilePath
|
||||
}
|
||||
|
||||
err := configuration.ValidateParams(config, log)
|
||||
if err != nil {
|
||||
log.Error("New:validateParams " + err.Error())
|
||||
return nil, err
|
||||
@@ -183,9 +193,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
}
|
||||
}
|
||||
|
||||
var banTemplate *htmltemplate.Template
|
||||
if config.BanHTMLFilePath != "" {
|
||||
banTemplate, _ = configuration.GetHTMLTemplate(config.BanHTMLFilePath)
|
||||
var banTemplate *template.Template
|
||||
var banTemplateContentType string
|
||||
if config.BanFilePath != "" {
|
||||
banTemplate, banTemplateContentType, _ = configuration.GetTemplate(config.BanFilePath)
|
||||
}
|
||||
|
||||
bouncer := &Bouncer{
|
||||
@@ -193,35 +204,37 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
name: name,
|
||||
template: template.New("CrowdsecBouncer").Delims("[[", "]]"),
|
||||
|
||||
enabled: config.Enabled,
|
||||
crowdsecMode: config.CrowdsecMode,
|
||||
appsecEnabled: config.CrowdsecAppsecEnabled,
|
||||
appsecScheme: config.CrowdsecAppsecScheme,
|
||||
appsecHost: config.CrowdsecAppsecHost,
|
||||
appsecPath: config.CrowdsecAppsecPath,
|
||||
appsecKey: config.CrowdsecAppsecKey,
|
||||
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
||||
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
||||
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
|
||||
crowdsecScheme: config.CrowdsecLapiScheme,
|
||||
crowdsecHost: config.CrowdsecLapiHost,
|
||||
crowdsecPath: config.CrowdsecLapiPath,
|
||||
crowdsecKey: config.CrowdsecLapiKey,
|
||||
crowdsecMachineID: config.CrowdsecCapiMachineID,
|
||||
crowdsecPassword: config.CrowdsecCapiPassword,
|
||||
crowdsecScenarios: config.CrowdsecCapiScenarios,
|
||||
updateInterval: config.UpdateIntervalSeconds,
|
||||
updateMaxFailure: config.UpdateMaxFailure,
|
||||
remediationCustomHeader: config.RemediationHeadersCustomName,
|
||||
forwardedCustomHeader: config.ForwardedHeadersCustomName,
|
||||
defaultDecisionTimeout: config.DefaultDecisionSeconds,
|
||||
remediationStatusCode: config.RemediationStatusCode,
|
||||
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
||||
banTemplate: banTemplate,
|
||||
traceCustomHeader: config.TraceHeadersCustomName,
|
||||
crowdsecStreamRoute: crowdsecStreamRoute,
|
||||
crowdsecHeader: crowdsecHeader,
|
||||
log: log,
|
||||
enabled: config.Enabled,
|
||||
crowdsecMode: config.CrowdsecMode,
|
||||
appsecEnabled: config.CrowdsecAppsecEnabled,
|
||||
appsecScheme: config.CrowdsecAppsecScheme,
|
||||
appsecHost: config.CrowdsecAppsecHost,
|
||||
appsecPath: config.CrowdsecAppsecPath,
|
||||
appsecKey: config.CrowdsecAppsecKey,
|
||||
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
||||
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
||||
appsecUnreadableBodyBlock: config.CrowdsecAppsecUnreadableBodyBlock,
|
||||
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
|
||||
crowdsecScheme: config.CrowdsecLapiScheme,
|
||||
crowdsecHost: config.CrowdsecLapiHost,
|
||||
crowdsecPath: config.CrowdsecLapiPath,
|
||||
crowdsecKey: config.CrowdsecLapiKey,
|
||||
crowdsecMachineID: config.CrowdsecCapiMachineID,
|
||||
crowdsecPassword: config.CrowdsecCapiPassword,
|
||||
crowdsecScenarios: config.CrowdsecCapiScenarios,
|
||||
updateInterval: config.UpdateIntervalSeconds,
|
||||
updateMaxFailure: config.UpdateMaxFailure,
|
||||
remediationCustomHeader: config.RemediationHeadersCustomName,
|
||||
forwardedCustomHeader: config.ForwardedHeadersCustomName,
|
||||
defaultDecisionTimeout: config.DefaultDecisionSeconds,
|
||||
remediationStatusCode: config.RemediationStatusCode,
|
||||
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
||||
banTemplate: banTemplate,
|
||||
banTemplateContentType: banTemplateContentType,
|
||||
traceCustomHeader: config.TraceHeadersCustomName,
|
||||
crowdsecStreamRoute: crowdsecStreamRoute,
|
||||
crowdsecHeader: crowdsecHeader,
|
||||
log: log,
|
||||
serverPoolStrategy: &ip.PoolStrategy{
|
||||
Checker: serverChecker,
|
||||
},
|
||||
@@ -255,6 +268,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
log,
|
||||
config.RedisCacheEnabled,
|
||||
config.RedisCacheHost,
|
||||
config.RedisCacheReadHosts,
|
||||
config.RedisCachePassword,
|
||||
config.RedisCacheDatabase,
|
||||
)
|
||||
@@ -275,7 +289,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
config.CaptchaSiteKey,
|
||||
config.CaptchaSecretKey,
|
||||
config.RemediationHeadersCustomName,
|
||||
config.CaptchaHTMLFilePath,
|
||||
config.CaptchaFilePath,
|
||||
config.CaptchaGracePeriodSeconds,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -290,8 +304,11 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
handleStreamTicker(bouncer)
|
||||
isStartup = false
|
||||
if config.StreamStartupBlock {
|
||||
handleStreamTicker(bouncer)
|
||||
} else {
|
||||
go handleStreamTicker(bouncer)
|
||||
}
|
||||
streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() {
|
||||
handleStreamTicker(bouncer)
|
||||
})
|
||||
@@ -300,7 +317,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
// Start metrics ticker if not already running
|
||||
if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 {
|
||||
lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker
|
||||
handleMetricsTicker(bouncer)
|
||||
go handleMetricsTicker(bouncer)
|
||||
metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() {
|
||||
handleMetricsTicker(bouncer)
|
||||
})
|
||||
@@ -313,7 +330,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
|
||||
// ServeHTTP principal function of plugin.
|
||||
//
|
||||
//nolint:nestif,gocyclo
|
||||
//nolint:nestif,gocognit
|
||||
func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
if !bouncer.enabled {
|
||||
bouncer.next.ServeHTTP(rw, req)
|
||||
@@ -375,6 +392,16 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
// Right here if we cannot join the stream we forbid the request to go on.
|
||||
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
|
||||
if isCrowdsecStreamHealthy {
|
||||
cidrValue, cidrErr := bouncer.cacheClient.GetCIDR(remoteIP)
|
||||
if cidrErr == nil {
|
||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cidr:hit isBanned:%v", remoteIP, cidrValue))
|
||||
if cidrValue == cache.NoBannedValue {
|
||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||
} else {
|
||||
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, cidrValue)
|
||||
}
|
||||
return
|
||||
}
|
||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||
} else {
|
||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure))
|
||||
@@ -429,14 +456,9 @@ func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Req
|
||||
if bouncer.remediationCustomHeader != "" {
|
||||
rw.Header().Set(bouncer.remediationCustomHeader, "ban")
|
||||
}
|
||||
if bouncer.banTemplate == nil {
|
||||
rw.WriteHeader(bouncer.remediationStatusCode)
|
||||
return
|
||||
}
|
||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
rw.Header().Set("Content-Type", bouncer.banTemplateContentType)
|
||||
rw.WriteHeader(bouncer.remediationStatusCode)
|
||||
|
||||
if req.Method == http.MethodHead {
|
||||
if bouncer.banTemplate == nil || req.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
templateData := map[string]string{
|
||||
@@ -455,7 +477,7 @@ func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Req
|
||||
err := bouncer.banTemplate.Execute(rw, templateData)
|
||||
|
||||
if err != nil {
|
||||
bouncer.log.Error("handleBanServeHTTP banTemplateServe " + err.Error())
|
||||
bouncer.log.Warn("handleBanServeHTTP could not write template to ResponseWriter: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,7 +508,7 @@ func (bouncer *Bouncer) handleNextServeHTTP(rw http.ResponseWriter, req *http.Re
|
||||
|
||||
func handleStreamTicker(bouncer *Bouncer) {
|
||||
if err := handleStreamCache(bouncer); err != nil {
|
||||
bouncer.log.Debug(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
|
||||
bouncer.log.Warn(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
|
||||
if bouncer.updateMaxFailure != -1 && updateFailure >= bouncer.updateMaxFailure && isCrowdsecStreamHealthy {
|
||||
isCrowdsecStreamHealthy = false
|
||||
bouncer.log.Error(fmt.Sprintf("handleStreamTicker:error updateFailure:%d %s", updateFailure, err.Error()))
|
||||
@@ -504,7 +526,7 @@ func handleMetricsTicker(bouncer *Bouncer) {
|
||||
}
|
||||
}
|
||||
|
||||
func startTicker(name string, updateInterval int64, log *logger.Log, work func()) chan bool {
|
||||
func startTicker(name string, updateInterval int64, log *slog.Logger, work func()) chan bool {
|
||||
ticker := time.NewTicker(time.Duration(updateInterval) * time.Second)
|
||||
stop := make(chan bool, 1)
|
||||
go func() {
|
||||
@@ -571,7 +593,7 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
|
||||
case "captcha":
|
||||
value = cache.CaptchaValue
|
||||
default:
|
||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
||||
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
|
||||
}
|
||||
if isLiveMode && bouncer.defaultDecisionTimeout > 0 {
|
||||
durationSecond := int64(duration.Seconds())
|
||||
@@ -607,11 +629,11 @@ func getToken(bouncer *Bouncer) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("getToken:parsingBody %w", err)
|
||||
}
|
||||
if login.Code == 200 && len(login.Token) > 0 {
|
||||
if login.Code == http.StatusOK && len(login.Token) > 0 {
|
||||
bouncer.crowdsecKey = login.Token
|
||||
bouncer.log.Debug(fmt.Sprintf("getToken statusCode:%d", login.Code))
|
||||
return nil
|
||||
}
|
||||
bouncer.log.Warn(fmt.Sprintf("getToken statusCode:%d", login.Code))
|
||||
return fmt.Errorf("getToken statusCode:%d", login.Code)
|
||||
}
|
||||
|
||||
@@ -623,17 +645,23 @@ func handleStreamCache(bouncer *Bouncer) error {
|
||||
_, err := bouncer.cacheClient.Get(cacheTimeoutKey)
|
||||
if err == nil {
|
||||
bouncer.log.Debug("handleStreamCache:alreadyUpdated")
|
||||
isCrowdsecStreamStartup = false
|
||||
return nil
|
||||
}
|
||||
if err.Error() != cache.CacheMiss {
|
||||
return err
|
||||
}
|
||||
bouncer.cacheClient.Set(cacheTimeoutKey, cache.NoBannedValue, bouncer.updateInterval-1)
|
||||
// To avoid every instance trying to update the cache, set 1 second at least
|
||||
leaseDuration := bouncer.updateInterval - 1
|
||||
if leaseDuration < 1 {
|
||||
leaseDuration = 1
|
||||
}
|
||||
bouncer.cacheClient.Set(cacheTimeoutKey, cache.NoBannedValue, leaseDuration)
|
||||
streamRouteURL := url.URL{
|
||||
Scheme: bouncer.crowdsecScheme,
|
||||
Host: bouncer.crowdsecHost,
|
||||
Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute,
|
||||
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup),
|
||||
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isCrowdsecStreamStartup),
|
||||
}
|
||||
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil)
|
||||
if err != nil {
|
||||
@@ -654,18 +682,33 @@ func handleStreamCache(bouncer *Bouncer) error {
|
||||
case "captcha":
|
||||
value = cache.CaptchaValue
|
||||
default:
|
||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
||||
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
|
||||
}
|
||||
if strings.Contains(decision.Value, "/") {
|
||||
bouncer.cacheClient.SetCIDR(decision.Value, value, int64(duration.Seconds()))
|
||||
} else {
|
||||
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
|
||||
}
|
||||
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
|
||||
}
|
||||
}
|
||||
for _, decision := range stream.Deleted {
|
||||
bouncer.cacheClient.Delete(decision.Value)
|
||||
if strings.Contains(decision.Value, "/") {
|
||||
bouncer.cacheClient.DeleteCIDR(decision.Value)
|
||||
} else {
|
||||
bouncer.cacheClient.Delete(decision.Value)
|
||||
}
|
||||
}
|
||||
bouncer.log.Debug("handleStreamCache:updated")
|
||||
isCrowdsecStreamStartup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func isReverseProxyError(statusCode int) bool {
|
||||
return statusCode == http.StatusBadGateway ||
|
||||
statusCode == http.StatusServiceUnavailable ||
|
||||
statusCode == http.StatusGatewayTimeout
|
||||
}
|
||||
|
||||
func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, error) {
|
||||
var req *http.Request
|
||||
if len(data) > 0 {
|
||||
@@ -673,11 +716,11 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
|
||||
} else {
|
||||
req, _ = http.NewRequest(http.MethodGet, stringURL, nil)
|
||||
}
|
||||
req.Header.Add(bouncer.crowdsecHeader, bouncer.crowdsecKey)
|
||||
req.Header.Add("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||
req.Header.Set(bouncer.crowdsecHeader, bouncer.crowdsecKey)
|
||||
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||
|
||||
res, err := bouncer.httpClient.Do(req)
|
||||
if err != nil {
|
||||
if err != nil || isReverseProxyError(res.StatusCode) {
|
||||
return nil, fmt.Errorf("crowdsecQuery:unreachable url:%s %w", stringURL, err)
|
||||
}
|
||||
defer func() {
|
||||
@@ -705,6 +748,27 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// isBodyUnreadable reports whether the request body cannot be buffered before
|
||||
// forwarding it to the Appsec component. An HTTP/2 or HTTP/3 request without a
|
||||
// Content-Length (typically a bidirectional gRPC stream) keeps its body open
|
||||
// for the whole life of the stream and never reaches EOF, so reading it with
|
||||
// io.ReadAll would block until the request times out and is wrongly turned into
|
||||
// a 403. This mirrors the reference lua-cs-bouncer behavior, which refuses to
|
||||
// read the body of an HTTP/2+ request that has no Content-Length.
|
||||
func isBodyUnreadable(httpReq *http.Request) bool {
|
||||
return httpReq.Body != nil && httpReq.Body != http.NoBody && httpReq.ProtoMajor >= 2 && httpReq.ContentLength < 0
|
||||
}
|
||||
|
||||
// isMethodWithBody used only when isBodyUnreadable returns true but the request method can't have body.
|
||||
func isMethodWithBody(method string) bool {
|
||||
switch method {
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||
routeURL := url.URL{
|
||||
Scheme: bouncer.appsecScheme,
|
||||
@@ -712,7 +776,13 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||
Path: bouncer.appsecPath,
|
||||
}
|
||||
var req *http.Request
|
||||
if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil && httpReq.ContentLength > 0 {
|
||||
switch {
|
||||
case isBodyUnreadable(httpReq):
|
||||
if bouncer.appsecUnreadableBodyBlock && isMethodWithBody(httpReq.Method) {
|
||||
return errors.New("appsecQuery:unreadableBody dropped")
|
||||
}
|
||||
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
|
||||
case bouncer.appsecBodyLimit > 0 && httpReq.Body != nil:
|
||||
var bodyBuffer bytes.Buffer
|
||||
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
|
||||
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
|
||||
@@ -723,7 +793,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||
// Conserve body intact after reading it for other middlewares and service
|
||||
httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body))
|
||||
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
|
||||
} else {
|
||||
default:
|
||||
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
|
||||
}
|
||||
|
||||
@@ -738,9 +808,10 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
|
||||
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
|
||||
req.Header.Set(crowdsecAppsecUserAgent, httpReq.Header.Get("User-Agent"))
|
||||
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||
|
||||
res, err := bouncer.httpAppsecClient.Do(req)
|
||||
if err != nil {
|
||||
if err != nil || isReverseProxyError(res.StatusCode) {
|
||||
bouncer.log.Error("appsecQuery:unreachable")
|
||||
if bouncer.appsecUnreachableBlock {
|
||||
return fmt.Errorf("appsecQuery:unreachable %w", err)
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||
)
|
||||
|
||||
// getTestConfig returns a minimal valid configuration for testing.
|
||||
// Override specific fields by modifying the returned config.
|
||||
func getTestConfig() *configuration.Config {
|
||||
return &configuration.Config{
|
||||
Enabled: true,
|
||||
LogLevel: "INFO",
|
||||
LogFormat: "common",
|
||||
LogFilePath: "",
|
||||
CrowdsecMode: "none",
|
||||
CrowdsecLapiKey: "test-key",
|
||||
CrowdsecLapiHost: "localhost",
|
||||
CrowdsecLapiScheme: "http",
|
||||
UpdateIntervalSeconds: 60,
|
||||
DefaultDecisionSeconds: 60,
|
||||
HTTPTimeoutSeconds: 10,
|
||||
ForwardedHeadersTrustedIPs: []string{"127.0.0.1"},
|
||||
ForwardedHeadersCustomName: "",
|
||||
RemediationStatusCode: 403,
|
||||
BanFilePath: "",
|
||||
RemediationHeadersCustomName: "",
|
||||
CaptchaProvider: "",
|
||||
CaptchaSiteKey: "",
|
||||
CaptchaSecretKey: "",
|
||||
CaptchaGracePeriodSeconds: 1,
|
||||
CaptchaFilePath: "",
|
||||
RedisCacheEnabled: false,
|
||||
RedisCacheHost: "",
|
||||
RedisCachePassword: "",
|
||||
RedisCacheDatabase: "",
|
||||
RedisCacheUnreachableBlock: false,
|
||||
CrowdsecAppsecEnabled: false,
|
||||
CrowdsecAppsecHost: "",
|
||||
CrowdsecAppsecPath: "",
|
||||
CrowdsecAppsecFailureBlock: false,
|
||||
CrowdsecAppsecUnreachableBlock: false,
|
||||
CrowdsecLapiTLSInsecureVerify: true,
|
||||
CrowdsecLapiTLSCertificateBouncer: "",
|
||||
CrowdsecLapiTLSCertificateBouncerKey: "",
|
||||
CrowdsecCapiMachineID: "",
|
||||
CrowdsecCapiPassword: "",
|
||||
CrowdsecCapiScenarios: []string{},
|
||||
UpdateMaxFailure: 0,
|
||||
MetricsUpdateIntervalSeconds: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create and execute a bouncer request for testing
|
||||
func createAndExecuteBouncerRequest(t *testing.T, config *configuration.Config) {
|
||||
t.Helper()
|
||||
|
||||
// Create a mock next handler
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
// Create the bouncer plugin (this will initialize the logger with file output)
|
||||
bouncerHandler, err := New(context.Background(), nextHandler, config, "test-bouncer")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create bouncer: %v", err)
|
||||
}
|
||||
|
||||
// Create a test request to trigger logging
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
|
||||
req.RemoteAddr = "192.168.1.100:12345" // Use a non-trusted IP to trigger logging
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
// Process the request (this should generate log entries)
|
||||
bouncerHandler.ServeHTTP(rw, req)
|
||||
|
||||
// Give a moment for log writes to complete
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Helper function to parse log file and extract found levels
|
||||
func parseLogFileAndExtractLevels(t *testing.T, logFile string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
// Verify the log file was created and contains entries
|
||||
if _, statErr := os.Stat(logFile); os.IsNotExist(statErr) {
|
||||
t.Fatalf("Log file was not created: %s", logFile)
|
||||
}
|
||||
|
||||
// Read the log file content
|
||||
// #nosec G304 - logFile is a test-generated temporary file path
|
||||
logContent, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
logString := string(logContent)
|
||||
if len(logString) == 0 {
|
||||
return make(map[string]bool) // Return empty map for empty log files
|
||||
}
|
||||
|
||||
// Parse and verify JSON log entries
|
||||
lines := strings.Split(strings.TrimSpace(logString), "\n")
|
||||
foundLevels := make(map[string]bool)
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var logEntry map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
|
||||
t.Errorf("Invalid JSON log entry: %s, error: %v", line, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify required fields
|
||||
validateLogEntry(t, logEntry)
|
||||
|
||||
// Track log levels we've seen
|
||||
if level, ok := logEntry["level"].(string); ok {
|
||||
foundLevels[level] = true
|
||||
}
|
||||
}
|
||||
|
||||
return foundLevels
|
||||
}
|
||||
|
||||
// Helper function to validate log entry structure
|
||||
func validateLogEntry(t *testing.T, logEntry map[string]interface{}) {
|
||||
t.Helper()
|
||||
|
||||
if logEntry["time"] == nil {
|
||||
t.Error("Log entry missing 'time' field")
|
||||
}
|
||||
if logEntry["level"] == nil {
|
||||
t.Error("Log entry missing 'level' field")
|
||||
}
|
||||
if logEntry["msg"] == nil {
|
||||
t.Error("Log entry missing 'msg' field")
|
||||
}
|
||||
if logEntry["component"] != "CrowdsecBouncerTraefikPlugin" {
|
||||
t.Errorf("Expected component 'CrowdsecBouncerTraefikPlugin', got %v", logEntry["component"])
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to verify expected and forbidden log levels
|
||||
func verifyLogLevels(t *testing.T, foundLevels map[string]bool, expectedLevels, forbiddenLevels []string, logLevel string) {
|
||||
t.Helper()
|
||||
|
||||
// Handle case where no logs are expected
|
||||
if len(expectedLevels) == 0 {
|
||||
if len(foundLevels) > 0 {
|
||||
t.Errorf("Expected no logs at %s level, but found: %v", logLevel, foundLevels)
|
||||
}
|
||||
} else {
|
||||
// Verify we got some log entries
|
||||
if len(foundLevels) == 0 {
|
||||
t.Fatal("No valid log entries found")
|
||||
}
|
||||
|
||||
// Verify expected levels are present
|
||||
for _, expectedLevel := range expectedLevels {
|
||||
if !foundLevels[expectedLevel] {
|
||||
t.Errorf("Expected to find %s level logs, but didn't. Found levels: %v", expectedLevel, foundLevels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify forbidden levels are NOT present
|
||||
for _, forbiddenLevel := range forbiddenLevels {
|
||||
if foundLevels[forbiddenLevel] {
|
||||
t.Errorf("Found forbidden %s level logs at %s level. Found levels: %v", forbiddenLevel, logLevel, foundLevels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBouncerFileLoggingLevels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
logLevel string
|
||||
expectedLevels []string // Levels that should appear
|
||||
forbiddenLevels []string // Levels that should NOT appear
|
||||
}{
|
||||
{
|
||||
name: "DEBUG level should show DEBUG only",
|
||||
logLevel: "DEBUG",
|
||||
expectedLevels: []string{"DEBUG"},
|
||||
forbiddenLevels: []string{},
|
||||
},
|
||||
{
|
||||
name: "INFO level should show no logs (bouncer doesn't generate INFO during normal operation)",
|
||||
logLevel: "INFO",
|
||||
expectedLevels: []string{}, // No logs expected for normal operation
|
||||
forbiddenLevels: []string{"DEBUG"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create temporary directory for log file
|
||||
tmpDir := t.TempDir()
|
||||
logFile := filepath.Join(tmpDir, "bouncer.log")
|
||||
|
||||
// Get test config and override specific fields
|
||||
config := getTestConfig()
|
||||
config.LogLevel = tt.logLevel
|
||||
config.LogFormat = "json" // Use JSON format for easier parsing
|
||||
config.LogFilePath = logFile
|
||||
|
||||
// Create and execute bouncer request
|
||||
createAndExecuteBouncerRequest(t, config)
|
||||
|
||||
// Parse log file and extract found levels
|
||||
foundLevels := parseLogFileAndExtractLevels(t, logFile)
|
||||
|
||||
// Handle empty log files for higher log levels (expected behavior)
|
||||
if len(foundLevels) == 0 && len(tt.expectedLevels) > 0 {
|
||||
t.Fatalf("Expected log entries but log file is empty for level %s", tt.logLevel)
|
||||
}
|
||||
if len(foundLevels) == 0 {
|
||||
// Empty file is expected for this log level
|
||||
t.Logf("LogLevel %s: No logs generated (expected behavior)", tt.logLevel)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify expected and forbidden log levels
|
||||
verifyLogLevels(t, foundLevels, tt.expectedLevels, tt.forbiddenLevels, tt.logLevel)
|
||||
|
||||
t.Logf("LogLevel %s: Successfully logged to file with levels: %v", tt.logLevel, foundLevels)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBouncerFileLoggingCommonFormat(t *testing.T) {
|
||||
// Create temporary directory for log file
|
||||
tmpDir := t.TempDir()
|
||||
logFile := filepath.Join(tmpDir, "bouncer-common.log")
|
||||
|
||||
// Get test config and override specific fields
|
||||
config := getTestConfig()
|
||||
config.LogLevel = "DEBUG"
|
||||
config.LogFormat = "common" // Use common format
|
||||
config.LogFilePath = logFile
|
||||
|
||||
// Create a mock next handler
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
// Create the bouncer plugin
|
||||
bouncerHandler, err := New(context.Background(), nextHandler, config, "test-bouncer")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create bouncer: %v", err)
|
||||
}
|
||||
|
||||
// Create a test request to trigger logging
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
|
||||
req.RemoteAddr = "192.168.1.100:12345"
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
// Process the request
|
||||
bouncerHandler.ServeHTTP(rw, req)
|
||||
|
||||
// Give a moment for log writes to complete
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Verify the log file was created and contains entries
|
||||
if _, statErr := os.Stat(logFile); os.IsNotExist(statErr) {
|
||||
t.Fatalf("Log file was not created: %s", logFile)
|
||||
}
|
||||
|
||||
// Read the log file content
|
||||
// #nosec G304 - logFile is a test-generated temporary file path
|
||||
logContent, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
logString := string(logContent)
|
||||
if len(logString) == 0 {
|
||||
t.Fatal("Log file is empty")
|
||||
}
|
||||
|
||||
// Verify common format structure
|
||||
lines := strings.Split(strings.TrimSpace(logString), "\n")
|
||||
foundDebug := false
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Common format should contain time, level, msg, and component
|
||||
if strings.Contains(line, "level=DEBUG") {
|
||||
foundDebug = true
|
||||
}
|
||||
if !strings.Contains(line, "component=CrowdsecBouncerTraefikPlugin") {
|
||||
t.Errorf("Log line missing component field: %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
// We should see DEBUG level logs since we set LogLevel to DEBUG
|
||||
if !foundDebug {
|
||||
t.Errorf("Expected to find DEBUG level logs in common format. Log content:\n%s", logString)
|
||||
}
|
||||
|
||||
t.Logf("Successfully logged to file %s in common format with %d lines", logFile, len(lines))
|
||||
}
|
||||
+226
-3
@@ -2,16 +2,20 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
htmltemplate "html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
||||
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
func TestServeHTTP(t *testing.T) {
|
||||
@@ -190,11 +194,11 @@ func Test_crowdsecQuery(t *testing.T) {
|
||||
|
||||
func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
|
||||
html := "<html>You are banned</html>"
|
||||
banTemplate, _ := htmltemplate.New("html").Parse(html)
|
||||
banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html)
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
banTemplate *htmltemplate.Template
|
||||
banTemplate *template.Template
|
||||
expectBodyContent bool
|
||||
}{
|
||||
{
|
||||
@@ -235,6 +239,7 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
|
||||
remediationStatusCode: http.StatusForbidden,
|
||||
remediationCustomHeader: "X-Test-Remediation",
|
||||
banTemplate: tt.banTemplate,
|
||||
banTemplateContentType: "text/html; charset=utf-8",
|
||||
}
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
@@ -269,6 +274,50 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBanServeHTTPContentType(t *testing.T) {
|
||||
html := "<html>You are banned</html>"
|
||||
banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html)
|
||||
tests := []struct {
|
||||
name string
|
||||
banTemplate *template.Template
|
||||
banTemplateContentType string
|
||||
}{
|
||||
{
|
||||
name: "Default HTML content type",
|
||||
banTemplate: banTemplate,
|
||||
banTemplateContentType: "text/html; charset=utf-8",
|
||||
},
|
||||
{
|
||||
name: "Custom JSON content type",
|
||||
banTemplate: banTemplate,
|
||||
banTemplateContentType: "application/json",
|
||||
},
|
||||
{
|
||||
name: "Content type set even when banTemplate is nil",
|
||||
banTemplate: nil,
|
||||
banTemplateContentType: "application/json",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bouncer := &Bouncer{
|
||||
remediationStatusCode: http.StatusForbidden,
|
||||
banTemplate: tt.banTemplate,
|
||||
banTemplateContentType: tt.banTemplateContentType,
|
||||
}
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
req := &http.Request{Method: http.MethodGet}
|
||||
bouncer.handleBanServeHTTP(rw, req, "0.0.0.0", "TEST")
|
||||
|
||||
if got := rw.Header().Get("Content-Type"); got != tt.banTemplateContentType {
|
||||
t.Errorf("Expected Content-Type %q, got %q", tt.banTemplateContentType, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptchaMethodBasedLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -332,3 +381,177 @@ func TestCaptchaMethodBasedLogic(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// blockingBody simulates a request body that never reaches EOF, like a
|
||||
// bidirectional gRPC stream that keeps its body open for the whole life of
|
||||
// the connection. Reading from it blocks until the test is done.
|
||||
type blockingBody struct {
|
||||
done <-chan struct{}
|
||||
}
|
||||
|
||||
func (b blockingBody) Read(_ []byte) (int, error) {
|
||||
<-b.done
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (blockingBody) Close() error { return nil }
|
||||
|
||||
func Test_isBodyUnreadable(t *testing.T) {
|
||||
realBody := func() io.ReadCloser { return io.NopCloser(strings.NewReader("data")) }
|
||||
tests := []struct {
|
||||
name string
|
||||
protoMajor int
|
||||
contentLength int64
|
||||
body io.ReadCloser
|
||||
want bool
|
||||
}{
|
||||
{name: "http2 grpc stream without content-length", protoMajor: 2, contentLength: -1, body: realBody(), want: true},
|
||||
{name: "http3 stream without content-length", protoMajor: 3, contentLength: -1, body: realBody(), want: true},
|
||||
{name: "http2 with content-length", protoMajor: 2, contentLength: 42, body: realBody(), want: false},
|
||||
{name: "http1.1 chunked without content-length", protoMajor: 1, contentLength: -1, body: realBody(), want: false},
|
||||
{name: "http2 without body", protoMajor: 2, contentLength: -1, body: nil, want: false},
|
||||
{name: "http2 with http.NoBody", protoMajor: 2, contentLength: -1, body: http.NoBody, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodPost, "http://localhost", nil)
|
||||
req.ProtoMajor = tt.protoMajor
|
||||
req.ContentLength = tt.contentLength
|
||||
req.Body = tt.body
|
||||
if got := isBodyUnreadable(req); got != tt.want {
|
||||
t.Errorf("isBodyUnreadable() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newStreamingRequest builds an HTTP/2 request whose body never reaches EOF,
|
||||
// like a bidirectional gRPC stream (issue #323).
|
||||
func newStreamingRequest(done <-chan struct{}) *http.Request {
|
||||
req, _ := http.NewRequest(http.MethodPost, "http://localhost/signalexchange.SignalExchange/ConnectStream", blockingBody{done: done})
|
||||
req.Header.Set("Content-Type", "application/grpc")
|
||||
req.ProtoMajor = 2
|
||||
req.ContentLength = -1
|
||||
return req
|
||||
}
|
||||
|
||||
// Test_appsecQuery_streamingDoesNotBlock is a regression test for issue #323:
|
||||
// a gRPC streaming request whose body never reaches EOF must not be buffered
|
||||
// (io.ReadAll would block until timeout and wrongly produce a 403). The appsec
|
||||
// query must complete promptly, inspecting headers only.
|
||||
func Test_appsecQuery_streamingDoesNotBlock(t *testing.T) {
|
||||
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer appsecServer.Close()
|
||||
|
||||
appsecURL, _ := url.Parse(appsecServer.URL)
|
||||
bouncer := &Bouncer{
|
||||
appsecScheme: appsecURL.Scheme,
|
||||
appsecHost: appsecURL.Host,
|
||||
appsecPath: "/",
|
||||
appsecBodyLimit: 10485760,
|
||||
appsecUnreachableBlock: true,
|
||||
appsecFailureBlock: true,
|
||||
httpAppsecClient: appsecServer.Client(),
|
||||
log: logger.New("INFO", ""),
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
|
||||
finished := make(chan error, 1)
|
||||
go func() {
|
||||
finished <- appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done))
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-finished:
|
||||
if err != nil {
|
||||
t.Errorf("appsecQuery() on streaming request returned error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("appsecQuery() blocked on a streaming request body (issue #323 regression)")
|
||||
}
|
||||
}
|
||||
|
||||
// Test_appsecQuery_dropUnreadableBody verifies that, when configured to do so,
|
||||
// a request with an unreadable body is dropped (blocked) instead of forwarded
|
||||
// without its body, mirroring the reference APPSEC_DROP_UNREADABLE_BODY option.
|
||||
func Test_appsecQuery_dropUnreadableBody(t *testing.T) {
|
||||
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer appsecServer.Close()
|
||||
|
||||
appsecURL, _ := url.Parse(appsecServer.URL)
|
||||
bouncer := &Bouncer{
|
||||
appsecScheme: appsecURL.Scheme,
|
||||
appsecHost: appsecURL.Host,
|
||||
appsecPath: "/",
|
||||
appsecBodyLimit: 10485760,
|
||||
appsecUnreadableBodyBlock: true,
|
||||
httpAppsecClient: appsecServer.Client(),
|
||||
log: logger.New("INFO", ""),
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
|
||||
finished := make(chan error, 1)
|
||||
go func() {
|
||||
finished <- appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done))
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-finished:
|
||||
if err == nil {
|
||||
t.Error("appsecQuery() expected an error to block the request, got nil")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("appsecQuery() blocked on a streaming request body (issue #323 regression)")
|
||||
}
|
||||
}
|
||||
|
||||
func newUnreadableGetRequest(done <-chan struct{}) *http.Request {
|
||||
req, _ := http.NewRequest(http.MethodGet, "http://localhost/", blockingBody{done: done})
|
||||
req.ProtoMajor = 3
|
||||
req.ContentLength = -1
|
||||
return req
|
||||
}
|
||||
|
||||
// Test_appsecQuery_unreadableBodyGetNotDropped is a regression test for issue #351
|
||||
func Test_appsecQuery_unreadableBodyGetNotDropped(t *testing.T) {
|
||||
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer appsecServer.Close()
|
||||
|
||||
appsecURL, _ := url.Parse(appsecServer.URL)
|
||||
bouncer := &Bouncer{
|
||||
appsecScheme: appsecURL.Scheme,
|
||||
appsecHost: appsecURL.Host,
|
||||
appsecPath: "/",
|
||||
appsecBodyLimit: 10485760,
|
||||
appsecUnreadableBodyBlock: true,
|
||||
httpAppsecClient: appsecServer.Client(),
|
||||
log: logger.New("INFO", ""),
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
|
||||
finished := make(chan error, 1)
|
||||
go func() {
|
||||
finished <- appsecQuery(bouncer, "1.2.3.4", newUnreadableGetRequest(done))
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-finished:
|
||||
if err != nil {
|
||||
t.Errorf("appsecQuery() on an HTTP/3 GET without content-length returned error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("appsecQuery() blocked on an HTTP/3 GET request body (issue #351 regression)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.5.0"
|
||||
image: "traefik:v3.7.11"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -80,7 +80,7 @@ services:
|
||||
- "traefik.http.routers.router-bar3.entrypoints=web"
|
||||
- "traefik.http.routers.router-bar3.middlewares=crowdsec2@docker"
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.8
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.11"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||
volumes:
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
# - './ban.html:/ban.html:ro'
|
||||
@@ -59,7 +59,7 @@ services:
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.8
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.5.0"
|
||||
image: "traefik:v3.7.11"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.5.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -47,7 +47,7 @@ services:
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
cloudflare:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.11"
|
||||
container_name: "cloudflare"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
- 8080:8080
|
||||
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.11"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
- "--entrypoints.web.forwardedheaders.trustedips=172.21.0.5"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- logs-traefik:/var/log/traefik
|
||||
@@ -79,7 +79,7 @@ services:
|
||||
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -52,7 +52,7 @@ More information is available on configuring Crowdsec in the [official documenta
|
||||
```yaml
|
||||
...
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.6.8
|
||||
volumes:
|
||||
# For captcha and ban mixed decision
|
||||
- './profiles.yaml:/etc/crowdsec/profiles.yaml:ro'
|
||||
@@ -100,9 +100,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Does the user IP has a crowdsec decision ?
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
```
|
||||
@@ -121,12 +121,12 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Fine, done!
|
||||
create participant ProviderCaptcha
|
||||
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
||||
Destroy ProviderCaptcha
|
||||
destroy ProviderCaptcha
|
||||
ProviderCaptcha-->>TraefikPlugin: Yes
|
||||
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Done
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
```
|
||||
@@ -140,7 +140,7 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Does the User IP has a Crowdsec Decision ?
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Yes a ban Decision
|
||||
TraefikPlugin->>User: No, HTTP 403
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.11"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -9,11 +9,11 @@ This can be usefull as some browser (Firefox for instance) return a 403 blank we
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
# Define ban HTML file path
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
|
||||
# Define ban file path
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html"
|
||||
```
|
||||
|
||||
The ban HTML file must be present in the Traefik container (bind mounted or added during a custom build).
|
||||
The ban file must be present in the Traefik container (bind mounted or added during a custom build).
|
||||
It is not directly accessible from Traefik even when importing the plugin, so [download](https://raw.githubusercontent.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/master/ban.html) it locally to expose it to Traefik.
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.11"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -42,11 +42,11 @@ services:
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||
# Define ban HTML file path
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
|
||||
# Define ban file path
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.5.0"
|
||||
image: "traefik:v3.7.11"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
- "--entrypoints.web.forwardedheaders.trustedips=172.18.0.0/24"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.4.5"
|
||||
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -59,7 +59,7 @@ services:
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
image:
|
||||
tag: v1.6.1-2
|
||||
tag: v1.7.8-2
|
||||
|
||||
agent:
|
||||
acquisition:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
image:
|
||||
tag: v3.0.0
|
||||
tag: v3.7.11
|
||||
|
||||
logs:
|
||||
general:
|
||||
@@ -15,4 +15,4 @@ experimental:
|
||||
plugins:
|
||||
bouncer:
|
||||
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
version: "v1.3.0"
|
||||
version: "v1.7.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.11"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -87,7 +87,7 @@ services:
|
||||
- "traefik.enable=false"
|
||||
|
||||
redis-secure:
|
||||
image: "redis:7.0.12-alpine"
|
||||
image: "redis:8.10.0-alpine"
|
||||
container_name: "redis-secure"
|
||||
hostname: redis-secure
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.11"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM ubuntu:24.04
|
||||
FROM ubuntu:26.04
|
||||
|
||||
RUN apt-get update && apt-get install -y curl wget
|
||||
RUN VERSION=$(curl --silent "https://api.github.com/repos/cloudflare/cfssl/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') && VNUMBER=${VERSION#"v"} && wget https://github.com/cloudflare/cfssl/releases/download/${VERSION}/cfssl_${VNUMBER}_linux_amd64 -O cfssl && chmod +x cfssl && mv cfssl /usr/local/bin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.5.0"
|
||||
image: "traefik:v3.7.11"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.5.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
# Define AppSec host and port informations
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:latest
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.11"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -65,7 +65,7 @@ services:
|
||||
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
|
||||
go 1.22
|
||||
go 1.22.12
|
||||
|
||||
require (
|
||||
github.com/leprosus/golang-ttl-map v1.1.7
|
||||
|
||||
Vendored
+131
-26
@@ -5,11 +5,15 @@ package cache
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
ttl_map "github.com/leprosus/golang-ttl-map"
|
||||
simpleredis "github.com/maxlerebourg/simpleredis"
|
||||
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
"github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -25,13 +29,21 @@ const (
|
||||
CacheMiss = "cache:miss"
|
||||
// CacheUnreachable error string when cache is unreachable.
|
||||
CacheUnreachable = "cache:unreachable"
|
||||
// cidrPrefix namespaces a CIDR decision, one cache key per CIDR.
|
||||
cidrPrefix = "cidr:"
|
||||
// cidrPrefixLensKey holds the prefix lengths that have a decision, so a lookup probes
|
||||
// only those. Its absence means no CIDR decision was ever stored.
|
||||
cidrPrefixLensKey = "cidrprefixlens"
|
||||
// cidrPrefixLensSeparator separates the prefix lengths in cidrPrefixLensKey.
|
||||
cidrPrefixLensSeparator = ","
|
||||
// cidrPrefixLensDuration has to outlive every decision it describes, so it is
|
||||
// effectively infinite. It cannot be zero: the local cache ignores a zero duration
|
||||
// and redis rejects a non positive EX.
|
||||
cidrPrefixLensDuration = 10 * 365 * 24 * 60 * 60
|
||||
)
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var (
|
||||
redis simpleredis.SimpleRedis
|
||||
cache = ttl_map.New()
|
||||
)
|
||||
var cache = ttl_map.New()
|
||||
|
||||
type localCache struct{}
|
||||
|
||||
@@ -53,33 +65,48 @@ func (localCache) delete(key string) {
|
||||
}
|
||||
|
||||
type redisCache struct {
|
||||
log *logger.Log
|
||||
log *slog.Logger
|
||||
writer simpleredis.SimpleRedis
|
||||
readers []simpleredis.SimpleRedis
|
||||
counter atomic.Uint64
|
||||
}
|
||||
|
||||
func (redisCache) get(key string) (string, error) {
|
||||
value, err := redis.Get(key)
|
||||
func (rc *redisCache) nextReader() *simpleredis.SimpleRedis {
|
||||
n := len(rc.readers)
|
||||
if n == 0 {
|
||||
return &rc.writer
|
||||
}
|
||||
idx := rc.counter.Add(1) % uint64(n)
|
||||
return &rc.readers[idx]
|
||||
}
|
||||
|
||||
func (rc *redisCache) get(key string) (string, error) {
|
||||
value, err := rc.nextReader().Get(key)
|
||||
if err != nil {
|
||||
switch err.Error() {
|
||||
case simpleredis.RedisMiss:
|
||||
return "", errors.New(CacheMiss)
|
||||
case simpleredis.RedisUnreachable:
|
||||
return "", errors.New(CacheUnreachable)
|
||||
default:
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
valueString := string(value)
|
||||
if err == nil && len(valueString) > 0 {
|
||||
if len(valueString) > 0 {
|
||||
return valueString, nil
|
||||
}
|
||||
errRedisMessage := err.Error()
|
||||
if errRedisMessage == simpleredis.RedisMiss {
|
||||
return "", errors.New(CacheMiss)
|
||||
}
|
||||
if errRedisMessage == simpleredis.RedisUnreachable {
|
||||
return "", errors.New(CacheUnreachable)
|
||||
}
|
||||
return "", err
|
||||
return "", errors.New(CacheMiss)
|
||||
}
|
||||
|
||||
func (rc redisCache) set(key, value string, duration int64) {
|
||||
if err := redis.Set(key, []byte(value), duration); err != nil {
|
||||
func (rc *redisCache) set(key, value string, duration int64) {
|
||||
if err := rc.writer.Set(key, []byte(value), duration); err != nil {
|
||||
rc.log.Error("cache:setDecisionRedisCache" + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (rc redisCache) delete(key string) {
|
||||
if err := redis.Del(key); err != nil {
|
||||
func (rc *redisCache) delete(key string) {
|
||||
if err := rc.writer.Del(key); err != nil {
|
||||
rc.log.Error("cache:deleteDecisionRedisCache " + err.Error())
|
||||
}
|
||||
}
|
||||
@@ -93,19 +120,25 @@ type cacheInterface interface {
|
||||
// Client Cache client.
|
||||
type Client struct {
|
||||
cache cacheInterface
|
||||
log *logger.Log
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New Initialize cache client.
|
||||
func (c *Client) New(log *logger.Log, isRedis bool, host, pass, database string) {
|
||||
func (c *Client) New(log *slog.Logger, isRedis bool, writeHost string, readHosts []string, pass, database string) {
|
||||
c.log = log
|
||||
if isRedis {
|
||||
redis.Init(host, pass, database)
|
||||
c.cache = &redisCache{log: log}
|
||||
rc := &redisCache{log: log}
|
||||
rc.writer.Init(writeHost, pass, database)
|
||||
for _, h := range readHosts {
|
||||
var r simpleredis.SimpleRedis
|
||||
r.Init(h, pass, database)
|
||||
rc.readers = append(rc.readers, r)
|
||||
}
|
||||
c.cache = rc
|
||||
} else {
|
||||
c.cache = &localCache{}
|
||||
}
|
||||
c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v", isRedis))
|
||||
c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v writeHost:%v readHosts:%v", isRedis, writeHost, readHosts))
|
||||
}
|
||||
|
||||
// Delete delete decision in cache.
|
||||
@@ -126,3 +159,75 @@ func (c *Client) Set(key string, value string, duration int64) {
|
||||
c.log.Debug(fmt.Sprintf("cache:Set key:%v value:%v duration:%vs", key, value, duration))
|
||||
c.cache.set(key, value, duration)
|
||||
}
|
||||
|
||||
// DeleteCIDR removes a CIDR decision from the cache.
|
||||
func (c *Client) DeleteCIDR(cidr string) {
|
||||
normalized := ip.NormalizeCIDR(cidr)
|
||||
if normalized == "" {
|
||||
c.log.Error(fmt.Sprintf("cache:DeleteCIDR:invalidCIDR cidr:%v decision is left in cache", cidr))
|
||||
return
|
||||
}
|
||||
cidr = normalized
|
||||
c.cache.delete(cidrPrefix + cidr)
|
||||
c.log.Debug(fmt.Sprintf("cache:DeleteCIDR cidr:%v", cidr))
|
||||
}
|
||||
|
||||
// GetCIDR checks if an IP matches a CIDR decision in the cache.
|
||||
// Only probes the prefix lengths that have a decision: it is on the request path.
|
||||
func (c *Client) GetCIDR(ipStr string) (string, error) {
|
||||
prefixLens, err := c.cache.get(cidrPrefixLensKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, key := range ip.CIDRLookupKeys(ipStr, parsePrefixLens(prefixLens)) {
|
||||
value, getErr := c.cache.get(cidrPrefix + key)
|
||||
if getErr == nil {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New(CacheMiss)
|
||||
}
|
||||
|
||||
// SetCIDR stores a CIDR decision in the cache.
|
||||
func (c *Client) SetCIDR(cidr, value string, duration int64) {
|
||||
normalized := ip.NormalizeCIDR(cidr)
|
||||
prefixLen := ip.CIDRPrefixLen(cidr)
|
||||
if normalized == "" || prefixLen < 0 {
|
||||
c.log.Error(fmt.Sprintf("cache:SetCIDR:invalidCIDR cidr:%v value:%v decision is not enforced", cidr, value))
|
||||
return
|
||||
}
|
||||
// Publish the length first, or a concurrent lookup misses the decision.
|
||||
c.addCIDRPrefixLen(prefixLen)
|
||||
c.cache.set(cidrPrefix+normalized, value, duration)
|
||||
c.log.Debug(fmt.Sprintf("cache:SetCIDR cidr:%v value:%v duration:%vs", normalized, value, duration))
|
||||
}
|
||||
|
||||
// addCIDRPrefixLen records a prefix length in the set probed on lookup. The set only grows:
|
||||
// a stale length costs one extra read, dropping one too early leaves decisions unmatched.
|
||||
func (c *Client) addCIDRPrefixLen(prefixLen int) {
|
||||
prefixLens, err := c.cache.get(cidrPrefixLensKey)
|
||||
if err == nil {
|
||||
for _, known := range parsePrefixLens(prefixLens) {
|
||||
if known == prefixLen {
|
||||
return
|
||||
}
|
||||
}
|
||||
prefixLens += cidrPrefixLensSeparator + strconv.Itoa(prefixLen)
|
||||
} else {
|
||||
prefixLens = strconv.Itoa(prefixLen)
|
||||
}
|
||||
c.cache.set(cidrPrefixLensKey, prefixLens, cidrPrefixLensDuration)
|
||||
c.log.Debug(fmt.Sprintf("cache:addCIDRPrefixLen prefixLens:%v", prefixLens))
|
||||
}
|
||||
|
||||
// parsePrefixLens decodes the set of prefix lengths stored in cidrPrefixLensKey.
|
||||
func parsePrefixLens(value string) []int {
|
||||
fields := strings.Split(value, cidrPrefixLensSeparator)
|
||||
prefixLens := make([]int, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
if prefixLen, err := strconv.Atoi(field); err == nil {
|
||||
prefixLens = append(prefixLens, prefixLen)
|
||||
}
|
||||
}
|
||||
return prefixLens
|
||||
}
|
||||
|
||||
Vendored
+222
@@ -3,9 +3,11 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
simpleredis "github.com/maxlerebourg/simpleredis"
|
||||
)
|
||||
|
||||
func Test_Get(t *testing.T) {
|
||||
@@ -122,3 +124,223 @@ func Test_Delete(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// indexOfReader returns the position of r inside rc.readers, or -1 when r is the writer (the no-readers fallback).
|
||||
func indexOfReader(rc *redisCache, r *simpleredis.SimpleRedis) int {
|
||||
if r == &rc.writer {
|
||||
return -1
|
||||
}
|
||||
for i := range rc.readers {
|
||||
if r == &rc.readers[i] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -2
|
||||
}
|
||||
|
||||
func Test_nextReader(t *testing.T) {
|
||||
// The counter starts at 0, so the first Add(1) yields index 1, then 2, 0, 1, ... over n readers.
|
||||
tests := []struct {
|
||||
name string
|
||||
readers int
|
||||
want []int
|
||||
}{
|
||||
{name: "round-robin over three readers", readers: 3, want: []int{1, 2, 0, 1, 2, 0, 1}},
|
||||
{name: "single reader always selected", readers: 1, want: []int{0, 0, 0, 0, 0}},
|
||||
{name: "no readers fall back to writer", readers: 0, want: []int{-1, -1, -1}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rc := &redisCache{log: logger.New("INFO", "")}
|
||||
rc.readers = make([]simpleredis.SimpleRedis, tt.readers)
|
||||
for call, want := range tt.want {
|
||||
if got := indexOfReader(rc, rc.nextReader()); got != want {
|
||||
t.Errorf("call %d: nextReader() -> reader[%d], want reader[%d]", call, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// countingCache is an isolated cacheInterface recording how many reads a lookup costs,
|
||||
// so a CIDR lookup can be checked for both its result and its price.
|
||||
type countingCache struct {
|
||||
values map[string]string
|
||||
reads int
|
||||
}
|
||||
|
||||
func newCountingCache() *countingCache {
|
||||
return &countingCache{values: map[string]string{}}
|
||||
}
|
||||
|
||||
func (c *countingCache) get(key string) (string, error) {
|
||||
c.reads++
|
||||
if value, found := c.values[key]; found && value != "" {
|
||||
return value, nil
|
||||
}
|
||||
return "", errors.New(CacheMiss)
|
||||
}
|
||||
|
||||
func (c *countingCache) set(key, value string, _ int64) {
|
||||
c.values[key] = value
|
||||
}
|
||||
|
||||
func (c *countingCache) delete(key string) {
|
||||
delete(c.values, key)
|
||||
}
|
||||
|
||||
func newCIDRClient(decisions map[string]string) (*Client, *countingCache) {
|
||||
counting := newCountingCache()
|
||||
client := &Client{cache: counting, log: logger.New("INFO", "")}
|
||||
for cidr, value := range decisions {
|
||||
client.SetCIDR(cidr, value, 60)
|
||||
}
|
||||
return client, counting
|
||||
}
|
||||
|
||||
func Test_GetCIDR(t *testing.T) {
|
||||
decisions := map[string]string{
|
||||
"10.0.0.0/24": BannedValue,
|
||||
"192.168.1.42/24": CaptchaValue, // not a network address, host bits are dropped
|
||||
"2001:db8::/32": BannedValue,
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
clientIP string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "IP inside a banned range", clientIP: "10.0.0.7", want: BannedValue},
|
||||
{name: "network address itself", clientIP: "10.0.0.0", want: BannedValue},
|
||||
{name: "broadcast address of the range", clientIP: "10.0.0.255", want: BannedValue},
|
||||
{name: "IP just outside the range", clientIP: "10.0.1.0", wantErr: true},
|
||||
{name: "IP inside a captcha range", clientIP: "192.168.1.7", want: CaptchaValue},
|
||||
{name: "IP inside an IPv6 range", clientIP: "2001:db8::dead:beef", want: BannedValue},
|
||||
{name: "IP outside the IPv6 range", clientIP: "2001:db9::1", wantErr: true},
|
||||
{name: "IPv4 mapped client against an IPv4 range", clientIP: "::ffff:10.0.0.7", want: BannedValue},
|
||||
{name: "unknown IP", clientIP: "8.8.8.8", wantErr: true},
|
||||
{name: "invalid IP", clientIP: "not-an-ip", wantErr: true},
|
||||
{name: "empty IP", clientIP: "", wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client, _ := newCIDRClient(decisions)
|
||||
got, err := client.GetCIDR(tt.clientIP)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("GetCIDR(%q) error = %v, wantErr %v", tt.clientIP, err, tt.wantErr)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("GetCIDR(%q) = %q, want %q", tt.clientIP, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test_GetCIDR_MostSpecific pins precedence: the narrowest range wins, so a captcha on
|
||||
// a /24 is not overruled by a ban on its /8.
|
||||
func Test_GetCIDR_MostSpecific(t *testing.T) {
|
||||
client, _ := newCIDRClient(map[string]string{
|
||||
"10.0.0.0/8": BannedValue,
|
||||
"10.1.0.0/16": CaptchaValue,
|
||||
"10.1.2.0/24": BannedValue,
|
||||
"2001:db8::/32": BannedValue,
|
||||
"2001:db8::/48": CaptchaValue,
|
||||
})
|
||||
tests := []struct {
|
||||
clientIP string
|
||||
want string
|
||||
}{
|
||||
{clientIP: "10.1.2.3", want: BannedValue},
|
||||
{clientIP: "10.1.3.3", want: CaptchaValue},
|
||||
{clientIP: "10.2.3.4", want: BannedValue},
|
||||
{clientIP: "2001:db8::1", want: CaptchaValue},
|
||||
{clientIP: "2001:db8:1::1", want: BannedValue},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.clientIP, func(t *testing.T) {
|
||||
got, err := client.GetCIDR(tt.clientIP)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCIDR(%q) unexpected error %v", tt.clientIP, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("GetCIDR(%q) = %q, want %q", tt.clientIP, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_DeleteCIDR(t *testing.T) {
|
||||
client, _ := newCIDRClient(map[string]string{
|
||||
"10.0.0.0/8": BannedValue,
|
||||
"10.1.2.0/24": CaptchaValue,
|
||||
})
|
||||
client.DeleteCIDR("10.1.2.0/24")
|
||||
// The wider decision is untouched and takes over.
|
||||
if got, err := client.GetCIDR("10.1.2.3"); err != nil || got != BannedValue {
|
||||
t.Errorf("after deleting the /24, GetCIDR = %q %v, want %q", got, err, BannedValue)
|
||||
}
|
||||
client.DeleteCIDR("10.0.0.0/8")
|
||||
if _, err := client.GetCIDR("10.1.2.3"); err == nil {
|
||||
t.Error("GetCIDR should miss once every decision is deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_SetCIDR_InvalidIsNotStored(t *testing.T) {
|
||||
for _, cidr := range []string{"", "garbage", "10.0.0.1", "10.0.0.0/33", "10.0.0.0/-1"} {
|
||||
t.Run(cidr, func(t *testing.T) {
|
||||
_, counting := newCIDRClient(map[string]string{cidr: BannedValue})
|
||||
if len(counting.values) != 0 {
|
||||
t.Errorf("SetCIDR(%q) stored %v, want nothing", cidr, counting.values)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test_GetCIDR_Reads guards the cost of the lookup: it must probe only the prefix
|
||||
// lengths that have a decision, not every possible one.
|
||||
func Test_GetCIDR_Reads(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
decisions map[string]string
|
||||
clientIP string
|
||||
wantReads int
|
||||
}{
|
||||
{name: "no decision at all, IPv4", decisions: nil, clientIP: "10.0.0.1", wantReads: 1},
|
||||
{name: "no decision at all, IPv6", decisions: nil, clientIP: "2001:db8::1", wantReads: 1},
|
||||
{
|
||||
name: "one prefix length, hit",
|
||||
decisions: map[string]string{"10.0.0.0/24": BannedValue},
|
||||
clientIP: "10.0.0.1",
|
||||
wantReads: 2,
|
||||
},
|
||||
{
|
||||
name: "one prefix length, miss",
|
||||
decisions: map[string]string{"10.0.0.0/24": BannedValue},
|
||||
clientIP: "11.0.0.1",
|
||||
wantReads: 2,
|
||||
},
|
||||
{
|
||||
name: "three prefix lengths, miss probes each once",
|
||||
decisions: map[string]string{"10.0.0.0/8": BannedValue, "10.1.0.0/16": BannedValue, "10.1.2.0/24": BannedValue},
|
||||
clientIP: "11.0.0.1",
|
||||
wantReads: 4,
|
||||
},
|
||||
{
|
||||
name: "IPv6 client does not probe every length",
|
||||
decisions: map[string]string{"2001:db8::/32": BannedValue},
|
||||
clientIP: "2001:dead::1",
|
||||
wantReads: 2,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client, counting := newCIDRClient(tt.decisions)
|
||||
counting.reads = 0
|
||||
// Only the number of reads matters here, the result is covered above.
|
||||
_, _ = client.GetCIDR(tt.clientIP)
|
||||
if counting.reads != tt.wantReads {
|
||||
t.Errorf("GetCIDR(%q) did %d cache reads, want %d", tt.clientIP, counting.reads, tt.wantReads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+11
-9
@@ -4,14 +4,14 @@ package captcha
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
||||
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
// Client Captcha client.
|
||||
@@ -21,10 +21,11 @@ type Client struct {
|
||||
secretKey string
|
||||
remediationCustomHeader string
|
||||
gracePeriodSeconds int64
|
||||
captchaTemplate *template.Template
|
||||
templateContentType string
|
||||
template *template.Template
|
||||
cacheClient *cache.Client
|
||||
httpClient *http.Client
|
||||
log *logger.Log
|
||||
log *slog.Logger
|
||||
infoProvider *infoProvider
|
||||
}
|
||||
|
||||
@@ -59,7 +60,7 @@ var infoProviders = map[string]*infoProvider{
|
||||
}
|
||||
|
||||
// New Initialize captcha client.
|
||||
func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
||||
func (c *Client) New(log *slog.Logger, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
||||
c.Valid = provider != ""
|
||||
if !c.Valid {
|
||||
return nil
|
||||
@@ -74,8 +75,9 @@ func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *htt
|
||||
c.siteKey = siteKey
|
||||
c.secretKey = secretKey
|
||||
c.remediationCustomHeader = remediationCustomHeader
|
||||
html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
|
||||
c.captchaTemplate = html
|
||||
template, contentType, _ := configuration.GetTemplate(captchaTemplatePath)
|
||||
c.template = template
|
||||
c.templateContentType = contentType
|
||||
c.gracePeriodSeconds = gracePeriodSeconds
|
||||
c.log = log
|
||||
c.httpClient = httpClient
|
||||
@@ -100,12 +102,12 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
|
||||
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
|
||||
return
|
||||
}
|
||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
rw.Header().Set("Content-Type", c.templateContentType)
|
||||
if c.remediationCustomHeader != "" {
|
||||
rw.Header().Set(c.remediationCustomHeader, "captcha")
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
err = c.captchaTemplate.Execute(rw, map[string]string{
|
||||
err = c.template.Execute(rw, map[string]string{
|
||||
"SiteKey": c.siteKey,
|
||||
"FrontendJS": c.infoProvider.js,
|
||||
"FrontendKey": c.infoProvider.key,
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
// Enums for crowdsec mode.
|
||||
@@ -30,6 +30,7 @@ const (
|
||||
HTTP = "http"
|
||||
LogDEBUG = "DEBUG"
|
||||
LogINFO = "INFO"
|
||||
LogWARN = "WARN"
|
||||
LogERROR = "ERROR"
|
||||
ReasonTECH = "TECHNICAL_ISSUE"
|
||||
ReasonLAPI = "LAPI"
|
||||
@@ -44,6 +45,7 @@ const (
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
LogFormat string `json:"logFormat,omitempty"`
|
||||
LogFilePath string `json:"logFilePath,omitempty"`
|
||||
CrowdsecMode string `json:"crowdsecMode,omitempty"`
|
||||
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
|
||||
@@ -61,6 +63,7 @@ type Config struct {
|
||||
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
|
||||
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
||||
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
||||
CrowdsecAppsecUnreadableBodyBlock bool `json:"crowdsecAppsecUnreadableBodyBlock,omitempty"`
|
||||
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
|
||||
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
|
||||
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
|
||||
@@ -82,6 +85,7 @@ type Config struct {
|
||||
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
||||
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
|
||||
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
|
||||
StreamStartupBlock bool `json:"streamStartupBlock,omitempty"`
|
||||
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
||||
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
|
||||
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
||||
@@ -92,12 +96,15 @@ type Config struct {
|
||||
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
|
||||
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
|
||||
RedisCacheHost string `json:"redisCacheHost,omitempty"`
|
||||
RedisCacheReadHosts []string `json:"redisCacheReadHosts,omitempty"`
|
||||
RedisCachePassword string `json:"redisCachePassword,omitempty"`
|
||||
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
|
||||
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
|
||||
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
|
||||
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
|
||||
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
|
||||
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
|
||||
BanFilePath string `json:"banFilePath,omitempty"`
|
||||
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
|
||||
CaptchaFilePath string `json:"captchaFilePath,omitempty"`
|
||||
CaptchaProvider string `json:"captchaProvider,omitempty"`
|
||||
CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"`
|
||||
CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"`
|
||||
@@ -122,50 +129,54 @@ func contains(source []string, target string) bool {
|
||||
// New creates the default plugin configuration.
|
||||
func New() *Config {
|
||||
return &Config{
|
||||
Enabled: false,
|
||||
LogLevel: LogINFO,
|
||||
LogFilePath: "",
|
||||
CrowdsecMode: LiveMode,
|
||||
CrowdsecAppsecEnabled: false,
|
||||
CrowdsecAppsecFailureBlock: true,
|
||||
CrowdsecAppsecUnreachableBlock: true,
|
||||
CrowdsecAppsecBodyLimit: 10485760,
|
||||
CrowdsecAppsecScheme: "",
|
||||
CrowdsecAppsecHost: "crowdsec:7422",
|
||||
CrowdsecAppsecPath: "/",
|
||||
CrowdsecAppsecKey: "",
|
||||
CrowdsecAppsecTLSInsecureVerify: false,
|
||||
CrowdsecLapiScheme: HTTP,
|
||||
CrowdsecLapiHost: "crowdsec:8080",
|
||||
CrowdsecLapiPath: "/",
|
||||
CrowdsecLapiKey: "",
|
||||
CrowdsecLapiTLSInsecureVerify: false,
|
||||
UpdateIntervalSeconds: 60,
|
||||
MetricsUpdateIntervalSeconds: 600,
|
||||
UpdateMaxFailure: 0,
|
||||
DefaultDecisionSeconds: 60,
|
||||
RemediationStatusCode: http.StatusForbidden,
|
||||
HTTPTimeoutSeconds: 10,
|
||||
CaptchaProvider: "",
|
||||
CaptchaCustomJsURL: "",
|
||||
CaptchaCustomValidateURL: "",
|
||||
CaptchaCustomKey: "",
|
||||
CaptchaCustomResponse: "",
|
||||
CaptchaSiteKey: "",
|
||||
CaptchaSecretKey: "",
|
||||
CaptchaGracePeriodSeconds: 1800,
|
||||
CaptchaHTMLFilePath: "/captcha.html",
|
||||
BanHTMLFilePath: "",
|
||||
TraceHeadersCustomName: "",
|
||||
RemediationHeadersCustomName: "",
|
||||
ForwardedHeadersCustomName: "X-Forwarded-For",
|
||||
ForwardedHeadersTrustedIPs: []string{},
|
||||
ClientTrustedIPs: []string{},
|
||||
RedisCacheEnabled: false,
|
||||
RedisCacheHost: "redis:6379",
|
||||
RedisCachePassword: "",
|
||||
RedisCacheDatabase: "",
|
||||
RedisCacheUnreachableBlock: true,
|
||||
Enabled: false,
|
||||
LogLevel: LogINFO,
|
||||
LogFormat: "common",
|
||||
LogFilePath: "",
|
||||
CrowdsecMode: LiveMode,
|
||||
CrowdsecAppsecEnabled: false,
|
||||
CrowdsecAppsecFailureBlock: true,
|
||||
CrowdsecAppsecUnreachableBlock: true,
|
||||
CrowdsecAppsecUnreadableBodyBlock: true,
|
||||
CrowdsecAppsecBodyLimit: 10485760,
|
||||
CrowdsecAppsecScheme: "",
|
||||
CrowdsecAppsecHost: "crowdsec:7422",
|
||||
CrowdsecAppsecPath: "/",
|
||||
CrowdsecAppsecKey: "",
|
||||
CrowdsecAppsecTLSInsecureVerify: false,
|
||||
CrowdsecLapiScheme: HTTP,
|
||||
CrowdsecLapiHost: "crowdsec:8080",
|
||||
CrowdsecLapiPath: "/",
|
||||
CrowdsecLapiKey: "",
|
||||
CrowdsecLapiTLSInsecureVerify: false,
|
||||
UpdateIntervalSeconds: 60,
|
||||
MetricsUpdateIntervalSeconds: 600,
|
||||
UpdateMaxFailure: 0,
|
||||
StreamStartupBlock: true,
|
||||
DefaultDecisionSeconds: 60,
|
||||
RemediationStatusCode: http.StatusForbidden,
|
||||
HTTPTimeoutSeconds: 10,
|
||||
CaptchaProvider: "",
|
||||
CaptchaCustomJsURL: "",
|
||||
CaptchaCustomValidateURL: "",
|
||||
CaptchaCustomKey: "",
|
||||
CaptchaCustomResponse: "",
|
||||
CaptchaSiteKey: "",
|
||||
CaptchaSecretKey: "",
|
||||
CaptchaGracePeriodSeconds: 1800,
|
||||
CaptchaFilePath: "/captcha.html",
|
||||
BanFilePath: "",
|
||||
TraceHeadersCustomName: "",
|
||||
RemediationHeadersCustomName: "",
|
||||
ForwardedHeadersCustomName: "X-Forwarded-For",
|
||||
ForwardedHeadersTrustedIPs: []string{},
|
||||
ClientTrustedIPs: []string{},
|
||||
RedisCacheEnabled: false,
|
||||
RedisCacheHost: "redis:6379",
|
||||
RedisCacheReadHosts: []string{},
|
||||
RedisCachePassword: "",
|
||||
RedisCacheDatabase: "",
|
||||
RedisCacheUnreachableBlock: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,29 +207,51 @@ func GetVariable(config *Config, key string) (string, error) {
|
||||
return strings.TrimSpace(value), nil
|
||||
}
|
||||
|
||||
// GetHTMLTemplate get compiled HTML template.
|
||||
func GetHTMLTemplate(path string) (*template.Template, error) {
|
||||
var err error
|
||||
func getContentTypeFromPath(path string) string {
|
||||
if path == "" {
|
||||
return nil, errors.New("no html template provided")
|
||||
return ""
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
contentTypeMap := map[string]string{
|
||||
".html": "text/html; charset=utf-8",
|
||||
".htm": "text/html; charset=utf-8",
|
||||
".json": "application/json",
|
||||
".txt": "text/plain",
|
||||
".xml": "application/xml",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
}
|
||||
if contentType, ok := contentTypeMap[ext]; ok {
|
||||
return contentType
|
||||
}
|
||||
// Default to HTML for backward compatibility
|
||||
return "text/html; charset=utf-8"
|
||||
}
|
||||
|
||||
// GetTemplate get compiled template with {{ and }} delimiters.
|
||||
// Uses text/template for all file types to avoid HTML escaping issues.
|
||||
func GetTemplate(path string) (*template.Template, string, error) {
|
||||
if path == "" {
|
||||
return nil, "", errors.New("no template file provided")
|
||||
}
|
||||
contentType := getContentTypeFromPath(path)
|
||||
//nolint:gosec
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
html := string(b)
|
||||
compiledTemplate, err := template.New("html").Parse(html)
|
||||
content := string(b)
|
||||
compiledTemplate, err := template.New(filepath.Base(path)).Delims("{{", "}}").Parse(content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("impossible to compile html template: %w", err)
|
||||
return nil, "", fmt.Errorf("impossible to compile template %s: %w", path, err)
|
||||
}
|
||||
return compiledTemplate, nil
|
||||
return compiledTemplate, contentType, nil
|
||||
}
|
||||
|
||||
// ValidateParams validate all the param gave by user.
|
||||
//
|
||||
//nolint:gocyclo,gocognit
|
||||
func ValidateParams(config *Config) error {
|
||||
//nolint:gocyclo,gocognit,nestif
|
||||
func ValidateParams(config *Config, log *slog.Logger) error {
|
||||
if err := validateParamsRequired(config); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -227,10 +260,10 @@ func ValidateParams(config *Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
||||
if err := validateParamsIPs(log, config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateParamsIPs(config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
|
||||
if err := validateParamsIPs(log, config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -255,12 +288,14 @@ func ValidateParams(config *Config) error {
|
||||
if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := GetHTMLTemplate(config.CaptchaHTMLFilePath); err != nil {
|
||||
return err
|
||||
if config.CaptchaFilePath != "" {
|
||||
if _, _, err := GetTemplate(config.CaptchaFilePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.BanHTMLFilePath != "" {
|
||||
if _, err := GetHTMLTemplate(config.BanHTMLFilePath); err != nil {
|
||||
if config.BanFilePath != "" {
|
||||
if _, _, err := GetTemplate(config.BanFilePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -317,8 +352,8 @@ func ValidateParams(config *Config) error {
|
||||
|
||||
// Check logging configuration
|
||||
// to upper allow of anycase of log level
|
||||
if !contains([]string{LogERROR, LogDEBUG, LogINFO}, strings.ToUpper(config.LogLevel)) {
|
||||
return fmt.Errorf("LogLevel should be one of (%s,%s,%s)", LogDEBUG, LogINFO, LogERROR)
|
||||
if !contains([]string{LogDEBUG, LogINFO, LogWARN, LogERROR}, strings.ToUpper(config.LogLevel)) {
|
||||
return fmt.Errorf("LogLevel should be one of (%s,%s,%s,%s)", LogDEBUG, LogINFO, LogWARN, LogERROR)
|
||||
}
|
||||
if config.LogFilePath != "" {
|
||||
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
@@ -356,7 +391,8 @@ func validateParamsTLS(config *Config) error {
|
||||
return err
|
||||
}
|
||||
if certAuth == "" {
|
||||
return errors.New("CrowdsecLapiTLSCertificateAuthority must be specified when CrowdsecLapiScheme='https' and CrowdsecLapiTLSInsecureVerify=false")
|
||||
// No custom CA — runtime will fall back to the system trust store.
|
||||
return nil
|
||||
}
|
||||
tlsConfig := new(tls.Config)
|
||||
tlsConfig.RootCAs = x509.NewCertPool()
|
||||
@@ -366,9 +402,9 @@ func validateParamsTLS(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateParamsIPs(listIP []string, key string) error {
|
||||
func validateParamsIPs(log *slog.Logger, listIP []string, key string) error {
|
||||
if len(listIP) > 0 {
|
||||
if _, err := ip.NewChecker(logger.New(LogINFO, ""), listIP); err != nil {
|
||||
if _, err := ip.NewChecker(log, listIP); err != nil {
|
||||
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
|
||||
}
|
||||
}
|
||||
@@ -446,31 +482,33 @@ func validateParamsRequired(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getTLSConfig(config *Config, log *logger.Log, 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.RootCAs = x509.NewCertPool()
|
||||
if scheme != HTTPS {
|
||||
log.Debug("getTLSConfigCrowdsec:" + prefix + "Scheme https:no")
|
||||
log.Debug("getTLSConfig:" + prefix + "Scheme https:no")
|
||||
return tlsConfig, nil
|
||||
}
|
||||
// RootCAs is intentionally left nil unless a custom CA is provided:
|
||||
// crypto/tls then falls back to x509.SystemCertPool(), which is what we
|
||||
// want when the LAPI is exposed behind a reverse proxy with a publicly
|
||||
// trusted certificate (e.g. Let's Encrypt).
|
||||
//nolint:nestif
|
||||
if insecureVerify {
|
||||
tlsConfig.InsecureSkipVerify = true
|
||||
log.Debug("getTLSConfigCrowdsec:" + prefix + "TLSInsecureVerify tlsInsecure:true")
|
||||
// If we return here and still want to use client auth this won't work
|
||||
// return tlsConfig, nil
|
||||
log.Debug("getTLSConfig:" + prefix + "TLSInsecureVerify tlsInsecure:true")
|
||||
} else {
|
||||
certAuthority, err := GetVariable(config, prefix+"TLSCertificateAuthority")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(certAuthority) > 0 {
|
||||
tlsConfig.RootCAs = x509.NewCertPool()
|
||||
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
||||
// here we return because if CrowdsecLapiTLSInsecureVerify is false
|
||||
// and CA not load, we can't communicate with https
|
||||
return nil, errors.New("getTLSConfigCrowdsec:" + 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("getTLSConfigCrowdsec:" + 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")
|
||||
@@ -494,7 +532,7 @@ func getTLSConfig(config *Config, log *logger.Log, prefix, scheme string, insecu
|
||||
}
|
||||
|
||||
// GetTLSConfigCrowdsec get TLS config from Config.
|
||||
func GetTLSConfigCrowdsec(config *Config, log *logger.Log, isAppsec bool) (*tls.Config, error) {
|
||||
func GetTLSConfigCrowdsec(config *Config, log *slog.Logger, isAppsec bool) (*tls.Config, error) {
|
||||
var prefix string
|
||||
if isAppsec && config.CrowdsecAppsecScheme != "" {
|
||||
prefix = "CrowdsecAppsec"
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
// validPEM is a minimal self-signed certificate accepted by AppendCertsFromPEM,
|
||||
// shared by the TLS tests below.
|
||||
const validPEM = `-----BEGIN CERTIFICATE-----
|
||||
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
|
||||
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
|
||||
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
|
||||
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
|
||||
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
|
||||
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
|
||||
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
|
||||
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
|
||||
6MF9+Yw1Yy0t
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
func getMinimalConfig() *Config {
|
||||
cfg := New()
|
||||
cfg.CrowdsecLapiKey = "test"
|
||||
@@ -72,6 +84,7 @@ func Test_GetVariable(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_ValidateParams(t *testing.T) {
|
||||
log := logger.New("INFO", "")
|
||||
cfg1 := New()
|
||||
cfg1.CrowdsecLapiKey = "test\n\n"
|
||||
cfg2 := New()
|
||||
@@ -110,14 +123,14 @@ func Test_ValidateParams(t *testing.T) {
|
||||
{name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true},
|
||||
// HTTPS enabled
|
||||
{name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false},
|
||||
{name: "Not validate https without cert authority", args: args{config: cfg7}, wantErr: true},
|
||||
{name: "Validate https without cert authority (falls back to system trust store)", args: args{config: cfg7}, wantErr: false},
|
||||
{name: "Valid log level uppercase INFO", args: args{config: cfg8}, wantErr: false},
|
||||
{name: "Valid log level lowercase info", args: args{config: cfg9}, wantErr: false},
|
||||
{name: "Invalid log level Warning", args: args{config: cfg10}, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := ValidateParams(tt.args.config); (err != nil) != tt.wantErr {
|
||||
if err := ValidateParams(tt.args.config, log); (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
@@ -125,19 +138,24 @@ func Test_ValidateParams(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_validateParamsTLS(t *testing.T) {
|
||||
type args struct {
|
||||
config *Config
|
||||
}
|
||||
cfgEmpty := getMinimalConfig()
|
||||
cfgValid := getMinimalConfig()
|
||||
cfgValid.CrowdsecLapiTLSCertificateAuthority = validPEM
|
||||
cfgInvalidCA := getMinimalConfig()
|
||||
cfgInvalidCA.CrowdsecLapiTLSCertificateAuthority = "not a pem"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
config *Config
|
||||
wantErr bool
|
||||
}{
|
||||
// TODO: Add test cases.
|
||||
{name: "Empty CA is accepted (system trust store used at runtime)", config: cfgEmpty, wantErr: false},
|
||||
{name: "Valid PEM CA is accepted", config: cfgValid, wantErr: false},
|
||||
{name: "Invalid CA is rejected", config: cfgInvalidCA, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := validateParamsTLS(tt.args.config); (err != nil) != tt.wantErr {
|
||||
if err := validateParamsTLS(tt.config); (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateParamsTLS() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
@@ -145,6 +163,7 @@ func Test_validateParamsTLS(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_validateParamsIPs(t *testing.T) {
|
||||
log := logger.New("INFO", "")
|
||||
type args struct {
|
||||
listIP []string
|
||||
key string
|
||||
@@ -164,7 +183,7 @@ func Test_validateParamsIPs(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := validateParamsIPs(tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
|
||||
if err := validateParamsIPs(log, tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
@@ -230,26 +249,79 @@ func Test_validateParamsAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
||||
type args struct {
|
||||
config *Config
|
||||
}
|
||||
log := logger.New("INFO", "")
|
||||
|
||||
httpCfg := getMinimalConfig()
|
||||
httpCfg.CrowdsecLapiScheme = HTTP
|
||||
|
||||
httpsSystemCA := getMinimalConfig()
|
||||
httpsSystemCA.CrowdsecLapiScheme = HTTPS
|
||||
|
||||
httpsCustomCA := getMinimalConfig()
|
||||
httpsCustomCA.CrowdsecLapiScheme = HTTPS
|
||||
httpsCustomCA.CrowdsecLapiTLSCertificateAuthority = validPEM
|
||||
|
||||
httpsInsecure := getMinimalConfig()
|
||||
httpsInsecure.CrowdsecLapiScheme = HTTPS
|
||||
httpsInsecure.CrowdsecLapiTLSInsecureVerify = true
|
||||
|
||||
httpsBadCA := getMinimalConfig()
|
||||
httpsBadCA.CrowdsecLapiScheme = HTTPS
|
||||
httpsBadCA.CrowdsecLapiTLSCertificateAuthority = "not a pem"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *tls.Config
|
||||
wantErr bool
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
wantRootCAsNil bool
|
||||
wantInsecureSkip bool
|
||||
}{
|
||||
// TODO: Add test cases.
|
||||
{name: "HTTP scheme returns empty tls.Config", config: httpCfg, wantRootCAsNil: true},
|
||||
{name: "HTTPS without CA leaves RootCAs nil (system trust store)", config: httpsSystemCA, wantRootCAsNil: true},
|
||||
{name: "HTTPS with custom CA populates RootCAs", config: httpsCustomCA, wantRootCAsNil: false},
|
||||
{name: "HTTPS with insecure verify sets InsecureSkipVerify", config: httpsInsecure, wantRootCAsNil: true, wantInsecureSkip: true},
|
||||
{name: "HTTPS with garbage CA is rejected", config: httpsBadCA, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO", ""), false)
|
||||
got, err := GetTLSConfigCrowdsec(tt.config, log, false)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
||||
t.Errorf("GetTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("getTLSConfigCrowdsec() = %v, want %v", got, tt.want)
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
if (got.RootCAs == nil) != tt.wantRootCAsNil {
|
||||
t.Errorf("GetTLSConfigCrowdsec() RootCAs nil = %v, want nil = %v", got.RootCAs == nil, tt.wantRootCAsNil)
|
||||
}
|
||||
if got.InsecureSkipVerify != tt.wantInsecureSkip {
|
||||
t.Errorf("GetTLSConfigCrowdsec() InsecureSkipVerify = %v, want %v", got.InsecureSkipVerify, tt.wantInsecureSkip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getContentTypeFromPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{name: "HTML file with .html extension", path: "/ban.html", expected: "text/html; charset=utf-8"},
|
||||
{name: "JSON file", path: "/ban.json", expected: "application/json"},
|
||||
{name: "Text file", path: "/ban.txt", expected: "text/plain"},
|
||||
{name: "File with mixed case extension", path: "/ban.HtMl", expected: "text/html; charset=utf-8"},
|
||||
{name: "Unknown extension defaults to HTML", path: "/ban.xyz", expected: "text/html; charset=utf-8"},
|
||||
{name: "File without extension", path: "/ban", expected: "text/html; charset=utf-8"},
|
||||
{name: "Empty path", path: "", expected: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := getContentTypeFromPath(tt.path)
|
||||
if got != tt.expected {
|
||||
t.Errorf("GetContentTypeFromPath(%q) = %q, want %q", tt.path, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package ip
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxIPv4PrefixLen = 32
|
||||
maxIPv6PrefixLen = 128
|
||||
)
|
||||
|
||||
// CIDRKeys returns all possible CIDR prefixes of an IP, from the most specific (/32 for IPv4, /128 for IPv6) to the least specific (/0).
|
||||
func CIDRKeys(ipStr string) []string {
|
||||
parsed, maxBits := parseForPrefix(ipStr)
|
||||
if parsed == nil {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, maxBits+1)
|
||||
for bits := maxBits; bits >= 0; bits-- {
|
||||
keys = append(keys, cidrKey(parsed, bits, maxBits))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// CIDRLookupKeys returns the keys of the CIDRs containing an IP for the given prefix lengths
|
||||
// only, most specific first. Duplicates and lengths of the other family are skipped.
|
||||
func CIDRLookupKeys(ipStr string, prefixLens []int) []string {
|
||||
parsed, maxBits := parseForPrefix(ipStr)
|
||||
if parsed == nil {
|
||||
return nil
|
||||
}
|
||||
var wanted [maxIPv6PrefixLen + 1]bool
|
||||
for _, bits := range prefixLens {
|
||||
if bits >= 0 && bits <= maxBits {
|
||||
wanted[bits] = true
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(prefixLens))
|
||||
for bits := maxBits; bits >= 0; bits-- {
|
||||
if wanted[bits] {
|
||||
keys = append(keys, cidrKey(parsed, bits, maxBits))
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// NormalizeCIDR parses a CIDR string and returns its normalized form, or an empty string if invalid.
|
||||
func NormalizeCIDR(cidrStr string) string {
|
||||
_, ipNet, err := net.ParseCIDR(strings.TrimSpace(cidrStr))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ipNet.String()
|
||||
}
|
||||
|
||||
// CIDRPrefixLen returns the prefix length of a CIDR, or -1 if it is not a valid CIDR.
|
||||
func CIDRPrefixLen(cidrStr string) int {
|
||||
_, ipNet, err := net.ParseCIDR(strings.TrimSpace(cidrStr))
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
prefixLen, _ := ipNet.Mask.Size()
|
||||
return prefixLen
|
||||
}
|
||||
|
||||
// parseForPrefix returns the IP in the native form of its family, and that family's bit length.
|
||||
func parseForPrefix(ipStr string) (net.IP, int) {
|
||||
parsed := net.ParseIP(ipStr)
|
||||
if parsed == nil {
|
||||
return nil, 0
|
||||
}
|
||||
if parsed4 := parsed.To4(); parsed4 != nil {
|
||||
return parsed4, maxIPv4PrefixLen
|
||||
}
|
||||
return parsed.To16(), maxIPv6PrefixLen
|
||||
}
|
||||
|
||||
// cidrKey builds the key of the CIDR of bits length containing the IP.
|
||||
// It formats through net.IPNet like NormalizeCIDR, so writes and lookups agree.
|
||||
func cidrKey(parsed net.IP, bits, maxBits int) string {
|
||||
mask := net.CIDRMask(bits, maxBits)
|
||||
ipNet := net.IPNet{IP: parsed.Mask(mask), Mask: mask}
|
||||
return ipNet.String()
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package ip
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCIDRKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
ip string
|
||||
wantKeys int
|
||||
checks map[int]string
|
||||
}{
|
||||
{
|
||||
ip: "10.0.0.1",
|
||||
wantKeys: 33,
|
||||
checks: map[int]string{0: "10.0.0.1/32", 8: "10.0.0.0/24", 32: "0.0.0.0/0"},
|
||||
},
|
||||
{
|
||||
ip: "2001:db8::1",
|
||||
wantKeys: 129,
|
||||
checks: map[int]string{0: "2001:db8::1/128", 32: "2001:db8::/96", 128: "::/0"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.ip, func(t *testing.T) {
|
||||
keys := CIDRKeys(tt.ip)
|
||||
if keys == nil {
|
||||
t.Fatal("CIDRKeys returned nil")
|
||||
}
|
||||
if len(keys) != tt.wantKeys {
|
||||
t.Fatalf("expected %d keys, got %d", tt.wantKeys, len(keys))
|
||||
}
|
||||
for idx, want := range tt.checks {
|
||||
if keys[idx] != want {
|
||||
t.Errorf("keys[%d] should be %s, got %s", idx, want, keys[idx])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDRKeys_MostToLeastSpecific(t *testing.T) {
|
||||
ips := []string{"10.0.0.1", "2001:db8::1"}
|
||||
for _, ip := range ips {
|
||||
t.Run(ip, func(t *testing.T) {
|
||||
keys := CIDRKeys(ip)
|
||||
for i := 1; i < len(keys); i++ {
|
||||
prevBits := strings.Split(keys[i-1], "/")[1]
|
||||
curBits := strings.Split(keys[i], "/")[1]
|
||||
prevN, _ := strconv.Atoi(prevBits)
|
||||
curN, _ := strconv.Atoi(curBits)
|
||||
if prevN <= curN {
|
||||
t.Errorf("keys should go from most specific to least specific at index %d: /%d <= /%d", i, prevN, curN)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDRKeys_IPVariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
ip string
|
||||
wantKeys int
|
||||
}{
|
||||
{"0.0.0.0", 33},
|
||||
{"255.255.255.255", 33},
|
||||
{"1.2.3.4", 33},
|
||||
{"10.0.0.1", 33},
|
||||
{"192.168.1.1", 33},
|
||||
{"invalid", 0},
|
||||
{"", 0},
|
||||
{" ", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.ip, func(t *testing.T) {
|
||||
keys := CIDRKeys(tt.ip)
|
||||
if len(keys) != tt.wantKeys {
|
||||
t.Errorf("CIDRKeys(%q) returned %d keys, want %d", tt.ip, len(keys), tt.wantKeys)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDRKeys_VerifyNetworkAddress(t *testing.T) {
|
||||
keys := CIDRKeys("10.1.2.3")
|
||||
tests := []struct {
|
||||
bits int
|
||||
want string
|
||||
}{
|
||||
{24, "10.1.2.0/24"},
|
||||
{16, "10.1.0.0/16"},
|
||||
{8, "10.0.0.0/8"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := keys[32-tt.bits]; got != tt.want {
|
||||
t.Errorf("/%d network should be %s, got %s", tt.bits, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDRKeys_VerifyNetworkAddressIPv6(t *testing.T) {
|
||||
keys := CIDRKeys("2001:db8:1:2:3:4:5:6")
|
||||
tests := []struct {
|
||||
bits int
|
||||
want string
|
||||
}{
|
||||
{64, "2001:db8:1:2::/64"},
|
||||
{48, "2001:db8:1::/48"},
|
||||
{32, "2001:db8::/32"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := keys[128-tt.bits]; got != tt.want {
|
||||
t.Errorf("/%d network should be %s, got %s", tt.bits, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCIDRKeys_MatchNormalizeCIDR covers the invariant range support rests on: the key
|
||||
// written for a decision is a key looked up for the IPs it covers, and only those.
|
||||
func TestCIDRKeys_MatchNormalizeCIDR(t *testing.T) {
|
||||
tests := []struct {
|
||||
cidr string
|
||||
ip string
|
||||
match bool
|
||||
}{
|
||||
{cidr: "10.0.0.0/8", ip: "10.1.2.3", match: true},
|
||||
{cidr: "10.0.0.0/24", ip: "10.0.0.1", match: true},
|
||||
{cidr: "10.0.0.0/24", ip: "10.0.1.1", match: false},
|
||||
{cidr: "1.2.3.4/32", ip: "1.2.3.4", match: true},
|
||||
{cidr: "1.2.3.4/32", ip: "1.2.3.5", match: false},
|
||||
{cidr: "0.0.0.0/0", ip: "8.8.8.8", match: true},
|
||||
// LAPI does not have to send a network address, the host bits are dropped.
|
||||
{cidr: "10.0.0.5/24", ip: "10.0.0.9", match: true},
|
||||
{cidr: " 192.168.1.0/24 ", ip: "192.168.1.42", match: true},
|
||||
{cidr: "2001:db8::/32", ip: "2001:db8::1", match: true},
|
||||
{cidr: "2001:db8::/32", ip: "2001:db9::1", match: false},
|
||||
{cidr: "::/0", ip: "2001:db8::1", match: true},
|
||||
// An IPv4 range and an IPv4 mapped client still have to meet.
|
||||
{cidr: "10.0.0.0/8", ip: "::ffff:10.1.2.3", match: true},
|
||||
{cidr: "::ffff:10.0.0.0/104", ip: "10.1.2.3", match: true},
|
||||
// Families do not mix.
|
||||
{cidr: "::/0", ip: "8.8.8.8", match: false},
|
||||
{cidr: "2001:db8::/32", ip: "::ffff:10.0.0.1", match: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.cidr+"_"+tt.ip, func(t *testing.T) {
|
||||
key := NormalizeCIDR(tt.cidr)
|
||||
if key == "" {
|
||||
t.Fatalf("NormalizeCIDR(%q) returned nothing", tt.cidr)
|
||||
}
|
||||
found := false
|
||||
for _, candidate := range CIDRKeys(tt.ip) {
|
||||
if candidate == key {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found != tt.match {
|
||||
t.Errorf("key %q of %q found in CIDRKeys(%q) = %v, want %v", key, tt.cidr, tt.ip, found, tt.match)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDRLookupKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
prefixLens []int
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "most specific first",
|
||||
ip: "10.1.2.3",
|
||||
prefixLens: []int{8, 32, 16},
|
||||
want: []string{"10.1.2.3/32", "10.1.0.0/16", "10.0.0.0/8"},
|
||||
},
|
||||
{
|
||||
name: "duplicates are dropped",
|
||||
ip: "10.1.2.3",
|
||||
prefixLens: []int{24, 24, 24},
|
||||
want: []string{"10.1.2.0/24"},
|
||||
},
|
||||
{
|
||||
name: "lengths of the other family are skipped",
|
||||
ip: "10.1.2.3",
|
||||
prefixLens: []int{48, 64, 24},
|
||||
want: []string{"10.1.2.0/24"},
|
||||
},
|
||||
{
|
||||
name: "out of range lengths are skipped",
|
||||
ip: "10.1.2.3",
|
||||
prefixLens: []int{-1, 33, 129, 8},
|
||||
want: []string{"10.0.0.0/8"},
|
||||
},
|
||||
{
|
||||
name: "ipv6 keeps its own lengths",
|
||||
ip: "2001:db8::1",
|
||||
prefixLens: []int{32, 64},
|
||||
want: []string{"2001:db8::/64", "2001:db8::/32"},
|
||||
},
|
||||
{
|
||||
name: "no length gives no key",
|
||||
ip: "10.1.2.3",
|
||||
prefixLens: []int{},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "invalid ip gives no key",
|
||||
ip: "invalid",
|
||||
prefixLens: []int{24},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := CIDRLookupKeys(tt.ip, tt.prefixLens)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("CIDRLookupKeys(%q, %v) = %v, want %v", tt.ip, tt.prefixLens, got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("CIDRLookupKeys(%q, %v)[%d] = %q, want %q", tt.ip, tt.prefixLens, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCIDRLookupKeys_SubsetOfCIDRKeys: restricting the lengths only removes candidates,
|
||||
// it never changes the key of a length that is kept.
|
||||
func TestCIDRLookupKeys_SubsetOfCIDRKeys(t *testing.T) {
|
||||
for _, ipStr := range []string{"10.1.2.3", "2001:db8::1", "::ffff:10.1.2.3"} {
|
||||
t.Run(ipStr, func(t *testing.T) {
|
||||
all := CIDRKeys(ipStr)
|
||||
maxBits := len(all) - 1
|
||||
for bits := 0; bits <= maxBits; bits++ {
|
||||
got := CIDRLookupKeys(ipStr, []int{bits})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("CIDRLookupKeys(%q, [%d]) returned %d keys", ipStr, bits, len(got))
|
||||
}
|
||||
if want := all[maxBits-bits]; got[0] != want {
|
||||
t.Errorf("CIDRLookupKeys(%q, [%d]) = %q, want %q", ipStr, bits, got[0], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDRPrefixLen(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int
|
||||
}{
|
||||
{"10.0.0.0/8", 8},
|
||||
{"10.0.0.0/32", 32},
|
||||
{"0.0.0.0/0", 0},
|
||||
{"10.0.0.5/24", 24},
|
||||
{" 10.0.0.0/16 ", 16},
|
||||
{"2001:db8::/32", 32},
|
||||
{"2001:db8::/128", 128},
|
||||
{"::/0", 0},
|
||||
{"10.0.0.1", -1},
|
||||
{"invalid", -1},
|
||||
{"", -1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
if got := CIDRPrefixLen(tt.input); got != tt.want {
|
||||
t.Errorf("CIDRPrefixLen(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCIDR(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"10.0.0.0/8", "10.0.0.0/8"},
|
||||
{"10.0.0.0/16", "10.0.0.0/16"},
|
||||
{"192.168.1.0/24", "192.168.1.0/24"},
|
||||
{"2001:db8::/32", "2001:db8::/32"},
|
||||
{"0.0.0.0/0", "0.0.0.0/0"},
|
||||
{"::/0", "::/0"},
|
||||
{"invalid", ""},
|
||||
{"", ""},
|
||||
{" 10.0.0.0/8 ", "10.0.0.0/8"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := NormalizeCIDR(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeCIDR(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -5,11 +5,10 @@ package ip
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
// CHECKER
|
||||
@@ -21,7 +20,7 @@ type Checker struct {
|
||||
}
|
||||
|
||||
// NewChecker builds a new Checker given a list of CIDR-Strings to trusted IPs.
|
||||
func NewChecker(log *logger.Log, trustedIPs []string) (*Checker, error) {
|
||||
func NewChecker(log *slog.Logger, trustedIPs []string) (*Checker, error) {
|
||||
checker := &Checker{}
|
||||
|
||||
for _, ipMaskRaw := range trustedIPs {
|
||||
|
||||
+68
-55
@@ -1,79 +1,92 @@
|
||||
// Package logger implements utility routines to write to stdout and stderr.
|
||||
// It supports trace, debug, info and error level
|
||||
// It supports trace, debug, info, warn and error level using Go's standard log/slog
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Log Logger struct.
|
||||
type Log struct {
|
||||
logError *log.Logger
|
||||
logInfo *log.Logger
|
||||
logDebug *log.Logger
|
||||
// Custom log levels following slog best practices.
|
||||
const (
|
||||
LevelDebug = slog.LevelDebug
|
||||
LevelInfo = slog.LevelInfo
|
||||
LevelWarn = slog.LevelWarn
|
||||
LevelError = slog.LevelError
|
||||
)
|
||||
|
||||
// New creates a Log wrapper with default format (common).
|
||||
func New(logLevel string, logFilePath string) *slog.Logger {
|
||||
return NewWithFormat(logLevel, logFilePath, "common")
|
||||
}
|
||||
|
||||
// New Set Default log level to info in case log level to defined.
|
||||
func New(logLevel string, logFilePath string) *Log {
|
||||
// Initialize loggers with discard output
|
||||
logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
||||
logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
||||
logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
||||
// NewWithFormat creates a Log wrapper with specified format (common or json).
|
||||
func NewWithFormat(logLevel, logFilePath, logFormat string) *slog.Logger {
|
||||
// Determine log level
|
||||
var level slog.Level
|
||||
switch logLevel {
|
||||
case "ERROR":
|
||||
level = LevelError
|
||||
case "WARN":
|
||||
level = LevelWarn
|
||||
case "INFO":
|
||||
level = LevelInfo
|
||||
case "DEBUG":
|
||||
level = LevelDebug
|
||||
default:
|
||||
// Default to INFO level
|
||||
level = LevelInfo
|
||||
}
|
||||
|
||||
// we initialize logger to STDOUT/STDERR first so if the file logger cannot be initialized we can inform the user
|
||||
output := os.Stdout
|
||||
errorOutput := os.Stderr
|
||||
|
||||
// prepare file logging if specified
|
||||
// Set output destination
|
||||
var output *os.File
|
||||
if logFilePath != "" {
|
||||
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err == nil {
|
||||
output = logFile
|
||||
errorOutput = logFile
|
||||
} else {
|
||||
_ = fmt.Errorf("LogFilePath is not writable %w", err)
|
||||
// Fall back to stdout and log the error
|
||||
output = os.Stdout
|
||||
slog.Warn("LogFilePath is not writable, using stdout", "error", err)
|
||||
}
|
||||
} else {
|
||||
output = os.Stdout
|
||||
}
|
||||
|
||||
// Set error logger output
|
||||
logError.SetOutput(errorOutput)
|
||||
|
||||
// Configure log levels
|
||||
switch logLevel {
|
||||
case "ERROR":
|
||||
// Only error logging is enabled
|
||||
case "INFO":
|
||||
logInfo.SetOutput(output)
|
||||
case "DEBUG":
|
||||
logInfo.SetOutput(output)
|
||||
logDebug.SetOutput(output)
|
||||
default:
|
||||
// Default to INFO level
|
||||
logInfo.SetOutput(output)
|
||||
// Create handler based on format with custom level names
|
||||
var handler slog.Handler
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: level,
|
||||
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||||
// Customize level names to match our expected format
|
||||
if a.Key == slog.LevelKey {
|
||||
lvl, ok := a.Value.Any().(slog.Level)
|
||||
if !ok {
|
||||
return a
|
||||
}
|
||||
switch {
|
||||
case lvl < LevelInfo:
|
||||
a.Value = slog.StringValue("DEBUG")
|
||||
case lvl < LevelWarn:
|
||||
a.Value = slog.StringValue("INFO")
|
||||
case lvl < LevelError:
|
||||
a.Value = slog.StringValue("WARN")
|
||||
default:
|
||||
a.Value = slog.StringValue("ERROR")
|
||||
}
|
||||
}
|
||||
return a
|
||||
},
|
||||
}
|
||||
|
||||
return &Log{
|
||||
logError: logError,
|
||||
logInfo: logInfo,
|
||||
logDebug: logDebug,
|
||||
if logFormat == "json" {
|
||||
handler = slog.NewJSONHandler(output, opts)
|
||||
} else {
|
||||
// Common format (default)
|
||||
handler = slog.NewTextHandler(output, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// Info log to Stdout.
|
||||
func (l *Log) Info(str string) {
|
||||
l.logInfo.Printf("%s", str)
|
||||
}
|
||||
|
||||
// Debug log to Stdout.
|
||||
func (l *Log) Debug(str string) {
|
||||
l.logDebug.Printf("%s", str)
|
||||
}
|
||||
|
||||
// Error log to Stderr.
|
||||
func (l *Log) Error(str string) {
|
||||
l.logError.Printf("%s", str)
|
||||
// Create logger with component attribute
|
||||
return slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
logLevel string
|
||||
}{
|
||||
{name: "ERROR level", logLevel: "ERROR"},
|
||||
{name: "WARN level", logLevel: "WARN"},
|
||||
{name: "INFO level", logLevel: "INFO"},
|
||||
{name: "DEBUG level", logLevel: "DEBUG"},
|
||||
{name: "Default level (INFO)", logLevel: "INVALID"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
logger := New(tt.logLevel, "")
|
||||
|
||||
// Verify logger is created
|
||||
if logger == nil {
|
||||
t.Fatal("Expected logger to be created, got nil")
|
||||
}
|
||||
|
||||
// Verify it's a slog.Logger (we can call methods on it)
|
||||
logger.Info("test initialization")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLogFormat(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Create a logger with JSON handler to capture output
|
||||
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||
|
||||
testMessage := "json test message"
|
||||
logger.Info(testMessage)
|
||||
|
||||
output := buf.String()
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("Expected 1 log line, got %d", len(lines))
|
||||
}
|
||||
|
||||
// Verify it's valid JSON
|
||||
var logEntry map[string]interface{}
|
||||
err := json.Unmarshal([]byte(lines[0]), &logEntry)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected valid JSON output, got error: %v, output: %s", err, output)
|
||||
}
|
||||
|
||||
// Verify JSON structure
|
||||
if logEntry["level"] != "INFO" {
|
||||
t.Errorf("Expected level 'INFO', got '%v'", logEntry["level"])
|
||||
}
|
||||
if logEntry["msg"] != testMessage {
|
||||
t.Errorf("Expected message '%s', got '%v'", testMessage, logEntry["msg"])
|
||||
}
|
||||
if logEntry["time"] == nil {
|
||||
t.Error("Expected timestamp to be set")
|
||||
}
|
||||
if logEntry["component"] != "CrowdsecBouncerTraefikPlugin" {
|
||||
t.Errorf("Expected component 'CrowdsecBouncerTraefikPlugin', got '%v'", logEntry["component"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommonLogFormat(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Create a logger with text handler to capture output
|
||||
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||
|
||||
testMessage := "common test message"
|
||||
logger.Info(testMessage)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Verify common format (should contain level and message)
|
||||
if !strings.Contains(output, "level=INFO") {
|
||||
t.Error("Expected common format with INFO level")
|
||||
}
|
||||
if !strings.Contains(output, testMessage) {
|
||||
t.Error("Expected test message in common format")
|
||||
}
|
||||
if !strings.Contains(output, "component=CrowdsecBouncerTraefikPlugin") {
|
||||
t.Error("Expected component field in common format")
|
||||
}
|
||||
|
||||
// Should NOT be JSON (should be slog text format)
|
||||
var logEntry map[string]interface{}
|
||||
err := json.Unmarshal([]byte(strings.TrimSpace(output)), &logEntry)
|
||||
if err == nil {
|
||||
t.Error("Expected common format (not JSON), but got valid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorLevel(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Create a logger with ERROR level to capture output
|
||||
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelError})
|
||||
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||
|
||||
testMessage := "error only test"
|
||||
|
||||
// Test all log methods
|
||||
logger.Error(testMessage)
|
||||
logger.Warn(testMessage) // Should not appear
|
||||
logger.Info(testMessage) // Should not appear
|
||||
logger.Debug(testMessage) // Should not appear
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Only ERROR should appear
|
||||
if !strings.Contains(output, "level=ERROR") {
|
||||
t.Error("Expected ERROR message to appear")
|
||||
}
|
||||
|
||||
// Other levels should NOT appear
|
||||
unwantedLevels := []string{"level=WARN", "level=INFO", "level=DEBUG"}
|
||||
for _, level := range unwantedLevels {
|
||||
if strings.Contains(output, level) {
|
||||
t.Errorf("Unexpected %s message appeared at ERROR level", level)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify only one message appears
|
||||
messageCount := strings.Count(output, testMessage)
|
||||
if messageCount != 1 {
|
||||
t.Errorf("Expected 1 occurrence of test message at ERROR level, got %d", messageCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidLogFile(t *testing.T) {
|
||||
// Try to create logger with invalid file path
|
||||
logger := New("INFO", "/invalid/path/that/does/not/exist/test.log")
|
||||
|
||||
// Logger should still be created (falls back to stdout)
|
||||
if logger == nil {
|
||||
t.Fatal("Expected logger to be created even with invalid file path")
|
||||
}
|
||||
|
||||
// Should not panic when logging
|
||||
logger.Info("test message")
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"gitAuthor": "Renovate Bot <22881669+maxlerebourg@users.noreply.github.com>",
|
||||
"fetchChangeLogs": "off",
|
||||
"labels": ["dependencies"],
|
||||
"ignorePaths": ["**/vendor/**", "**/node_modules/**"],
|
||||
"rangeStrategy": "bump",
|
||||
"prConcurrentLimit": 1,
|
||||
"branchPrefix": "renovate/",
|
||||
"commitMessagePrefix": "⬆️ renovate: ",
|
||||
"groupName": "all",
|
||||
"dependencyDashboard": false,
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Cap the Go version at what yaegi supports. The plugin is interpreted by yaegi (bundled in Traefik), and even Traefik v3.7.1 ships yaegi v0.16.1 = Go 1.22. A newer Go would break the plugin on every current Traefik. Raise this only once Traefik ships a yaegi supporting a newer Go.",
|
||||
"matchManagers": ["gomod"],
|
||||
"matchDepNames": ["go", "toolchain"],
|
||||
"allowedVersions": "<1.23"
|
||||
},
|
||||
{
|
||||
"description": "whoami is a throwaway demo backend; leave it on latest",
|
||||
"matchPackageNames": ["traefik/whoami"],
|
||||
"enabled": false
|
||||
}
|
||||
],
|
||||
"customManagers": [
|
||||
{
|
||||
"description": "Plugin self-pin in docker-compose CLI args (--experimental.plugins.bouncer.version=vX)",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/(^|/)docker-compose[^/]*\\.ya?ml$/"],
|
||||
"matchStrings": [
|
||||
"experimental\\.plugins\\.bouncer\\.version=(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)"
|
||||
],
|
||||
"depNameTemplate": "maxlerebourg/crowdsec-bouncer-traefik-plugin",
|
||||
"datasourceTemplate": "github-tags"
|
||||
},
|
||||
{
|
||||
"description": "Plugin self-pin in the Traefik Helm values (version: \"vX\")",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^examples/kubernetes/traefik/values\\.ya?ml$/"],
|
||||
"matchStrings": [
|
||||
"version:\\s*\"(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)\""
|
||||
],
|
||||
"depNameTemplate": "maxlerebourg/crowdsec-bouncer-traefik-plugin",
|
||||
"datasourceTemplate": "github-tags"
|
||||
},
|
||||
{
|
||||
"description": "Traefik image tag in the Traefik Helm values (no repository key, so match by file)",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^examples/kubernetes/traefik/values\\.ya?ml$/"],
|
||||
"matchStrings": ["tag:\\s*(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)"],
|
||||
"depNameTemplate": "traefik",
|
||||
"datasourceTemplate": "docker"
|
||||
},
|
||||
{
|
||||
"description": "Crowdsec image tag in the Crowdsec Helm values (no repository key, so match by file)",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/^examples/kubernetes/crowdsec/values\\.ya?ml$/"
|
||||
],
|
||||
"matchStrings": ["tag:\\s*(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)"],
|
||||
"depNameTemplate": "crowdsecurity/crowdsec",
|
||||
"datasourceTemplate": "docker"
|
||||
},
|
||||
{
|
||||
"description": "Pinned Traefik binary in the e2e mock suite (TRAEFIK_VERSION:-vX in common.sh)",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^tests/e2e/mock/lib/common\\.sh$/"],
|
||||
"matchStrings": [
|
||||
"TRAEFIK_VERSION:-(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)"
|
||||
],
|
||||
"depNameTemplate": "traefik/traefik",
|
||||
"datasourceTemplate": "github-releases"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# Binary e2e suite (Traefik binary + mock LAPI)
|
||||
|
||||
This suite runs **Traefik as a downloaded binary** with the plugin loaded from
|
||||
the local source tree, and replaces Crowdsec with a small **HTTP mock**
|
||||
([`mocklapi/`](mocklapi/main.go), a stdlib-only Go command). No Docker, no real
|
||||
Crowdsec.
|
||||
|
||||
It is what **CI runs** (`make e2e_mock`). A separate, local-only **Docker
|
||||
suite** (real Traefik + Crowdsec, under `tests/e2e/scenarios`) is kept for
|
||||
high-fidelity debugging against a real Crowdsec but is not exercised in CI; it
|
||||
ships in its own PR (#333).
|
||||
|
||||
## Scope — what this suite tests
|
||||
|
||||
These tests validate the **plugin's own behaviour**: the request flow through
|
||||
the Traefik middleware, the live / none / stream modes, caching, trusted-IP
|
||||
bypass, ban / captcha page rendering, and the AppSec request path (header
|
||||
forwarding + enforcing the engine's allow/block verdict).
|
||||
|
||||
The mock stands in for Crowdsec, emulating the slice of the LAPI HTTP contract
|
||||
the plugin consumes — including a single, deterministic AppSec rule (block any
|
||||
URI containing `rpc2`, the probe from [`examples/appsec-enabled`](../../../examples/appsec-enabled)).
|
||||
It is not the real WAF engine, so this suite exercises the plugin's AppSec
|
||||
*wiring* rather than the detection accuracy of OWASP CRS / virtual patching —
|
||||
that lives upstream in Crowdsec.
|
||||
|
||||
## What runs
|
||||
|
||||
| Component | How |
|
||||
|-----------|-----|
|
||||
| Traefik | Binary `v3.7.1`, downloaded into `.cache/` (reused across local runs; re-downloaded on fresh CI runners) |
|
||||
| Plugin | Loaded via `experimental.localPlugins` from the repo root (symlinked into `plugins-local/`) |
|
||||
| LAPI | `mocklapi` — a stdlib-only Go command (its own nested module), compiled and cached under `.cache/`, driven through `/admin` endpoints instead of `cscli`. Serves plain HTTP, or HTTPS when `--lapi-tls-cert/--lapi-tls-key` are passed (the `tls-system-ca` scenario) |
|
||||
| AppSec | WAF stand-in built into the mock — blocks URIs containing `rpc2`, allows the rest |
|
||||
| Backend | A plain HTTP responder built into the mock |
|
||||
|
||||
Fixed ports (override with env vars if needed): Traefik `8000`, LAPI `8090`,
|
||||
backend `8091`, AppSec `8092`.
|
||||
|
||||
## Running locally
|
||||
|
||||
Prerequisites: `bash`, `curl`, `go`, `tar` (plus `openssl` for the
|
||||
`tls-system-ca` scenario, which mints a throwaway CA at runtime). On first use
|
||||
the Traefik binary is fetched and the mock is compiled into `.cache/`. That
|
||||
cache is reused across local runs; CI runs on fresh runners, so both are
|
||||
recreated on every CI run.
|
||||
|
||||
The `tls-system-ca` scenario verifies that, with no custom CA configured, the
|
||||
bouncer falls back to the OS/system trust store for an HTTPS LAPI: it serves the
|
||||
mock over TLS and points the Traefik process's `SSL_CERT_FILE` at the test CA
|
||||
(trusted → 200) or an empty bundle (untrusted → 403, proving it still verifies).
|
||||
|
||||
```bash
|
||||
# one scenario
|
||||
make e2e_mock_stream-mode
|
||||
# or directly
|
||||
./tests/e2e/mock/scenarios/stream-mode/run.sh
|
||||
|
||||
# the whole suite
|
||||
make e2e_mock
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
mock/
|
||||
lib/
|
||||
common.sh # stack lifecycle, Traefik download, mock build, assertions, admin client
|
||||
traefik.yml # static Traefik config (shared by all scenarios)
|
||||
mocklapi/
|
||||
go.mod # nested module — kept out of the plugin's build/lint/vendor
|
||||
main.go # mock LAPI + AppSec stand-in + backend
|
||||
scenarios/
|
||||
<name>/
|
||||
dynamic.yml # Traefik dynamic config (router + bouncer middleware + backend)
|
||||
run.sh # assertions for the scenario
|
||||
*.html # optional fixtures (ban / captcha templates)
|
||||
```
|
||||
|
||||
`dynamic.yml` uses placeholders (`@@APIKEY@@`, `@@LAPI_HOST@@`,
|
||||
`@@BACKEND_URL@@`, `@@SCENARIO_DIR@@`) that `common.sh` substitutes at runtime.
|
||||
|
||||
## Adding a scenario
|
||||
|
||||
1. Create `scenarios/<name>/dynamic.yml` and `run.sh` (copy `stream-mode/` as a
|
||||
template).
|
||||
2. In `run.sh`, define a `body` function with the assertions and call
|
||||
`run_scenario "<name>" "$HERE" body`.
|
||||
3. Drive decisions with `lapi_add_decision <ip> [type] [duration]` and
|
||||
`lapi_delete_decision <ip>`.
|
||||
4. Add `<name>` to `E2E_MOCK_SCENARIOS` in the `Makefile`.
|
||||
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helpers for the binary (mock) e2e suite.
|
||||
#
|
||||
# Unlike the Docker suite under tests/e2e/scenarios, this one runs Traefik as a
|
||||
# downloaded binary and replaces Crowdsec with a small HTTP mock (the mocklapi
|
||||
# Go command). It validates the plugin's own behaviour (modes, cache, trusted
|
||||
# IPs, ban / captcha rendering, AppSec wiring) — not the accuracy of Crowdsec's
|
||||
# detection or its WAF engine, which the mock only stands in for.
|
||||
#
|
||||
# Dependencies: bash, curl, go, tar. The Traefik binary is downloaded and the
|
||||
# mock is compiled into .cache/ on first use. That cache persists across local
|
||||
# runs; CI runs on fresh runners, so both are recreated on every CI run.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Pinned to match the Docker suite (tests/e2e/scenarios/*/docker-compose.yml).
|
||||
TRAEFIK_VERSION="${TRAEFIK_VERSION:-v3.7.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:-15}"
|
||||
shift 3 || true
|
||||
local elapsed=0 got=""
|
||||
while (( elapsed < timeout )); do
|
||||
got=$(curl -s -m 1 -o /dev/null -w '%{http_code}' "$@" "$url" || true)
|
||||
if [[ "$got" == "$expected" ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
# Note: `((elapsed++))` returns exit 1 when elapsed is 0, which trips set -e.
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
echo "wait_for_status: $url expected $expected, last seen ${got:-<none>}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Poll a URL until its body contains a substring, or fail. Used when the status
|
||||
# code alone can't tell the states apart (e.g. captcha page vs backend, both 200).
|
||||
# Usage: wait_for_body_contains URL NEEDLE [TIMEOUT_SECONDS] [curl args...]
|
||||
wait_for_body_contains() {
|
||||
local url="$1" needle="$2" timeout="${3:-15}"
|
||||
shift 3 || true
|
||||
local elapsed=0 body=""
|
||||
while (( elapsed < timeout )); do
|
||||
body=$(curl -s -m 1 "$@" "$url" || true)
|
||||
if grep -q "$needle" <<<"$body"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
echo "wait_for_body_contains: $url did not contain \"$needle\" within ${timeout}s" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Assert a single curl returns the expected status code.
|
||||
# Usage: assert_status URL CODE [curl args...]
|
||||
assert_status() {
|
||||
local url="$1" expected="$2"
|
||||
shift 2 || true
|
||||
local got
|
||||
got=$(curl -s --connect-timeout 1 -m 5 -o /dev/null -w '%{http_code}' "$@" "$url")
|
||||
if [[ "$got" != "$expected" ]]; then
|
||||
echo "assert_status: $url expected $expected, got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Assert a response header matches a value (case-insensitive name).
|
||||
# Usage: assert_header URL HEADER VALUE [curl args...]
|
||||
assert_header() {
|
||||
local url="$1" header="$2" expected="$3"
|
||||
shift 3 || true
|
||||
local got
|
||||
got=$(curl -s --connect-timeout 1 -m 5 -D - -o /dev/null "$@" "$url" | tr -d '\r' \
|
||||
| awk -v h="${header,,}" -F': ' 'tolower($1) == h { print $2; exit }')
|
||||
if [[ "$got" != "$expected" ]]; then
|
||||
echo "assert_header: $url header $header expected \"$expected\", got \"$got\"" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Assert a response body contains a substring.
|
||||
# Usage: assert_body_contains URL NEEDLE [curl args...]
|
||||
assert_body_contains() {
|
||||
local url="$1" needle="$2"
|
||||
shift 2 || true
|
||||
local body
|
||||
body=$(curl -s --connect-timeout 1 -m 5 "$@" "$url")
|
||||
if ! grep -q "$needle" <<<"$body"; then
|
||||
echo "assert_body_contains: $url expected to contain \"$needle\", got:" >&2
|
||||
echo "$body" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- mock admin client -------------------------------------------------------
|
||||
|
||||
lapi_add_decision() {
|
||||
local ip="$1" type="${2:-ban}" duration="${3:-4h}"
|
||||
curl -sS --connect-timeout 1 -m 5 -X POST "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}&type=${type}&duration=${duration}" >/dev/null
|
||||
}
|
||||
|
||||
lapi_delete_decision() {
|
||||
local ip="$1"
|
||||
curl -sS --connect-timeout 1 -m 5 -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}" >/dev/null
|
||||
}
|
||||
|
||||
lapi_set_stream_fail() {
|
||||
curl -sS --connect-timeout 1 -m 5 -X POST "http://127.0.0.1:${LAPI_PORT}/admin/stream-fail" >/dev/null
|
||||
}
|
||||
|
||||
lapi_clear_stream_fail() {
|
||||
curl -sS --connect-timeout 1 -m 5 -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/stream-fail" >/dev/null
|
||||
}
|
||||
|
||||
# --- stack lifecycle ---------------------------------------------------------
|
||||
|
||||
# start_stack SCENARIO_DIR
|
||||
# Spins up the mock + Traefik (with the scenario's dynamic.yml) and waits ready.
|
||||
start_stack() {
|
||||
local scenario_dir="$1"
|
||||
local traefik_bin mock_bin
|
||||
traefik_bin="$(ensure_traefik)"
|
||||
mock_bin="$(ensure_mock)"
|
||||
|
||||
WORKDIR="$(mktemp -d)"
|
||||
# Expose the plugin source where Traefik's localPlugins loader expects it.
|
||||
mkdir -p "$WORKDIR/plugins-local/src/github.com/maxlerebourg"
|
||||
ln -s "$REPO_ROOT" "$WORKDIR/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
|
||||
cp "$MOCK_LIB_DIR/traefik.yml" "$WORKDIR/traefik.yml"
|
||||
|
||||
# Render the scenario's dynamic config with the live ports / key / paths.
|
||||
sed \
|
||||
-e "s|@@APIKEY@@|${LAPI_KEY}|g" \
|
||||
-e "s|@@LAPI_HOST@@|127.0.0.1:${LAPI_PORT}|g" \
|
||||
-e "s|@@APPSEC_HOST@@|127.0.0.1:${APPSEC_PORT}|g" \
|
||||
-e "s|@@BACKEND_URL@@|http://127.0.0.1:${BACKEND_PORT}|g" \
|
||||
-e "s|@@REDIS_HOST@@|127.0.0.1:${REDIS_PORT}|g" \
|
||||
-e "s|@@REDIS_READ_HOST@@|127.0.0.1:${REDIS_READ_PORT}|g" \
|
||||
-e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \
|
||||
"$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml"
|
||||
|
||||
# Opt-in HTTPS LAPI: a scenario exports LAPI_TLS_CERT/LAPI_TLS_KEY to serve the
|
||||
# LAPI over TLS (used by tls-system-ca). Default empty -> plaintext as before.
|
||||
local mock_tls_args=() lapi_scheme=http lapi_curl=()
|
||||
if [[ -n "${LAPI_TLS_CERT:-}" && -n "${LAPI_TLS_KEY:-}" ]]; then
|
||||
mock_tls_args=(--lapi-tls-cert "$LAPI_TLS_CERT" --lapi-tls-key "$LAPI_TLS_KEY")
|
||||
lapi_scheme=https
|
||||
lapi_curl=(-k) # the readiness probe ignores trust; the bouncer's trust is what we test
|
||||
fi
|
||||
|
||||
"$mock_bin" \
|
||||
--lapi-addr "127.0.0.1:${LAPI_PORT}" \
|
||||
--backend-addr "127.0.0.1:${BACKEND_PORT}" \
|
||||
--appsec-addr "127.0.0.1:${APPSEC_PORT}" \
|
||||
--redis-addr "127.0.0.1:${REDIS_PORT}" \
|
||||
--redis-read-addr "127.0.0.1:${REDIS_READ_PORT}" \
|
||||
"${mock_tls_args[@]}" >"$WORKDIR/mock.log" 2>&1 &
|
||||
MOCK_PID=$!
|
||||
|
||||
# Opt-in trust store for the Traefik process: a scenario exports
|
||||
# TRAEFIK_SSL_CERT_FILE to point Go's x509.SystemCertPool() at a specific CA
|
||||
# bundle. Empty -> Go's default system store (unchanged behaviour).
|
||||
( cd "$WORKDIR" && SSL_CERT_FILE="${TRAEFIK_SSL_CERT_FILE:-}" exec "$traefik_bin" --configfile=traefik.yml ) >"$WORKDIR/traefik.log" 2>&1 &
|
||||
TRAEFIK_PID=$!
|
||||
|
||||
wait_for_status "${lapi_scheme}://127.0.0.1:${LAPI_PORT}/health" 200 30 "${lapi_curl[@]}"
|
||||
# AppSec stand-in: a bare GET carries no "rpc2" URI, so it answers 200 (allow).
|
||||
wait_for_status "http://127.0.0.1:${APPSEC_PORT}/" 200 30
|
||||
# /ping is served by Traefik itself once it is up (plugin compilation included).
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/ping" 200 60
|
||||
}
|
||||
|
||||
stop_stack() {
|
||||
[[ -n "$TRAEFIK_PID" ]] && kill "$TRAEFIK_PID" 2>/dev/null || true
|
||||
[[ -n "$MOCK_PID" ]] && kill "$MOCK_PID" 2>/dev/null || true
|
||||
[[ -n "$TRAEFIK_PID" ]] && wait "$TRAEFIK_PID" 2>/dev/null || true
|
||||
[[ -n "$MOCK_PID" ]] && wait "$MOCK_PID" 2>/dev/null || true
|
||||
[[ -n "$WORKDIR" && -d "$WORKDIR" ]] && rm -rf "$WORKDIR" || true
|
||||
}
|
||||
|
||||
dump_diagnostics() {
|
||||
echo "=== traefik.log ==="
|
||||
cat "$WORKDIR/traefik.log" 2>/dev/null || true
|
||||
echo "=== mock.log ==="
|
||||
cat "$WORKDIR/mock.log" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# EXIT trap: runs after the scenario body (or after a failed assertion under
|
||||
# `set -e`), so it relies only on globals, never on run_scenario's locals.
|
||||
_scenario_cleanup() {
|
||||
local rc=$?
|
||||
if (( rc != 0 )); then
|
||||
dump_diagnostics > "$SCENARIO_LOG" 2>&1 || true
|
||||
echo "[$SCENARIO_NAME] failed. Logs written to $SCENARIO_LOG" >&2
|
||||
fi
|
||||
stop_stack
|
||||
exit $rc
|
||||
}
|
||||
|
||||
# run_scenario SCENARIO_NAME SCENARIO_DIR BODY_FN
|
||||
# Wraps lifecycle + diagnostics so each run.sh stays declarative.
|
||||
run_scenario() {
|
||||
SCENARIO_NAME="$1"
|
||||
local dir="$2" body="$3"
|
||||
SCENARIO_LOG="/tmp/e2e-mock-${SCENARIO_NAME}.log"
|
||||
trap _scenario_cleanup EXIT
|
||||
|
||||
echo "[$SCENARIO_NAME] starting binary stack (Traefik + mock LAPI)..."
|
||||
start_stack "$dir"
|
||||
"$body"
|
||||
echo "[$SCENARIO_NAME] OK"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Static Traefik configuration for the binary e2e suite.
|
||||
# The dynamic part (router + bouncer middleware + backend service) lives in
|
||||
# dynamic.yml, generated per scenario by common.sh.
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":8000"
|
||||
forwardedHeaders:
|
||||
# The test's curl sets X-Forwarded-For; preserve it through the proxy.
|
||||
insecure: true
|
||||
|
||||
log:
|
||||
level: INFO
|
||||
|
||||
accessLog: {}
|
||||
|
||||
# /ping on the web entrypoint is the readiness probe — no dashboard/API needed.
|
||||
ping:
|
||||
entryPoint: web
|
||||
|
||||
providers:
|
||||
file:
|
||||
filename: dynamic.yml
|
||||
watch: false
|
||||
|
||||
experimental:
|
||||
localPlugins:
|
||||
bouncer:
|
||||
moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
@@ -0,0 +1,6 @@
|
||||
// Standalone module so this test helper stays out of the plugin module:
|
||||
// it is excluded from the plugin's `go build ./...`, `go test ./...`,
|
||||
// golangci-lint and `go mod vendor`. Stdlib only — no dependencies.
|
||||
module mocklapi
|
||||
|
||||
go 1.22.12
|
||||
@@ -0,0 +1,237 @@
|
||||
// Command mocklapi is a minimal Crowdsec LAPI stand-in for the binary e2e
|
||||
// suite. It answers only the few LAPI routes the plugin calls — live/none
|
||||
// decision lookups, the stream poll and the usage-metrics push — and lets the
|
||||
// test drive decisions through /admin instead of `cscli`. It also serves the
|
||||
// stub upstream that Traefik proxies allowed requests to, and a hardcoded Redis
|
||||
// stand-in for exercising the redis cache path.
|
||||
//
|
||||
// It is NOT a Crowdsec/AppSec conformance harness — the real WAF engine (OWASP
|
||||
// CRS, virtual patching) is out of scope. The AppSec endpoint here emulates a
|
||||
// single deterministic rule so the suite can exercise the plugin's AppSec
|
||||
// wiring (header forwarding, allow/block handling) end to end. See the README.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Decision is the subset of a LAPI decision the plugin actually reads.
|
||||
type Decision struct {
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
active = map[string]Decision{} // ip -> decision currently in force
|
||||
deleted = map[string]Decision{} // ip -> decision to report in the stream "deleted" list
|
||||
// streamFail makes /v1/decisions/stream return 500 when set, to exercise the
|
||||
// bouncer's fail-closed behaviour on consecutive stream poll failures.
|
||||
streamFail atomic.Bool
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func list(m map[string]Decision) []Decision {
|
||||
out := make([]Decision, 0, len(m))
|
||||
for _, d := range m {
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Redis mock (inline-command wire format, as spoken by simpleredis) ---
|
||||
|
||||
// serveRedis is a hardcoded stand-in. When verdicts is true it plays a replica
|
||||
// that holds decisions: every line is scanned for known IPs, 1.2.3.4 → "f"
|
||||
// (clean), 1.2.3.5 → "t" (banned); any other GET is a miss ($-1). When verdicts
|
||||
// is false it plays the primary and answers every GET with a miss, so a
|
||||
// scenario can prove reads are served from the replica and not the primary.
|
||||
// SET, DEL, AUTH, SELECT get +OK (they don't read the response anyway).
|
||||
func serveRedis(addr string, verdicts bool) {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
go func(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
rd := bufio.NewReader(conn)
|
||||
for {
|
||||
line, _, err := rd.ReadLine()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s := string(line)
|
||||
switch {
|
||||
case verdicts && strings.Contains(s, "1.2.3.4"):
|
||||
conn.Write([]byte("$1\r\nf\r\n"))
|
||||
case verdicts && strings.Contains(s, "1.2.3.5"):
|
||||
conn.Write([]byte("$1\r\nt\r\n"))
|
||||
case strings.HasPrefix(strings.ToUpper(s), "GET "):
|
||||
conn.Write([]byte("$-1\r\n"))
|
||||
default:
|
||||
conn.Write([]byte("+OK\r\n"))
|
||||
}
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
lapiAddr := flag.String("lapi-addr", "127.0.0.1:8090", "address for the LAPI mock")
|
||||
// The stub upstream Traefik proxies allowed requests to — the binary-suite
|
||||
// equivalent of the traefik/whoami container. Not AppSec.
|
||||
backendAddr := flag.String("backend-addr", "127.0.0.1:8091", "address for the stub upstream service")
|
||||
// AppSec WAF stand-in (the real engine listens on :7422). Not a CRS engine.
|
||||
appsecAddr := flag.String("appsec-addr", "127.0.0.1:8092", "address for the AppSec mock")
|
||||
// Redis stand-ins on plain TCP ports, enough to exercise the plugin's redis
|
||||
// cache path. The primary answers every GET with a miss; the replica serves
|
||||
// the hardcoded verdicts, so a scenario pointing redisCacheReadHosts at the
|
||||
// replica proves reads are offloaded to replicas.
|
||||
redisAddr := flag.String("redis-addr", "127.0.0.1:8093", "address for the Redis primary mock (writes; GET always misses)")
|
||||
redisReadAddr := flag.String("redis-read-addr", "127.0.0.1:8094", "address for the Redis replica mock (serves cached verdicts)")
|
||||
// Optional TLS for the LAPI: when both are set the LAPI is served over HTTPS
|
||||
// (cert signed by the scenario's throwaway CA) so the suite can exercise the
|
||||
// bouncer's system-trust-store path. Backend and AppSec stay plaintext.
|
||||
lapiTLSCert := flag.String("lapi-tls-cert", "", "PEM cert to serve the LAPI over HTTPS (optional)")
|
||||
lapiTLSKey := flag.String("lapi-tls-key", "", "PEM key for --lapi-tls-cert")
|
||||
flag.Parse()
|
||||
|
||||
go func() {
|
||||
log.Fatal(http.ListenAndServe(*backendAddr, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("E2E_BACKEND_OK\n"))
|
||||
})))
|
||||
}()
|
||||
|
||||
// AppSec mock: the plugin forwards the request metadata in X-Crowdsec-Appsec-*
|
||||
// headers and reads our status — 200 allows, 403 blocks. We emulate one
|
||||
// deterministic virtual-patching rule (block any URI containing "rpc2", the
|
||||
// exact probe from examples/appsec-enabled) so the plugin's AppSec path is
|
||||
// exercised without standing up the real WAF.
|
||||
go func() {
|
||||
log.Fatal(http.ListenAndServe(*appsecAddr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "403") {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}
|
||||
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "500") {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "502") {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}
|
||||
// Read body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
if strings.Contains(string(body), "a=0") {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
})))
|
||||
}()
|
||||
|
||||
go serveRedis(*redisAddr, false)
|
||||
go serveRedis(*redisReadAddr, true)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Readiness probe for the test harness (empty body, 200).
|
||||
mux.HandleFunc("/health", func(http.ResponseWriter, *http.Request) {})
|
||||
|
||||
// live / none mode: the plugin asks about one IP and expects a decision
|
||||
// array, or the literal `null` when there is none.
|
||||
mux.HandleFunc("/v1/decisions", func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if d, ok := active[r.URL.Query().Get("ip")]; ok {
|
||||
writeJSON(w, []Decision{d})
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("null"))
|
||||
})
|
||||
|
||||
// stream mode: report the whole active set as "new" and anything removed as
|
||||
// "deleted". Re-sending the same on every poll is harmless — the plugin just
|
||||
// re-adds to / re-deletes from its cache.
|
||||
mux.HandleFunc("/v1/decisions/stream", func(w http.ResponseWriter, _ *http.Request) {
|
||||
if streamFail.Load() {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
writeJSON(w, map[string][]Decision{"new": list(active), "deleted": list(deleted)})
|
||||
})
|
||||
|
||||
// usage-metrics push: accept and ignore.
|
||||
mux.HandleFunc("/v1/usage-metrics", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
|
||||
// Test control plane: make the stream endpoint fail (POST) or recover (DELETE).
|
||||
mux.HandleFunc("/admin/stream-fail", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
streamFail.Store(true)
|
||||
case http.MethodDelete:
|
||||
streamFail.Store(false)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Test control plane: add / remove decisions instead of cscli.
|
||||
mux.HandleFunc("/admin/decisions", func(_ http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
ip := q.Get("ip")
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
dtype := q.Get("type")
|
||||
if dtype == "" {
|
||||
dtype = "ban"
|
||||
}
|
||||
duration := q.Get("duration")
|
||||
if duration == "" {
|
||||
duration = "4h"
|
||||
}
|
||||
active[ip] = Decision{Value: ip, Type: dtype, Duration: duration}
|
||||
delete(deleted, ip)
|
||||
case http.MethodDelete:
|
||||
if d, ok := active[ip]; ok {
|
||||
deleted[ip] = d
|
||||
delete(active, ip)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if *lapiTLSCert != "" && *lapiTLSKey != "" {
|
||||
log.Printf("mocklapi: LAPI on %s (TLS), backend on %s, appsec on %s, redis on %s (read %s)", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr, *redisReadAddr)
|
||||
log.Fatal(http.ListenAndServeTLS(*lapiAddr, *lapiTLSCert, *lapiTLSKey, mux))
|
||||
}
|
||||
log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s, redis on %s (read %s)", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr, *redisReadAddr)
|
||||
log.Fatal(http.ListenAndServe(*lapiAddr, mux))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
http:
|
||||
routers:
|
||||
r:
|
||||
rule: "PathPrefix(`/foo`)"
|
||||
entryPoints:
|
||||
- web
|
||||
service: backend
|
||||
middlewares:
|
||||
- bouncer
|
||||
services:
|
||||
backend:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "@@BACKEND_URL@@"
|
||||
middlewares:
|
||||
bouncer:
|
||||
plugin:
|
||||
bouncer:
|
||||
enabled: "true"
|
||||
# IP bouncing disabled — this scenario exercises AppSec only.
|
||||
crowdsecMode: appsec
|
||||
crowdsecLapiScheme: http
|
||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||
crowdsecLapiKey: "@@APIKEY@@"
|
||||
crowdsecAppsecEnabled: "true"
|
||||
crowdsecAppsecFailureBlock: "true"
|
||||
crowdsecAppsecBodyLimit: 4
|
||||
crowdsecAppsecUnreachableBlock: "false"
|
||||
crowdsecAppsecScheme: http
|
||||
crowdsecAppsecHost: "@@APPSEC_HOST@@"
|
||||
forwardedHeadersTrustedIps:
|
||||
- "127.0.0.1/32"
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=appsec
|
||||
|
||||
# AppSec wiring check: the plugin forwards each request to the AppSec engine and
|
||||
# enforces its verdict. The mock emulates one virtual-patching rule (block any
|
||||
# URI containing "rpc2"), mirroring examples/appsec-enabled. This proves the
|
||||
# plugin's AppSec path end to end (header forwarding + allow/block handling); it
|
||||
# does not test the real WAF's detection accuracy.
|
||||
body() {
|
||||
echo "[$SCENARIO] benign request must pass (AppSec 200)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] request that return 403 must be blocked (AppSec 403)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo/403" 403 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] request that return 500 must be blocked (because CrowdsecAppsecFailureBlock = true) (AppSec 500)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo/500" 403 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] request that return 502 must pass (because CrowdsecAppsecUnreachableBlock = false) (Proxy error 502)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo/502" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] request that send bad body after crowdsecAppsecBodyLimit must pass (AppSec 200)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4" -X POST -d "______&a=0"
|
||||
|
||||
echo "[$SCENARIO] request that send bad body before crowdsecAppsecBodyLimit must pass (AppSec 403)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" -X POST -d "a=0&______"
|
||||
|
||||
echo "[$SCENARIO] request http2 that send no body GET (AppSec 200)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:"
|
||||
|
||||
echo "[$SCENARIO] request http2 that send unreadable body GET (AppSec 403)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:" -d "test"
|
||||
|
||||
echo "[$SCENARIO] request http2 that send unreadable body POST (AppSec 403)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:" -X POST -d "test"
|
||||
}
|
||||
|
||||
run_scenario "$SCENARIO" "$HERE" body
|
||||
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>E2E captcha marker</title></head>
|
||||
<body>
|
||||
<h1 id="e2e-captcha-marker">E2E_CAPTCHA_PAGE_MARKER</h1>
|
||||
<script src="{{ .FrontendJS }}"></script>
|
||||
<div class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
http:
|
||||
routers:
|
||||
r:
|
||||
rule: "PathPrefix(`/foo`)"
|
||||
entryPoints:
|
||||
- web
|
||||
service: backend
|
||||
middlewares:
|
||||
- bouncer
|
||||
services:
|
||||
backend:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "@@BACKEND_URL@@"
|
||||
middlewares:
|
||||
bouncer:
|
||||
plugin:
|
||||
bouncer:
|
||||
enabled: "true"
|
||||
crowdsecMode: stream
|
||||
updateIntervalSeconds: "2"
|
||||
crowdsecLapiScheme: http
|
||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||
crowdsecLapiKey: "@@APIKEY@@"
|
||||
forwardedHeadersTrustedIps:
|
||||
- "127.0.0.1/32"
|
||||
captchaProvider: turnstile
|
||||
# Cloudflare Turnstile public test keys: render a valid widget without
|
||||
# contacting a real API key.
|
||||
captchaSiteKey: "1x00000000000000000000AA"
|
||||
captchaSecretKey: "1x0000000000000000000000000000000AA"
|
||||
captchaHtmlFilePath: "@@SCENARIO_DIR@@/captcha.html"
|
||||
captchaGracePeriodSeconds: "10"
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=captcha
|
||||
|
||||
body() {
|
||||
echo "[$SCENARIO] adding captcha decision for 1.2.3.4"
|
||||
lapi_add_decision 1.2.3.4 captcha 5m
|
||||
|
||||
# Status stays 200 before/after (captcha page vs backend), so gate on the body
|
||||
# marker appearing once the captcha decision has been polled.
|
||||
echo "[$SCENARIO] captcha page must be served once the decision is polled (200 + marker)"
|
||||
wait_for_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CAPTCHA_PAGE_MARKER" 15 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] captcha response Content-Type is HTML"
|
||||
assert_header "http://127.0.0.1:${WEB_PORT}/foo" Content-Type "text/html; charset=utf-8" -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] captcha response is HTTP 200 (the captcha page itself, not a 403)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] non-flagged IP must still pass through to the backend"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
|
||||
}
|
||||
|
||||
run_scenario "$SCENARIO" "$HERE" body
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"marker": "E2E_CUSTOM_BAN_PAGE_MARKER",
|
||||
"body": "IP: {{ .ClientIP }}, reason: {{ .RemediationReason }}, trace: {{ .TraceID }}"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
http:
|
||||
routers:
|
||||
r:
|
||||
rule: "PathPrefix(`/foo`)"
|
||||
entryPoints:
|
||||
- web
|
||||
service: backend
|
||||
middlewares:
|
||||
- bouncer
|
||||
services:
|
||||
backend:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "@@BACKEND_URL@@"
|
||||
middlewares:
|
||||
bouncer:
|
||||
plugin:
|
||||
bouncer:
|
||||
enabled: "true"
|
||||
crowdsecMode: stream
|
||||
updateIntervalSeconds: "2"
|
||||
crowdsecLapiScheme: http
|
||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||
crowdsecLapiKey: "@@APIKEY@@"
|
||||
forwardedHeadersTrustedIps:
|
||||
- "127.0.0.1/32"
|
||||
banFilePath: "@@SCENARIO_DIR@@/ban.json"
|
||||
remediationHeadersCustomName: "X-E2E-Remediation"
|
||||
traceHeadersCustomName: x-trace
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=custom-ban-page
|
||||
|
||||
body() {
|
||||
echo "[$SCENARIO] adding ban decision"
|
||||
lapi_add_decision 1.2.3.4 ban 5m
|
||||
|
||||
echo "[$SCENARIO] banned response becomes 403 once the next stream poll lands"
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] banned response Content-Type is HTML"
|
||||
assert_header "http://127.0.0.1:${WEB_PORT}/foo" Content-Type "application/json" -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] banned response body contains the custom marker"
|
||||
assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CUSTOM_BAN_PAGE_MARKER" -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] banned response body contains the IP and reason from templating"
|
||||
assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "IP: 1.2.3.4, reason: LAPI, trace: 0123456789" -H "X-Forwarded-For: 1.2.3.4" -H "X-Trace: 0123456789"
|
||||
|
||||
echo "[$SCENARIO] banned response carries the custom remediation header (remediationHeadersCustomName)"
|
||||
assert_header "http://127.0.0.1:${WEB_PORT}/foo" X-E2E-Remediation "ban" -H "X-Forwarded-For: 1.2.3.4"
|
||||
}
|
||||
|
||||
run_scenario "$SCENARIO" "$HERE" body
|
||||
@@ -0,0 +1,26 @@
|
||||
http:
|
||||
routers:
|
||||
r:
|
||||
rule: "PathPrefix(`/foo`)"
|
||||
entryPoints:
|
||||
- web
|
||||
service: backend
|
||||
middlewares:
|
||||
- bouncer
|
||||
services:
|
||||
backend:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "@@BACKEND_URL@@"
|
||||
middlewares:
|
||||
bouncer:
|
||||
plugin:
|
||||
bouncer:
|
||||
enabled: "true"
|
||||
crowdsecMode: live
|
||||
defaultDecisionSeconds: "2"
|
||||
crowdsecLapiScheme: http
|
||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||
crowdsecLapiKey: "@@APIKEY@@"
|
||||
forwardedHeadersTrustedIps:
|
||||
- "127.0.0.1/32"
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=live-mode
|
||||
|
||||
body() {
|
||||
echo "[$SCENARIO] no decision -> first hit queries LAPI, returns 200, caches 'allowed' for 2s"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
|
||||
lapi_add_decision 1.2.3.4 ban 5m
|
||||
|
||||
# Stays 200 until the cached 'allowed' (defaultDecisionSeconds) expires, then
|
||||
# the re-query sees the ban — poll instead of guessing the cache TTL.
|
||||
echo "[$SCENARIO] hit must turn 403 once the cached 'allowed' expires and LAPI is re-queried"
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] another non-banned IP must still pass"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
|
||||
}
|
||||
|
||||
run_scenario "$SCENARIO" "$HERE" body
|
||||
@@ -0,0 +1,25 @@
|
||||
http:
|
||||
routers:
|
||||
r:
|
||||
rule: "PathPrefix(`/foo`)"
|
||||
entryPoints:
|
||||
- web
|
||||
service: backend
|
||||
middlewares:
|
||||
- bouncer
|
||||
services:
|
||||
backend:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "@@BACKEND_URL@@"
|
||||
middlewares:
|
||||
bouncer:
|
||||
plugin:
|
||||
bouncer:
|
||||
enabled: "true"
|
||||
crowdsecMode: none
|
||||
crowdsecLapiScheme: http
|
||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||
crowdsecLapiKey: "@@APIKEY@@"
|
||||
forwardedHeadersTrustedIps:
|
||||
- "127.0.0.1/32"
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=none-mode
|
||||
|
||||
body() {
|
||||
echo "[$SCENARIO] no decision -> request passes (LAPI queried per request)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] adding ban decision for 1.2.3.4 and 2001:db8::1"
|
||||
lapi_add_decision 1.2.3.4 ban 5m
|
||||
lapi_add_decision "2001:db8::1" ban 5m
|
||||
|
||||
echo "[$SCENARIO] IP banned must be blocked (HTTP 403)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] IPv6 banned must be blocked (HTTP 403)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 2001:db8::1"
|
||||
|
||||
echo "[$SCENARIO] deleting decision"
|
||||
lapi_delete_decision 1.2.3.4
|
||||
lapi_delete_decision "2001:db8::1"
|
||||
|
||||
echo "[$SCENARIO] previously banned IP must pass again"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] previously banned IPv6 must pass again"
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 2001:db8::1"
|
||||
}
|
||||
|
||||
run_scenario "$SCENARIO" "$HERE" body
|
||||
@@ -0,0 +1,30 @@
|
||||
http:
|
||||
routers:
|
||||
r:
|
||||
rule: "PathPrefix(`/foo`)"
|
||||
entryPoints:
|
||||
- web
|
||||
service: backend
|
||||
middlewares:
|
||||
- bouncer
|
||||
services:
|
||||
backend:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "@@BACKEND_URL@@"
|
||||
middlewares:
|
||||
bouncer:
|
||||
plugin:
|
||||
bouncer:
|
||||
enabled: "true"
|
||||
crowdsecMode: live
|
||||
crowdsecLapiScheme: http
|
||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||
crowdsecLapiKey: "@@APIKEY@@"
|
||||
redisCacheEnabled: "true"
|
||||
redisCacheHost: "@@REDIS_HOST@@"
|
||||
redisCacheReadHosts:
|
||||
- "@@REDIS_READ_HOST@@"
|
||||
- "@@REDIS_HOST@@"
|
||||
forwardedHeadersTrustedIps:
|
||||
- "127.0.0.1/32"
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=redis
|
||||
|
||||
# The replica mock returns "f" (not banned) for 1.2.3.4 and "t" (banned) for 1.2.3.5.
|
||||
# The primary mock always misses.
|
||||
body() {
|
||||
echo "[$SCENARIO] cached banned IP must not be blocked because call for primary (test rotation)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.5"
|
||||
|
||||
echo "[$SCENARIO] cached banned IP must be blocked"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.5"
|
||||
|
||||
echo "[$SCENARIO] cached clean IP must pass"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] unknown IP (redis miss) must fall through to LAPI and pass"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.6"
|
||||
}
|
||||
|
||||
run_scenario "$SCENARIO" "$HERE" body
|
||||
@@ -0,0 +1,27 @@
|
||||
http:
|
||||
routers:
|
||||
r:
|
||||
rule: "PathPrefix(`/foo`)"
|
||||
entryPoints:
|
||||
- web
|
||||
service: backend
|
||||
middlewares:
|
||||
- bouncer
|
||||
services:
|
||||
backend:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "@@BACKEND_URL@@"
|
||||
middlewares:
|
||||
bouncer:
|
||||
plugin:
|
||||
bouncer:
|
||||
enabled: "true"
|
||||
crowdsecMode: stream
|
||||
updateIntervalSeconds: "1"
|
||||
updateMaxFailure: "2"
|
||||
crowdsecLapiScheme: http
|
||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||
crowdsecLapiKey: "@@APIKEY@@"
|
||||
forwardedHeadersTrustedIps:
|
||||
- "127.0.0.1/32"
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=stream-mode
|
||||
|
||||
body() {
|
||||
echo "[$SCENARIO] no decision yet -> request allowed"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] adding ban decision for 1.2.3.4 and 10.0.0.0/8"
|
||||
lapi_add_decision 1.2.3.4 ban 5m
|
||||
lapi_add_decision 10.0.0.0/24 ban 5m
|
||||
|
||||
echo "[$SCENARIO] banned IP must be blocked once the next stream poll lands (HTTP 403)"
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] non-banned IP must still pass (HTTP 200)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
|
||||
|
||||
echo "[$SCENARIO] banned IP in CIDR must be blocked once polled (HTTP 403)"
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 10.0.0.1"
|
||||
|
||||
echo "[$SCENARIO] deleting ban decision for 1.2.3.4 and 10.0.0.0/8"
|
||||
lapi_delete_decision 1.2.3.4
|
||||
lapi_delete_decision 10.0.0.0/24
|
||||
|
||||
echo "[$SCENARIO] previously banned IP must pass again once the deletion is polled"
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] previously CIDR-banned IP must pass again once deletion is polled"
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 10.0.0.1"
|
||||
|
||||
echo "[$SCENARIO] making the stream endpoint fail -> bouncer must pass for one more cycle (updateMaxFailure: 2)"
|
||||
lapi_set_stream_fail
|
||||
sleep 2 # update cache is every 1 seconds then waiting for minimum 1 cycle
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 8.8.8.8"
|
||||
|
||||
echo "[$SCENARIO] bouncer must block everything (isStreamHealthy: false)"
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 8.8.8.8"
|
||||
|
||||
echo "[$SCENARIO] restoring the stream endpoint -> bouncer must recover and pass again"
|
||||
lapi_clear_stream_fail
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 8.8.8.8"
|
||||
}
|
||||
|
||||
run_scenario "$SCENARIO" "$HERE" body
|
||||
@@ -0,0 +1,28 @@
|
||||
http:
|
||||
routers:
|
||||
r:
|
||||
rule: "PathPrefix(`/foo`)"
|
||||
entryPoints:
|
||||
- web
|
||||
service: backend
|
||||
middlewares:
|
||||
- bouncer
|
||||
services:
|
||||
backend:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "@@BACKEND_URL@@"
|
||||
middlewares:
|
||||
bouncer:
|
||||
plugin:
|
||||
bouncer:
|
||||
enabled: "true"
|
||||
crowdsecMode: live
|
||||
defaultDecisionSeconds: "2"
|
||||
# HTTPS LAPI with NO custom CA configured: the bouncer must fall back to
|
||||
# the OS/system trust store (which the scenario controls via SSL_CERT_FILE).
|
||||
crowdsecLapiScheme: https
|
||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||
crowdsecLapiKey: "@@APIKEY@@"
|
||||
forwardedHeadersTrustedIps:
|
||||
- "127.0.0.1/32"
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Scenario: HTTPS LAPI with no custom CA configured -> the bouncer must fall back
|
||||
# to the OS/system trust store (PR #331). In the binary suite the "system trust
|
||||
# store" is whatever Go's x509.SystemCertPool() reads, which honours SSL_CERT_FILE
|
||||
# on the Traefik process. We mint a throwaway CA, serve the mock LAPI over HTTPS
|
||||
# with a cert signed by it, and run the stack twice:
|
||||
#
|
||||
# positive: SSL_CERT_FILE = our CA -> LAPI trusted -> 200
|
||||
# negative: SSL_CERT_FILE = empty bundle -> LAPI not trusted -> 403
|
||||
#
|
||||
# live mode is fail-closed, so a TLS error becomes a 403. The negative run proves
|
||||
# the patch still VERIFIES (it is not an insecure skip).
|
||||
#
|
||||
# Extra dependency vs other scenarios: openssl.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=tls-system-ca
|
||||
SCENARIO_NAME="$SCENARIO"
|
||||
SCENARIO_LOG="/tmp/e2e-mock-${SCENARIO}.log"
|
||||
CERT_DIR="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
if (( rc != 0 )); then
|
||||
dump_diagnostics > "$SCENARIO_LOG" 2>&1 || true
|
||||
echo "[$SCENARIO] failed. Logs written to $SCENARIO_LOG" >&2
|
||||
fi
|
||||
stop_stack
|
||||
rm -rf "$CERT_DIR"
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[$SCENARIO] minting throwaway CA + LAPI cert (SAN=IP:127.0.0.1)..."
|
||||
openssl ecparam -name prime256v1 -genkey -noout -out "$CERT_DIR/ca.key" 2>/dev/null
|
||||
openssl req -x509 -new -key "$CERT_DIR/ca.key" -sha256 -days 3650 \
|
||||
-subj "/CN=crowdsec-bouncer e2e test CA" -out "$CERT_DIR/ca.crt" 2>/dev/null
|
||||
openssl ecparam -name prime256v1 -genkey -noout -out "$CERT_DIR/lapi.key" 2>/dev/null
|
||||
openssl req -new -key "$CERT_DIR/lapi.key" -subj "/CN=lapi" -out "$CERT_DIR/lapi.csr" 2>/dev/null
|
||||
openssl x509 -req -in "$CERT_DIR/lapi.csr" -CA "$CERT_DIR/ca.crt" -CAkey "$CERT_DIR/ca.key" \
|
||||
-CAcreateserial -days 3650 -sha256 -out "$CERT_DIR/lapi.crt" \
|
||||
-extfile <(printf "subjectAltName=IP:127.0.0.1\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth") 2>/dev/null
|
||||
: > "$CERT_DIR/empty.crt" # an empty bundle = a system store that trusts nothing
|
||||
|
||||
# The mock serves the same CA-signed cert in both runs; only Traefik's trust differs.
|
||||
export LAPI_TLS_CERT="$CERT_DIR/lapi.crt" LAPI_TLS_KEY="$CERT_DIR/lapi.key"
|
||||
|
||||
echo "[$SCENARIO] === positive: CA in the system trust store ==="
|
||||
export TRAEFIK_SSL_CERT_FILE="$CERT_DIR/ca.crt"
|
||||
start_stack "$HERE"
|
||||
echo "[$SCENARIO] HTTPS LAPI verifies via system trust store -> request passes (200)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
stop_stack
|
||||
|
||||
echo "[$SCENARIO] === negative: CA absent from the system trust store ==="
|
||||
export TRAEFIK_SSL_CERT_FILE="$CERT_DIR/empty.crt"
|
||||
start_stack "$HERE"
|
||||
echo "[$SCENARIO] LAPI cert not trusted -> TLS fails, fail-closed (403)"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4"
|
||||
stop_stack
|
||||
|
||||
echo "[$SCENARIO] OK"
|
||||
@@ -0,0 +1,28 @@
|
||||
http:
|
||||
routers:
|
||||
r:
|
||||
rule: "PathPrefix(`/foo`)"
|
||||
entryPoints:
|
||||
- web
|
||||
service: backend
|
||||
middlewares:
|
||||
- bouncer
|
||||
services:
|
||||
backend:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "@@BACKEND_URL@@"
|
||||
middlewares:
|
||||
bouncer:
|
||||
plugin:
|
||||
bouncer:
|
||||
enabled: "true"
|
||||
crowdsecMode: stream
|
||||
updateIntervalSeconds: "2"
|
||||
crowdsecLapiScheme: http
|
||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||
crowdsecLapiKey: "@@APIKEY@@"
|
||||
forwardedHeadersTrustedIps:
|
||||
- "127.0.0.1/32"
|
||||
clientTrustedIps:
|
||||
- "1.2.3.4/32"
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=trusted-ips
|
||||
|
||||
body() {
|
||||
echo "[$SCENARIO] banning the trusted IP 1.2.3.4 and an untrusted IP 5.6.7.8"
|
||||
lapi_add_decision 1.2.3.4 ban 5m
|
||||
lapi_add_decision 5.6.7.8 ban 5m
|
||||
|
||||
# The untrusted IP turning 403 is our signal that the bans have been polled;
|
||||
# it also doubles as the control proving the bouncer is active.
|
||||
echo "[$SCENARIO] untrusted banned IP must be blocked once the bans are polled (HTTP 403)"
|
||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 5.6.7.8"
|
||||
|
||||
echo "[$SCENARIO] trusted IP must bypass the bouncer even though it is banned"
|
||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
}
|
||||
|
||||
run_scenario "$SCENARIO" "$HERE" body
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||
|
||||
// pluginVersion is updated automatically by the release workflow.
|
||||
var pluginVersion = "1.5.0" //nolint:gochecknoglobals
|
||||
// pluginVersion is what the plugin reports to the Crowdsec LAPI.
|
||||
// Do not edit by hand: the "Release (1/2) Prepare" workflow bumps it.
|
||||
var pluginVersion = "v1.7.1" //nolint:gochecknoglobals
|
||||
|
||||
Reference in New Issue
Block a user