mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae7481caa5 | ||
|
|
9b8d6b937c | ||
|
|
d57ead2ec7 | ||
|
|
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 | ||
|
|
c2bbc4dac5 | ||
|
|
889c5b55fe | ||
|
|
efb3a67019 | ||
|
|
0780027252 | ||
|
|
892909b9b8 | ||
|
|
c26923dee5 | ||
|
|
a9d83f2097 | ||
|
|
e20ccc5d0c | ||
|
|
50beb4294f | ||
|
|
4ab4f3f183 | ||
|
|
2aac531ea7 | ||
|
|
e8e60c958f | ||
|
|
a2d3708bc3 | ||
|
|
65a2f79fb3 | ||
|
|
a2ecc95dc9 | ||
|
|
7c4f5163e9 | ||
|
|
734975c206 | ||
|
|
7397834e58 | ||
|
|
84a5674b14 | ||
|
|
de7e382fde | ||
|
|
abae855d9e | ||
|
|
4bb6e830dc | ||
|
|
78869ecf77 | ||
|
|
7f99266f99 | ||
|
|
5418d35feb | ||
|
|
a184ae6db9 | ||
|
|
e4c84409e7 | ||
|
|
4708d76854 | ||
|
|
c34d7f4617 | ||
|
|
0e9620bfe9 | ||
|
|
92f05b0ba5 | ||
|
|
980a7dd05e | ||
|
|
5c8a60118f | ||
|
|
8fb0a016b6 | ||
|
|
45d5f38c4d |
@@ -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
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
name: Main
|
name: Main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -12,8 +15,13 @@ jobs:
|
|||||||
name: Main Process
|
name: Main Process
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
|
# Keep in sync with go.mod. Capped at 1.22 because the plugin is run by
|
||||||
|
# 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
|
GO_VERSION: 1.22
|
||||||
GOLANGCI_LINT_VERSION: v1.57.2
|
GOLANGCI_LINT_VERSION: v1.63.4
|
||||||
|
# yaegi_test guard — pin to the version current Traefik bundles.
|
||||||
YAEGI_VERSION: v0.16.1
|
YAEGI_VERSION: v0.16.1
|
||||||
CGO_ENABLED: 0
|
CGO_ENABLED: 0
|
||||||
defaults:
|
defaults:
|
||||||
@@ -24,20 +32,20 @@ jobs:
|
|||||||
|
|
||||||
# https://github.com/marketplace/actions/setup-go-environment
|
# https://github.com/marketplace/actions/setup-go-environment
|
||||||
- name: Set up Go ${{ env.GO_VERSION }}
|
- name: Set up Go ${{ env.GO_VERSION }}
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
# https://github.com/marketplace/actions/checkout
|
# https://github.com/marketplace/actions/checkout
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
path: go/src/github.com/${{ github.repository }}
|
path: go/src/github.com/${{ github.repository }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# https://github.com/marketplace/actions/cache
|
# https://github.com/marketplace/actions/cache
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: ${{ github.workspace }}/go/pkg/mod
|
path: ${{ github.workspace }}/go/pkg/mod
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
db
|
||||||
logs
|
logs
|
||||||
docker-compose.dev.yml
|
docker-compose.dev.yml
|
||||||
|
|
||||||
|
# Binary e2e suite working cache: Traefik binary + compiled mock. Persisted
|
||||||
|
# across local runs; CI runs on fresh runners so it is recreated every time.
|
||||||
|
tests/e2e/mock/.cache/
|
||||||
|
|||||||
+24
-35
@@ -7,7 +7,7 @@ linters-settings:
|
|||||||
disable:
|
disable:
|
||||||
- fieldalignment
|
- fieldalignment
|
||||||
gocyclo:
|
gocyclo:
|
||||||
min-complexity: 15
|
min-complexity: 20
|
||||||
goconst:
|
goconst:
|
||||||
min-len: 5
|
min-len: 5
|
||||||
min-occurrences: 4
|
min-occurrences: 4
|
||||||
@@ -15,7 +15,7 @@ linters-settings:
|
|||||||
locale: US
|
locale: US
|
||||||
funlen:
|
funlen:
|
||||||
lines: -1
|
lines: -1
|
||||||
statements: 50
|
statements: 60
|
||||||
godox:
|
godox:
|
||||||
keywords:
|
keywords:
|
||||||
- FIXME
|
- FIXME
|
||||||
@@ -25,45 +25,34 @@ linters-settings:
|
|||||||
rules:
|
rules:
|
||||||
Main:
|
Main:
|
||||||
files:
|
files:
|
||||||
- $all
|
- $all
|
||||||
- "!$test"
|
- "!$test"
|
||||||
allow:
|
allow:
|
||||||
- $gostd
|
- $gostd
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
|
||||||
- github.com/leprosus/golang-ttl-map
|
- github.com/leprosus/golang-ttl-map
|
||||||
- github.com/maxlerebourg/simpleredis
|
- github.com/maxlerebourg/simpleredis
|
||||||
Test:
|
Test:
|
||||||
files:
|
files:
|
||||||
- $test
|
- $test
|
||||||
allow:
|
allow:
|
||||||
- $gostd
|
- $gostd
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
|
- github.com/maxlerebourg/simpleredis
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
|
||||||
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable-all: true
|
enable-all: true
|
||||||
disable:
|
disable:
|
||||||
- deadcode # deprecated
|
|
||||||
- exhaustivestruct # deprecated
|
|
||||||
- golint # deprecated
|
|
||||||
- ifshort # deprecated
|
|
||||||
- interfacer # deprecated
|
|
||||||
- maligned # deprecated
|
|
||||||
- nosnakecase # deprecated
|
|
||||||
- scopelint # deprecated
|
|
||||||
- scopelint # deprecated
|
|
||||||
- structcheck # deprecated
|
|
||||||
- varcheck # deprecated
|
|
||||||
- sqlclosecheck # not relevant (SQL)
|
- sqlclosecheck # not relevant (SQL)
|
||||||
- rowserrcheck # not relevant (SQL)
|
- rowserrcheck # not relevant (SQL)
|
||||||
- execinquery # not relevant (SQL)
|
|
||||||
- cyclop # duplicate of gocyclo
|
- cyclop # duplicate of gocyclo
|
||||||
- bodyclose # Too many false positives: https://github.com/timakin/bodyclose/issues/30
|
- bodyclose # Too many false positives: https://github.com/timakin/bodyclose/issues/30
|
||||||
- dupl
|
- dupl
|
||||||
@@ -74,18 +63,18 @@ linters:
|
|||||||
- wsl
|
- wsl
|
||||||
- exhaustive
|
- exhaustive
|
||||||
- exhaustruct
|
- exhaustruct
|
||||||
- goerr113
|
- err113
|
||||||
- wrapcheck
|
- wrapcheck
|
||||||
- ifshort
|
|
||||||
- noctx
|
- noctx
|
||||||
- lll
|
- lll
|
||||||
- gomnd
|
|
||||||
- forbidigo
|
- forbidigo
|
||||||
- varnamelen
|
- varnamelen
|
||||||
- wastedassign # is disabled because of generics
|
- wastedassign # is disabled because of generics
|
||||||
- gofumpt
|
- gofumpt
|
||||||
- gci
|
- gci
|
||||||
|
- mnd
|
||||||
|
- exportloopref
|
||||||
|
- contextcheck
|
||||||
issues:
|
issues:
|
||||||
exclude-use-default: false
|
exclude-use-default: false
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
.PHONY: lint test vendor clean
|
.PHONY: lint test vendor clean e2e_mock
|
||||||
|
|
||||||
export GO111MODULE=on
|
export GO111MODULE=on
|
||||||
|
|
||||||
|
# Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs.
|
||||||
|
# The local Docker suite (make e2e) lives in a separate PR/branch.
|
||||||
|
E2E_MOCK_SCENARIOS := $(notdir $(wildcard tests/e2e/mock/scenarios/*))
|
||||||
|
|
||||||
default: lint test
|
default: lint test
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@@ -13,6 +17,11 @@ test:
|
|||||||
yaegi_test:
|
yaegi_test:
|
||||||
yaegi test -v .
|
yaegi test -v .
|
||||||
|
|
||||||
|
e2e_mock: $(addprefix e2e_mock_,$(E2E_MOCK_SCENARIOS))
|
||||||
|
|
||||||
|
e2e_mock_%:
|
||||||
|
bash ./tests/e2e/mock/scenarios/$*/run.sh
|
||||||
|
|
||||||
vendor:
|
vendor:
|
||||||
go mod vendor
|
go mod vendor
|
||||||
|
|
||||||
@@ -41,13 +50,16 @@ run_tlsauth:
|
|||||||
docker compose -f examples/tls-auth/docker-compose.yml up -d --remove-orphans
|
docker compose -f examples/tls-auth/docker-compose.yml up -d --remove-orphans
|
||||||
|
|
||||||
run_appsec:
|
run_appsec:
|
||||||
docker compose -f examples/appsec-enabled/docker-compose.yml up -d
|
docker compose -f examples/appsec-enabled/docker-compose.yml up -d --remove-orphans
|
||||||
|
|
||||||
|
run_custom_captcha:
|
||||||
|
docker compose -f examples/custom-captcha/docker-compose.yml up -d --remove-orphans
|
||||||
|
|
||||||
run_captcha:
|
run_captcha:
|
||||||
docker compose -f examples/captcha/docker-compose.yml up -d
|
docker compose -f examples/captcha/docker-compose.yml up -d --remove-orphans
|
||||||
|
|
||||||
run_custom_ban_page:
|
run_custom_ban_page:
|
||||||
docker compose -f examples/custom-ban-page/docker-compose.yml up -d
|
docker compose -f examples/custom-ban-page/docker-compose.yml up -d --remove-orphans
|
||||||
|
|
||||||
run:
|
run:
|
||||||
docker compose -f docker-compose.yml up -d --remove-orphans
|
docker compose -f docker-compose.yml up -d --remove-orphans
|
||||||
@@ -98,6 +110,7 @@ clean_all_docker:
|
|||||||
docker compose -f examples/tls-auth/docker-compose.yml down --remove-orphans
|
docker compose -f examples/tls-auth/docker-compose.yml down --remove-orphans
|
||||||
docker compose -f examples/appsec-enabled/docker-compose.yml down --remove-orphans
|
docker compose -f examples/appsec-enabled/docker-compose.yml down --remove-orphans
|
||||||
docker compose -f examples/captcha/docker-compose.yml down --remove-orphans
|
docker compose -f examples/captcha/docker-compose.yml down --remove-orphans
|
||||||
|
docker compose -f examples/custom-captcha/docker-compose.yml down --remove-orphans
|
||||||
docker compose -f examples/custom-ban-page/docker-compose.yml down --remove-orphans
|
docker compose -f examples/custom-ban-page/docker-compose.yml down --remove-orphans
|
||||||
docker compose -f docker-compose.local.yml down --remove-orphans
|
docker compose -f docker-compose.local.yml down --remove-orphans
|
||||||
docker compose -f docker-compose.yml down --remove-orphans
|
docker compose -f docker-compose.yml down --remove-orphans
|
||||||
@@ -111,4 +124,3 @@ show_metrics:
|
|||||||
|
|
||||||
show_decisions:
|
show_decisions:
|
||||||
docker exec crowdsec cscli decisions list
|
docker exec crowdsec cscli decisions list
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ The AppSec Component offers:
|
|||||||
- Low-effort virtual patching capabilities.
|
- Low-effort virtual patching capabilities.
|
||||||
- Support for your legacy ModSecurity rules.
|
- Support for your legacy ModSecurity rules.
|
||||||
- Combining classic WAF benefits with advanced CrowdSec features for otherwise difficult advanced behavior detection.
|
- Combining classic WAF benefits with advanced CrowdSec features for otherwise difficult advanced behavior detection.
|
||||||
|
|
||||||
More information on appsec in the [Crowdsec Documentation](https://doc.crowdsec.net/docs/next/appsec/intro/).
|
More information on appsec in the [Crowdsec Documentation](https://doc.crowdsec.net/docs/next/appsec/intro/).
|
||||||
|
|
||||||
Remediation offered by [Crowdsec](https://docs.crowdsec.net/u/bouncers/intro) and supported by the plugin can be either `ban` or `captcha`.
|
Remediation offered by [Crowdsec](https://docs.crowdsec.net/u/bouncers/intro) and supported by the plugin can be either `ban` or `captcha`.
|
||||||
@@ -34,20 +35,21 @@ For the `captcha` remediation, the user will be redirected to a page to complete
|
|||||||
|
|
||||||
On successfull completion, he will be cleaned for a specified period of time before a new resolution challenge is expected if Crowdsec still has a decision to verify the user behavior. See the example captcha for more informations and configuration intructions.
|
On successfull completion, he will be cleaned for a specified period of time before a new resolution challenge is expected if Crowdsec still has a decision to verify the user behavior. See the example captcha for more informations and configuration intructions.
|
||||||
The following captcha providers are supported now:
|
The following captcha providers are supported now:
|
||||||
- [hcaptcha](https://www.hcaptcha.com/)
|
|
||||||
- [recaptcha](https://www.google.com/recaptcha/about/)
|
|
||||||
- [turnstile](https://www.cloudflare.com/products/turnstile/)
|
|
||||||
|
|
||||||
|
- [hcaptcha](https://www.hcaptcha.com/)
|
||||||
|
- [recaptcha](https://www.google.com/recaptcha/about/)
|
||||||
|
- [turnstile](https://www.cloudflare.com/products/turnstile/)
|
||||||
|
- [custom/wicketkeeper](https://github.com/a-ve/wicketkeeper)
|
||||||
|
|
||||||
There are 5 operating modes (CrowdsecMode) for this plugin:
|
There are 5 operating modes (CrowdsecMode) for this plugin:
|
||||||
|
|
||||||
| Mode | Description |
|
| Mode | Description |
|
||||||
|------|------|
|
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| none | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. All request call the Crowdsec LAPI |
|
| none | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. All request call the Crowdsec LAPI |
|
||||||
| live | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. The bouncer can leverage use of a local cache in order to reduce the number of requests made to the Crowdsec LAPI. It will keep in cache the status for each IP that makes queries. |
|
| live | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. The bouncer can leverage use of a local cache in order to reduce the number of requests made to the Crowdsec LAPI. It will keep in cache the status for each IP that makes queries. |
|
||||||
| stream | Stream Streaming mode allows you to keep in the local cache only the Banned IPs, every requests that does not hit the cache is authorized. Every minute, the cache is updated with news from the Crowdsec LAPI. |
|
| stream | Stream Streaming mode allows you to keep in the local cache only the Banned IPs, every requests that does not hit the cache is authorized. Every minute, the cache is updated with news from the Crowdsec LAPI. |
|
||||||
| alone | Standalone mode, similar to the streaming mode but the blacklisted IPs are fetched on the CAPI. Every 2 hours, the cache is updated with news from the Crowdsec CAPI. It does not include any locally banned IP, but can work without a crowdsec service. |
|
| alone | Standalone mode, similar to the streaming mode but the blacklisted IPs are fetched on the CAPI. Every 2 hours, the cache is updated with news from the Crowdsec CAPI. It does not include any locally banned IP, but can work without a crowdsec service. |
|
||||||
| appsec | Disable Crowdsec IP checking but apply Crowdsec Appsec checking. This mode is intended to be used when Crowdsec IP checking is applied at the Firewall Level. |
|
| appsec | Disable Crowdsec IP checking but apply Crowdsec Appsec checking. This mode is intended to be used when Crowdsec IP checking is applied at the Firewall Level. |
|
||||||
|
|
||||||
The `streaming mode` is recommended for performance, decisions are updated every 60 sec by default and that's the only communication between Traefik and Crowdsec. Every request that happens hits the cache for quick decisions.
|
The `streaming mode` is recommended for performance, decisions are updated every 60 sec by default and that's the only communication between Traefik and Crowdsec. Every request that happens hits the cache for quick decisions.
|
||||||
|
|
||||||
@@ -66,7 +68,7 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant CrowdsecLAPI
|
create participant CrowdsecLAPI
|
||||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a Crowdsec Decision ?
|
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a Crowdsec Decision ?
|
||||||
Destroy CrowdsecLAPI
|
destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
@@ -80,9 +82,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant CrowdsecLAPI
|
create participant CrowdsecLAPI
|
||||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||||
Destroy CrowdsecLAPI
|
destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -103,10 +105,10 @@ sequenceDiagram
|
|||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
create participant CrowdsecLAPI
|
create participant CrowdsecLAPI
|
||||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||||
Destroy CrowdsecLAPI
|
destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
||||||
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
@@ -123,10 +125,10 @@ sequenceDiagram
|
|||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
create participant CrowdsecLAPI
|
create participant CrowdsecLAPI
|
||||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||||
Destroy CrowdsecLAPI
|
destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
||||||
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
@@ -143,11 +145,11 @@ sequenceDiagram
|
|||||||
participant TraefikPlugin
|
participant TraefikPlugin
|
||||||
participant CrowdsecLAPI
|
participant CrowdsecLAPI
|
||||||
TraefikPlugin->>CrowdsecLAPI: What are the current decisions
|
TraefikPlugin->>CrowdsecLAPI: What are the current decisions
|
||||||
Destroy CrowdsecLAPI
|
destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI->>TraefikPlugin: Here is the list
|
CrowdsecLAPI->>TraefikPlugin: Here is the list
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Store this list
|
TraefikPlugin-->>PluginCache: Store this list
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -160,9 +162,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Yes a ban decision
|
PluginCache-->>TraefikPlugin: Yes a ban decision
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -175,9 +177,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -193,11 +195,11 @@ sequenceDiagram
|
|||||||
participant TraefikPlugin
|
participant TraefikPlugin
|
||||||
participant CrowdsecCAPI
|
participant CrowdsecCAPI
|
||||||
TraefikPlugin->>CrowdsecCAPI: What are the current decisions from CAPI
|
TraefikPlugin->>CrowdsecCAPI: What are the current decisions from CAPI
|
||||||
Destroy CrowdsecCAPI
|
destroy CrowdsecCAPI
|
||||||
CrowdsecCAPI->>TraefikPlugin: Here is the list
|
CrowdsecCAPI->>TraefikPlugin: Here is the list
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Store this list
|
TraefikPlugin-->>PluginCache: Store this list
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -210,9 +212,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Yes a ban decision
|
PluginCache-->>TraefikPlugin: Yes a ban decision
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -225,9 +227,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -245,9 +247,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant CrowdsecAppSec
|
create participant CrowdsecAppSec
|
||||||
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
||||||
Destroy CrowdsecAppSec
|
destroy CrowdsecAppSec
|
||||||
CrowdsecAppSec-->>TraefikPlugin: Yes I think so
|
CrowdsecAppSec-->>TraefikPlugin: Yes I think so
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -260,9 +262,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant CrowdsecAppSec
|
create participant CrowdsecAppSec
|
||||||
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
||||||
Destroy CrowdsecAppSec
|
destroy CrowdsecAppSec
|
||||||
CrowdsecAppSec-->>TraefikPlugin: No I don't think so
|
CrowdsecAppSec-->>TraefikPlugin: No I don't think so
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -283,12 +285,12 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Fine, done!
|
User->>TraefikPlugin: Fine, done!
|
||||||
create participant ProviderCaptcha
|
create participant ProviderCaptcha
|
||||||
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
||||||
Destroy ProviderCaptcha
|
destroy ProviderCaptcha
|
||||||
ProviderCaptcha-->>TraefikPlugin: Yes
|
ProviderCaptcha-->>TraefikPlugin: Yes
|
||||||
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -300,24 +302,48 @@ sequenceDiagram
|
|||||||
To get started, use the `docker-compose.yml` file.
|
To get started, use the `docker-compose.yml` file.
|
||||||
|
|
||||||
You can run it with:
|
You can run it with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make run
|
make run
|
||||||
```
|
```
|
||||||
|
|
||||||
### Note
|
### Note
|
||||||
|
|
||||||
**/!\ Cache is shared by all services**
|
> [!IMPORTANT]
|
||||||
*This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP*
|
> Some of the behaviours and configuration parameters are shared globally across _all_ crowdsec middlewares even if you declare different middlewares with different settings.
|
||||||
Only one instance of the plugin is *possible*.
|
>
|
||||||
|
> **Cache is shared by all services**: This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP
|
||||||
|
>
|
||||||
|
> If you define different caches for different middlewares, only the first one to be instantiated will be bound to the crowdsec stream.
|
||||||
|
>
|
||||||
|
> Overall, this middleware is designed in such a way that **only one instance of the plugin is _possible_.** You can have multiple crowdsec middlewares in the same cluster, the key parameters must be aligned (MetricsUpdateIntervalSeconds, CrowdsecMode, CrowdsecAppsecEnabled, etc.)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **Appsec maximum body limit is defaulted to 10MB** > _Be careful when you upgrade to >1.4.x_
|
||||||
|
|
||||||
### Variables
|
### Variables
|
||||||
|
|
||||||
- Enabled
|
- Enabled
|
||||||
- bool
|
- bool
|
||||||
- default: false
|
- default: false
|
||||||
- Enable the plugin
|
- Enable the plugin
|
||||||
- LogLevel
|
- LogLevel
|
||||||
- string
|
- string
|
||||||
- default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR`, log are written to `stdout` / `stderr`
|
- default: `INFO`, expected values are: `DEBUG`, `INFO`, `WARN`, `ERROR`
|
||||||
|
- Log are written to `stdout` / `stderr` or file if LogFilePath is provided
|
||||||
|
- LogFormat
|
||||||
|
- string
|
||||||
|
- default: `common`, expected values are: `common`, `json`
|
||||||
|
- Log format: `common` for traditional text logs, `json` for structured JSON logs
|
||||||
|
- LogFilePath
|
||||||
|
- string
|
||||||
|
- default: ""
|
||||||
|
- File Path to write logs, must be writable by Traefik, Log rotation may require a restart of traefik
|
||||||
|
- MetricsUpdateIntervalSeconds
|
||||||
|
- int64
|
||||||
|
- default: 600
|
||||||
|
- Interval in seconds between metrics updates to Crowdsec
|
||||||
|
- If set to zero or less, metrics collection is disabled
|
||||||
- CrowdsecMode
|
- CrowdsecMode
|
||||||
- string
|
- string
|
||||||
- default: `live`, expected values are: `none`, `live`, `stream`, `alone`, `appsec`
|
- default: `live`, expected values are: `none`, `live`, `stream`, `alone`, `appsec`
|
||||||
@@ -328,7 +354,22 @@ Only one instance of the plugin is *possible*.
|
|||||||
- CrowdsecAppsecHost
|
- CrowdsecAppsecHost
|
||||||
- string
|
- string
|
||||||
- default: "crowdsec:7422"
|
- default: "crowdsec:7422"
|
||||||
- Crowdsec Appsec Server available on which host and port. The scheme will be handled by the CrowdsecLapiScheme var.
|
- Crowdsec Appsec Server available on which host and port.
|
||||||
|
- CrowdsecAppsecTlsInsecureVerify
|
||||||
|
- bool
|
||||||
|
- default: false
|
||||||
|
- Disable verification of certificate presented by Appsec
|
||||||
|
- CrowdsecAppsecTlsCertificateAuthority
|
||||||
|
- string
|
||||||
|
- default: ""
|
||||||
|
- PEM-encoded Certificate Authority 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`
|
||||||
|
- CrowdsecAppsecPath
|
||||||
|
- string
|
||||||
|
- default: "/"
|
||||||
|
- Crowdsec Appsec Server available on this path. Will be appended to CrowdsecAppsecHost. Need to finish with "/".
|
||||||
- CrowdsecAppsecFailureBlock
|
- CrowdsecAppsecFailureBlock
|
||||||
- bool
|
- bool
|
||||||
- default: true
|
- default: true
|
||||||
@@ -337,6 +378,18 @@ Only one instance of the plugin is *possible*.
|
|||||||
- bool
|
- bool
|
||||||
- default: true
|
- default: true
|
||||||
- Block request when Crowdsec Appsec Server is unreachable.
|
- Block request when Crowdsec Appsec Server is unreachable.
|
||||||
|
- CrowdsecAppsecBodyLimit
|
||||||
|
- int64
|
||||||
|
- default: 10485760 (= 10MB)
|
||||||
|
- Transmit only the first number of bytes to Crowdsec Appsec Server.
|
||||||
|
- 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`
|
||||||
|
- Crowdsec AppSec key for the bouncer.
|
||||||
- CrowdsecLapiScheme
|
- CrowdsecLapiScheme
|
||||||
- string
|
- string
|
||||||
- default: `http`, expected values are: `http`, `https`
|
- default: `http`, expected values are: `http`, `https`
|
||||||
@@ -344,6 +397,10 @@ Only one instance of the plugin is *possible*.
|
|||||||
- string
|
- string
|
||||||
- default: "crowdsec:8080"
|
- default: "crowdsec:8080"
|
||||||
- Crowdsec LAPI available on which host and port.
|
- Crowdsec LAPI available on which host and port.
|
||||||
|
- CrowdsecLapiPath
|
||||||
|
- string
|
||||||
|
- default: "/"
|
||||||
|
- Crowdsec LAPI Server available on this path. Will be appended to CrowdsecLapiHost. Need to finish with "/".
|
||||||
- CrowdsecLapiKey
|
- CrowdsecLapiKey
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
@@ -355,7 +412,7 @@ Only one instance of the plugin is *possible*.
|
|||||||
- CrowdsecLapiTlsCertificateAuthority
|
- CrowdsecLapiTlsCertificateAuthority
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- 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
|
- CrowdsecLapiTlsCertificateBouncer
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
@@ -368,14 +425,18 @@ Only one instance of the plugin is *possible*.
|
|||||||
- string
|
- string
|
||||||
- default: []
|
- default: []
|
||||||
- List of client IPs to trust, they will bypass any check from the bouncer or cache (useful for LAN or VPN IP)
|
- List of client IPs to trust, they will bypass any check from the bouncer or cache (useful for LAN or VPN IP)
|
||||||
- ForwardedHeadersTrustedIPs
|
- RemediationHeadersCustomName
|
||||||
- []string
|
- string
|
||||||
- default: []
|
- default: ""
|
||||||
- List of IPs of trusted Proxies that are in front of traefik (ex: Cloudflare)
|
- Name of the header you want in response when request are handled by plugin (possible value of the header `ban`, `captcha` or `solved-captcha`)
|
||||||
- ForwardedHeadersCustomName
|
- ForwardedHeadersCustomName
|
||||||
- string
|
- string
|
||||||
- default: "X-Forwarded-For"
|
- default: "X-Forwarded-For"
|
||||||
- Name of the header where the real IP of the client should be retrieved
|
- Name of the header where the real IP of the client should be retrieved
|
||||||
|
- ForwardedHeadersTrustedIPs
|
||||||
|
- []string
|
||||||
|
- default: []
|
||||||
|
- List of IPs of trusted Proxies that are in front of traefik (ex: Cloudflare)
|
||||||
- RedisCacheEnabled
|
- RedisCacheEnabled
|
||||||
- bool
|
- bool
|
||||||
- default: false
|
- default: false
|
||||||
@@ -383,7 +444,12 @@ Only one instance of the plugin is *possible*.
|
|||||||
- RedisCacheHost
|
- RedisCacheHost
|
||||||
- string
|
- string
|
||||||
- default: "redis:6379"
|
- 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
|
- RedisCachePassword
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
@@ -392,6 +458,10 @@ Only one instance of the plugin is *possible*.
|
|||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- Database selection for the Redis service
|
- Database selection for the Redis service
|
||||||
|
- RedisCacheUnreachableBlock
|
||||||
|
- bool
|
||||||
|
- default: true
|
||||||
|
- Block request when Redis is unreachable (if Redis is unreachable, 1-second delay is added to each request)
|
||||||
- HTTPTimeoutSeconds
|
- HTTPTimeoutSeconds
|
||||||
- int64
|
- int64
|
||||||
- default: 10
|
- default: 10
|
||||||
@@ -404,10 +474,20 @@ Only one instance of the plugin is *possible*.
|
|||||||
- int64
|
- int64
|
||||||
- default: 0
|
- default: 0
|
||||||
- Used only in `stream` and `alone` mode, the maximum number of time we can not reach Crowdsec before blocking traffic (set -1 to never block)
|
- Used only in `stream` and `alone` mode, the maximum number of time we can not reach Crowdsec before blocking traffic (set -1 to never block)
|
||||||
|
- StreamStartupBlock
|
||||||
|
- bool
|
||||||
|
- default: true
|
||||||
|
- Used only in `stream` and `alone` mode, controls whether the initial stream update runs synchronously or asynchronously during plugin initialization
|
||||||
|
- When `true`, plugin initialization waits for Crowdsec to be ready before serving traffic.
|
||||||
|
- **Warning**: When `false`, all requests bypass remediation until the first stream sync completes — banned IPs will be allowed through during this window. Only disable when startup availability is more important than blocking at startup.
|
||||||
- DefaultDecisionSeconds
|
- DefaultDecisionSeconds
|
||||||
- int64
|
- int64
|
||||||
- default: 60
|
- default: 60
|
||||||
- Used only in `live` mode, maximum decision duration
|
- Used only in `live` mode, maximum decision duration
|
||||||
|
- RemediationStatusCode
|
||||||
|
- int
|
||||||
|
- default: 403
|
||||||
|
- HTTP status code for banned user (not captcha)
|
||||||
- CrowdsecCapiMachineId
|
- CrowdsecCapiMachineId
|
||||||
- string
|
- string
|
||||||
- Used only in `alone` mode, login for Crowdsec CAPI
|
- Used only in `alone` mode, login for Crowdsec CAPI
|
||||||
@@ -419,7 +499,19 @@ Only one instance of the plugin is *possible*.
|
|||||||
- Used only in `alone` mode, scenarios for Crowdsec CAPI
|
- Used only in `alone` mode, scenarios for Crowdsec CAPI
|
||||||
- CaptchaProvider
|
- CaptchaProvider
|
||||||
- string
|
- string
|
||||||
- Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile`
|
- Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile` or `custom`
|
||||||
|
- CaptchaCustomJsURL
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, URL used to load the challenge in the HTML (in case of hcaptcha: `https://hcaptcha.com/1/api.js`)
|
||||||
|
- CaptchaCustomValidateURL
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, URL used to validate the challenge (in case of hcaptcha: `https://api.hcaptcha.com/siteverify`)
|
||||||
|
- CaptchaCustomKey
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, used to set class name of the div used by captcha provider (in case of hcaptcha: `h-captcha`)
|
||||||
|
- CaptchaCustomResponse
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, used to set the field in the POST body from the captcha.html to Traefik (in case of hcaptcha: `h-captcha-response`)
|
||||||
- CaptchaSiteKey
|
- CaptchaSiteKey
|
||||||
- string
|
- string
|
||||||
- Site key for the captcha provider
|
- Site key for the captcha provider
|
||||||
@@ -430,20 +522,25 @@ Only one instance of the plugin is *possible*.
|
|||||||
- int64
|
- int64
|
||||||
- default: 1800 (= 30 minutes)
|
- default: 1800 (= 30 minutes)
|
||||||
- Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid
|
- Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid
|
||||||
- CaptchaHTMLFilePath
|
- CaptchaFilePath
|
||||||
- string
|
- string
|
||||||
- default: /captcha.html
|
- default: /captcha.html
|
||||||
- Path where the captcha template is stored
|
- Path where the captcha template is stored. The Content-Type header is automatically inferred from the file extension.
|
||||||
- BanHTMLFilePath
|
- BanFilePath
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- 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: ""
|
||||||
|
- Request Header name whose value to inject in ban HTML response (default empty ""=disabled)
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
For each plugin, the Traefik static configuration must define the module name (as is usual for Go packages).
|
For each plugin, the Traefik static configuration must define the module name (as is usual for Go packages).
|
||||||
|
|
||||||
The following declaration (given here in YAML) defines a plugin:
|
The following declaration (given here in YAML) defines a plugin:
|
||||||
|
|
||||||
> Note that you don't need to copy all thoses settings but only the ones you want to use.
|
> Note that you don't need to copy all thoses settings but only the ones you want to use.
|
||||||
> See the examples for advanced usage.
|
> See the examples for advanced usage.
|
||||||
|
|
||||||
@@ -458,7 +555,37 @@ experimental:
|
|||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Dynamic configuration
|
# Simplified dynamic configuration
|
||||||
|
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
my-router:
|
||||||
|
rule: host(`whoami.localhost`)
|
||||||
|
service: service-foo
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
middlewares:
|
||||||
|
- crowdsec
|
||||||
|
|
||||||
|
services:
|
||||||
|
service-foo:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: http://127.0.0.1:5000
|
||||||
|
|
||||||
|
middlewares:
|
||||||
|
crowdsec:
|
||||||
|
plugin:
|
||||||
|
bouncer:
|
||||||
|
enabled: true
|
||||||
|
logLevel: DEBUG
|
||||||
|
crowdsecMode: live
|
||||||
|
crowdsecLapiKey: privateKey-foo
|
||||||
|
crowdsecLapiHost: crowdsec:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Full dynamic configuration
|
||||||
|
|
||||||
http:
|
http:
|
||||||
routers:
|
routers:
|
||||||
@@ -482,19 +609,27 @@ http:
|
|||||||
bouncer:
|
bouncer:
|
||||||
enabled: false
|
enabled: false
|
||||||
logLevel: DEBUG
|
logLevel: DEBUG
|
||||||
|
logFormat: common
|
||||||
|
LogFilePath: ""
|
||||||
updateIntervalSeconds: 60
|
updateIntervalSeconds: 60
|
||||||
updateMaxFailure: 0
|
updateMaxFailure: 0
|
||||||
|
streamStartupBlock: true
|
||||||
defaultDecisionSeconds: 60
|
defaultDecisionSeconds: 60
|
||||||
|
remediationStatusCode: 403
|
||||||
httpTimeoutSeconds: 10
|
httpTimeoutSeconds: 10
|
||||||
crowdsecMode: live
|
crowdsecMode: live
|
||||||
crowdsecAppsecEnabled: false
|
crowdsecAppsecEnabled: false
|
||||||
|
crowdsecAppsecScheme: ""
|
||||||
crowdsecAppsecHost: crowdsec:7422
|
crowdsecAppsecHost: crowdsec:7422
|
||||||
|
crowdsecAppsecPath: "/"
|
||||||
crowdsecAppsecFailureBlock: true
|
crowdsecAppsecFailureBlock: true
|
||||||
crowdsecAppsecUnreachableBlock: true
|
crowdsecAppsecUnreachableBlock: true
|
||||||
|
crowdsecAppsecBodyLimit: 10485760
|
||||||
|
crowdsecAppsecUnreadableBodyBlock: false
|
||||||
crowdsecLapiKey: privateKey-foo
|
crowdsecLapiKey: privateKey-foo
|
||||||
crowdsecLapiKeyFile: /etc/traefik/cs-privateKey-foo
|
|
||||||
crowdsecLapiHost: crowdsec:8080
|
|
||||||
crowdsecLapiScheme: http
|
crowdsecLapiScheme: http
|
||||||
|
crowdsecLapiHost: crowdsec:8080
|
||||||
|
crowdsecLapiPath: "/"
|
||||||
crowdsecLapiTLSInsecureVerify: false
|
crowdsecLapiTLSInsecureVerify: false
|
||||||
crowdsecCapiMachineId: login
|
crowdsecCapiMachineId: login
|
||||||
crowdsecCapiPassword: password
|
crowdsecCapiPassword: password
|
||||||
@@ -508,17 +643,21 @@ http:
|
|||||||
clientTrustedIPs:
|
clientTrustedIPs:
|
||||||
- 192.168.1.0/24
|
- 192.168.1.0/24
|
||||||
forwardedHeadersCustomName: X-Custom-Header
|
forwardedHeadersCustomName: X-Custom-Header
|
||||||
|
remediationHeadersCustomName: cs-remediation
|
||||||
redisCacheEnabled: false
|
redisCacheEnabled: false
|
||||||
redisCacheHost: "redis:6379"
|
redisCacheHost: "redis-primary:6379"
|
||||||
|
redisCacheReadHosts:
|
||||||
|
- "redis-replica-1:6379"
|
||||||
|
- "redis-replica-2:6379"
|
||||||
redisCachePassword: password
|
redisCachePassword: password
|
||||||
redisCacheDatabase: "5"
|
redisCacheDatabase: "5"
|
||||||
|
redisCacheUnreachableBlock: true
|
||||||
crowdsecLapiTLSCertificateAuthority: |-
|
crowdsecLapiTLSCertificateAuthority: |-
|
||||||
-----BEGIN CERTIFICATE-----
|
-----BEGIN CERTIFICATE-----
|
||||||
MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
|
MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
|
||||||
...
|
...
|
||||||
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
|
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
|
||||||
-----END CERTIFICATE-----
|
-----END CERTIFICATE-----
|
||||||
crowdsecLapiTLSCertificateAuthorityFile: /etc/traefik/crowdsec-certs/ca.pem
|
|
||||||
crowdsecLapiTLSCertificateBouncer: |-
|
crowdsecLapiTLSCertificateBouncer: |-
|
||||||
-----BEGIN CERTIFICATE-----
|
-----BEGIN CERTIFICATE-----
|
||||||
MIIEHjCCAwagAwIBAgIUOBTs1eqkaAUcPplztUr2xRapvNAwDQYJKoZIhvcNAQEL
|
MIIEHjCCAwagAwIBAgIUOBTs1eqkaAUcPplztUr2xRapvNAwDQYJKoZIhvcNAQEL
|
||||||
@@ -526,30 +665,31 @@ http:
|
|||||||
RaXAnYYUVRblS1jmePemh388hFxbmrpG2pITx8B5FMULqHoj11o2Rl0gSV6tHIHz
|
RaXAnYYUVRblS1jmePemh388hFxbmrpG2pITx8B5FMULqHoj11o2Rl0gSV6tHIHz
|
||||||
N2U=
|
N2U=
|
||||||
-----END CERTIFICATE-----
|
-----END CERTIFICATE-----
|
||||||
crowdsecLapiTLSCertificateBouncerFile: /etc/traefik/crowdsec-certs/bouncer.pem
|
|
||||||
crowdsecLapiTLSCertificateBouncerKey: |-
|
crowdsecLapiTLSCertificateBouncerKey: |-
|
||||||
-----BEGIN RSA PRIVATE KEY-----
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
MIIEogIBAAKCAQEAtYQnbJqifH+ZymePylDxGGLIuxzcAUU4/ajNj+qRAdI/Ux3d
|
MIIEogIBAAKCAQEAtYQnbJqifH+ZymePylDxGGLIuxzcAUU4/ajNj+qRAdI/Ux3d
|
||||||
...
|
...
|
||||||
ic5cDRo6/VD3CS3MYzyBcibaGaV34nr0G/pI+KEqkYChzk/PZRA=
|
ic5cDRo6/VD3CS3MYzyBcibaGaV34nr0G/pI+KEqkYChzk/PZRA=
|
||||||
-----END RSA PRIVATE KEY-----
|
-----END RSA PRIVATE KEY-----
|
||||||
crowdsecLapiTLSCertificateBouncerKeyFile: /etc/traefik/crowdsec-certs/bouncer-key.pem
|
|
||||||
captchaProvider: hcaptcha
|
captchaProvider: hcaptcha
|
||||||
captchaSiteKey: FIXME
|
captchaSiteKey: FIXME
|
||||||
captchaSecretKey: FIXME
|
captchaSecretKey: FIXME
|
||||||
captchaGracePeriodSeconds: 1800
|
captchaGracePeriodSeconds: 1800
|
||||||
captchaHTMLFilePath: /captcha.html
|
captchaHTMLFilePath: /captcha.html
|
||||||
banHTMLFilePath: /ban.html
|
banHTMLFilePath: /ban.html
|
||||||
|
traceHeadersCustomName: X-Request-ID
|
||||||
|
metricsUpdateIntervalSeconds: 600
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Fill variable with value of file
|
#### Fill variable with value of file
|
||||||
|
|
||||||
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey` and `CaptchaSecretKey` can be provided with the content as raw or through a file path that Traefik can read.
|
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecAppsecTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CrowdsecAppsecKey`, `CaptchaSiteKey`, `CaptchaSecretKey` and `RedisCachePassword` can be provided with the content as raw or through a file path that Traefik can read.
|
||||||
The file variable will be used as preference if both content and file are provided for the same variable.
|
The file variable will be used as preference if both content and file are provided for the same variable.
|
||||||
|
|
||||||
Format is:
|
Format is:
|
||||||
|
|
||||||
- Content: VariableName: XXX
|
- Content: VariableName: XXX
|
||||||
- File : VariableNameFile: /path
|
- File : VariableNameFile: /path
|
||||||
|
|
||||||
#### Authenticate with LAPI
|
#### Authenticate with LAPI
|
||||||
|
|
||||||
@@ -557,6 +697,7 @@ You can authenticate to the LAPI either with LAPIKEY or by using client certific
|
|||||||
Please see below for more details on each option.
|
Please see below for more details on each option.
|
||||||
|
|
||||||
#### Generate LAPI KEY
|
#### Generate LAPI KEY
|
||||||
|
|
||||||
You can generate a crowdsec API key for the LAPI.
|
You can generate a crowdsec API key for the LAPI.
|
||||||
You can follow the documentation here: [docs.crowdsec.net/docs/user_guides/lapi_mgmt](https://docs.crowdsec.net/docs/user_guides/lapi_mgmt)
|
You can follow the documentation here: [docs.crowdsec.net/docs/user_guides/lapi_mgmt](https://docs.crowdsec.net/docs/user_guides/lapi_mgmt)
|
||||||
|
|
||||||
@@ -566,24 +707,26 @@ docker exec crowdsec cscli bouncers add crowdsecBouncer
|
|||||||
```
|
```
|
||||||
|
|
||||||
This LAPI key must be set where is noted FIXME-LAPI-KEY in the docker-compose.yml
|
This LAPI key must be set where is noted FIXME-LAPI-KEY in the docker-compose.yml
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
...
|
..
|
||||||
whoami:
|
whoami:
|
||||||
labels:
|
labels:
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=FIXME-LAPI-KEY"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=FIXME-LAPI-KEY"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=http"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=http"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapihost=crowdsec:8080"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapihost=crowdsec:8080"
|
||||||
...
|
..
|
||||||
crowdsec:
|
crowdsec:
|
||||||
environment:
|
environment:
|
||||||
BOUNCER_KEY_TRAEFIK: FIXME-LAPI-KEY
|
BOUNCER_KEY_TRAEFIK: FIXME-LAPI-KEY
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
|
|
||||||
> Crowdsec does not require a specific format for la LAPI-key, you may use something like FIXME-LAPI-KEY but that is not recommanded for obvious reasons
|
> Crowdsec does not require a specific format for la LAPI-key, you may use something like FIXME-LAPI-KEY but that is not recommanded for obvious reasons
|
||||||
|
|
||||||
You can then run all the containers:
|
You can then run all the containers:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
@@ -597,12 +740,21 @@ A script is available to generate certificates in `examples/tls-auth/gencerts.sh
|
|||||||
|
|
||||||
#### Use HTTPS to communicate with the LAPI
|
#### Use HTTPS to communicate with the LAPI
|
||||||
|
|
||||||
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 `crowdsecLapiScheme` to `https`. The plugin then validates Crowdsec's server certificate. Three options:
|
||||||
Set the `crowdsecLapiScheme` to https.
|
|
||||||
|
- **Publicly trusted certificate** (e.g. Let's Encrypt behind a reverse proxy): leave `crowdsecLapiTLSCertificateAuthority` empty and `crowdsecLapiTLSInsecureVerify` `false`. The plugin falls back to the host's system trust store (the `traefik` image ships `ca-certificates`).
|
||||||
|
- **Private/self-signed CA**: set `crowdsecLapiTLSCertificateAuthority` (or `…File`) to the PEM-encoded CA that signed Crowdsec's server cert.
|
||||||
|
- **Skip verification entirely** (not recommended for production): set `crowdsecLapiTLSInsecureVerify` to `true`.
|
||||||
|
|
||||||
Crowdsec must be listening in HTTPS for this to work.
|
Crowdsec must be listening in HTTPS for this to work.
|
||||||
Please see the [tls-auth example](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/tls-auth/README.md) or the official documentation: [docs.crowdsec.net/docs/local_api/tls_auth/](https://docs.crowdsec.net/docs/local_api/tls_auth/)
|
Please see the [tls-auth example](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/tls-auth/README.md) or the official documentation: [docs.crowdsec.net/docs/local_api/tls_auth/](https://docs.crowdsec.net/docs/local_api/tls_auth/)
|
||||||
|
|
||||||
|
#### Use HTTPS to communicate with the Appsec
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
#### Manually add an IP to the blocklist (for testing purposes)
|
#### Manually add an IP to the blocklist (for testing purposes)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -635,6 +787,8 @@ docker exec crowdsec cscli decisions remove --ip 10.0.0.10 -t captcha
|
|||||||
|
|
||||||
#### 10. Using Traefik with Custom Ban HTML Page [examples/custom-ban-page/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/custom-ban-page/README.md)
|
#### 10. Using Traefik with Custom Ban HTML Page [examples/custom-ban-page/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/custom-ban-page/README.md)
|
||||||
|
|
||||||
|
#### 11. Using Traefik with Custom Captcha Whiketkeeper[examples/custom-captcha/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/custom-captcha/README.md)
|
||||||
|
|
||||||
### Local Mode
|
### Local Mode
|
||||||
|
|
||||||
Traefik also offers a developer mode that can be used for temporary testing of plugins not hosted on GitHub.
|
Traefik also offers a developer mode that can be used for temporary testing of plugins not hosted on GitHub.
|
||||||
@@ -660,12 +814,14 @@ The source code of the plugin should be organized as follows:
|
|||||||
```
|
```
|
||||||
|
|
||||||
For local development, a `docker-compose.local.yml` is provided which reproduces the directory layout needed by Traefik.
|
For local development, a `docker-compose.local.yml` is provided which reproduces the directory layout needed by Traefik.
|
||||||
This works once you have generated and filled your *LAPI-KEY* (crowdsecLapiKey), if not read above for informations.
|
This works once you have generated and filled your _LAPI-KEY_ (crowdsecLapiKey), if not read above for informations.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.local.yml up -d
|
docker compose -f docker-compose.local.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Equivalent to
|
Equivalent to
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make run_local
|
make run_local
|
||||||
```
|
```
|
||||||
|
|||||||
+388
-136
@@ -10,9 +10,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ const (
|
|||||||
crowdsecLapiHeader = "X-Api-Key"
|
crowdsecLapiHeader = "X-Api-Key"
|
||||||
crowdsecLapiRoute = "v1/decisions"
|
crowdsecLapiRoute = "v1/decisions"
|
||||||
crowdsecLapiStreamRoute = "v1/decisions/stream"
|
crowdsecLapiStreamRoute = "v1/decisions/stream"
|
||||||
|
crowdsecLapiMetricsRoute = "v1/usage-metrics"
|
||||||
crowdsecCapiHost = "api.crowdsec.net"
|
crowdsecCapiHost = "api.crowdsec.net"
|
||||||
crowdsecCapiHeader = "Authorization"
|
crowdsecCapiHeader = "Authorization"
|
||||||
crowdsecCapiLoginRoute = "v2/watchers/login"
|
crowdsecCapiLoginRoute = "v2/watchers/login"
|
||||||
@@ -40,12 +44,32 @@ const (
|
|||||||
cacheTimeoutKey = "updated"
|
cacheTimeoutKey = "updated"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ##############################################################
|
||||||
|
// Important: traefik creates an instance of the bouncer per route.
|
||||||
|
// We rely on globals (both here and in the memory cache) to share info between
|
||||||
|
// routes. This means that some of the plugins parameters will only work "once"
|
||||||
|
// and will take the values of the first middleware that was instantiated even
|
||||||
|
// if you have different middlewares with different parameters. This design
|
||||||
|
// makes it impossible to have multiple crowdsec implementations per cluster (unless you have multiple traefik deployments in it)
|
||||||
|
// - updateInterval
|
||||||
|
// - updateMaxFailure
|
||||||
|
// - defaultDecisionTimeout
|
||||||
|
// - redisUnreachableBlock
|
||||||
|
// - appsecEnabled
|
||||||
|
// - appsecHost
|
||||||
|
// - metricsUpdateIntervalSeconds
|
||||||
|
// - others...
|
||||||
|
// ###################################
|
||||||
|
|
||||||
//nolint:gochecknoglobals
|
//nolint:gochecknoglobals
|
||||||
var (
|
var (
|
||||||
isStartup = true
|
isCrowdsecStreamStartup = true
|
||||||
isCrowdsecStreamHealthy = true
|
isCrowdsecStreamHealthy = true
|
||||||
updateFailure = 0
|
updateFailure int64
|
||||||
ticker chan bool
|
streamTicker chan bool
|
||||||
|
metricsTicker chan bool
|
||||||
|
lastMetricsPush time.Time
|
||||||
|
blockedRequests int64
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateConfig creates the default plugin configuration.
|
// CreateConfig creates the default plugin configuration.
|
||||||
@@ -59,37 +83,60 @@ type Bouncer struct {
|
|||||||
name string
|
name string
|
||||||
template *template.Template
|
template *template.Template
|
||||||
|
|
||||||
enabled bool
|
enabled bool
|
||||||
appsecEnabled bool
|
appsecEnabled bool
|
||||||
appsecHost string
|
appsecScheme string
|
||||||
appsecFailureBlock bool
|
appsecHost string
|
||||||
appsecUnreachableBlock bool
|
appsecPath string
|
||||||
crowdsecScheme string
|
appsecKey string
|
||||||
crowdsecHost string
|
appsecFailureBlock bool
|
||||||
crowdsecKey string
|
appsecUnreachableBlock bool
|
||||||
crowdsecMode string
|
appsecUnreadableBodyBlock bool
|
||||||
crowdsecMachineID string
|
appsecBodyLimit int64
|
||||||
crowdsecPassword string
|
crowdsecScheme string
|
||||||
crowdsecScenarios []string
|
crowdsecHost string
|
||||||
updateInterval int64
|
crowdsecPath string
|
||||||
updateMaxFailure int
|
crowdsecKey string
|
||||||
defaultDecisionTimeout int64
|
crowdsecMode string
|
||||||
customHeader string
|
crowdsecMachineID string
|
||||||
crowdsecStreamRoute string
|
crowdsecPassword string
|
||||||
crowdsecHeader string
|
crowdsecScenarios []string
|
||||||
banTemplateString string
|
updateInterval int64
|
||||||
clientPoolStrategy *ip.PoolStrategy
|
updateMaxFailure int64
|
||||||
serverPoolStrategy *ip.PoolStrategy
|
defaultDecisionTimeout int64
|
||||||
httpClient *http.Client
|
remediationStatusCode int
|
||||||
cacheClient *cache.Client
|
remediationCustomHeader string
|
||||||
captchaClient *captcha.Client
|
forwardedCustomHeader string
|
||||||
log *logger.Log
|
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.
|
// New creates the crowdsec bouncer plugin.
|
||||||
|
//
|
||||||
|
//nolint:nestif,gocyclo,gocognit,funlen,maintidx
|
||||||
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
||||||
log := logger.New(config.LogLevel)
|
config.LogLevel = strings.ToUpper(config.LogLevel)
|
||||||
err := configuration.ValidateParams(config)
|
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
|
||||||
|
|
||||||
|
if config.BanFilePath == "" && config.BanHTMLFilePath != "" {
|
||||||
|
config.BanFilePath = config.BanHTMLFilePath
|
||||||
|
}
|
||||||
|
if config.CaptchaHTMLFilePath != "" {
|
||||||
|
config.CaptchaFilePath = config.CaptchaHTMLFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
err := configuration.ValidateParams(config, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("New:validateParams " + err.Error())
|
log.Error("New:validateParams " + err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -98,43 +145,58 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
serverChecker, _ := ip.NewChecker(log, config.ForwardedHeadersTrustedIPs)
|
serverChecker, _ := ip.NewChecker(log, config.ForwardedHeadersTrustedIPs)
|
||||||
clientChecker, _ := ip.NewChecker(log, config.ClientTrustedIPs)
|
clientChecker, _ := ip.NewChecker(log, config.ClientTrustedIPs)
|
||||||
|
|
||||||
|
var tlsAppsecConfig *tls.Config
|
||||||
|
if config.CrowdsecAppsecEnabled {
|
||||||
|
tlsAppsecConfig, err = configuration.GetTLSConfigCrowdsec(config, log, true)
|
||||||
|
if config.CrowdsecAppsecScheme == "" {
|
||||||
|
config.CrowdsecAppsecScheme = config.CrowdsecLapiScheme
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error("New:getTLSConfigCrowdsec fail to get tlsAppsecConfig " + err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
apiAppsecKey, errAppsecKey := configuration.GetVariable(config, "CrowdsecAppsecKey")
|
||||||
|
if errAppsecKey != nil && len(tlsAppsecConfig.Certificates) == 0 {
|
||||||
|
log.Info("New:crowdsecLapiKey fail to get CrowdsecAppsecKey and no client certificate setup " + errAppsecKey.Error())
|
||||||
|
}
|
||||||
|
config.CrowdsecAppsecKey = apiAppsecKey
|
||||||
|
}
|
||||||
|
|
||||||
var tlsConfig *tls.Config
|
var tlsConfig *tls.Config
|
||||||
crowdsecStreamRoute := ""
|
crowdsecStreamRoute := ""
|
||||||
crowdsecHeader := ""
|
crowdsecHeader := ""
|
||||||
if config.CrowdsecMode == configuration.AloneMode {
|
if config.CrowdsecMode == configuration.AloneMode {
|
||||||
config.CrowdsecCapiMachineID, _ = configuration.GetVariable(config, "CrowdsecCapiMachineID")
|
config.CrowdsecCapiMachineID, _ = configuration.GetVariable(config, "CrowdsecCapiMachineID")
|
||||||
config.CrowdsecCapiPassword, _ = configuration.GetVariable(config, "CrowdsecCapiPassword")
|
config.CrowdsecCapiPassword, _ = configuration.GetVariable(config, "CrowdsecCapiPassword")
|
||||||
|
config.CrowdsecLapiScheme = configuration.HTTPS
|
||||||
config.CrowdsecLapiHost = crowdsecCapiHost
|
config.CrowdsecLapiHost = crowdsecCapiHost
|
||||||
config.CrowdsecLapiScheme = "https"
|
config.CrowdsecLapiPath = "/"
|
||||||
config.UpdateIntervalSeconds = 7200 // 2 hours
|
config.UpdateIntervalSeconds = 7200 // 2 hours
|
||||||
crowdsecStreamRoute = crowdsecCapiStreamRoute
|
crowdsecStreamRoute = crowdsecCapiStreamRoute
|
||||||
crowdsecHeader = crowdsecCapiHeader
|
crowdsecHeader = crowdsecCapiHeader
|
||||||
} else {
|
} else {
|
||||||
crowdsecStreamRoute = crowdsecLapiStreamRoute
|
crowdsecStreamRoute = crowdsecLapiStreamRoute
|
||||||
crowdsecHeader = crowdsecLapiHeader
|
crowdsecHeader = crowdsecLapiHeader
|
||||||
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log)
|
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("New:getTLSConfigCrowdsec fail to get tlsConfig " + err.Error())
|
log.Error("New:getTLSConfigCrowdsec fail to get tlsConfig " + err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
apiKey, errAPIKey := configuration.GetVariable(config, "CrowdsecLapiKey")
|
apiKey, errKey := configuration.GetVariable(config, "CrowdsecLapiKey")
|
||||||
if errAPIKey != nil && len(tlsConfig.Certificates) == 0 {
|
if errKey != nil && len(tlsConfig.Certificates) == 0 {
|
||||||
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errAPIKey.Error())
|
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errKey.Error())
|
||||||
return nil, err
|
return nil, errKey
|
||||||
}
|
}
|
||||||
config.CrowdsecLapiKey = apiKey
|
config.CrowdsecLapiKey = apiKey
|
||||||
|
if config.CrowdsecAppsecKey == "" {
|
||||||
|
config.CrowdsecAppsecKey = apiKey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var banTemplateString string
|
var banTemplate *template.Template
|
||||||
if config.BanHTMLFilePath != "" {
|
var banTemplateContentType string
|
||||||
var buf bytes.Buffer
|
if config.BanFilePath != "" {
|
||||||
banTemplate, _ := configuration.GetHTMLTemplate(config.BanHTMLFilePath)
|
banTemplate, banTemplateContentType, _ = configuration.GetTemplate(config.BanFilePath)
|
||||||
err = banTemplate.Execute(&buf, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("New:banTemplate is bad formatted " + err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
banTemplateString = buf.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bouncer := &Bouncer{
|
bouncer := &Bouncer{
|
||||||
@@ -142,26 +204,37 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
name: name,
|
name: name,
|
||||||
template: template.New("CrowdsecBouncer").Delims("[[", "]]"),
|
template: template.New("CrowdsecBouncer").Delims("[[", "]]"),
|
||||||
|
|
||||||
enabled: config.Enabled,
|
enabled: config.Enabled,
|
||||||
crowdsecMode: config.CrowdsecMode,
|
crowdsecMode: config.CrowdsecMode,
|
||||||
appsecEnabled: config.CrowdsecAppsecEnabled,
|
appsecEnabled: config.CrowdsecAppsecEnabled,
|
||||||
appsecHost: config.CrowdsecAppsecHost,
|
appsecScheme: config.CrowdsecAppsecScheme,
|
||||||
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
appsecHost: config.CrowdsecAppsecHost,
|
||||||
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
appsecPath: config.CrowdsecAppsecPath,
|
||||||
crowdsecScheme: config.CrowdsecLapiScheme,
|
appsecKey: config.CrowdsecAppsecKey,
|
||||||
crowdsecHost: config.CrowdsecLapiHost,
|
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
||||||
crowdsecKey: config.CrowdsecLapiKey,
|
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
||||||
crowdsecMachineID: config.CrowdsecCapiMachineID,
|
appsecUnreadableBodyBlock: config.CrowdsecAppsecUnreadableBodyBlock,
|
||||||
crowdsecPassword: config.CrowdsecCapiPassword,
|
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
|
||||||
crowdsecScenarios: config.CrowdsecCapiScenarios,
|
crowdsecScheme: config.CrowdsecLapiScheme,
|
||||||
updateInterval: config.UpdateIntervalSeconds,
|
crowdsecHost: config.CrowdsecLapiHost,
|
||||||
updateMaxFailure: config.UpdateMaxFailure,
|
crowdsecPath: config.CrowdsecLapiPath,
|
||||||
customHeader: config.ForwardedHeadersCustomName,
|
crowdsecKey: config.CrowdsecLapiKey,
|
||||||
defaultDecisionTimeout: config.DefaultDecisionSeconds,
|
crowdsecMachineID: config.CrowdsecCapiMachineID,
|
||||||
banTemplateString: banTemplateString,
|
crowdsecPassword: config.CrowdsecCapiPassword,
|
||||||
crowdsecStreamRoute: crowdsecStreamRoute,
|
crowdsecScenarios: config.CrowdsecCapiScenarios,
|
||||||
crowdsecHeader: crowdsecHeader,
|
updateInterval: config.UpdateIntervalSeconds,
|
||||||
log: log,
|
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{
|
serverPoolStrategy: &ip.PoolStrategy{
|
||||||
Checker: serverChecker,
|
Checker: serverChecker,
|
||||||
},
|
},
|
||||||
@@ -176,6 +249,14 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
},
|
},
|
||||||
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||||
},
|
},
|
||||||
|
httpAppsecClient: &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
MaxIdleConns: 10,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
TLSClientConfig: tlsAppsecConfig,
|
||||||
|
},
|
||||||
|
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||||
|
},
|
||||||
cacheClient: &cache.Client{},
|
cacheClient: &cache.Client{},
|
||||||
captchaClient: &captcha.Client{},
|
captchaClient: &captcha.Client{},
|
||||||
}
|
}
|
||||||
@@ -187,6 +268,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
log,
|
log,
|
||||||
config.RedisCacheEnabled,
|
config.RedisCacheEnabled,
|
||||||
config.RedisCacheHost,
|
config.RedisCacheHost,
|
||||||
|
config.RedisCacheReadHosts,
|
||||||
config.RedisCachePassword,
|
config.RedisCachePassword,
|
||||||
config.RedisCacheDatabase,
|
config.RedisCacheDatabase,
|
||||||
)
|
)
|
||||||
@@ -200,28 +282,47 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||||
},
|
},
|
||||||
config.CaptchaProvider,
|
config.CaptchaProvider,
|
||||||
|
config.CaptchaCustomJsURL,
|
||||||
|
config.CaptchaCustomKey,
|
||||||
|
config.CaptchaCustomResponse,
|
||||||
|
config.CaptchaCustomValidateURL,
|
||||||
config.CaptchaSiteKey,
|
config.CaptchaSiteKey,
|
||||||
config.CaptchaSecretKey,
|
config.CaptchaSecretKey,
|
||||||
config.CaptchaHTMLFilePath,
|
config.RemediationHeadersCustomName,
|
||||||
|
config.CaptchaFilePath,
|
||||||
config.CaptchaGracePeriodSeconds,
|
config.CaptchaGracePeriodSeconds,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("CaptchaClient not valid " + err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.CrowdsecMode == configuration.StreamMode || config.CrowdsecMode == configuration.AloneMode) && ticker == nil {
|
if (config.CrowdsecMode == configuration.StreamMode || config.CrowdsecMode == configuration.AloneMode) && streamTicker == nil {
|
||||||
if config.CrowdsecMode == configuration.AloneMode {
|
if config.CrowdsecMode == configuration.AloneMode {
|
||||||
if err := getToken(bouncer); err != nil {
|
if err := getToken(bouncer); err != nil {
|
||||||
bouncer.log.Error("New:getToken " + err.Error())
|
bouncer.log.Error("New:getToken " + err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handleStreamTicker(bouncer)
|
if config.StreamStartupBlock {
|
||||||
isStartup = false
|
handleStreamTicker(bouncer)
|
||||||
ticker = startTicker(config, log, func() {
|
} else {
|
||||||
|
go handleStreamTicker(bouncer)
|
||||||
|
}
|
||||||
|
streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() {
|
||||||
handleStreamTicker(bouncer)
|
handleStreamTicker(bouncer)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start metrics ticker if not already running
|
||||||
|
if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 {
|
||||||
|
lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker
|
||||||
|
go handleMetricsTicker(bouncer)
|
||||||
|
metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() {
|
||||||
|
handleMetricsTicker(bouncer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
bouncer.log.Debug("New initialized mode:" + config.CrowdsecMode)
|
bouncer.log.Debug("New initialized mode:" + config.CrowdsecMode)
|
||||||
|
|
||||||
return bouncer, nil
|
return bouncer, nil
|
||||||
@@ -236,17 +337,17 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Here we check for the trusted IPs in the customHeader
|
// Here we check for the trusted IPs in the forwardedCustomHeader
|
||||||
remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.customHeader)
|
remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.forwardedCustomHeader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error()))
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error()))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP)
|
isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error()))
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error()))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// if our IP is in the trusted list we bypass the next checks
|
// if our IP is in the trusted list we bypass the next checks
|
||||||
@@ -257,7 +358,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if bouncer.crowdsecMode == configuration.AppsecMode {
|
if bouncer.crowdsecMode == configuration.AppsecMode {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,17 +368,22 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
if cacheErr != nil {
|
if cacheErr != nil {
|
||||||
cacheErrString := cacheErr.Error()
|
cacheErrString := cacheErr.Error()
|
||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString))
|
||||||
|
if !bouncer.redisUnreachableBlock && cacheErrString == cache.CacheUnreachable {
|
||||||
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s redisUnreachable=true", remoteIP))
|
||||||
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
|
return
|
||||||
|
}
|
||||||
if cacheErrString != cache.CacheMiss {
|
if cacheErrString != cache.CacheMiss {
|
||||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
|
||||||
if value == cache.NoBannedValue {
|
if value == cache.NoBannedValue {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
} else {
|
} else {
|
||||||
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
|
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -286,18 +392,21 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
// Right here if we cannot join the stream we forbid the request to go on.
|
// Right here if we cannot join the stream we forbid the request to go on.
|
||||||
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
|
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
|
||||||
if isCrowdsecStreamHealthy {
|
if isCrowdsecStreamHealthy {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
} else {
|
} else {
|
||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
value, err := handleNoStreamCache(bouncer, remoteIP)
|
value, err := handleNoStreamCache(bouncer, remoteIP)
|
||||||
|
if err != nil {
|
||||||
|
bouncer.log.Debug("handleNoStreamCache:crowdsecQuery " + err.Error())
|
||||||
|
}
|
||||||
if value == cache.NoBannedValue {
|
if value == cache.NoBannedValue {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
} else {
|
} else {
|
||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error()))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error()))
|
||||||
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
|
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,34 +440,56 @@ type Login struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// To append Headers we need to call rw.WriteHeader after set any header.
|
// To append Headers we need to call rw.WriteHeader after set any header.
|
||||||
func handleBanServeHTTP(bouncer *Bouncer, rw http.ResponseWriter) {
|
func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP, reason string) {
|
||||||
if bouncer.banTemplateString == "" {
|
atomic.AddInt64(&blockedRequests, 1)
|
||||||
rw.WriteHeader(http.StatusForbidden)
|
|
||||||
|
if bouncer.remediationCustomHeader != "" {
|
||||||
|
rw.Header().Set(bouncer.remediationCustomHeader, "ban")
|
||||||
|
}
|
||||||
|
rw.Header().Set("Content-Type", bouncer.banTemplateContentType)
|
||||||
|
rw.WriteHeader(bouncer.remediationStatusCode)
|
||||||
|
if bouncer.banTemplate == nil || req.Method == http.MethodHead {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
templateData := map[string]string{
|
||||||
rw.WriteHeader(http.StatusForbidden)
|
"RemediationReason": reason,
|
||||||
fmt.Fprint(rw, bouncer.banTemplateString)
|
"ClientIP": remoteIP,
|
||||||
|
}
|
||||||
|
|
||||||
|
if bouncer.traceCustomHeader != "" {
|
||||||
|
headerVal := req.Header.Get(bouncer.traceCustomHeader)
|
||||||
|
|
||||||
|
if headerVal != "" {
|
||||||
|
templateData["TraceID"] = headerVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := bouncer.banTemplate.Execute(rw, templateData)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
bouncer.log.Warn("handleBanServeHTTP could not write template to ResponseWriter: " + err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRemediationServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) {
|
func (bouncer *Bouncer) handleRemediationServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP, remediation string) {
|
||||||
bouncer.log.Debug(fmt.Sprintf("handleRemediationServeHTTP ip:%s remediation:%s", remoteIP, remediation))
|
bouncer.log.Debug(fmt.Sprintf("handleRemediationServeHTTP ip:%s remediation:%s", remoteIP, remediation))
|
||||||
if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue {
|
if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue && req.Method != http.MethodHead {
|
||||||
if bouncer.captchaClient.Check(remoteIP) {
|
if bouncer.captchaClient.Check(remoteIP) {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
atomic.AddInt64(&blockedRequests, 1) // If we serve a captcha that should count as a dropped request.
|
||||||
bouncer.captchaClient.ServeHTTP(rw, req, remoteIP)
|
bouncer.captchaClient.ServeHTTP(rw, req, remoteIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonLAPI)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWriter, req *http.Request) {
|
func (bouncer *Bouncer) handleNextServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP string) {
|
||||||
if bouncer.appsecEnabled {
|
if bouncer.appsecEnabled {
|
||||||
if err := appsecQuery(bouncer, remoteIP, req); err != nil {
|
if err := appsecQuery(bouncer, remoteIP, req); err != nil {
|
||||||
bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error()))
|
bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error()))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonAPPSEC)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,7 +498,7 @@ func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWrit
|
|||||||
|
|
||||||
func handleStreamTicker(bouncer *Bouncer) {
|
func handleStreamTicker(bouncer *Bouncer) {
|
||||||
if err := handleStreamCache(bouncer); err != nil {
|
if err := handleStreamCache(bouncer); err != nil {
|
||||||
bouncer.log.Debug(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
|
bouncer.log.Warn(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
|
||||||
if bouncer.updateMaxFailure != -1 && updateFailure >= bouncer.updateMaxFailure && isCrowdsecStreamHealthy {
|
if bouncer.updateMaxFailure != -1 && updateFailure >= bouncer.updateMaxFailure && isCrowdsecStreamHealthy {
|
||||||
isCrowdsecStreamHealthy = false
|
isCrowdsecStreamHealthy = false
|
||||||
bouncer.log.Error(fmt.Sprintf("handleStreamTicker:error updateFailure:%d %s", updateFailure, err.Error()))
|
bouncer.log.Error(fmt.Sprintf("handleStreamTicker:error updateFailure:%d %s", updateFailure, err.Error()))
|
||||||
@@ -379,11 +510,17 @@ func handleStreamTicker(bouncer *Bouncer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startTicker(config *configuration.Config, log *logger.Log, work func()) chan bool {
|
func handleMetricsTicker(bouncer *Bouncer) {
|
||||||
ticker := time.NewTicker(time.Duration(config.UpdateIntervalSeconds) * time.Second)
|
if err := reportMetrics(bouncer); err != nil {
|
||||||
|
bouncer.log.Error("handleMetricsTicker:reportMetrics " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTicker(name string, updateInterval int64, log *slog.Logger, work func()) chan bool {
|
||||||
|
ticker := time.NewTicker(time.Duration(updateInterval) * time.Second)
|
||||||
stop := make(chan bool, 1)
|
stop := make(chan bool, 1)
|
||||||
go func() {
|
go func() {
|
||||||
defer log.Debug("ticker:stopped")
|
defer log.Debug(name + "_ticker:stopped")
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
@@ -402,10 +539,10 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
|
|||||||
routeURL := url.URL{
|
routeURL := url.URL{
|
||||||
Scheme: bouncer.crowdsecScheme,
|
Scheme: bouncer.crowdsecScheme,
|
||||||
Host: bouncer.crowdsecHost,
|
Host: bouncer.crowdsecHost,
|
||||||
Path: crowdsecLapiRoute,
|
Path: bouncer.crowdsecPath + crowdsecLapiRoute,
|
||||||
RawQuery: fmt.Sprintf("ip=%v&banned=true", remoteIP),
|
RawQuery: fmt.Sprintf("ip=%v", remoteIP),
|
||||||
}
|
}
|
||||||
body, err := crowdsecQuery(bouncer, routeURL.String(), false)
|
body, err := crowdsecQuery(bouncer, routeURL.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cache.BannedValue, err
|
return cache.BannedValue, err
|
||||||
}
|
}
|
||||||
@@ -446,9 +583,9 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
|
|||||||
case "captcha":
|
case "captcha":
|
||||||
value = cache.CaptchaValue
|
value = cache.CaptchaValue
|
||||||
default:
|
default:
|
||||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
|
||||||
}
|
}
|
||||||
if isLiveMode {
|
if isLiveMode && bouncer.defaultDecisionTimeout > 0 {
|
||||||
durationSecond := int64(duration.Seconds())
|
durationSecond := int64(duration.Seconds())
|
||||||
if bouncer.defaultDecisionTimeout < durationSecond {
|
if bouncer.defaultDecisionTimeout < durationSecond {
|
||||||
durationSecond = bouncer.defaultDecisionTimeout
|
durationSecond = bouncer.defaultDecisionTimeout
|
||||||
@@ -464,7 +601,16 @@ func getToken(bouncer *Bouncer) error {
|
|||||||
Host: bouncer.crowdsecHost,
|
Host: bouncer.crowdsecHost,
|
||||||
Path: crowdsecCapiLoginRoute,
|
Path: crowdsecCapiLoginRoute,
|
||||||
}
|
}
|
||||||
body, err := crowdsecQuery(bouncer, loginURL.String(), true)
|
|
||||||
|
// Move the login-specific payload here
|
||||||
|
loginData := []byte(fmt.Sprintf(
|
||||||
|
`{"machine_id": "%v","password": "%v","scenarios": ["%v"]}`,
|
||||||
|
bouncer.crowdsecMachineID,
|
||||||
|
bouncer.crowdsecPassword,
|
||||||
|
strings.Join(bouncer.crowdsecScenarios, `","`),
|
||||||
|
))
|
||||||
|
|
||||||
|
body, err := crowdsecQuery(bouncer, loginURL.String(), loginData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -473,11 +619,11 @@ func getToken(bouncer *Bouncer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getToken:parsingBody %w", err)
|
return fmt.Errorf("getToken:parsingBody %w", err)
|
||||||
}
|
}
|
||||||
if login.Code == 200 && len(login.Token) > 0 {
|
if login.Code == http.StatusOK && len(login.Token) > 0 {
|
||||||
bouncer.crowdsecKey = login.Token
|
bouncer.crowdsecKey = login.Token
|
||||||
bouncer.log.Debug(fmt.Sprintf("getToken statusCode:%d", login.Code))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
bouncer.log.Warn(fmt.Sprintf("getToken statusCode:%d", login.Code))
|
||||||
return fmt.Errorf("getToken statusCode:%d", login.Code)
|
return fmt.Errorf("getToken statusCode:%d", login.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,19 +635,25 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
_, err := bouncer.cacheClient.Get(cacheTimeoutKey)
|
_, err := bouncer.cacheClient.Get(cacheTimeoutKey)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
bouncer.log.Debug("handleStreamCache:alreadyUpdated")
|
bouncer.log.Debug("handleStreamCache:alreadyUpdated")
|
||||||
|
isCrowdsecStreamStartup = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err.Error() != cache.CacheMiss {
|
if err.Error() != cache.CacheMiss {
|
||||||
return err
|
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{
|
streamRouteURL := url.URL{
|
||||||
Scheme: bouncer.crowdsecScheme,
|
Scheme: bouncer.crowdsecScheme,
|
||||||
Host: bouncer.crowdsecHost,
|
Host: bouncer.crowdsecHost,
|
||||||
Path: bouncer.crowdsecStreamRoute,
|
Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute,
|
||||||
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup),
|
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isCrowdsecStreamStartup),
|
||||||
}
|
}
|
||||||
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), false)
|
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -520,7 +672,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
case "captcha":
|
case "captcha":
|
||||||
value = cache.CaptchaValue
|
value = cache.CaptchaValue
|
||||||
default:
|
default:
|
||||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
|
||||||
}
|
}
|
||||||
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
|
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
|
||||||
}
|
}
|
||||||
@@ -529,25 +681,28 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
bouncer.cacheClient.Delete(decision.Value)
|
bouncer.cacheClient.Delete(decision.Value)
|
||||||
}
|
}
|
||||||
bouncer.log.Debug("handleStreamCache:updated")
|
bouncer.log.Debug("handleStreamCache:updated")
|
||||||
|
isCrowdsecStreamStartup = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, error) {
|
func 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
|
var req *http.Request
|
||||||
if isPost {
|
if len(data) > 0 {
|
||||||
data := []byte(fmt.Sprintf(
|
|
||||||
`{"machine_id": "%v","password": "%v","scenarios": ["%v"]}`,
|
|
||||||
bouncer.crowdsecMachineID,
|
|
||||||
bouncer.crowdsecPassword,
|
|
||||||
strings.Join(bouncer.crowdsecScenarios, `","`),
|
|
||||||
))
|
|
||||||
req, _ = http.NewRequest(http.MethodPost, stringURL, bytes.NewBuffer(data))
|
req, _ = http.NewRequest(http.MethodPost, stringURL, bytes.NewBuffer(data))
|
||||||
} else {
|
} else {
|
||||||
req, _ = http.NewRequest(http.MethodGet, stringURL, nil)
|
req, _ = http.NewRequest(http.MethodGet, stringURL, nil)
|
||||||
}
|
}
|
||||||
req.Header.Add(bouncer.crowdsecHeader, bouncer.crowdsecKey)
|
req.Header.Set(bouncer.crowdsecHeader, bouncer.crowdsecKey)
|
||||||
|
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||||
|
|
||||||
res, err := bouncer.httpClient.Do(req)
|
res, err := bouncer.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil || isReverseProxyError(res.StatusCode) {
|
||||||
return nil, fmt.Errorf("crowdsecQuery:unreachable url:%s %w", stringURL, err)
|
return nil, fmt.Errorf("crowdsecQuery:unreachable url:%s %w", stringURL, err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -559,34 +714,68 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, err
|
|||||||
if errToken := getToken(bouncer); errToken != nil {
|
if errToken := getToken(bouncer); errToken != nil {
|
||||||
return nil, fmt.Errorf("crowdsecQuery:renewToken url:%s %w", stringURL, errToken)
|
return nil, fmt.Errorf("crowdsecQuery:renewToken url:%s %w", stringURL, errToken)
|
||||||
}
|
}
|
||||||
return crowdsecQuery(bouncer, stringURL, false)
|
return crowdsecQuery(bouncer, stringURL, nil)
|
||||||
}
|
}
|
||||||
if res.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("crowdsecQuery url:%s, statusCode:%d", stringURL, res.StatusCode)
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(res.Body)
|
|
||||||
|
|
||||||
|
// Check if the status code starts with 2
|
||||||
|
statusStr := strconv.Itoa(res.StatusCode)
|
||||||
|
if len(statusStr) < 1 || statusStr[0] != '2' {
|
||||||
|
return nil, fmt.Errorf("crowdsecQuery method:%s url:%s, statusCode:%d (expected: 2xx)", req.Method, stringURL, res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("crowdsecQuery:readBody %w", err)
|
return nil, fmt.Errorf("crowdsecQuery:readBody %w", err)
|
||||||
}
|
}
|
||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isBodyUnreadable reports whether the request body cannot be buffered before
|
||||||
|
// forwarding it to the Appsec component. An HTTP/2 or HTTP/3 request without a
|
||||||
|
// Content-Length (typically a bidirectional gRPC stream) keeps its body open
|
||||||
|
// for the whole life of the stream and never reaches EOF, so reading it with
|
||||||
|
// io.ReadAll would block until the request times out and is wrongly turned into
|
||||||
|
// a 403. This mirrors the reference lua-cs-bouncer behavior, which refuses to
|
||||||
|
// read the body of an HTTP/2+ request that has no Content-Length.
|
||||||
|
func isBodyUnreadable(httpReq *http.Request) bool {
|
||||||
|
return httpReq.Body != nil && httpReq.Body != http.NoBody && httpReq.ProtoMajor >= 2 && httpReq.ContentLength < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMethodWithBody used only when isBodyUnreadable returns true but the request method can't have body.
|
||||||
|
func isMethodWithBody(method string) bool {
|
||||||
|
switch method {
|
||||||
|
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||||
routeURL := url.URL{
|
routeURL := url.URL{
|
||||||
Scheme: bouncer.crowdsecScheme,
|
Scheme: bouncer.appsecScheme,
|
||||||
Host: bouncer.appsecHost,
|
Host: bouncer.appsecHost,
|
||||||
Path: "/",
|
Path: bouncer.appsecPath,
|
||||||
}
|
}
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
if httpReq.Body != nil && httpReq.ContentLength > 0 {
|
switch {
|
||||||
bodyBytes, err := io.ReadAll(httpReq.Body)
|
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)
|
||||||
|
bodyBytes, err := io.ReadAll(teeReader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("appsecQuery:GetBody %w", err)
|
return fmt.Errorf("appsecQuery:GetBody %w", err)
|
||||||
}
|
}
|
||||||
httpReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
// Conserve body intact after reading it for other middlewares and service
|
||||||
|
httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body))
|
||||||
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
|
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
|
||||||
} else {
|
default:
|
||||||
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
|
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,15 +784,16 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
|||||||
req.Header.Add(key, value)
|
req.Header.Add(key, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
req.Header.Set(crowdsecAppsecHeader, bouncer.crowdsecKey)
|
req.Header.Set(crowdsecAppsecHeader, bouncer.appsecKey)
|
||||||
req.Header.Set(crowdsecAppsecIPHeader, ip)
|
req.Header.Set(crowdsecAppsecIPHeader, ip)
|
||||||
req.Header.Set(crowdsecAppsecVerbHeader, httpReq.Method)
|
req.Header.Set(crowdsecAppsecVerbHeader, httpReq.Method)
|
||||||
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
|
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
|
||||||
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
|
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
|
||||||
req.Header.Set(crowdsecAppsecUserAgent, httpReq.Header.Get("User-Agent"))
|
req.Header.Set(crowdsecAppsecUserAgent, httpReq.Header.Get("User-Agent"))
|
||||||
|
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||||
|
|
||||||
res, err := bouncer.httpClient.Do(req)
|
res, err := bouncer.httpAppsecClient.Do(req)
|
||||||
if err != nil {
|
if err != nil || isReverseProxyError(res.StatusCode) {
|
||||||
bouncer.log.Error("appsecQuery:unreachable")
|
bouncer.log.Error("appsecQuery:unreachable")
|
||||||
if bouncer.appsecUnreachableBlock {
|
if bouncer.appsecUnreachableBlock {
|
||||||
return fmt.Errorf("appsecQuery:unreachable %w", err)
|
return fmt.Errorf("appsecQuery:unreachable %w", err)
|
||||||
@@ -631,3 +821,65 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reportMetrics(bouncer *Bouncer) error {
|
||||||
|
now := time.Now()
|
||||||
|
currentCount := atomic.LoadInt64(&blockedRequests)
|
||||||
|
windowSizeSeconds := int(now.Sub(lastMetricsPush).Seconds())
|
||||||
|
|
||||||
|
bouncer.log.Debug(fmt.Sprintf("reportMetrics: blocked_requests=%d window_size=%ds", currentCount, windowSizeSeconds))
|
||||||
|
|
||||||
|
metrics := map[string]interface{}{
|
||||||
|
"remediation_components": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"version": pluginVersion,
|
||||||
|
"type": "bouncer",
|
||||||
|
"name": "traefik_plugin",
|
||||||
|
"metrics": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"items": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "dropped",
|
||||||
|
"value": currentCount,
|
||||||
|
"unit": "request",
|
||||||
|
"labels": map[string]string{
|
||||||
|
"type": "traefik_plugin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"meta": map[string]interface{}{
|
||||||
|
"window_size_seconds": windowSizeSeconds,
|
||||||
|
"utc_now_timestamp": now.Unix(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"utc_startup_timestamp": time.Now().Unix(),
|
||||||
|
"feature_flags": []string{},
|
||||||
|
"os": map[string]string{
|
||||||
|
"name": "unknown",
|
||||||
|
"version": "unknown",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(metrics)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reportMetrics:marshal %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metricsURL := url.URL{
|
||||||
|
Scheme: bouncer.crowdsecScheme,
|
||||||
|
Host: bouncer.crowdsecHost,
|
||||||
|
Path: bouncer.crowdsecPath + crowdsecLapiMetricsRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = crowdsecQuery(bouncer, metricsURL.String(), data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reportMetrics:query %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.StoreInt64(&blockedRequests, 0)
|
||||||
|
lastMetricsPush = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
+373
-4
@@ -2,15 +2,20 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
||||||
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||||
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
||||||
|
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestServeHTTP(t *testing.T) {
|
func TestServeHTTP(t *testing.T) {
|
||||||
@@ -75,7 +80,7 @@ func TestBouncer_ServeHTTP(t *testing.T) {
|
|||||||
crowdsecMode string
|
crowdsecMode string
|
||||||
updateInterval int64
|
updateInterval int64
|
||||||
defaultDecisionTimeout int64
|
defaultDecisionTimeout int64
|
||||||
customHeader string
|
forwardedCustomHeader string
|
||||||
clientPoolStrategy *ip.PoolStrategy
|
clientPoolStrategy *ip.PoolStrategy
|
||||||
serverPoolStrategy *ip.PoolStrategy
|
serverPoolStrategy *ip.PoolStrategy
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -105,7 +110,7 @@ func TestBouncer_ServeHTTP(t *testing.T) {
|
|||||||
crowdsecMode: tt.fields.crowdsecMode,
|
crowdsecMode: tt.fields.crowdsecMode,
|
||||||
updateInterval: tt.fields.updateInterval,
|
updateInterval: tt.fields.updateInterval,
|
||||||
defaultDecisionTimeout: tt.fields.defaultDecisionTimeout,
|
defaultDecisionTimeout: tt.fields.defaultDecisionTimeout,
|
||||||
customHeader: tt.fields.customHeader,
|
forwardedCustomHeader: tt.fields.forwardedCustomHeader,
|
||||||
clientPoolStrategy: tt.fields.clientPoolStrategy,
|
clientPoolStrategy: tt.fields.clientPoolStrategy,
|
||||||
serverPoolStrategy: tt.fields.serverPoolStrategy,
|
serverPoolStrategy: tt.fields.serverPoolStrategy,
|
||||||
httpClient: tt.fields.httpClient,
|
httpClient: tt.fields.httpClient,
|
||||||
@@ -163,7 +168,7 @@ func Test_crowdsecQuery(t *testing.T) {
|
|||||||
type args struct {
|
type args struct {
|
||||||
bouncer *Bouncer
|
bouncer *Bouncer
|
||||||
stringURL string
|
stringURL string
|
||||||
isPost bool
|
data []byte
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -175,7 +180,7 @@ func Test_crowdsecQuery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := crowdsecQuery(tt.args.bouncer, tt.args.stringURL, tt.args.isPost)
|
got, err := crowdsecQuery(tt.args.bouncer, tt.args.stringURL, tt.args.data)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("crowdsecQuery() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("crowdsecQuery() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
return
|
return
|
||||||
@@ -186,3 +191,367 @@ func Test_crowdsecQuery(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
|
||||||
|
html := "<html>You are banned</html>"
|
||||||
|
banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
banTemplate *template.Template
|
||||||
|
expectBodyContent bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "GET request should have body with template",
|
||||||
|
method: http.MethodGet,
|
||||||
|
banTemplate: banTemplate,
|
||||||
|
expectBodyContent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HEAD request should NOT have body even with template",
|
||||||
|
method: http.MethodHead,
|
||||||
|
banTemplate: banTemplate,
|
||||||
|
expectBodyContent: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "POST request should have body with template",
|
||||||
|
method: http.MethodPost,
|
||||||
|
banTemplate: banTemplate,
|
||||||
|
expectBodyContent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PUT request should have body with template",
|
||||||
|
method: http.MethodPut,
|
||||||
|
banTemplate: banTemplate,
|
||||||
|
expectBodyContent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DELETE request should have body with template",
|
||||||
|
method: http.MethodDelete,
|
||||||
|
banTemplate: banTemplate,
|
||||||
|
expectBodyContent: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
bouncer := &Bouncer{
|
||||||
|
remediationStatusCode: http.StatusForbidden,
|
||||||
|
remediationCustomHeader: "X-Test-Remediation",
|
||||||
|
banTemplate: tt.banTemplate,
|
||||||
|
banTemplateContentType: "text/html; charset=utf-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
req := &http.Request{Method: tt.method}
|
||||||
|
bouncer.handleBanServeHTTP(rw, req, "0.0.0.0", "TEST")
|
||||||
|
|
||||||
|
// Check status code
|
||||||
|
if rw.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("Expected status code 403, got %d", rw.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check custom header
|
||||||
|
headerValue := rw.Header().Get("X-Test-Remediation")
|
||||||
|
if headerValue != "ban" {
|
||||||
|
t.Errorf("Expected header X-Test-Remediation to be 'ban', got %s", headerValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check body content
|
||||||
|
body := rw.Body.String()
|
||||||
|
hasBodyContent := len(body) > 0
|
||||||
|
|
||||||
|
if hasBodyContent != tt.expectBodyContent {
|
||||||
|
t.Errorf("Method %s: expected body content: %v, got body content: %v (body: %q)",
|
||||||
|
tt.method, tt.expectBodyContent, hasBodyContent, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we expect body content, verify it matches template
|
||||||
|
if tt.expectBodyContent && body != html {
|
||||||
|
t.Errorf("Expected body %q, got %q", html, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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
|
||||||
|
method string
|
||||||
|
remediation string
|
||||||
|
expectBanFallback bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "GET with captcha remediation should allow captcha",
|
||||||
|
method: http.MethodGet,
|
||||||
|
remediation: cache.CaptchaValue,
|
||||||
|
expectBanFallback: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HEAD with captcha remediation should fallback to ban",
|
||||||
|
method: http.MethodHead,
|
||||||
|
remediation: cache.CaptchaValue,
|
||||||
|
expectBanFallback: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "POST with captcha remediation should allow captcha",
|
||||||
|
method: http.MethodPost,
|
||||||
|
remediation: cache.CaptchaValue,
|
||||||
|
expectBanFallback: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PUT with captcha remediation should allow captcha",
|
||||||
|
method: http.MethodPut,
|
||||||
|
remediation: cache.CaptchaValue,
|
||||||
|
expectBanFallback: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DELETE with captcha remediation should allow captcha",
|
||||||
|
method: http.MethodDelete,
|
||||||
|
remediation: cache.CaptchaValue,
|
||||||
|
expectBanFallback: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PATCH with captcha remediation should allow captcha",
|
||||||
|
method: http.MethodPatch,
|
||||||
|
remediation: cache.CaptchaValue,
|
||||||
|
expectBanFallback: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OPTIONS with captcha remediation should allow captcha",
|
||||||
|
method: http.MethodOptions,
|
||||||
|
remediation: cache.CaptchaValue,
|
||||||
|
expectBanFallback: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Test the core logic: captcha is served for all methods except HEAD
|
||||||
|
shouldUseCaptcha := tt.remediation == cache.CaptchaValue && tt.method != http.MethodHead
|
||||||
|
|
||||||
|
if shouldUseCaptcha == tt.expectBanFallback {
|
||||||
|
t.Errorf("Method %s with %s remediation: expected ban fallback %v, but logic would use captcha %v",
|
||||||
|
tt.method, tt.remediation, tt.expectBanFallback, shouldUseCaptcha)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
-1
@@ -293,7 +293,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
|
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
|
||||||
</div>
|
</div>
|
||||||
<form action="" method="POST" class="flex flex-col space-y-1" id="captcha-form">
|
<form action="" method="POST" class="flex flex-col items-center space-y-1" id="captcha-form">
|
||||||
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
|
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.7.11"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -16,8 +16,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- logs-local:/var/log/traefik
|
- logs-local:/var/log/traefik
|
||||||
- './ban.html:/ban.html:ro'
|
- "./ban.html:/ban.html:ro"
|
||||||
- './captcha.html:/captcha.html:ro'
|
- "./captcha.html:/captcha.html:ro"
|
||||||
- ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
- ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
ports:
|
ports:
|
||||||
- 8000:80
|
- 8000:80
|
||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
- "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
|
- "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
|
||||||
- "traefik.http.services.service-foo.loadbalancer.server.port=80"
|
- "traefik.http.services.service-foo.loadbalancer.server.port=80"
|
||||||
|
|
||||||
whoami2:
|
bar:
|
||||||
image: traefik/whoami
|
image: traefik/whoami
|
||||||
container_name: "simple-service-bar"
|
container_name: "simple-service-bar"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -48,12 +48,39 @@ services:
|
|||||||
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.metricsupdateintervalseconds=15"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=stream"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=stream"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.ForwardedHeadersTrustedIPs=172.21.0.1/8"
|
||||||
|
|
||||||
|
bar2:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: "simple-service-bar2"
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.router-bar2.rule=PathPrefix(`/bar2`)"
|
||||||
|
- "traefik.http.routers.router-bar2.entrypoints=web"
|
||||||
|
- "traefik.http.routers.router-bar2.middlewares=crowdsec2@docker"
|
||||||
|
- "traefik.http.services.service-bar2.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.enabled=true"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.loglevel=DEBUG"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.crowdsecmode=stream"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.updateintervalseconds=10"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.updatemaxfailure=-1"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
|
||||||
|
bar3:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: "simple-service-bar3"
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.router-bar3.rule=PathPrefix(`/bar3`)"
|
||||||
|
- "traefik.http.routers.router-bar3.entrypoints=web"
|
||||||
|
- "traefik.http.routers.router-bar3.middlewares=crowdsec2@docker"
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -67,7 +94,6 @@ services:
|
|||||||
- crowdsec-config-local:/etc/crowdsec/
|
- crowdsec-config-local:/etc/crowdsec/
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=false"
|
- "traefik.enable=false"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
logs-local:
|
logs-local:
|
||||||
crowdsec-db-local:
|
crowdsec-db-local:
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.7.11"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -12,7 +12,7 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||||
volumes:
|
volumes:
|
||||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||||
# - './ban.html:/ban.html:ro'
|
# - './ban.html:/ban.html:ro'
|
||||||
@@ -59,7 +59,7 @@ services:
|
|||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+3
-7
@@ -1,8 +1,6 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.7.11"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -15,7 +13,7 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
@@ -48,10 +46,8 @@ services:
|
|||||||
# Define AppSec host and port informations
|
# Define AppSec host and port informations
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
cloudflare:
|
cloudflare:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.7.11"
|
||||||
container_name: "cloudflare"
|
container_name: "cloudflare"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
- 8080:8080
|
- 8080:8080
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.7.11"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -33,7 +33,7 @@ services:
|
|||||||
- "--entrypoints.web.forwardedheaders.trustedips=172.21.0.5"
|
- "--entrypoints.web.forwardedheaders.trustedips=172.21.0.5"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- logs-traefik:/var/log/traefik
|
- logs-traefik:/var/log/traefik
|
||||||
@@ -79,7 +79,7 @@ services:
|
|||||||
|
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ For now 3 captcha providers are supported:
|
|||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSiteKey=FIXME"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSiteKey=FIXME"
|
||||||
# Define captcha secret key
|
# Define captcha secret key
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSecretKey=FIXME"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSecretKey=FIXME"
|
||||||
# Define captcha grade period seconds
|
# Define captcha grace period seconds
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800"
|
||||||
# Define captcha HTML file path
|
# Define captcha HTML file path
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||||
@@ -47,12 +47,12 @@ To instruct Crowdsec to use captcha remediation, change the `/etc/crowdsec/profi
|
|||||||
- Return a captcha decision the first X times and then a ban decision.
|
- Return a captcha decision the first X times and then a ban decision.
|
||||||
|
|
||||||
The second mode could be used to prevent repeated malicious activity.
|
The second mode could be used to prevent repeated malicious activity.
|
||||||
More information is available on configuring Crowdsec in the [official documentation](https://docs.crowdsec.net/docs/next/profiles/captcha_profile/).
|
More information is available on configuring Crowdsec in the [official documentation](https://docs.crowdsec.net/docs/next/local_api/profiles/captcha_profile/).
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
...
|
...
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.6.8
|
||||||
volumes:
|
volumes:
|
||||||
# For captcha and ban mixed decision
|
# For captcha and ban mixed decision
|
||||||
- './profiles.yaml:/etc/crowdsec/profiles.yaml:ro'
|
- './profiles.yaml:/etc/crowdsec/profiles.yaml:ro'
|
||||||
@@ -100,9 +100,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the user IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the user IP has a crowdsec decision ?
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -121,12 +121,12 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Fine, done!
|
User->>TraefikPlugin: Fine, done!
|
||||||
create participant ProviderCaptcha
|
create participant ProviderCaptcha
|
||||||
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
||||||
Destroy ProviderCaptcha
|
destroy ProviderCaptcha
|
||||||
ProviderCaptcha-->>TraefikPlugin: Yes
|
ProviderCaptcha-->>TraefikPlugin: Yes
|
||||||
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -140,7 +140,7 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a Crowdsec Decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a Crowdsec Decision ?
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Yes a ban Decision
|
PluginCache-->>TraefikPlugin: Yes a ban Decision
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -293,7 +293,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
|
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
|
||||||
</div>
|
</div>
|
||||||
<form action="" method="POST" class="flex flex-col space-y-1" id="captcha-form">
|
<form action="" method="POST" class="flex flex-col items-center space-y-1" id="captcha-form">
|
||||||
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
|
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.7.11"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
@@ -55,7 +55,7 @@ services:
|
|||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ This can be usefull as some browser (Firefox for instance) return a 403 blank we
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
labels:
|
labels:
|
||||||
# Define ban HTML file path
|
# Define ban file path
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
|
- "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.
|
It is not directly accessible from Traefik even when importing the plugin, so [download](https://raw.githubusercontent.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/master/ban.html) it locally to expose it to Traefik.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -45,3 +45,15 @@ To play the demo environment run:
|
|||||||
```bash
|
```bash
|
||||||
make run_custom_ban_page
|
make run_custom_ban_page
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Another thing to note
|
||||||
|
In the html of the ban page, you can use:
|
||||||
|
- {{ .ClientIP }} to display the IP used to ban the request.
|
||||||
|
- {{ .RemediationReason }} that convert on runtime into why the ban page is served. It's an enum with "APPSEC", "LAPI", "TECHNICAL_ISSUE" and it is useful to help user understand why the request is blocked.
|
||||||
|
- {{ .CustomHeader }} value of the specified Request Header (for example X-Request-ID)
|
||||||
|
```
|
||||||
|
<script>var remediation = "{{ .RemediationReason }}"</script>
|
||||||
|
<script>var clientIp = "{{ .ClientIP }}"</script>
|
||||||
|
<script>var traceID = "{{ .TraceID }}"</script>
|
||||||
|
```
|
||||||
|
With the above tweak and some other js, you can customize your ban page on runtime.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.7.11"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
@@ -42,11 +42,11 @@ services:
|
|||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
# Define ban HTML file path
|
# Define ban file path
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html"
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Example
|
||||||
|
|
||||||
|
Read the example captcha before this, to better understand what is done here.
|
||||||
|
|
||||||
|
### Traefik configuration
|
||||||
|
|
||||||
|
The minimal configuration is defined below to implement custom captcha.
|
||||||
|
This documentation use https://github.com/a-ve/wicketkeeper, a self-hosted captcha provider that have a similar API than big providers.
|
||||||
|
|
||||||
|
Minimal API requirement:
|
||||||
|
|
||||||
|
- the JS file URL to load the captcha on the served `captcha.html`
|
||||||
|
- the HTML className to tell to the JS where to display the challenge
|
||||||
|
- the verify URL endpoint to send the field `response` from the captcha with `content-type: application/x-www-form-urlencoded`
|
||||||
|
- the name of the field when you POST the resolved captcha to Traefik
|
||||||
|
|
||||||
|
- the JS file need to respect the `data-callback` on the div that contains the captcha if you use our template, but you can customize it by your side
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
traefik:
|
||||||
|
...
|
||||||
|
labels:
|
||||||
|
# Choose captcha provider
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaProvider=custom"
|
||||||
|
# Define captcha grace period seconds
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaCustomJsURL=http://captcha.localhost:8000/fast.js"
|
||||||
|
# Inside Traefik container the plugin must be able to reach wicketkeeper service so we can go through a Traefik localhost
|
||||||
|
# domain which would resolve traefik itself and the port for the dashboard
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomValidateURL=http://wicketkeeper:8080/v0/siteverify"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomKey=wicketkeeper"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomResponse=wicketkeeper_solution"
|
||||||
|
# Define captcha HTML file path
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
wicketkeeper:
|
||||||
|
image: ghcr.io/a-ve/wicketkeeper:latest
|
||||||
|
user: root
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- LISTEN_PORT=8080
|
||||||
|
- REDIS_ADDR=redis:6379
|
||||||
|
- DIFFICULTY=4
|
||||||
|
- ALLOWED_ORIGINS=*
|
||||||
|
- PRIVATE_KEY_PATH=/data/wicketkeeper.key
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
redis:
|
||||||
|
image: redis/redis-stack-server:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback" data-challenge-url="http://captcha.localhost:8000/v0/challenge">
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exemple navigation
|
||||||
|
|
||||||
|
We can try to query normally the whoami server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/foo
|
||||||
|
```
|
||||||
|
|
||||||
|
We can try to ban ourself and retry.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec crowdsec cscli decisions add --ip 10.0.0.20 -d 10m --type captcha
|
||||||
|
```
|
||||||
|
|
||||||
|
To play the demo environment run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run_custom_captcha
|
||||||
|
```
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
filenames:
|
||||||
|
- /var/log/traefik/access.log
|
||||||
|
labels:
|
||||||
|
type: traefik
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>CrowdSec Captcha</title>
|
||||||
|
<meta content="text/html; charset=utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<style>
|
||||||
|
/*! tailwindcss v3.2.7 | MIT License | https://tailwindcss.com*/
|
||||||
|
*,
|
||||||
|
:after,
|
||||||
|
:before {
|
||||||
|
border: 0 solid #e5e7eb;
|
||||||
|
box-sizing: border-box
|
||||||
|
}
|
||||||
|
|
||||||
|
:after,
|
||||||
|
:before {
|
||||||
|
--tw-content: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
font-feature-settings: normal;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
|
||||||
|
line-height: 1.5;
|
||||||
|
-moz-tab-size: 4;
|
||||||
|
-o-tab-size: 4;
|
||||||
|
tab-size: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
line-height: inherit;
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6,
|
||||||
|
hr,
|
||||||
|
p,
|
||||||
|
pre {
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
::backdrop,
|
||||||
|
:after,
|
||||||
|
:before {
|
||||||
|
--tw-border-spacing-x: 0;
|
||||||
|
--tw-border-spacing-y: 0;
|
||||||
|
--tw-translate-x: 0;
|
||||||
|
--tw-translate-y: 0;
|
||||||
|
--tw-rotate: 0;
|
||||||
|
--tw-skew-x: 0;
|
||||||
|
--tw-skew-y: 0;
|
||||||
|
--tw-scale-x: 1;
|
||||||
|
--tw-scale-y: 1;
|
||||||
|
--tw-pan-x: ;
|
||||||
|
--tw-pan-y: ;
|
||||||
|
--tw-pinch-zoom: ;
|
||||||
|
--tw-scroll-snap-strictness: proximity;
|
||||||
|
--tw-ordinal: ;
|
||||||
|
--tw-slashed-zero: ;
|
||||||
|
--tw-numeric-figure: ;
|
||||||
|
--tw-numeric-spacing: ;
|
||||||
|
--tw-numeric-fraction: ;
|
||||||
|
--tw-ring-inset: ;
|
||||||
|
--tw-ring-offset-width: 0px;
|
||||||
|
--tw-ring-offset-color: #fff;
|
||||||
|
--tw-ring-color: #3b82f680;
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000;
|
||||||
|
--tw-ring-shadow: 0 0 #0000;
|
||||||
|
--tw-shadow: 0 0 #0000;
|
||||||
|
--tw-shadow-colored: 0 0 #0000;
|
||||||
|
--tw-blur: ;
|
||||||
|
--tw-brightness: ;
|
||||||
|
--tw-contrast: ;
|
||||||
|
--tw-grayscale: ;
|
||||||
|
--tw-hue-rotate: ;
|
||||||
|
--tw-invert: ;
|
||||||
|
--tw-saturate: ;
|
||||||
|
--tw-sepia: ;
|
||||||
|
--tw-drop-shadow: ;
|
||||||
|
--tw-backdrop-blur: ;
|
||||||
|
--tw-backdrop-brightness: ;
|
||||||
|
--tw-backdrop-contrast: ;
|
||||||
|
--tw-backdrop-grayscale: ;
|
||||||
|
--tw-backdrop-hue-rotate: ;
|
||||||
|
--tw-backdrop-invert: ;
|
||||||
|
--tw-backdrop-opacity: ;
|
||||||
|
--tw-backdrop-saturate: ;
|
||||||
|
--tw-backdrop-sepia:
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex
|
||||||
|
}
|
||||||
|
.flex-wrap {
|
||||||
|
flex-wrap: wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-flex {
|
||||||
|
display: inline-flex
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-24 {
|
||||||
|
height: 6rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-6 {
|
||||||
|
height: 1.5rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-full {
|
||||||
|
height: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-screen {
|
||||||
|
height: 100vh
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-24 {
|
||||||
|
width: 6rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-6 {
|
||||||
|
width: 1.5rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-full {
|
||||||
|
width: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-screen {
|
||||||
|
width: 100vw
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-3 {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-col {
|
||||||
|
flex-direction: column
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-center {
|
||||||
|
align-items: center
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-y-1>:not([hidden])~:not([hidden]) {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
margin-bottom: calc(.25rem*var(--tw-space-y-reverse));
|
||||||
|
margin-top: calc(.25rem*(1 - var(--tw-space-y-reverse)))
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-y-4>:not([hidden])~:not([hidden]) {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
margin-bottom: calc(1rem*var(--tw-space-y-reverse));
|
||||||
|
margin-top: calc(1rem*(1 - var(--tw-space-y-reverse)))
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-xl {
|
||||||
|
border-radius: .75rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-2 {
|
||||||
|
border-width: 2px
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-black {
|
||||||
|
--tw-border-opacity: 1;
|
||||||
|
border-color: rgb(0 0 0/var(--tw-border-opacity))
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-4 {
|
||||||
|
padding: 1rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.px-4 {
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-2 {
|
||||||
|
padding-bottom: .5rem;
|
||||||
|
padding-top: .5rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-2xl {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 2rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-sm {
|
||||||
|
font-size: .875rem;
|
||||||
|
line-height: 1.25rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-xl {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.75rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-bold {
|
||||||
|
font-weight: 700
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-white {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(255 255 255/var(--tw-text-opacity))
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width:640px) {
|
||||||
|
.sm\:w-2\/3 {
|
||||||
|
width: 66.666667%
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width:768px) {
|
||||||
|
.md\:flex-row {
|
||||||
|
flex-direction: row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width:1024px) {
|
||||||
|
.lg\:w-1\/2 {
|
||||||
|
width: 50%
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg\:text-3xl {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
line-height: 2.25rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg\:text-xl {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.75rem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width:1280px) {
|
||||||
|
.xl\:text-4xl {
|
||||||
|
font-size: 2.25rem;
|
||||||
|
line-height: 2.5rem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="{{ .FrontendJS }}" async defer></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="h-screen w-screen p-4">
|
||||||
|
<div class="h-full w-full flex flex-col justify-center items-center">
|
||||||
|
<div class="border-2 border-black rounded-xl p-4 text-center w-full sm:w-2/3 lg:w-1/2">
|
||||||
|
<div class="flex flex-col items-center space-y-4">
|
||||||
|
<svg fill="black" class="h-24 w-24" aria-hidden="true" focusable="false" data-prefix="fas"
|
||||||
|
data-icon="exclamation-triangle" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"
|
||||||
|
class="warning">
|
||||||
|
<path
|
||||||
|
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
|
||||||
|
</div>
|
||||||
|
<form action="" method="POST" class="flex flex-col items-center space-y-1" id="captcha-form">
|
||||||
|
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback" data-challenge-url="http://captcha.localhost:8000/v0/challenge">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="flex justify-center flex-wrap">
|
||||||
|
<p class="my-3">This security check has been powered by</p>
|
||||||
|
<a href="https://crowdsec.net/" target="_blank" rel="noopener" class="inline-flex flex-col items-center">
|
||||||
|
<svg fill="black" width="33.92" height="33.76" viewBox="0 0 254.4 253.2">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="a">
|
||||||
|
<path d="M0 52h84v201.2H0zm0 0" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="b">
|
||||||
|
<path d="M170 52h84.4v201.2H170zm0 0" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
d="M59.3 128.4c1.4 2.3 2.5 4.6 3.4 7-1-4.1-2.3-8.1-4.3-12-3.1-6-7.8-5.8-10.7 0-2 4-3.2 8-4.3 12.1 1-2.4 2-4.8 3.4-7.1 3.4-5.8 8.8-6 12.5 0M207.8 128.4a42.9 42.9 0 013.4 7c-1-4.1-2.3-8.1-4.3-12-3.2-6-7.8-5.8-10.7 0-2 4-3.3 8-4.3 12.1.9-2.4 2-4.8 3.4-7.1 3.4-5.8 8.8-6 12.5 0M134.6 92.9c2 3.5 3.6 7 4.8 10.7-1.3-5.4-3-10.6-5.6-15.7-4-7.5-9.7-7.2-13.3 0a75.4 75.4 0 00-5.6 16c1.2-3.8 2.7-7.4 4.7-11 4.1-7.2 10.6-7.5 15 0M43.8 136.8c.9 4.6 3.7 8.3 7.3 9.2 0 2.7 0 5.5.2 8.2.3 3.3.4 6.6 1 9.6.3 2.3 1 2.2 1.3 0 .5-3 .6-6.3 1-9.6l.2-8.2c3.5-1 6.4-4.6 7.2-9.2a17.8 17.8 0 01-9 2.4c-3.5 0-6.6-1-9.2-2.4M192.4 136.8c.8 4.6 3.7 8.3 7.2 9.2 0 2.7 0 5.5.3 8.2.3 3.3.4 6.6 1 9.6.3 2.3.9 2.2 1.2 0 .6-3 .7-6.3 1-9.6.2-2.7.3-5.5.2-8.2 3.6-1 6.4-4.6 7.3-9.2a17.8 17.8 0 01-9.1 2.4c-3.4 0-6.6-1-9.1-2.4M138.3 104.6c-3.1 1.9-7 3-11.3 3-4.3 0-8.2-1.1-11.3-3 1 5.8 4.5 10.3 9 11.5 0 3.4 0 6.8.3 10.2.4 4.1.5 8.2 1.2 12 .4 2.9 1.2 2.7 1.6 0 .7-3.8.8-7.9 1.2-12 .3-3.4.3-6.8.3-10.2 4.5-1.2 8-5.7 9-11.5" />
|
||||||
|
<path
|
||||||
|
d="M51 146c0 2.7.1 5.5.3 8.2.3 3.3.4 6.6 1 9.6.3 2.3 1 2.2 1.3 0 .5-3 .6-6.3 1-9.6l.2-8.2c3.5-1 6.4-4.6 7.2-9.2a17.8 17.8 0 01-9 2.4c-3.5 0-6.6-1-9.2-2.4.9 4.6 3.7 8.3 7.3 9.2M143.9 105c-1.9-.4-3.5-1.2-4.9-2.3 1.4 5.6 2.5 11.3 4 17 1.2 5 2 10 2.4 15 .6 7.8-4.5 14.5-10.9 14.5h-15c-6.4 0-11.5-6.7-11-14.5.5-5 1.3-10 2.6-15 1.3-5.3 2.3-10.5 3.6-15.7-2.2 1.2-4.8 1.9-7.7 2-4.7.1-9.4-.3-14-1-4-.4-6.7-3-8-6.7-1.3-3.4-2-7-3.3-10.4-.5-1.5-1.6-2.8-2.4-4.2-.4-.6-.8-1.2-.9-1.8v-7.8a77 77 0 0124.5-3c6.1 0 12 1 17.8 3.2 4.7 1.7 9.7 1.8 14.4 0 9-3.4 18.2-3.8 27.5-3 4.9.5 9.8 1.6 14.8 2.4v8.2c0 .6-.3 1.5-.7 1.7-2 .9-2.2 2.7-2.7 4.5-.9 3.2-1.8 6.4-2.9 9.5a11 11 0 01-8.8 7.7 40.6 40.6 0 01-18.4-.2m29.4 80.6c-3.2-26.8-6.4-50-8.9-60.7a14.3 14.3 0 0014.1-14h.4a9 9 0 005.6-16.5 14.3 14.3 0 00-3.7-27.2 9 9 0 00-6.9-14.6c2.4-1.1 4.5-3 5.8-5 3.4-5.3 4-29-8-44.4-5-6.3-9.8-2.5-10 1.8-1 13.2-1.1 23-4.5 34.3a9 9 0 00-16-4.1 14.3 14.3 0 00-28.4 0 9 9 0 00-16 4.1c-3.4-11.2-3.5-21.1-4.4-34.3-.3-4.3-5.2-8-10-1.8-12 15.3-11.5 39-8.1 44.4 1.3 2 3.4 3.9 5.8 5a9 9 0 00-7 14.6 14.3 14.3 0 00-3.6 27.2A9 9 0 0075 111h.5a14.5 14.5 0 0014.3 14c-4 17.2-10 66.3-15 111.3l-1.3 13.4a1656.4 1656.4 0 01106.6 0l-1.4-12.7-5.4-51.3" />
|
||||||
|
<g clip-path="url(#a)">
|
||||||
|
<path
|
||||||
|
d="M83.5 136.6l-2.3.7c-5 1-9.8 1-14.8-.2-1.4-.3-2.7-1-3.8-1.9l3.1 13.7c1 4 1.7 8 2 12 .5 6.3-3.6 11.6-8.7 11.6H46.9c-5.1 0-9.2-5.3-8.7-11.6.3-4 1-8 2-12 1-4.2 1.8-8.5 2.9-12.6-1.8 1-3.9 1.5-6.3 1.6a71 71 0 01-11.1-.7 7.7 7.7 0 01-6.5-5.5c-1-2.7-1.6-5.6-2.6-8.3-.4-1.2-1.3-2.3-2-3.4-.2-.4-.6-1-.6-1.4v-6.3c6.4-2 13-2.6 19.6-2.5 4.9.1 9.6 1 14.2 2.6 3.9 1.4 7.9 1.5 11.7 0 1.8-.7 3.6-1.2 5.5-1.6a13 13 0 01-1.6-15.5A18.3 18.3 0 0159 73.1a11.5 11.5 0 00-17.4 8.1 7.2 7.2 0 00-12.9 3.3c-2.7-9-2.8-17-3.6-27.5-.2-3.4-4-6.5-8-1.4C7.5 67.8 7.9 86.9 10.6 91c1.1 1.7 2.8 3.1 4.7 4a7.2 7.2 0 00-5.6 11.7 11.5 11.5 0 00-2.9 21.9 7.2 7.2 0 004.5 13.2h.3c0 .6 0 1.1.2 1.7.9 5.4 5.6 9.5 11.3 9.5A1177.2 1177.2 0 0010 253.2c18.1-1.5 38.1-2.6 59.5-3.4.4-4.6.8-9.3 1.4-14 1.2-11.6 3.3-30.5 5.7-49.7 2.2-18 4.7-36.3 7-49.5" />
|
||||||
|
</g>
|
||||||
|
<g clip-path="url(#b)">
|
||||||
|
<path
|
||||||
|
d="M254.4 118.2c0-5.8-4.2-10.5-9.7-11.4a7.2 7.2 0 00-5.6-11.7c2-.9 3.6-2.3 4.7-4 2.7-4.2 3.1-23.3-6.5-35.5-4-5.1-7.8-2-8 1.4-.8 10.5-.9 18.5-3.6 27.5a7.2 7.2 0 00-12.8-3.3 11.5 11.5 0 00-17.8-7.9 18.4 18.4 0 01-4.5 22 13 13 0 01-1.3 15.2c2.4.5 4.8 1 7.1 2 3.8 1.3 7.8 1.4 11.6 0 7.2-2.8 14.6-3 22-2.4 4 .4 7.9 1.2 12 1.9l-.1 6.6c0 .5-.2 1.2-.5 1.3-1.7.7-1.8 2.2-2.2 3.7l-2.3 7.6a8.8 8.8 0 01-7 6.1c-5 1-10 1-14.9-.2-1.5-.3-2.8-1-3.9-1.9 1.2 4.5 2 9.1 3.2 13.7 1 4 1.6 8 2 12 .4 6.3-3.6 11.6-8.8 11.6h-12c-5.2 0-9.3-5.3-8.8-11.6.4-4 1-8 2-12 1-4.2 1.9-8.5 3-12.6-1.8 1-4 1.5-6.3 1.6-3.7 0-7.5-.3-11.2-.7a7.7 7.7 0 01-3.7-1.5c3.1 18.4 7.1 51.2 12.5 100.9l.6 5.3.8 7.9c21.4.7 41.5 1.9 59.7 3.4L243 243l-4.4-41.2a606 606 0 00-7-48.7 11.5 11.5 0 0011.2-11.2h.4a7.2 7.2 0 004.4-13.2c4-1.8 6.8-5.8 6.8-10.5" />
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
d="M180 249.6h.4a6946 6946 0 00-7.1-63.9l5.4 51.3 1.4 12.6M164.4 125c2.5 10.7 5.7 33.9 8.9 60.7a570.9 570.9 0 00-8.9-60.7M74.8 236.3l-1.4 13.4 1.4-13.4" />
|
||||||
|
</svg>
|
||||||
|
<span>CrowdSec</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function captchaCallback() {
|
||||||
|
setTimeout(() => document.querySelector('#captcha-form').submit(), 500);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: "traefik:v3.7.11"
|
||||||
|
container_name: "traefik"
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
# - "--log.level=DEBUG"
|
||||||
|
- "--accesslog"
|
||||||
|
- "--accesslog.filepath=/var/log/traefik/access.log"
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--providers.docker=true"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
- "--entrypoints.web.address=:80"
|
||||||
|
- "--entrypoints.web.forwardedheaders.trustedips=172.18.0.0/24"
|
||||||
|
|
||||||
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||||
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- logs-custom-captcha-enabled:/var/log/traefik
|
||||||
|
- "./captcha.html:/captcha.html"
|
||||||
|
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
ports:
|
||||||
|
- 8000:80
|
||||||
|
- 8080:8080
|
||||||
|
depends_on:
|
||||||
|
- crowdsec
|
||||||
|
|
||||||
|
whoami-foo:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: "simple-service-custom-captcha-foo"
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
# Definition of the router
|
||||||
|
- "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)"
|
||||||
|
- "traefik.http.routers.router-foo.entrypoints=web"
|
||||||
|
- "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
|
||||||
|
# Definition of the service
|
||||||
|
- "traefik.http.services.service-foo.loadbalancer.server.port=80"
|
||||||
|
# Definition of the middleware
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=none"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||||
|
|
||||||
|
# Choose captcha provider
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaProvider=custom"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaCustomJsURL=http://captcha.localhost:8000/fast.js"
|
||||||
|
# Inside Traefik container the plugin must be able to reach wicketkeeper service so we can go through a Traefik localhost
|
||||||
|
# domain which would resolve traefik itself and the port for the dashboard
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomValidateURL=http://wicketkeeper:8080/v0/siteverify"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomKey=wicketkeeper"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomResponse=wicketkeeper_solution"
|
||||||
|
# Define captcha grade period seconds
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=20"
|
||||||
|
# Define captcha HTML file path
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||||
|
|
||||||
|
crowdsec:
|
||||||
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
|
container_name: "crowdsec"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
COLLECTIONS: crowdsecurity/traefik
|
||||||
|
CUSTOM_HOSTNAME: crowdsec
|
||||||
|
BOUNCER_KEY_TRAEFIK_DEV: 40796d93c2958f9e58345514e67740e5
|
||||||
|
volumes:
|
||||||
|
# For captcha and ban mixed decision
|
||||||
|
- "./profiles.yaml:/etc/crowdsec/profiles.yaml:ro"
|
||||||
|
# For captcha only remediation
|
||||||
|
# - './profiles_captcha_only.yaml:/etc/crowdsec/profiles.yaml:ro'
|
||||||
|
- "./acquis.yaml:/etc/crowdsec/acquis.yaml:ro"
|
||||||
|
- logs-custom-captcha-enabled:/var/log/traefik:ro
|
||||||
|
- crowdsec-db-custom-captcha-enabled:/var/lib/crowdsec/data/
|
||||||
|
- crowdsec-config-custom-captcha-enabled:/etc/crowdsec/
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=false"
|
||||||
|
|
||||||
|
wicketkeeper:
|
||||||
|
image: ghcr.io/a-ve/wicketkeeper:latest
|
||||||
|
container_name: "wicketkeeper"
|
||||||
|
environment:
|
||||||
|
- LISTEN_PORT=8080
|
||||||
|
- REDIS_ADDR=redis:6379
|
||||||
|
- DIFFICULTY=4
|
||||||
|
- ALLOWED_ORIGINS=*
|
||||||
|
- PRIVATE_KEY_PATH=/data/wicketkeeper.key # To override in production environment
|
||||||
|
volumes:
|
||||||
|
- wicketkeeper-custom-captcha-enabled:/data
|
||||||
|
user: root
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
# Definition of the router
|
||||||
|
- "traefik.http.routers.router-wicketkeeper.rule=Host(`captcha.localhost`)"
|
||||||
|
- "traefik.http.routers.router-wicketkeeper.entrypoints=web"
|
||||||
|
# Definition of the service
|
||||||
|
- "traefik.http.services.service-wicketkeeper.loadbalancer.server.port=8080"
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis/redis-stack-server:latest
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
logs-custom-captcha-enabled:
|
||||||
|
wicketkeeper-custom-captcha-enabled:
|
||||||
|
crowdsec-db-custom-captcha-enabled:
|
||||||
|
crowdsec-config-custom-captcha-enabled:
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: captcha_remediation
|
||||||
|
filters:
|
||||||
|
- Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http" && GetDecisionsSinceCount(Alert.GetValue(), "24h") <= 3
|
||||||
|
## Same as above but only 3 captcha decision per 24 hours before ban
|
||||||
|
decisions:
|
||||||
|
- type: captcha
|
||||||
|
duration: 4h
|
||||||
|
on_success: break
|
||||||
|
---
|
||||||
|
name: default_ip_remediation
|
||||||
|
filters:
|
||||||
|
- Alert.Remediation == true && Alert.GetScope() == "Ip"
|
||||||
|
decisions:
|
||||||
|
- type: ban
|
||||||
|
duration: 4h
|
||||||
|
#duration_expr: "Sprintf('%dh', (GetDecisionsCount(Alert.GetValue()) + 1) * 4)"
|
||||||
|
on_success: break
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
image:
|
image:
|
||||||
tag: v1.6.1-2
|
tag: v1.7.8-2
|
||||||
|
|
||||||
agent:
|
agent:
|
||||||
acquisition:
|
acquisition:
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
image:
|
image:
|
||||||
tag: v3.0.0
|
tag: v3.7.11
|
||||||
|
|
||||||
logs:
|
logs:
|
||||||
general:
|
general:
|
||||||
level: DEBUG
|
level: DEBUG
|
||||||
access:
|
access:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
format: json
|
||||||
fields:
|
fields:
|
||||||
headers:
|
headers:
|
||||||
defaultmode: keep
|
defaultmode: keep
|
||||||
@@ -14,4 +15,4 @@ experimental:
|
|||||||
plugins:
|
plugins:
|
||||||
bouncer:
|
bouncer:
|
||||||
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
version: "v1.3.0"
|
version: "v1.7.1"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.7.11"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
@@ -71,7 +71,7 @@ services:
|
|||||||
|
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -87,7 +87,7 @@ services:
|
|||||||
- "traefik.enable=false"
|
- "traefik.enable=false"
|
||||||
|
|
||||||
redis-secure:
|
redis-secure:
|
||||||
image: "redis:7.0.12-alpine"
|
image: "redis:8.10.0-alpine"
|
||||||
container_name: "redis-secure"
|
container_name: "redis-secure"
|
||||||
hostname: redis-secure
|
hostname: redis-secure
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ You need to create a crowdsec API credentials for the CAPI.
|
|||||||
You can follow the documentation here: https://docs.crowdsec.net/docs/central_api/intro
|
You can follow the documentation here: https://docs.crowdsec.net/docs/central_api/intro
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "https://api.crowdsec.net/v2/watchers" -H "accept: application/json" -H "Content-Type: application/json" -d "{ \"password\": \"PASSWORD\", \"machine_id\": \"LOGIN\"}"
|
LOGIN=...
|
||||||
|
PASSWORD=...
|
||||||
|
curl -X POST "https://api.crowdsec.net/v2/watchers" -H "accept: application/json" -H "Content-Type: application/json" -d "{ \"password\": \"$PASSWORD\", \"machine_id\": \"$LOGIN\"}"
|
||||||
```
|
```
|
||||||
|
|
||||||
These CAPI credentials must be set in your docker-compose.yml or in your config files
|
These CAPI credentials must be set in your docker-compose.yml or in your config files
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.7.11"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
@@ -35,8 +35,7 @@ services:
|
|||||||
|
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=alone"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecMode=alone"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiMachineId=FIXME"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiMachineId=FIXME"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiPassword=FIXME"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiPassword=FIXME"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseccapiscenarios=crowdsecurity/sshd,crowdsecurity/asterisk_bf,crowdsecurity/asterisk_user_enum,crowdsecurity/base-http-scenarios"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseccapiscenarios=crowdsecurity/sshd,crowdsecurity/asterisk_bf,crowdsecurity/asterisk_user_enum,crowdsecurity/base-http-scenarios"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM ubuntu:24.04
|
FROM ubuntu:26.04
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y curl wget
|
RUN apt-get update && apt-get install -y curl wget
|
||||||
RUN VERSION=$(curl --silent "https://api.github.com/repos/cloudflare/cfssl/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') && VNUMBER=${VERSION#"v"} && wget https://github.com/cloudflare/cfssl/releases/download/${VERSION}/cfssl_${VNUMBER}_linux_amd64 -O cfssl && chmod +x cfssl && mv cfssl /usr/local/bin
|
RUN VERSION=$(curl --silent "https://api.github.com/repos/cloudflare/cfssl/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') && VNUMBER=${VERSION#"v"} && wget https://github.com/cloudflare/cfssl/releases/download/${VERSION}/cfssl_${VNUMBER}_linux_amd64 -O cfssl && chmod +x cfssl && mv cfssl /usr/local/bin
|
||||||
|
|||||||
+30
-10
@@ -1,7 +1,9 @@
|
|||||||
# Example
|
# Example
|
||||||
|
|
||||||
## Using https communication and tls authentication with Crowdsec
|
## Using https communication and tls authentication with Crowdsec
|
||||||
|
|
||||||
##### Summary
|
##### Summary
|
||||||
|
|
||||||
This example demonstrates the use of https between the Traefik plugin and the Crowdsec LAPI.
|
This example demonstrates the use of https between the Traefik plugin and the Crowdsec LAPI.
|
||||||
|
|
||||||
It is possible to communicate with the LAPI in https and still authenticate with API key.
|
It is possible to communicate with the LAPI in https and still authenticate with API key.
|
||||||
@@ -17,7 +19,9 @@ In that case the setting **crowdsecLapiTLSInsecureVerify** must be set to true.
|
|||||||
It is recommended to validate the certificate presented by Crowdsec LAPI using the Certificate Authority which created it.
|
It is recommended to validate the certificate presented by Crowdsec LAPI using the Certificate Authority which created it.
|
||||||
|
|
||||||
You can provide the Certificate Authority using:
|
You can provide the Certificate Authority using:
|
||||||
* A file path readable by Traefik
|
|
||||||
|
- A file path readable by Traefik
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
http:
|
http:
|
||||||
middlewares:
|
middlewares:
|
||||||
@@ -26,9 +30,11 @@ http:
|
|||||||
bouncer:
|
bouncer:
|
||||||
crowdsecLapiTlsCertificateAuthorityFile: /etc/traefik/certs/crowdsecCA.pem
|
crowdsecLapiTlsCertificateAuthorityFile: /etc/traefik/certs/crowdsecCA.pem
|
||||||
```
|
```
|
||||||
* The PEM encoded certificate as a text variable
|
|
||||||
|
- The PEM encoded certificate as a text variable
|
||||||
|
|
||||||
In the static file configuration of Traefik
|
In the static file configuration of Traefik
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
http:
|
http:
|
||||||
middlewares:
|
middlewares:
|
||||||
@@ -36,15 +42,17 @@ http:
|
|||||||
plugin:
|
plugin:
|
||||||
bouncer:
|
bouncer:
|
||||||
crowdsecLapiTlsCertificateAuthority: |-
|
crowdsecLapiTlsCertificateAuthority: |-
|
||||||
-----BEGIN CERTIFICATE-----
|
-----BEGIN CERTIFICATE-----
|
||||||
MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
|
MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
|
||||||
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
|
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
|
||||||
...
|
...
|
||||||
C6qNieSwcvWL7C03ri0DefTQMY54r5wP33QU5hJ71JoaZI3YTeT0Nf+NRL4hM++w
|
C6qNieSwcvWL7C03ri0DefTQMY54r5wP33QU5hJ71JoaZI3YTeT0Nf+NRL4hM++w
|
||||||
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
|
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
|
||||||
-----END CERTIFICATE-----
|
-----END CERTIFICATE-----
|
||||||
```
|
```
|
||||||
|
|
||||||
In a dynamic configuration of a provider (ex docker) as a Label
|
In a dynamic configuration of a provider (ex docker) as a Label
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
whoami-foo:
|
whoami-foo:
|
||||||
@@ -71,22 +79,34 @@ The service `whoami-foo` will authenticate with an **API key** over HTTPS after
|
|||||||
The service `whoami-bar` will authenticate with a **client certificate** signed by the CA.
|
The service `whoami-bar` will authenticate with a **client certificate** signed by the CA.
|
||||||
|
|
||||||
Access to a route that communicate via https and authenticate with API-key:
|
Access to a route that communicate via https and authenticate with API-key:
|
||||||
|
|
||||||
```
|
```
|
||||||
curl http://localhost:8000/foo
|
curl http://localhost:8000/foo
|
||||||
```
|
```
|
||||||
|
|
||||||
Access to a route that communicate via https and authenticate with a client certificate:
|
Access to a route that communicate via https and authenticate with a client certificate:
|
||||||
|
|
||||||
```
|
```
|
||||||
curl http://localhost:8000/bar
|
curl http://localhost:8000/bar
|
||||||
```
|
```
|
||||||
|
|
||||||
Access to the traefik dashboard
|
Access to the traefik dashboard
|
||||||
|
|
||||||
```
|
```
|
||||||
curl http://localhost:8080/dashboard/#/
|
curl http://localhost:8080/dashboard/#/
|
||||||
```
|
```
|
||||||
|
|
||||||
To play the demo environnement run:
|
To play the demo environnement run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make run_tlsauth
|
make run_tlsauth
|
||||||
```
|
```
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
> Traefik need to be restart if certificates are regenerated after his launch
|
|
||||||
|
> Traefik need to be restarted if certificates are regenerated after his launch, crowdsec also
|
||||||
|
|
||||||
|
## Separate LAPI and Appsec HTTP/S config
|
||||||
|
|
||||||
|
To separate TLS config for LAPI and Appsec, you can use all the TLS LAPI variable beginning with `CrowdsecLapi...` into `CrowdsecAppsec...`.
|
||||||
|
Don't forget to set `CrowdsecAppsecScheme: HTTP` or `HTTPS` to trigger the separate setup.
|
||||||
|
|||||||
@@ -2,3 +2,12 @@ filenames:
|
|||||||
- /var/log/traefik/access.log
|
- /var/log/traefik/access.log
|
||||||
labels:
|
labels:
|
||||||
type: traefik
|
type: traefik
|
||||||
|
---
|
||||||
|
listen_addr: 0.0.0.0:7422
|
||||||
|
appsec_config: crowdsecurity/virtual-patching
|
||||||
|
name: myAppSecComponent
|
||||||
|
source: appsec
|
||||||
|
labels:
|
||||||
|
type: appsec
|
||||||
|
cert_file: /etc/crowdsec/certs/server.pem
|
||||||
|
key_file: /etc/crowdsec/certs/server-key.pem
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.7.11"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -13,15 +13,13 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- ./LAPIKEY:/etc/traefik/LAPIKEY:ro
|
|
||||||
- logs-tls-auth:/var/log/traefik
|
- logs-tls-auth:/var/log/traefik
|
||||||
- crowdsec-certs-tls-auth:/etc/traefik/crowdsec-certs
|
- crowdsec-certs-tls-auth:/etc/traefik/crowdsec-certs
|
||||||
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- 8000:80
|
- 8000:80
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
@@ -29,7 +27,7 @@ services:
|
|||||||
- crowdsec
|
- crowdsec
|
||||||
- gencert
|
- gencert
|
||||||
|
|
||||||
# Use HTTPS scheme but with lapikey authentication
|
# Use HTTPS scheme but with lapikey authentication
|
||||||
# whoami-foo:
|
# whoami-foo:
|
||||||
# image: traefik/whoami
|
# image: traefik/whoami
|
||||||
# container_name: "simple-service-foo"
|
# container_name: "simple-service-foo"
|
||||||
@@ -46,34 +44,41 @@ services:
|
|||||||
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
||||||
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
||||||
|
|
||||||
# Use HTTPS scheme with TLS cert authentication
|
# Use HTTPS scheme with TLS cert authentication
|
||||||
whoami-bar:
|
whoami-bar:
|
||||||
image: traefik/whoami
|
image: traefik/whoami
|
||||||
container_name: "simple-service-bar"
|
container_name: "simple-service-bar"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.router-bar.rule=Path(`/bar`)"
|
- "traefik.http.routers.router-bar.rule=PathPrefix(`/bar`)"
|
||||||
- "traefik.http.routers.router-bar.entrypoints=web"
|
- "traefik.http.routers.router-bar.entrypoints=web"
|
||||||
- "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
|
- "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
|
||||||
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecMode=none"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecscheme=https"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecAppsecTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
||||||
|
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapikey=40796d93c2958f9e58345514e67740e5="
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerFile=/etc/traefik/crowdsec-certs/bouncer.pem"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerFile=/etc/traefik/crowdsec-certs/bouncer.pem"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerKeyFile=/etc/traefik/crowdsec-certs/bouncer-key.pem"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerKeyFile=/etc/traefik/crowdsec-certs/bouncer-key.pem"
|
||||||
|
# Enable AppSec
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
||||||
|
# Define AppSec host and port informations
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
COLLECTIONS: crowdsecurity/traefik
|
|
||||||
CUSTOM_HOSTNAME: crowdsec
|
CUSTOM_HOSTNAME: crowdsec
|
||||||
# whoami-foo is authenticating with api key over https
|
# whoami-foo is authenticating with api key over https
|
||||||
# whoami-bar is authenticating with tls cert over https
|
# whoami-bar is authenticating with tls cert over https
|
||||||
BOUNCER_KEY_TRAEFIK_FOO: 40796d93c2958f9e58345514e67740e5
|
BOUNCER_KEY_TRAEFIK_FOO: 40796d93c2958f9e58345514e67740e5=
|
||||||
LOCAL_API_URL: https://127.0.0.1:8080
|
LOCAL_API_URL: https://127.0.0.1:8080
|
||||||
USE_TLS: "true"
|
USE_TLS: "true"
|
||||||
CERT_FILE: "/etc/crowdsec/certs/server.pem"
|
CERT_FILE: "/etc/crowdsec/certs/server.pem"
|
||||||
@@ -88,6 +93,7 @@ services:
|
|||||||
# DISABLE_AGENT: "true"
|
# DISABLE_AGENT: "true"
|
||||||
# Disabled for the examples
|
# Disabled for the examples
|
||||||
DISABLE_ONLINE_API: "true"
|
DISABLE_ONLINE_API: "true"
|
||||||
|
COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/acquis.yaml:/etc/crowdsec/acquis.yaml
|
- ./config/acquis.yaml:/etc/crowdsec/acquis.yaml
|
||||||
# - ./config/config.yaml:/etc/crowdsec/config_local.yaml
|
# - ./config/config.yaml:/etc/crowdsec/config_local.yaml
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
stdout=/out/res.log
|
if [ -f "/out/inter-key.pem" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
cfssl gencert --initca /in/ca.json 2>${stdout} | cfssljson --bare "/out/ca" && \
|
cfssl gencert --initca /in/ca.json 2>${stdout} | cfssljson --bare "/out/ca" && \
|
||||||
# Generate an intermediate certificate that will be used to sign the client certificates
|
# Generate an intermediate certificate that will be used to sign the client certificates
|
||||||
cfssl gencert --initca /in/intermediate.json 2>${stdout} | cfssljson --bare "/out/inter" && \
|
cfssl gencert --initca /in/intermediate.json 2>${stdout} | cfssljson --bare "/out/inter" && \
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.7.11"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.7.1"
|
||||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
@@ -65,7 +65,7 @@ services:
|
|||||||
|
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.7.8
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
module github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
module github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
|
||||||
go 1.22
|
go 1.22.12
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/leprosus/golang-ttl-map v1.1.7
|
github.com/leprosus/golang-ttl-map v1.1.7
|
||||||
github.com/maxlerebourg/simpleredis v1.0.11
|
github.com/maxlerebourg/simpleredis v1.0.12
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
github.com/leprosus/golang-ttl-map v1.1.7 h1:cF4AAFDDnJTFSV+/42sKLhmMluvLdRlCGS2UaifH6UM=
|
github.com/leprosus/golang-ttl-map v1.1.7 h1:cF4AAFDDnJTFSV+/42sKLhmMluvLdRlCGS2UaifH6UM=
|
||||||
github.com/leprosus/golang-ttl-map v1.1.7/go.mod h1:4QWHJPeVBbrkhOhXdhCv9IEiyj/YzkO04/iexy4vSe0=
|
github.com/leprosus/golang-ttl-map v1.1.7/go.mod h1:4QWHJPeVBbrkhOhXdhCv9IEiyj/YzkO04/iexy4vSe0=
|
||||||
github.com/maxlerebourg/simpleredis v1.0.11 h1:B33TUeIrHtJH2/Qj2bRdU+UZ1BvZwFyP55JWMxHirWg=
|
github.com/maxlerebourg/simpleredis v1.0.12 h1:VsJpk2l8U9QqxOWbYnEXbrs7iSNZFm2fNmFvlxYlQNk=
|
||||||
github.com/maxlerebourg/simpleredis v1.0.11/go.mod h1:lT4LX02SOsE9PxUcSrz1QW5ZnO86gPbaiYBxmtcXEls=
|
github.com/maxlerebourg/simpleredis v1.0.12/go.mod h1:lT4LX02SOsE9PxUcSrz1QW5ZnO86gPbaiYBxmtcXEls=
|
||||||
|
|||||||
Vendored
+47
-23
@@ -5,11 +5,11 @@ package cache
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
ttl_map "github.com/leprosus/golang-ttl-map"
|
ttl_map "github.com/leprosus/golang-ttl-map"
|
||||||
simpleredis "github.com/maxlerebourg/simpleredis"
|
simpleredis "github.com/maxlerebourg/simpleredis"
|
||||||
|
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -23,13 +23,12 @@ const (
|
|||||||
CaptchaDoneValue = "d"
|
CaptchaDoneValue = "d"
|
||||||
// CacheMiss error string when cache is miss.
|
// CacheMiss error string when cache is miss.
|
||||||
CacheMiss = "cache:miss"
|
CacheMiss = "cache:miss"
|
||||||
|
// CacheUnreachable error string when cache is unreachable.
|
||||||
|
CacheUnreachable = "cache:unreachable"
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:gochecknoglobals
|
//nolint:gochecknoglobals
|
||||||
var (
|
var cache = ttl_map.New()
|
||||||
redis simpleredis.SimpleRedis
|
|
||||||
cache = ttl_map.New()
|
|
||||||
)
|
|
||||||
|
|
||||||
type localCache struct{}
|
type localCache struct{}
|
||||||
|
|
||||||
@@ -51,29 +50,48 @@ func (localCache) delete(key string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type redisCache struct {
|
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) {
|
func (rc *redisCache) nextReader() *simpleredis.SimpleRedis {
|
||||||
value, err := redis.Get(key)
|
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)
|
valueString := string(value)
|
||||||
if err == nil && len(valueString) > 0 {
|
if len(valueString) > 0 {
|
||||||
return valueString, nil
|
return valueString, nil
|
||||||
}
|
}
|
||||||
if err.Error() == simpleredis.RedisMiss {
|
return "", errors.New(CacheMiss)
|
||||||
return "", errors.New(CacheMiss)
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc redisCache) set(key, value string, duration int64) {
|
func (rc *redisCache) set(key, value string, duration int64) {
|
||||||
if err := redis.Set(key, []byte(value), duration); err != nil {
|
if err := rc.writer.Set(key, []byte(value), duration); err != nil {
|
||||||
rc.log.Error("cache:setDecisionRedisCache" + err.Error())
|
rc.log.Error("cache:setDecisionRedisCache" + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc redisCache) delete(key string) {
|
func (rc *redisCache) delete(key string) {
|
||||||
if err := redis.Del(key); err != nil {
|
if err := rc.writer.Del(key); err != nil {
|
||||||
rc.log.Error("cache:deleteDecisionRedisCache " + err.Error())
|
rc.log.Error("cache:deleteDecisionRedisCache " + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,19 +105,25 @@ type cacheInterface interface {
|
|||||||
// Client Cache client.
|
// Client Cache client.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
cache cacheInterface
|
cache cacheInterface
|
||||||
log *logger.Log
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New Initialize cache client.
|
// New Initialize cache client.
|
||||||
func (c *Client) New(log *logger.Log, isRedis bool, host, pass, database string) {
|
func (c *Client) New(log *slog.Logger, isRedis bool, writeHost string, readHosts []string, pass, database string) {
|
||||||
c.log = log
|
c.log = log
|
||||||
if isRedis {
|
if isRedis {
|
||||||
redis.Init(host, pass, database)
|
rc := &redisCache{log: log}
|
||||||
c.cache = &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 {
|
} else {
|
||||||
c.cache = &localCache{}
|
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.
|
// Delete delete decision in cache.
|
||||||
|
|||||||
Vendored
+41
-3
@@ -6,12 +6,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||||
|
simpleredis "github.com/maxlerebourg/simpleredis"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_Get(t *testing.T) {
|
func Test_Get(t *testing.T) {
|
||||||
IPInCache := "10.0.0.10"
|
IPInCache := "10.0.0.10"
|
||||||
IPNotInCache := "10.0.0.20"
|
IPNotInCache := "10.0.0.20"
|
||||||
client := &Client{cache: &localCache{}, log: logger.New("INFO")}
|
client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
|
||||||
client.Set(IPInCache, BannedValue, 10)
|
client.Set(IPInCache, BannedValue, 10)
|
||||||
type args struct {
|
type args struct {
|
||||||
clientIP string
|
clientIP string
|
||||||
@@ -47,7 +48,7 @@ func Test_Get(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_Set(t *testing.T) {
|
func Test_Set(t *testing.T) {
|
||||||
client := &Client{cache: &localCache{}, log: logger.New("INFO")}
|
client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
|
||||||
IPInCache := "10.0.0.11"
|
IPInCache := "10.0.0.11"
|
||||||
type args struct {
|
type args struct {
|
||||||
clientIP string
|
clientIP string
|
||||||
@@ -88,7 +89,7 @@ func Test_Set(t *testing.T) {
|
|||||||
func Test_Delete(t *testing.T) {
|
func Test_Delete(t *testing.T) {
|
||||||
IPInCache := "10.0.0.12"
|
IPInCache := "10.0.0.12"
|
||||||
IPNotInCache := "10.0.0.22"
|
IPNotInCache := "10.0.0.22"
|
||||||
client := &Client{cache: &localCache{}, log: logger.New("INFO")}
|
client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
|
||||||
client.Set(IPInCache, BannedValue, 10)
|
client.Set(IPInCache, BannedValue, 10)
|
||||||
type args struct {
|
type args struct {
|
||||||
clientIP string
|
clientIP string
|
||||||
@@ -122,3 +123,40 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+61
-42
@@ -4,67 +4,80 @@ package captcha
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
||||||
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client Captcha client.
|
// Client Captcha client.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Valid bool
|
Valid bool
|
||||||
provider string
|
siteKey string
|
||||||
siteKey string
|
secretKey string
|
||||||
secretKey string
|
remediationCustomHeader string
|
||||||
gracePeriodSeconds int64
|
gracePeriodSeconds int64
|
||||||
captchaTemplate *template.Template
|
templateContentType string
|
||||||
cacheClient *cache.Client
|
template *template.Template
|
||||||
httpClient *http.Client
|
cacheClient *cache.Client
|
||||||
log *logger.Log
|
httpClient *http.Client
|
||||||
|
log *slog.Logger
|
||||||
|
infoProvider *infoProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Information for self-hosted provider.
|
||||||
type infoProvider struct {
|
type infoProvider struct {
|
||||||
js string
|
js string
|
||||||
key string
|
key string
|
||||||
|
response string
|
||||||
validate string
|
validate string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
//nolint:gochecknoglobals
|
||||||
//nolint:gochecknoglobals
|
var infoProviders = map[string]*infoProvider{
|
||||||
captcha = map[string]infoProvider{
|
configuration.HcaptchaProvider: {
|
||||||
configuration.HcaptchaProvider: {
|
js: "https://hcaptcha.com/1/api.js",
|
||||||
js: "https://hcaptcha.com/1/api.js",
|
key: "h-captcha",
|
||||||
key: "h-captcha",
|
response: "h-captcha-response",
|
||||||
validate: "https://api.hcaptcha.com/siteverify",
|
validate: "https://api.hcaptcha.com/siteverify",
|
||||||
},
|
},
|
||||||
configuration.RecaptchaProvider: {
|
configuration.RecaptchaProvider: {
|
||||||
js: "https://www.google.com/recaptcha/api.js",
|
js: "https://www.google.com/recaptcha/api.js",
|
||||||
key: "g-recaptcha",
|
key: "g-recaptcha",
|
||||||
validate: "https://www.google.com/recaptcha/api/siteverify",
|
response: "g-recaptcha-response",
|
||||||
},
|
validate: "https://www.google.com/recaptcha/api/siteverify",
|
||||||
configuration.TurnstileProvider: {
|
},
|
||||||
js: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
configuration.TurnstileProvider: {
|
||||||
key: "cf-turnstile",
|
js: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||||
validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
key: "cf-turnstile",
|
||||||
},
|
response: "cf-turnstile-response",
|
||||||
}
|
validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||||
)
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// New Initialize captcha client.
|
// New Initialize captcha client.
|
||||||
func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, siteKey, secretKey, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
func (c *Client) New(log *slog.Logger, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
||||||
c.Valid = provider != ""
|
c.Valid = provider != ""
|
||||||
if !c.Valid {
|
if !c.Valid {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
var info *infoProvider
|
||||||
|
if provider == configuration.CustomProvider {
|
||||||
|
info = &infoProvider{js: js, key: key, response: response, validate: validate}
|
||||||
|
} else {
|
||||||
|
info = infoProviders[provider]
|
||||||
|
}
|
||||||
|
c.infoProvider = info
|
||||||
c.siteKey = siteKey
|
c.siteKey = siteKey
|
||||||
c.secretKey = secretKey
|
c.secretKey = secretKey
|
||||||
c.provider = provider
|
c.remediationCustomHeader = remediationCustomHeader
|
||||||
html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
|
template, contentType, _ := configuration.GetTemplate(captchaTemplatePath)
|
||||||
c.captchaTemplate = html
|
c.template = template
|
||||||
|
c.templateContentType = contentType
|
||||||
c.gracePeriodSeconds = gracePeriodSeconds
|
c.gracePeriodSeconds = gracePeriodSeconds
|
||||||
c.log = log
|
c.log = log
|
||||||
c.httpClient = httpClient
|
c.httpClient = httpClient
|
||||||
@@ -83,15 +96,21 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
|
|||||||
if valid {
|
if valid {
|
||||||
c.log.Debug("captcha:ServeHTTP captcha:valid")
|
c.log.Debug("captcha:ServeHTTP captcha:valid")
|
||||||
c.cacheClient.Set(remoteIP+"_captcha", cache.CaptchaDoneValue, c.gracePeriodSeconds)
|
c.cacheClient.Set(remoteIP+"_captcha", cache.CaptchaDoneValue, c.gracePeriodSeconds)
|
||||||
|
if c.remediationCustomHeader != "" {
|
||||||
|
rw.Header().Set(c.remediationCustomHeader, "solved-captcha")
|
||||||
|
}
|
||||||
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
|
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
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)
|
rw.WriteHeader(http.StatusOK)
|
||||||
err = c.captchaTemplate.Execute(rw, map[string]string{
|
err = c.template.Execute(rw, map[string]string{
|
||||||
"SiteKey": c.siteKey,
|
"SiteKey": c.siteKey,
|
||||||
"FrontendJS": captcha[c.provider].js,
|
"FrontendJS": c.infoProvider.js,
|
||||||
"FrontendKey": captcha[c.provider].key,
|
"FrontendKey": c.infoProvider.key,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Info("captcha:ServeHTTP captchaTemplateServe " + err.Error())
|
c.log.Info("captcha:ServeHTTP captchaTemplateServe " + err.Error())
|
||||||
@@ -116,7 +135,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
|
|||||||
c.log.Debug("captcha:Validate invalid method: " + r.Method)
|
c.log.Debug("captcha:Validate invalid method: " + r.Method)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
var response = r.FormValue(captcha[c.provider].key + "-response")
|
var response = r.FormValue(c.infoProvider.response)
|
||||||
if response == "" {
|
if response == "" {
|
||||||
c.log.Debug("captcha:Validate no captcha response found in request")
|
c.log.Debug("captcha:Validate no captcha response found in request")
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -124,7 +143,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
|
|||||||
var body = url.Values{}
|
var body = url.Values{}
|
||||||
body.Add("secret", c.secretKey)
|
body.Add("secret", c.secretKey)
|
||||||
body.Add("response", response)
|
body.Add("response", response)
|
||||||
res, err := c.httpClient.PostForm(captcha[c.provider].validate, body)
|
res, err := c.httpClient.PostForm(c.infoProvider.validate, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -133,7 +152,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
|
|||||||
c.log.Error("captcha:Validate " + err.Error())
|
c.log.Error("captcha:Validate " + err.Error())
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if !strings.Contains(res.Header.Get("content-type"), "application/json") {
|
if !strings.Contains(res.Header.Get("Content-Type"), "application/json") {
|
||||||
c.log.Debug("captcha:Validate responseType:noJson")
|
c.log.Debug("captcha:Validate responseType:noJson")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+283
-130
@@ -6,7 +6,7 @@ import (
|
|||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -14,9 +14,9 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Enums for crowdsec mode.
|
// Enums for crowdsec mode.
|
||||||
@@ -28,56 +28,93 @@ const (
|
|||||||
AppsecMode = "appsec"
|
AppsecMode = "appsec"
|
||||||
HTTPS = "https"
|
HTTPS = "https"
|
||||||
HTTP = "http"
|
HTTP = "http"
|
||||||
|
LogDEBUG = "DEBUG"
|
||||||
|
LogINFO = "INFO"
|
||||||
|
LogWARN = "WARN"
|
||||||
|
LogERROR = "ERROR"
|
||||||
|
ReasonTECH = "TECHNICAL_ISSUE"
|
||||||
|
ReasonLAPI = "LAPI"
|
||||||
|
ReasonAPPSEC = "APPSEC"
|
||||||
HcaptchaProvider = "hcaptcha"
|
HcaptchaProvider = "hcaptcha"
|
||||||
RecaptchaProvider = "recaptcha"
|
RecaptchaProvider = "recaptcha"
|
||||||
TurnstileProvider = "turnstile"
|
TurnstileProvider = "turnstile"
|
||||||
|
CustomProvider = "custom"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config the plugin configuration.
|
// Config the plugin configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled bool `json:"enabled,omitempty"`
|
Enabled bool `json:"enabled,omitempty"`
|
||||||
LogLevel string `json:"logLevel,omitempty"`
|
LogLevel string `json:"logLevel,omitempty"`
|
||||||
CrowdsecMode string `json:"crowdsecMode,omitempty"`
|
LogFormat string `json:"logFormat,omitempty"`
|
||||||
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
|
LogFilePath string `json:"logFilePath,omitempty"`
|
||||||
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
|
CrowdsecMode string `json:"crowdsecMode,omitempty"`
|
||||||
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
|
||||||
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
CrowdsecAppsecScheme string `json:"crowdsecAppsecScheme,omitempty"`
|
||||||
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
|
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
|
||||||
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
|
CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"`
|
||||||
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
|
CrowdsecAppsecKey string `json:"crowdsecAppsecKey,omitempty"`
|
||||||
CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"`
|
CrowdsecAppsecKeyFile string `json:"crowdsecAppsecKeyFile,omitempty"`
|
||||||
CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"`
|
CrowdsecAppsecTLSInsecureVerify bool `json:"crowdsecAppsecTlsInsecureVerify,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateAuthority string `json:"crowdsecLapiTlsCertificateAuthority,omitempty"`
|
CrowdsecAppsecTLSCertificateAuthority string `json:"crowdsecAppsecTlsCertificateAuthority,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateAuthorityFile string `json:"crowdsecLapiTlsCertificateAuthorityFile,omitempty"`
|
CrowdsecAppsecTLSCertificateAuthorityFile string `json:"crowdsecAppsecTlsCertificateAuthorityFile,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncer string `json:"crowdsecLapiTlsCertificateBouncer,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncer string `json:"crowdsecAppsecTlsCertificateBouncer,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncerFile string `json:"crowdsecLapiTlsCertificateBouncerFile,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncerFile string `json:"crowdsecAppsecTlsCertificateBouncerFile,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncerKey string `json:"crowdsecLapiTlsCertificateBouncerKey,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncerKey string `json:"crowdsecAppsecTlsCertificateBouncerKey,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncerKeyFile string `json:"crowdsecLapiTlsCertificateBouncerKeyFile,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
|
||||||
CrowdsecCapiMachineID string `json:"crowdsecCapiMachineId,omitempty"`
|
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
||||||
CrowdsecCapiMachineIDFile string `json:"crowdsecCapiMachineIdFile,omitempty"`
|
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
||||||
CrowdsecCapiPassword string `json:"crowdsecCapiPassword,omitempty"`
|
CrowdsecAppsecUnreadableBodyBlock bool `json:"crowdsecAppsecUnreadableBodyBlock,omitempty"`
|
||||||
CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"`
|
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
|
||||||
CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"`
|
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
|
||||||
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
|
||||||
UpdateMaxFailure int `json:"updateMaxFailure,omitempty"`
|
CrowdsecLapiPath string `json:"crowdsecLapiPath,omitempty"`
|
||||||
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
|
||||||
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"`
|
||||||
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
|
CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"`
|
||||||
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
|
CrowdsecLapiTLSCertificateAuthority string `json:"crowdsecLapiTlsCertificateAuthority,omitempty"`
|
||||||
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
|
CrowdsecLapiTLSCertificateAuthorityFile string `json:"crowdsecLapiTlsCertificateAuthorityFile,omitempty"`
|
||||||
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
|
CrowdsecLapiTLSCertificateBouncer string `json:"crowdsecLapiTlsCertificateBouncer,omitempty"`
|
||||||
RedisCacheHost string `json:"redisCacheHost,omitempty"`
|
CrowdsecLapiTLSCertificateBouncerFile string `json:"crowdsecLapiTlsCertificateBouncerFile,omitempty"`
|
||||||
RedisCachePassword string `json:"redisCachePassword,omitempty"`
|
CrowdsecLapiTLSCertificateBouncerKey string `json:"crowdsecLapiTlsCertificateBouncerKey,omitempty"`
|
||||||
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
|
CrowdsecLapiTLSCertificateBouncerKeyFile string `json:"crowdsecLapiTlsCertificateBouncerKeyFile,omitempty"`
|
||||||
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
|
CrowdsecCapiMachineID string `json:"crowdsecCapiMachineId,omitempty"`
|
||||||
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
|
CrowdsecCapiMachineIDFile string `json:"crowdsecCapiMachineIdFile,omitempty"`
|
||||||
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
|
CrowdsecCapiPassword string `json:"crowdsecCapiPassword,omitempty"`
|
||||||
CaptchaProvider string `json:"captchaProvider,omitempty"`
|
CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"`
|
||||||
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"`
|
CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"`
|
||||||
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"`
|
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
||||||
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"`
|
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
|
||||||
CaptchaSecretKeyFile string `json:"captchaSecretKeyFile,omitempty"`
|
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
|
||||||
CaptchaGracePeriodSeconds int64 `json:"captchaGracePeriodSeconds,omitempty"`
|
StreamStartupBlock bool `json:"streamStartupBlock,omitempty"`
|
||||||
|
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
||||||
|
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
|
||||||
|
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
||||||
|
TraceHeadersCustomName string `json:"traceHeadersCustomName,omitempty"`
|
||||||
|
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"`
|
||||||
|
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
|
||||||
|
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
|
||||||
|
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
|
||||||
|
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
|
||||||
|
RedisCacheHost string `json:"redisCacheHost,omitempty"`
|
||||||
|
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"` // 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"`
|
||||||
|
CaptchaCustomKey string `json:"captchaCustomKey,omitempty"`
|
||||||
|
CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"`
|
||||||
|
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"`
|
||||||
|
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"`
|
||||||
|
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"`
|
||||||
|
CaptchaSecretKeyFile string `json:"captchaSecretKeyFile,omitempty"`
|
||||||
|
CaptchaGracePeriodSeconds int64 `json:"captchaGracePeriodSeconds,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func contains(source []string, target string) bool {
|
func contains(source []string, target string) bool {
|
||||||
@@ -92,34 +129,54 @@ func contains(source []string, target string) bool {
|
|||||||
// New creates the default plugin configuration.
|
// New creates the default plugin configuration.
|
||||||
func New() *Config {
|
func New() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
LogLevel: "INFO",
|
LogLevel: LogINFO,
|
||||||
CrowdsecMode: LiveMode,
|
LogFormat: "common",
|
||||||
CrowdsecAppsecEnabled: false,
|
LogFilePath: "",
|
||||||
CrowdsecAppsecHost: "crowdsec:7422",
|
CrowdsecMode: LiveMode,
|
||||||
CrowdsecAppsecFailureBlock: true,
|
CrowdsecAppsecEnabled: false,
|
||||||
CrowdsecAppsecUnreachableBlock: true,
|
CrowdsecAppsecFailureBlock: true,
|
||||||
CrowdsecLapiScheme: HTTP,
|
CrowdsecAppsecUnreachableBlock: true,
|
||||||
CrowdsecLapiHost: "crowdsec:8080",
|
CrowdsecAppsecUnreadableBodyBlock: true,
|
||||||
CrowdsecLapiKey: "",
|
CrowdsecAppsecBodyLimit: 10485760,
|
||||||
CrowdsecLapiTLSInsecureVerify: false,
|
CrowdsecAppsecScheme: "",
|
||||||
UpdateIntervalSeconds: 60,
|
CrowdsecAppsecHost: "crowdsec:7422",
|
||||||
UpdateMaxFailure: 0,
|
CrowdsecAppsecPath: "/",
|
||||||
DefaultDecisionSeconds: 60,
|
CrowdsecAppsecKey: "",
|
||||||
HTTPTimeoutSeconds: 10,
|
CrowdsecAppsecTLSInsecureVerify: false,
|
||||||
CaptchaProvider: "",
|
CrowdsecLapiScheme: HTTP,
|
||||||
CaptchaSiteKey: "",
|
CrowdsecLapiHost: "crowdsec:8080",
|
||||||
CaptchaSecretKey: "",
|
CrowdsecLapiPath: "/",
|
||||||
CaptchaGracePeriodSeconds: 1800,
|
CrowdsecLapiKey: "",
|
||||||
CaptchaHTMLFilePath: "/captcha.html",
|
CrowdsecLapiTLSInsecureVerify: false,
|
||||||
BanHTMLFilePath: "",
|
UpdateIntervalSeconds: 60,
|
||||||
ForwardedHeadersCustomName: "X-Forwarded-For",
|
MetricsUpdateIntervalSeconds: 600,
|
||||||
ForwardedHeadersTrustedIPs: []string{},
|
UpdateMaxFailure: 0,
|
||||||
ClientTrustedIPs: []string{},
|
StreamStartupBlock: true,
|
||||||
RedisCacheEnabled: false,
|
DefaultDecisionSeconds: 60,
|
||||||
RedisCacheHost: "redis:6379",
|
RemediationStatusCode: http.StatusForbidden,
|
||||||
RedisCachePassword: "",
|
HTTPTimeoutSeconds: 10,
|
||||||
RedisCacheDatabase: "",
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,37 +207,63 @@ func GetVariable(config *Config, key string) (string, error) {
|
|||||||
return strings.TrimSpace(value), nil
|
return strings.TrimSpace(value), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHTMLTemplate get compiled HTML template.
|
func getContentTypeFromPath(path string) string {
|
||||||
func GetHTMLTemplate(path string) (*template.Template, error) {
|
|
||||||
var err error
|
|
||||||
if path == "" {
|
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
|
//nolint:gosec
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
html := string(b)
|
content := string(b)
|
||||||
compiledTemplate, err := template.New("html").Parse(html)
|
compiledTemplate, err := template.New(filepath.Base(path)).Delims("{{", "}}").Parse(content)
|
||||||
if err != nil {
|
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.
|
// ValidateParams validate all the param gave by user.
|
||||||
//
|
//
|
||||||
//nolint:gocyclo,gocognit
|
//nolint:gocyclo,gocognit,nestif
|
||||||
func ValidateParams(config *Config) error {
|
func ValidateParams(config *Config, log *slog.Logger) error {
|
||||||
if err := validateParamsRequired(config); err != nil {
|
if err := validateParamsRequired(config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
if err := validateCaptcha(config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := validateParamsIPs(config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
|
|
||||||
|
if err := validateParamsIPs(log, config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateParamsIPs(log, config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,21 +288,23 @@ func ValidateParams(config *Config) error {
|
|||||||
if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil {
|
if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := GetHTMLTemplate(config.CaptchaHTMLFilePath); err != nil {
|
if config.CaptchaFilePath != "" {
|
||||||
return err
|
if _, _, err := GetTemplate(config.CaptchaFilePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if config.BanHTMLFilePath != "" {
|
if config.BanFilePath != "" {
|
||||||
if _, err := GetHTMLTemplate(config.BanHTMLFilePath); err != nil {
|
if _, _, err := GetTemplate(config.BanFilePath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateURL("CrowdsecLapi", config.CrowdsecLapiScheme, config.CrowdsecLapiHost); err != nil {
|
if err := validateURL("CrowdsecLapi", config.CrowdsecLapiScheme, config.CrowdsecLapiHost, config.CrowdsecLapiPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateURL("CrowdsecAppsec", config.CrowdsecLapiScheme, config.CrowdsecAppsecHost); err != nil {
|
if err := validateURL("CrowdsecAppsec", config.CrowdsecLapiScheme, config.CrowdsecAppsecHost, config.CrowdsecAppsecPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +312,10 @@ func ValidateParams(config *Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
appsecKey, err := GetVariable(config, "CrowdsecAppsecKey")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -235,12 +324,21 @@ func ValidateParams(config *Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to either have crowdsecLapiKey defined or the BouncerCert and Bouncerkey
|
// We need to either have crowdsecLapiKey defined or the BouncerCert and Bouncerkey
|
||||||
if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") {
|
if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") && config.CrowdsecMode != AppsecMode {
|
||||||
return errors.New("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be all empty")
|
return errors.New("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be all empty")
|
||||||
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
|
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
|
||||||
lapiKey = strings.TrimSpace(lapiKey)
|
lapiKey = strings.TrimSpace(lapiKey)
|
||||||
if err = validateParamsAPIKey(lapiKey); err != nil {
|
if err = validateParamsAPIKey(lapiKey, "CrowdsecLapiKey"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CrowdsecAppsecKey if provided
|
||||||
|
if appsecKey != "" {
|
||||||
|
appsecKey = strings.TrimSpace(appsecKey)
|
||||||
|
if err = validateParamsAPIKey(appsecKey, "CrowdsecAppsecKey"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,14 +350,25 @@ func ValidateParams(config *Config) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check logging configuration
|
||||||
|
// to upper allow of anycase of log level
|
||||||
|
if !contains([]string{LogDEBUG, LogINFO, LogWARN, LogERROR}, strings.ToUpper(config.LogLevel)) {
|
||||||
|
return fmt.Errorf("LogLevel should be one of (%s,%s,%s,%s)", LogDEBUG, LogINFO, LogWARN, LogERROR)
|
||||||
|
}
|
||||||
|
if config.LogFilePath != "" {
|
||||||
|
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("LogFilePath is not writable %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateURL(variable, scheme, host string) error {
|
func validateURL(variable, scheme, host, path string) error {
|
||||||
// This only check that the format of the URL scheme://host is correct and do not make requests
|
// This only check that the format of the URL scheme://host/path is correct and do not make requests
|
||||||
testURL := url.URL{Scheme: scheme, Host: host}
|
testURL := url.URL{Scheme: scheme, Host: host, Path: path}
|
||||||
if _, err := http.NewRequest(http.MethodGet, testURL.String(), nil); err != nil {
|
if _, err := http.NewRequest(http.MethodGet, testURL.String(), nil); err != nil {
|
||||||
return fmt.Errorf("CrowdsecLapiScheme://%sHost: '%v://%v' must be an URL", variable, scheme, host)
|
return fmt.Errorf("CrowdsecLapiScheme://%sHost: '%v://%v%v' must be a valid URL", variable, scheme, host, path)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -268,10 +377,10 @@ func validateURL(variable, scheme, host string) error {
|
|||||||
// field name. RFC 7230 says:
|
// field name. RFC 7230 says:
|
||||||
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
|
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
|
||||||
// See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators
|
// See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators
|
||||||
func validateParamsAPIKey(lapiKey string) error {
|
func validateParamsAPIKey(key string, paramName string) error {
|
||||||
reg := regexp.MustCompile("^[a-zA-Z0-9 !#$%&'*+-.^_`|~=/]*$")
|
reg := regexp.MustCompile("^[a-zA-Z0-9 !#$%&'*+-.^_`|~=/]*$")
|
||||||
if !reg.MatchString(lapiKey) {
|
if !reg.MatchString(key) {
|
||||||
return fmt.Errorf("CrowdsecLapiKey doesn't valid this regexp: '/%s/'", reg.String())
|
return fmt.Errorf("%s doesn't validate this regexp: '/%s/'", paramName, reg.String())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -282,7 +391,8 @@ func validateParamsTLS(config *Config) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if certAuth == "" {
|
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 := new(tls.Config)
|
||||||
tlsConfig.RootCAs = x509.NewCertPool()
|
tlsConfig.RootCAs = x509.NewCertPool()
|
||||||
@@ -292,15 +402,33 @@ func validateParamsTLS(config *Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateParamsIPs(listIP []string, key string) error {
|
func validateParamsIPs(log *slog.Logger, listIP []string, key string) error {
|
||||||
if len(listIP) > 0 {
|
if len(listIP) > 0 {
|
||||||
if _, err := ip.NewChecker(logger.New("INFO"), listIP); err != nil {
|
if _, err := ip.NewChecker(log, listIP); err != nil {
|
||||||
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
|
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateCaptcha(config *Config) error {
|
||||||
|
if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider}, config.CaptchaProvider) {
|
||||||
|
return fmt.Errorf("CaptchaProvider: must be one of '%s', '%s', '%s' or '%s'", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider)
|
||||||
|
}
|
||||||
|
if config.CaptchaProvider == CustomProvider {
|
||||||
|
if config.CaptchaCustomKey == "" || config.CaptchaCustomResponse == "" || config.CaptchaCustomValidateURL == "" || config.CaptchaCustomJsURL == "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"CaptchaProvider: provider is custom, captchaCustom variables must be filled: CaptchaCustomKey:%s, CaptchaCustomResponse:%s, CaptchaCustomValidateURL:%s, CaptchaCustomJsURL:%s",
|
||||||
|
config.CaptchaCustomKey,
|
||||||
|
config.CaptchaCustomResponse,
|
||||||
|
config.CaptchaCustomValidateURL,
|
||||||
|
config.CaptchaCustomJsURL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateParamsRequired(config *Config) error {
|
func validateParamsRequired(config *Config) error {
|
||||||
requiredStrings := map[string]string{
|
requiredStrings := map[string]string{
|
||||||
"CrowdsecLapiScheme": config.CrowdsecLapiScheme,
|
"CrowdsecLapiScheme": config.CrowdsecLapiScheme,
|
||||||
@@ -309,23 +437,38 @@ func validateParamsRequired(config *Config) error {
|
|||||||
}
|
}
|
||||||
for key, val := range requiredStrings {
|
for key, val := range requiredStrings {
|
||||||
if len(val) == 0 {
|
if len(val) == 0 {
|
||||||
return fmt.Errorf("%v: cannot be empty", key)
|
return errors.New(key + ": cannot be empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requiredInt := map[string]int64{
|
requiredInt0 := map[string]int64{
|
||||||
|
"CrowdsecAppsecBodyLimit": config.CrowdsecAppsecBodyLimit,
|
||||||
|
"MetricsUpdateIntervalSeconds": config.MetricsUpdateIntervalSeconds,
|
||||||
|
}
|
||||||
|
for key, val := range requiredInt0 {
|
||||||
|
if val < 0 {
|
||||||
|
return errors.New(key + ": cannot be less than 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requiredInt1 := map[string]int64{
|
||||||
"UpdateIntervalSeconds": config.UpdateIntervalSeconds,
|
"UpdateIntervalSeconds": config.UpdateIntervalSeconds,
|
||||||
"DefaultDecisionSeconds": config.DefaultDecisionSeconds,
|
"DefaultDecisionSeconds": config.DefaultDecisionSeconds,
|
||||||
"HTTPTimeoutSeconds": config.HTTPTimeoutSeconds,
|
"HTTPTimeoutSeconds": config.HTTPTimeoutSeconds,
|
||||||
"CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds,
|
"CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds,
|
||||||
}
|
}
|
||||||
for key, val := range requiredInt {
|
for key, val := range requiredInt1 {
|
||||||
if val < 1 {
|
if val < 1 {
|
||||||
return fmt.Errorf("%v: cannot be less than 1", key)
|
return errors.New(key + ": cannot be less than 1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if config.UpdateMaxFailure < -1 {
|
if config.UpdateMaxFailure < -1 {
|
||||||
return errors.New("UpdateMaxFailure: cannot be less than -1")
|
return errors.New("UpdateMaxFailure: cannot be less than -1")
|
||||||
}
|
}
|
||||||
|
if config.CrowdsecAppsecBodyLimit < 0 {
|
||||||
|
return errors.New("CrowdsecAppsecBodyLimit: cannot be less than 0")
|
||||||
|
}
|
||||||
|
if config.RemediationStatusCode < 100 || config.RemediationStatusCode >= 600 {
|
||||||
|
return errors.New("RemediationStatusCode: cannot be less than 100 and more than 600")
|
||||||
|
}
|
||||||
|
|
||||||
if !contains([]string{NoneMode, LiveMode, StreamMode, AloneMode, AppsecMode}, config.CrowdsecMode) {
|
if !contains([]string{NoneMode, LiveMode, StreamMode, AloneMode, AppsecMode}, config.CrowdsecMode) {
|
||||||
return errors.New("CrowdsecMode: must be one of 'none', 'live', 'stream', 'alone' or 'appsec'")
|
return errors.New("CrowdsecMode: must be one of 'none', 'live', 'stream', 'alone' or 'appsec'")
|
||||||
@@ -333,47 +476,46 @@ func validateParamsRequired(config *Config) error {
|
|||||||
if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) {
|
if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) {
|
||||||
return errors.New("CrowdsecLapiScheme: must be one of 'http' or 'https'")
|
return errors.New("CrowdsecLapiScheme: must be one of 'http' or 'https'")
|
||||||
}
|
}
|
||||||
if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider}, config.CaptchaProvider) {
|
if !contains([]string{HTTP, HTTPS, ""}, config.CrowdsecAppsecScheme) {
|
||||||
return errors.New("CaptchaProvider: must be one of 'hcaptcha', 'recaptcha' or 'turnstile'")
|
return errors.New("CrowdsecAppsecScheme: must be one of 'http' or 'https'")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTLSConfigCrowdsec get TLS config from Config.
|
func getTLSConfig(config *Config, log *slog.Logger, prefix, scheme string, insecureVerify bool) (*tls.Config, error) {
|
||||||
//
|
|
||||||
//nolint:nestif
|
|
||||||
func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error) {
|
|
||||||
tlsConfig := new(tls.Config)
|
tlsConfig := new(tls.Config)
|
||||||
tlsConfig.RootCAs = x509.NewCertPool()
|
if scheme != HTTPS {
|
||||||
//nolint:gocritic
|
log.Debug("getTLSConfig:" + prefix + "Scheme https:no")
|
||||||
if config.CrowdsecLapiScheme != HTTPS {
|
|
||||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiScheme https:no")
|
|
||||||
return tlsConfig, nil
|
return tlsConfig, nil
|
||||||
} else if config.CrowdsecLapiTLSInsecureVerify {
|
}
|
||||||
|
// 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
|
tlsConfig.InsecureSkipVerify = true
|
||||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSInsecureVerify tlsInsecure:true")
|
log.Debug("getTLSConfig:" + prefix + "TLSInsecureVerify tlsInsecure:true")
|
||||||
// If we return here and still want to use client auth this won't work
|
|
||||||
// return tlsConfig, nil
|
|
||||||
} else {
|
} else {
|
||||||
certAuthority, err := GetVariable(config, "CrowdsecLapiTLSCertificateAuthority")
|
certAuthority, err := GetVariable(config, prefix+"TLSCertificateAuthority")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(certAuthority) > 0 {
|
if len(certAuthority) > 0 {
|
||||||
|
tlsConfig.RootCAs = x509.NewCertPool()
|
||||||
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
||||||
// here we return because if CrowdsecLapiTLSInsecureVerify is false
|
return nil, errors.New("getTLSConfig:" + prefix + " cannot load CA and verify cert is enabled")
|
||||||
// and CA not load, we can't communicate with https
|
|
||||||
return nil, errors.New("getTLSConfigCrowdsec:cannot load CA and verify cert is enabled")
|
|
||||||
}
|
}
|
||||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSCertificateAuthority CA added successfully")
|
log.Debug("getTLSConfig:" + prefix + "TLSCertificateAuthority CA added successfully")
|
||||||
|
} else {
|
||||||
|
log.Debug("getTLSConfig:" + prefix + " no CA provided, using system trust store")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
|
||||||
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
certBouncerKey, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncerKey")
|
certBouncerKey, err := GetVariable(config, prefix+"TLSCertificateBouncerKey")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -388,3 +530,14 @@ func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error)
|
|||||||
|
|
||||||
return tlsConfig, nil
|
return tlsConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTLSConfigCrowdsec get TLS config from Config.
|
||||||
|
func GetTLSConfigCrowdsec(config *Config, log *slog.Logger, isAppsec bool) (*tls.Config, error) {
|
||||||
|
var prefix string
|
||||||
|
if isAppsec && config.CrowdsecAppsecScheme != "" {
|
||||||
|
prefix = "CrowdsecAppsec"
|
||||||
|
return getTLSConfig(config, log, prefix, config.CrowdsecAppsecScheme, config.CrowdsecAppsecTLSInsecureVerify)
|
||||||
|
}
|
||||||
|
prefix = "CrowdsecLapi"
|
||||||
|
return getTLSConfig(config, log, prefix, config.CrowdsecLapiScheme, config.CrowdsecLapiTLSInsecureVerify)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
package configuration
|
package configuration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// validPEM is a minimal self-signed certificate accepted by AppendCertsFromPEM,
|
||||||
|
// shared by the TLS tests below.
|
||||||
|
const validPEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
|
||||||
|
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
|
||||||
|
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
|
||||||
|
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
|
||||||
|
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
|
||||||
|
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
|
||||||
|
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
|
||||||
|
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
|
||||||
|
6MF9+Yw1Yy0t
|
||||||
|
-----END CERTIFICATE-----`
|
||||||
|
|
||||||
func getMinimalConfig() *Config {
|
func getMinimalConfig() *Config {
|
||||||
cfg := New()
|
cfg := New()
|
||||||
cfg.CrowdsecLapiKey = "test"
|
cfg.CrowdsecLapiKey = "test"
|
||||||
@@ -72,6 +84,7 @@ func Test_GetVariable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_ValidateParams(t *testing.T) {
|
func Test_ValidateParams(t *testing.T) {
|
||||||
|
log := logger.New("INFO", "")
|
||||||
cfg1 := New()
|
cfg1 := New()
|
||||||
cfg1.CrowdsecLapiKey = "test\n\n"
|
cfg1.CrowdsecLapiKey = "test\n\n"
|
||||||
cfg2 := New()
|
cfg2 := New()
|
||||||
@@ -85,8 +98,14 @@ func Test_ValidateParams(t *testing.T) {
|
|||||||
cfg6 := getMinimalConfig()
|
cfg6 := getMinimalConfig()
|
||||||
cfg6.CrowdsecLapiScheme = HTTPS
|
cfg6.CrowdsecLapiScheme = HTTPS
|
||||||
cfg6.CrowdsecLapiTLSInsecureVerify = true
|
cfg6.CrowdsecLapiTLSInsecureVerify = true
|
||||||
|
cfg7 := getMinimalConfig()
|
||||||
|
cfg7.CrowdsecLapiScheme = HTTPS
|
||||||
cfg8 := getMinimalConfig()
|
cfg8 := getMinimalConfig()
|
||||||
cfg8.CrowdsecLapiScheme = HTTPS
|
cfg8.LogLevel = LogINFO
|
||||||
|
cfg9 := getMinimalConfig()
|
||||||
|
cfg9.LogLevel = "info"
|
||||||
|
cfg10 := getMinimalConfig()
|
||||||
|
cfg10.LogLevel = "Warning"
|
||||||
type args struct {
|
type args struct {
|
||||||
config *Config
|
config *Config
|
||||||
}
|
}
|
||||||
@@ -104,11 +123,14 @@ func Test_ValidateParams(t *testing.T) {
|
|||||||
{name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true},
|
{name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true},
|
||||||
// HTTPS enabled
|
// HTTPS enabled
|
||||||
{name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false},
|
{name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false},
|
||||||
{name: "Not validate https without cert authority", args: args{config: cfg8}, wantErr: true},
|
{name: "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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := ValidateParams(tt.args.config); (err != nil) != tt.wantErr {
|
if err := ValidateParams(tt.args.config, log); (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -116,19 +138,24 @@ func Test_ValidateParams(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_validateParamsTLS(t *testing.T) {
|
func Test_validateParamsTLS(t *testing.T) {
|
||||||
type args struct {
|
cfgEmpty := getMinimalConfig()
|
||||||
config *Config
|
cfgValid := getMinimalConfig()
|
||||||
}
|
cfgValid.CrowdsecLapiTLSCertificateAuthority = validPEM
|
||||||
|
cfgInvalidCA := getMinimalConfig()
|
||||||
|
cfgInvalidCA.CrowdsecLapiTLSCertificateAuthority = "not a pem"
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
args args
|
config *Config
|
||||||
wantErr bool
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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)
|
t.Errorf("validateParamsTLS() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -136,6 +163,7 @@ func Test_validateParamsTLS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_validateParamsIPs(t *testing.T) {
|
func Test_validateParamsIPs(t *testing.T) {
|
||||||
|
log := logger.New("INFO", "")
|
||||||
type args struct {
|
type args struct {
|
||||||
listIP []string
|
listIP []string
|
||||||
key string
|
key string
|
||||||
@@ -155,7 +183,7 @@ func Test_validateParamsIPs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := validateParamsIPs(tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
|
if err := validateParamsIPs(log, tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -196,23 +224,24 @@ func Test_validateParamsRequired(t *testing.T) {
|
|||||||
|
|
||||||
func Test_validateParamsAPIKey(t *testing.T) {
|
func Test_validateParamsAPIKey(t *testing.T) {
|
||||||
type args struct {
|
type args struct {
|
||||||
lapiKey string
|
lapiKey string
|
||||||
|
paramName string
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
args args
|
args args
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~"}, wantErr: false},
|
{name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~", paramName: "CrowdsecParamName"}, wantErr: false},
|
||||||
{name: "Not validate a @", args: args{lapiKey: "test@"}, wantErr: true},
|
{name: "Not validate a @", args: args{lapiKey: "test@", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a (", args: args{lapiKey: "test("}, wantErr: true},
|
{name: "Not validate a (", args: args{lapiKey: "test(", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a [", args: args{lapiKey: "test["}, wantErr: true},
|
{name: "Not validate a [", args: args{lapiKey: "test[", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a ?", args: args{lapiKey: "test?"}, wantErr: true},
|
{name: "Not validate a ?", args: args{lapiKey: "test?", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n"}, wantErr: true},
|
{name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := validateParamsAPIKey(tt.args.lapiKey); (err != nil) != tt.wantErr {
|
if err := validateParamsAPIKey(tt.args.lapiKey, tt.args.paramName); (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateParamsAPIKey() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateParamsAPIKey() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -220,26 +249,79 @@ func Test_validateParamsAPIKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
||||||
type args struct {
|
log := logger.New("INFO", "")
|
||||||
config *Config
|
|
||||||
}
|
httpCfg := getMinimalConfig()
|
||||||
|
httpCfg.CrowdsecLapiScheme = HTTP
|
||||||
|
|
||||||
|
httpsSystemCA := getMinimalConfig()
|
||||||
|
httpsSystemCA.CrowdsecLapiScheme = HTTPS
|
||||||
|
|
||||||
|
httpsCustomCA := getMinimalConfig()
|
||||||
|
httpsCustomCA.CrowdsecLapiScheme = HTTPS
|
||||||
|
httpsCustomCA.CrowdsecLapiTLSCertificateAuthority = validPEM
|
||||||
|
|
||||||
|
httpsInsecure := getMinimalConfig()
|
||||||
|
httpsInsecure.CrowdsecLapiScheme = HTTPS
|
||||||
|
httpsInsecure.CrowdsecLapiTLSInsecureVerify = true
|
||||||
|
|
||||||
|
httpsBadCA := getMinimalConfig()
|
||||||
|
httpsBadCA.CrowdsecLapiScheme = HTTPS
|
||||||
|
httpsBadCA.CrowdsecLapiTLSCertificateAuthority = "not a pem"
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
args args
|
config *Config
|
||||||
want *tls.Config
|
wantErr bool
|
||||||
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO"))
|
got, err := GetTLSConfigCrowdsec(tt.config, log, false)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("GetTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
if tt.wantErr {
|
||||||
t.Errorf("getTLSConfigCrowdsec() = %v, want %v", got, tt.want)
|
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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -5,11 +5,10 @@ package ip
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CHECKER
|
// CHECKER
|
||||||
@@ -21,7 +20,7 @@ type Checker struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewChecker builds a new Checker given a list of CIDR-Strings to trusted IPs.
|
// NewChecker builds a new Checker given a list of CIDR-Strings to trusted IPs.
|
||||||
func NewChecker(log *logger.Log, trustedIPs []string) (*Checker, error) {
|
func NewChecker(log *slog.Logger, trustedIPs []string) (*Checker, error) {
|
||||||
checker := &Checker{}
|
checker := &Checker{}
|
||||||
|
|
||||||
for _, ipMaskRaw := range trustedIPs {
|
for _, ipMaskRaw := range trustedIPs {
|
||||||
|
|||||||
+77
-33
@@ -1,48 +1,92 @@
|
|||||||
// Package logger implements utility routines to write to stdout and stderr.
|
// Package logger implements utility routines to write to stdout and stderr.
|
||||||
// It supports debug, info and error level
|
// It supports trace, debug, info, warn and error level using Go's standard log/slog
|
||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"log/slog"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Log Logger struct.
|
// Custom log levels following slog best practices.
|
||||||
type Log struct {
|
const (
|
||||||
logError *log.Logger
|
LevelDebug = slog.LevelDebug
|
||||||
logInfo *log.Logger
|
LevelInfo = slog.LevelInfo
|
||||||
logDebug *log.Logger
|
LevelWarn = slog.LevelWarn
|
||||||
|
LevelError = slog.LevelError
|
||||||
|
)
|
||||||
|
|
||||||
|
// New creates a Log wrapper with default format (common).
|
||||||
|
func New(logLevel string, logFilePath string) *slog.Logger {
|
||||||
|
return NewWithFormat(logLevel, logFilePath, "common")
|
||||||
}
|
}
|
||||||
|
|
||||||
// New Set Default log level to info in case log level to defined.
|
// NewWithFormat creates a Log wrapper with specified format (common or json).
|
||||||
func New(logLevel string) *Log {
|
func NewWithFormat(logLevel, logFilePath, logFormat string) *slog.Logger {
|
||||||
logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
// Determine log level
|
||||||
logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
var level slog.Level
|
||||||
logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
switch logLevel {
|
||||||
logError.SetOutput(os.Stderr)
|
case "ERROR":
|
||||||
logInfo.SetOutput(os.Stdout)
|
level = LevelError
|
||||||
if logLevel == "DEBUG" {
|
case "WARN":
|
||||||
logDebug.SetOutput(os.Stdout)
|
level = LevelWarn
|
||||||
|
case "INFO":
|
||||||
|
level = LevelInfo
|
||||||
|
case "DEBUG":
|
||||||
|
level = LevelDebug
|
||||||
|
default:
|
||||||
|
// Default to INFO level
|
||||||
|
level = LevelInfo
|
||||||
}
|
}
|
||||||
return &Log{
|
|
||||||
logError: logError,
|
// Set output destination
|
||||||
logInfo: logInfo,
|
var output *os.File
|
||||||
logDebug: logDebug,
|
if logFilePath != "" {
|
||||||
|
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if err == nil {
|
||||||
|
output = logFile
|
||||||
|
} else {
|
||||||
|
// Fall back to stdout and log the error
|
||||||
|
output = os.Stdout
|
||||||
|
slog.Warn("LogFilePath is not writable, using stdout", "error", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output = os.Stdout
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Info log to Stdout.
|
// Create handler based on format with custom level names
|
||||||
func (l *Log) Info(str string) {
|
var handler slog.Handler
|
||||||
l.logInfo.Printf(str)
|
opts := &slog.HandlerOptions{
|
||||||
}
|
Level: level,
|
||||||
|
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||||||
|
// Customize level names to match our expected format
|
||||||
|
if a.Key == slog.LevelKey {
|
||||||
|
lvl, ok := a.Value.Any().(slog.Level)
|
||||||
|
if !ok {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case lvl < LevelInfo:
|
||||||
|
a.Value = slog.StringValue("DEBUG")
|
||||||
|
case lvl < LevelWarn:
|
||||||
|
a.Value = slog.StringValue("INFO")
|
||||||
|
case lvl < LevelError:
|
||||||
|
a.Value = slog.StringValue("WARN")
|
||||||
|
default:
|
||||||
|
a.Value = slog.StringValue("ERROR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// Debug log to Stdout.
|
if logFormat == "json" {
|
||||||
func (l *Log) Debug(str string) {
|
handler = slog.NewJSONHandler(output, opts)
|
||||||
l.logDebug.Printf(str)
|
} else {
|
||||||
}
|
// Common format (default)
|
||||||
|
handler = slog.NewTextHandler(output, opts)
|
||||||
|
}
|
||||||
|
|
||||||
// Error log to Stderr.
|
// Create logger with component attribute
|
||||||
func (l *Log) Error(str string) {
|
return slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||||
l.logError.Printf(str)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,267 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared helpers for the binary (mock) e2e suite.
|
||||||
|
#
|
||||||
|
# Unlike the Docker suite under tests/e2e/scenarios, this one runs Traefik as a
|
||||||
|
# downloaded binary and replaces Crowdsec with a small HTTP mock (the mocklapi
|
||||||
|
# Go command). It validates the plugin's own behaviour (modes, cache, trusted
|
||||||
|
# IPs, ban / captcha rendering, AppSec wiring) — not the accuracy of Crowdsec's
|
||||||
|
# detection or its WAF engine, which the mock only stands in for.
|
||||||
|
#
|
||||||
|
# Dependencies: bash, curl, go, tar. The Traefik binary is downloaded and the
|
||||||
|
# mock is compiled into .cache/ on first use. That cache persists across local
|
||||||
|
# runs; CI runs on fresh runners, so both are recreated on every CI run.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Pinned to match the Docker suite (tests/e2e/scenarios/*/docker-compose.yml).
|
||||||
|
TRAEFIK_VERSION="${TRAEFIK_VERSION:-v3.7.11}"
|
||||||
|
|
||||||
|
WEB_PORT="${WEB_PORT:-8000}"
|
||||||
|
LAPI_PORT="${LAPI_PORT:-8090}"
|
||||||
|
BACKEND_PORT="${BACKEND_PORT:-8091}"
|
||||||
|
APPSEC_PORT="${APPSEC_PORT:-8092}"
|
||||||
|
REDIS_PORT="${REDIS_PORT:-8093}"
|
||||||
|
REDIS_READ_PORT="${REDIS_READ_PORT:-8094}"
|
||||||
|
LAPI_KEY="${LAPI_KEY:-e2e-mock-key}"
|
||||||
|
|
||||||
|
MOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$MOCK_LIB_DIR/../../../.." && pwd)"
|
||||||
|
CACHE_DIR="$MOCK_LIB_DIR/../.cache"
|
||||||
|
|
||||||
|
# Populated by start_stack / run_scenario, consumed by the EXIT trap.
|
||||||
|
WORKDIR=""
|
||||||
|
TRAEFIK_PID=""
|
||||||
|
MOCK_PID=""
|
||||||
|
SCENARIO_NAME=""
|
||||||
|
SCENARIO_LOG=""
|
||||||
|
|
||||||
|
# Resolve (and cache) the Traefik binary for this host, echoing its path.
|
||||||
|
ensure_traefik() {
|
||||||
|
local bin="$CACHE_DIR/traefik-$TRAEFIK_VERSION"
|
||||||
|
if [[ -x "$bin" ]]; then
|
||||||
|
echo "$bin"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
mkdir -p "$CACHE_DIR"
|
||||||
|
local os arch
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Linux) os=linux ;;
|
||||||
|
Darwin) os=darwin ;;
|
||||||
|
*) echo "ensure_traefik: unsupported OS $(uname -s)" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64 | amd64) arch=amd64 ;;
|
||||||
|
aarch64 | arm64) arch=arm64 ;;
|
||||||
|
*) echo "ensure_traefik: unsupported arch $(uname -m)" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
local url="https://github.com/traefik/traefik/releases/download/${TRAEFIK_VERSION}/traefik_${TRAEFIK_VERSION}_${os}_${arch}.tar.gz"
|
||||||
|
echo "ensure_traefik: downloading $url" >&2
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -sSfL "$url" -o "$tmp/traefik.tar.gz"
|
||||||
|
tar -xzf "$tmp/traefik.tar.gz" -C "$tmp" traefik
|
||||||
|
mv "$tmp/traefik" "$bin"
|
||||||
|
chmod +x "$bin"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
echo "$bin"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build (and cache) the mock LAPI binary, echoing its path. Go's build cache
|
||||||
|
# makes the rebuild near-instant after the first run.
|
||||||
|
ensure_mock() {
|
||||||
|
local bin="$CACHE_DIR/mocklapi"
|
||||||
|
mkdir -p "$CACHE_DIR"
|
||||||
|
( cd "$MOCK_LIB_DIR/../mocklapi" && go build -o "$bin" . ) >&2
|
||||||
|
echo "$bin"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Poll a URL until it returns the expected status code, or fail.
|
||||||
|
# Usage: wait_for_status URL CODE [TIMEOUT_SECONDS] [curl args...]
|
||||||
|
wait_for_status() {
|
||||||
|
local url="$1" expected="$2" timeout="${3:-30}"
|
||||||
|
shift 3 || true
|
||||||
|
local elapsed=0 got=""
|
||||||
|
while (( elapsed < timeout )); do
|
||||||
|
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url" || true)
|
||||||
|
if [[ "$got" == "$expected" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
# Note: `((elapsed++))` returns exit 1 when elapsed is 0, which trips set -e.
|
||||||
|
elapsed=$((elapsed + 1))
|
||||||
|
done
|
||||||
|
echo "wait_for_status: $url expected $expected, last seen ${got:-<none>}" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Poll a URL until its body contains a substring, or fail. Used when the status
|
||||||
|
# code alone can't tell the states apart (e.g. captcha page vs backend, both 200).
|
||||||
|
# Usage: wait_for_body_contains URL NEEDLE [TIMEOUT_SECONDS] [curl args...]
|
||||||
|
wait_for_body_contains() {
|
||||||
|
local url="$1" needle="$2" timeout="${3:-30}"
|
||||||
|
shift 3 || true
|
||||||
|
local elapsed=0 body=""
|
||||||
|
while (( elapsed < timeout )); do
|
||||||
|
body=$(curl -s "$@" "$url" || true)
|
||||||
|
if grep -q "$needle" <<<"$body"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
elapsed=$((elapsed + 1))
|
||||||
|
done
|
||||||
|
echo "wait_for_body_contains: $url did not contain \"$needle\" within ${timeout}s" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Assert a single curl returns the expected status code.
|
||||||
|
# Usage: assert_status URL CODE [curl args...]
|
||||||
|
assert_status() {
|
||||||
|
local url="$1" expected="$2"
|
||||||
|
shift 2 || true
|
||||||
|
local got
|
||||||
|
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url")
|
||||||
|
if [[ "$got" != "$expected" ]]; then
|
||||||
|
echo "assert_status: $url expected $expected, got $got" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Assert a response header matches a value (case-insensitive name).
|
||||||
|
# Usage: assert_header URL HEADER VALUE [curl args...]
|
||||||
|
assert_header() {
|
||||||
|
local url="$1" header="$2" expected="$3"
|
||||||
|
shift 3 || true
|
||||||
|
local got
|
||||||
|
got=$(curl -s -D - -o /dev/null "$@" "$url" | tr -d '\r' \
|
||||||
|
| awk -v h="${header,,}" -F': ' 'tolower($1) == h { print $2; exit }')
|
||||||
|
if [[ "$got" != "$expected" ]]; then
|
||||||
|
echo "assert_header: $url header $header expected \"$expected\", got \"$got\"" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Assert a response body contains a substring.
|
||||||
|
# Usage: assert_body_contains URL NEEDLE [curl args...]
|
||||||
|
assert_body_contains() {
|
||||||
|
local url="$1" needle="$2"
|
||||||
|
shift 2 || true
|
||||||
|
local body
|
||||||
|
body=$(curl -s "$@" "$url")
|
||||||
|
if ! grep -q "$needle" <<<"$body"; then
|
||||||
|
echo "assert_body_contains: $url expected to contain \"$needle\", got:" >&2
|
||||||
|
echo "$body" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- mock admin client -------------------------------------------------------
|
||||||
|
|
||||||
|
lapi_add_decision() {
|
||||||
|
local ip="$1" type="${2:-ban}" duration="${3:-4h}"
|
||||||
|
curl -sS -X POST "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}&type=${type}&duration=${duration}" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
lapi_delete_decision() {
|
||||||
|
local ip="$1"
|
||||||
|
curl -sS -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- stack lifecycle ---------------------------------------------------------
|
||||||
|
|
||||||
|
# start_stack SCENARIO_DIR
|
||||||
|
# Spins up the mock + Traefik (with the scenario's dynamic.yml) and waits ready.
|
||||||
|
start_stack() {
|
||||||
|
local scenario_dir="$1"
|
||||||
|
local traefik_bin mock_bin
|
||||||
|
traefik_bin="$(ensure_traefik)"
|
||||||
|
mock_bin="$(ensure_mock)"
|
||||||
|
|
||||||
|
WORKDIR="$(mktemp -d)"
|
||||||
|
# Expose the plugin source where Traefik's localPlugins loader expects it.
|
||||||
|
mkdir -p "$WORKDIR/plugins-local/src/github.com/maxlerebourg"
|
||||||
|
ln -s "$REPO_ROOT" "$WORKDIR/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
|
||||||
|
cp "$MOCK_LIB_DIR/traefik.yml" "$WORKDIR/traefik.yml"
|
||||||
|
|
||||||
|
# Render the scenario's dynamic config with the live ports / key / paths.
|
||||||
|
sed \
|
||||||
|
-e "s|@@APIKEY@@|${LAPI_KEY}|g" \
|
||||||
|
-e "s|@@LAPI_HOST@@|127.0.0.1:${LAPI_PORT}|g" \
|
||||||
|
-e "s|@@APPSEC_HOST@@|127.0.0.1:${APPSEC_PORT}|g" \
|
||||||
|
-e "s|@@BACKEND_URL@@|http://127.0.0.1:${BACKEND_PORT}|g" \
|
||||||
|
-e "s|@@REDIS_HOST@@|127.0.0.1:${REDIS_PORT}|g" \
|
||||||
|
-e "s|@@REDIS_READ_HOST@@|127.0.0.1:${REDIS_READ_PORT}|g" \
|
||||||
|
-e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \
|
||||||
|
"$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml"
|
||||||
|
|
||||||
|
# Opt-in HTTPS LAPI: a scenario exports LAPI_TLS_CERT/LAPI_TLS_KEY to serve the
|
||||||
|
# LAPI over TLS (used by tls-system-ca). Default empty -> plaintext as before.
|
||||||
|
local mock_tls_args=() lapi_scheme=http lapi_curl=()
|
||||||
|
if [[ -n "${LAPI_TLS_CERT:-}" && -n "${LAPI_TLS_KEY:-}" ]]; then
|
||||||
|
mock_tls_args=(--lapi-tls-cert "$LAPI_TLS_CERT" --lapi-tls-key "$LAPI_TLS_KEY")
|
||||||
|
lapi_scheme=https
|
||||||
|
lapi_curl=(-k) # the readiness probe ignores trust; the bouncer's trust is what we test
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$mock_bin" \
|
||||||
|
--lapi-addr "127.0.0.1:${LAPI_PORT}" \
|
||||||
|
--backend-addr "127.0.0.1:${BACKEND_PORT}" \
|
||||||
|
--appsec-addr "127.0.0.1:${APPSEC_PORT}" \
|
||||||
|
--redis-addr "127.0.0.1:${REDIS_PORT}" \
|
||||||
|
--redis-read-addr "127.0.0.1:${REDIS_READ_PORT}" \
|
||||||
|
"${mock_tls_args[@]}" >"$WORKDIR/mock.log" 2>&1 &
|
||||||
|
MOCK_PID=$!
|
||||||
|
|
||||||
|
# Opt-in trust store for the Traefik process: a scenario exports
|
||||||
|
# TRAEFIK_SSL_CERT_FILE to point Go's x509.SystemCertPool() at a specific CA
|
||||||
|
# bundle. Empty -> Go's default system store (unchanged behaviour).
|
||||||
|
( cd "$WORKDIR" && SSL_CERT_FILE="${TRAEFIK_SSL_CERT_FILE:-}" exec "$traefik_bin" --configfile=traefik.yml ) >"$WORKDIR/traefik.log" 2>&1 &
|
||||||
|
TRAEFIK_PID=$!
|
||||||
|
|
||||||
|
wait_for_status "${lapi_scheme}://127.0.0.1:${LAPI_PORT}/health" 200 30 "${lapi_curl[@]}"
|
||||||
|
# AppSec stand-in: a bare GET carries no "rpc2" URI, so it answers 200 (allow).
|
||||||
|
wait_for_status "http://127.0.0.1:${APPSEC_PORT}/" 200 30
|
||||||
|
# /ping is served by Traefik itself once it is up (plugin compilation included).
|
||||||
|
wait_for_status "http://127.0.0.1:${WEB_PORT}/ping" 200 60
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_stack() {
|
||||||
|
[[ -n "$TRAEFIK_PID" ]] && kill "$TRAEFIK_PID" 2>/dev/null || true
|
||||||
|
[[ -n "$MOCK_PID" ]] && kill "$MOCK_PID" 2>/dev/null || true
|
||||||
|
[[ -n "$TRAEFIK_PID" ]] && wait "$TRAEFIK_PID" 2>/dev/null || true
|
||||||
|
[[ -n "$MOCK_PID" ]] && wait "$MOCK_PID" 2>/dev/null || true
|
||||||
|
[[ -n "$WORKDIR" && -d "$WORKDIR" ]] && rm -rf "$WORKDIR" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
dump_diagnostics() {
|
||||||
|
echo "=== traefik.log ==="
|
||||||
|
cat "$WORKDIR/traefik.log" 2>/dev/null || true
|
||||||
|
echo "=== mock.log ==="
|
||||||
|
cat "$WORKDIR/mock.log" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# EXIT trap: runs after the scenario body (or after a failed assertion under
|
||||||
|
# `set -e`), so it relies only on globals, never on run_scenario's locals.
|
||||||
|
_scenario_cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
if (( rc != 0 )); then
|
||||||
|
dump_diagnostics > "$SCENARIO_LOG" 2>&1 || true
|
||||||
|
echo "[$SCENARIO_NAME] failed. Logs written to $SCENARIO_LOG" >&2
|
||||||
|
fi
|
||||||
|
stop_stack
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
# run_scenario SCENARIO_NAME SCENARIO_DIR BODY_FN
|
||||||
|
# Wraps lifecycle + diagnostics so each run.sh stays declarative.
|
||||||
|
run_scenario() {
|
||||||
|
SCENARIO_NAME="$1"
|
||||||
|
local dir="$2" body="$3"
|
||||||
|
SCENARIO_LOG="/tmp/e2e-mock-${SCENARIO_NAME}.log"
|
||||||
|
trap _scenario_cleanup EXIT
|
||||||
|
|
||||||
|
echo "[$SCENARIO_NAME] starting binary stack (Traefik + mock LAPI)..."
|
||||||
|
start_stack "$dir"
|
||||||
|
"$body"
|
||||||
|
echo "[$SCENARIO_NAME] OK"
|
||||||
|
}
|
||||||
@@ -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,218 @@
|
|||||||
|
// Command mocklapi is a minimal Crowdsec LAPI stand-in for the binary e2e
|
||||||
|
// suite. It answers only the few LAPI routes the plugin calls — live/none
|
||||||
|
// decision lookups, the stream poll and the usage-metrics push — and lets the
|
||||||
|
// test drive decisions through /admin instead of `cscli`. It also serves the
|
||||||
|
// stub upstream that Traefik proxies allowed requests to, and a hardcoded Redis
|
||||||
|
// stand-in for exercising the redis cache path.
|
||||||
|
//
|
||||||
|
// It is NOT a Crowdsec/AppSec conformance harness — the real WAF engine (OWASP
|
||||||
|
// CRS, virtual patching) is out of scope. The AppSec endpoint here emulates a
|
||||||
|
// single deterministic rule so the suite can exercise the plugin's AppSec
|
||||||
|
// wiring (header forwarding, allow/block handling) end to end. See the README.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decision is the subset of a LAPI decision the plugin actually reads.
|
||||||
|
type Decision struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
active = map[string]Decision{} // ip -> decision currently in force
|
||||||
|
deleted = map[string]Decision{} // ip -> decision to report in the stream "deleted" list
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(m map[string]Decision) []Decision {
|
||||||
|
out := make([]Decision, 0, len(m))
|
||||||
|
for _, d := range m {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Redis mock (inline-command wire format, as spoken by simpleredis) ---
|
||||||
|
|
||||||
|
// serveRedis is a hardcoded stand-in. When verdicts is true it plays a replica
|
||||||
|
// that holds decisions: every line is scanned for known IPs, 1.2.3.4 → "f"
|
||||||
|
// (clean), 1.2.3.5 → "t" (banned); any other GET is a miss ($-1). When verdicts
|
||||||
|
// is false it plays the primary and answers every GET with a miss, so a
|
||||||
|
// scenario can prove reads are served from the replica and not the primary.
|
||||||
|
// SET, DEL, AUTH, SELECT get +OK (they don't read the response anyway).
|
||||||
|
func serveRedis(addr string, verdicts bool) {
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go func(conn net.Conn) {
|
||||||
|
defer conn.Close()
|
||||||
|
rd := bufio.NewReader(conn)
|
||||||
|
for {
|
||||||
|
line, _, err := rd.ReadLine()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := string(line)
|
||||||
|
switch {
|
||||||
|
case verdicts && strings.Contains(s, "1.2.3.4"):
|
||||||
|
conn.Write([]byte("$1\r\nf\r\n"))
|
||||||
|
case verdicts && strings.Contains(s, "1.2.3.5"):
|
||||||
|
conn.Write([]byte("$1\r\nt\r\n"))
|
||||||
|
case strings.HasPrefix(strings.ToUpper(s), "GET "):
|
||||||
|
conn.Write([]byte("$-1\r\n"))
|
||||||
|
default:
|
||||||
|
conn.Write([]byte("+OK\r\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
lapiAddr := flag.String("lapi-addr", "127.0.0.1:8090", "address for the LAPI mock")
|
||||||
|
// The stub upstream Traefik proxies allowed requests to — the binary-suite
|
||||||
|
// equivalent of the traefik/whoami container. Not AppSec.
|
||||||
|
backendAddr := flag.String("backend-addr", "127.0.0.1:8091", "address for the stub upstream service")
|
||||||
|
// AppSec WAF stand-in (the real engine listens on :7422). Not a CRS engine.
|
||||||
|
appsecAddr := flag.String("appsec-addr", "127.0.0.1:8092", "address for the AppSec mock")
|
||||||
|
// Redis stand-ins on plain TCP ports, enough to exercise the plugin's redis
|
||||||
|
// cache path. The primary answers every GET with a miss; the replica serves
|
||||||
|
// the hardcoded verdicts, so a scenario pointing redisCacheReadHosts at the
|
||||||
|
// replica proves reads are offloaded to replicas.
|
||||||
|
redisAddr := flag.String("redis-addr", "127.0.0.1:8093", "address for the Redis primary mock (writes; GET always misses)")
|
||||||
|
redisReadAddr := flag.String("redis-read-addr", "127.0.0.1:8094", "address for the Redis replica mock (serves cached verdicts)")
|
||||||
|
// Optional TLS for the LAPI: when both are set the LAPI is served over HTTPS
|
||||||
|
// (cert signed by the scenario's throwaway CA) so the suite can exercise the
|
||||||
|
// bouncer's system-trust-store path. Backend and AppSec stay plaintext.
|
||||||
|
lapiTLSCert := flag.String("lapi-tls-cert", "", "PEM cert to serve the LAPI over HTTPS (optional)")
|
||||||
|
lapiTLSKey := flag.String("lapi-tls-key", "", "PEM key for --lapi-tls-cert")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Fatal(http.ListenAndServe(*backendAddr, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("E2E_BACKEND_OK\n"))
|
||||||
|
})))
|
||||||
|
}()
|
||||||
|
|
||||||
|
// AppSec mock: the plugin forwards the request metadata in X-Crowdsec-Appsec-*
|
||||||
|
// headers and reads our status — 200 allows, 403 blocks. We emulate one
|
||||||
|
// deterministic virtual-patching rule (block any URI containing "rpc2", the
|
||||||
|
// exact probe from examples/appsec-enabled) so the plugin's AppSec path is
|
||||||
|
// exercised without standing up the real WAF.
|
||||||
|
go func() {
|
||||||
|
log.Fatal(http.ListenAndServe(*appsecAddr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "403") {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
}
|
||||||
|
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "500") {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "502") {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
// Read body
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
if strings.Contains(string(body), "a=0") {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}()
|
||||||
|
|
||||||
|
go serveRedis(*redisAddr, false)
|
||||||
|
go serveRedis(*redisReadAddr, true)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Readiness probe for the test harness (empty body, 200).
|
||||||
|
mux.HandleFunc("/health", func(http.ResponseWriter, *http.Request) {})
|
||||||
|
|
||||||
|
// live / none mode: the plugin asks about one IP and expects a decision
|
||||||
|
// array, or the literal `null` when there is none.
|
||||||
|
mux.HandleFunc("/v1/decisions", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if d, ok := active[r.URL.Query().Get("ip")]; ok {
|
||||||
|
writeJSON(w, []Decision{d})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("null"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// stream mode: report the whole active set as "new" and anything removed as
|
||||||
|
// "deleted". Re-sending the same on every poll is harmless — the plugin just
|
||||||
|
// re-adds to / re-deletes from its cache.
|
||||||
|
mux.HandleFunc("/v1/decisions/stream", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
writeJSON(w, map[string][]Decision{"new": list(active), "deleted": list(deleted)})
|
||||||
|
})
|
||||||
|
|
||||||
|
// usage-metrics push: accept and ignore.
|
||||||
|
mux.HandleFunc("/v1/usage-metrics", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test control plane: add / remove decisions instead of cscli.
|
||||||
|
mux.HandleFunc("/admin/decisions", func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
ip := q.Get("ip")
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
dtype := q.Get("type")
|
||||||
|
if dtype == "" {
|
||||||
|
dtype = "ban"
|
||||||
|
}
|
||||||
|
duration := q.Get("duration")
|
||||||
|
if duration == "" {
|
||||||
|
duration = "4h"
|
||||||
|
}
|
||||||
|
active[ip] = Decision{Value: ip, Type: dtype, Duration: duration}
|
||||||
|
delete(deleted, ip)
|
||||||
|
case http.MethodDelete:
|
||||||
|
if d, ok := active[ip]; ok {
|
||||||
|
deleted[ip] = d
|
||||||
|
delete(active, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if *lapiTLSCert != "" && *lapiTLSKey != "" {
|
||||||
|
log.Printf("mocklapi: LAPI on %s (TLS), backend on %s, appsec on %s, redis on %s (read %s)", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr, *redisReadAddr)
|
||||||
|
log.Fatal(http.ListenAndServeTLS(*lapiAddr, *lapiTLSCert, *lapiTLSKey, mux))
|
||||||
|
}
|
||||||
|
log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s, redis on %s (read %s)", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr, *redisReadAddr)
|
||||||
|
log.Fatal(http.ListenAndServe(*lapiAddr, mux))
|
||||||
|
}
|
||||||
@@ -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
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=../../lib/common.sh
|
||||||
|
source "$HERE/../../lib/common.sh"
|
||||||
|
|
||||||
|
SCENARIO=none-mode
|
||||||
|
|
||||||
|
body() {
|
||||||
|
echo "[$SCENARIO] no decision -> request passes (LAPI queried per request)"
|
||||||
|
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
|
||||||
|
lapi_add_decision 1.2.3.4 ban 5m
|
||||||
|
|
||||||
|
echo "[$SCENARIO] none mode has no cache -> next request must be blocked immediately"
|
||||||
|
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] deleting decision"
|
||||||
|
lapi_delete_decision 1.2.3.4
|
||||||
|
|
||||||
|
echo "[$SCENARIO] previously banned IP must pass again immediately"
|
||||||
|
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_scenario "$SCENARIO" "$HERE" body
|
||||||
@@ -0,0 +1,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,26 @@
|
|||||||
|
http:
|
||||||
|
routers:
|
||||||
|
r:
|
||||||
|
rule: "PathPrefix(`/foo`)"
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
service: backend
|
||||||
|
middlewares:
|
||||||
|
- bouncer
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "@@BACKEND_URL@@"
|
||||||
|
middlewares:
|
||||||
|
bouncer:
|
||||||
|
plugin:
|
||||||
|
bouncer:
|
||||||
|
enabled: "true"
|
||||||
|
crowdsecMode: stream
|
||||||
|
updateIntervalSeconds: "2"
|
||||||
|
crowdsecLapiScheme: http
|
||||||
|
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||||
|
crowdsecLapiKey: "@@APIKEY@@"
|
||||||
|
forwardedHeadersTrustedIps:
|
||||||
|
- "127.0.0.1/32"
|
||||||
Executable
+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=stream-mode
|
||||||
|
|
||||||
|
body() {
|
||||||
|
echo "[$SCENARIO] no decision yet -> request allowed"
|
||||||
|
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
|
||||||
|
lapi_add_decision 1.2.3.4 ban 5m
|
||||||
|
|
||||||
|
echo "[$SCENARIO] banned IP must be blocked once the next stream poll lands (HTTP 403)"
|
||||||
|
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] non-banned IP must still pass (HTTP 200)"
|
||||||
|
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] deleting ban decision"
|
||||||
|
lapi_delete_decision 1.2.3.4
|
||||||
|
|
||||||
|
echo "[$SCENARIO] previously banned IP must pass again once the deletion is polled"
|
||||||
|
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_scenario "$SCENARIO" "$HERE" body
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
http:
|
||||||
|
routers:
|
||||||
|
r:
|
||||||
|
rule: "PathPrefix(`/foo`)"
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
service: backend
|
||||||
|
middlewares:
|
||||||
|
- bouncer
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "@@BACKEND_URL@@"
|
||||||
|
middlewares:
|
||||||
|
bouncer:
|
||||||
|
plugin:
|
||||||
|
bouncer:
|
||||||
|
enabled: "true"
|
||||||
|
crowdsecMode: 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
|
||||||
+1
-1
@@ -117,7 +117,7 @@ func (sr *SimpleRedis) askRedis(cmd redisCmd, channel chan redisCmd) redisCmd {
|
|||||||
str := string(read)
|
str := string(read)
|
||||||
if strings.Contains(str, "-NOAUTH") {
|
if strings.Contains(str, "-NOAUTH") {
|
||||||
return redisCmd{Error: fmt.Errorf(RedisNoAuth)}
|
return redisCmd{Error: fmt.Errorf(RedisNoAuth)}
|
||||||
} else if str != "$1" {
|
} else if str == "$-1" {
|
||||||
return redisCmd{Error: fmt.Errorf(RedisMiss)}
|
return redisCmd{Error: fmt.Errorf(RedisMiss)}
|
||||||
}
|
}
|
||||||
read, _ = reader.ReadLineBytes()
|
read, _ = reader.ReadLineBytes()
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# github.com/leprosus/golang-ttl-map v1.1.7
|
# github.com/leprosus/golang-ttl-map v1.1.7
|
||||||
## explicit; go 1.15
|
## explicit; go 1.15
|
||||||
github.com/leprosus/golang-ttl-map
|
github.com/leprosus/golang-ttl-map
|
||||||
# github.com/maxlerebourg/simpleredis v1.0.11
|
# github.com/maxlerebourg/simpleredis v1.0.12
|
||||||
## explicit; go 1.22
|
## explicit; go 1.22
|
||||||
github.com/maxlerebourg/simpleredis
|
github.com/maxlerebourg/simpleredis
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||||
|
|
||||||
|
// 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