mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ea2ea2419 | ||
|
|
1dedaa0571 | ||
|
|
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 |
@@ -1,24 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain dependencies for Go
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
# Maintain dependencies for build tools
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/tools"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
# Maintain dependencies for GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -0,0 +1,42 @@
|
||||
name: E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: e2e-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: e2e (binary + mock LAPI)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
# Track go.mod (Go 1.22) — the plugin's yaegi-bound floor. Keeps the
|
||||
# single source of truth and builds the mock on the supported version.
|
||||
go-version-file: go.mod
|
||||
# CI runs the binary/mock suite only: Traefik as a downloaded binary +
|
||||
# a small LAPI mock (no Docker, no real Crowdsec). It validates the
|
||||
# plugin's own behaviour. Crowdsec / AppSec correctness is upstream's
|
||||
# responsibility, so those are intentionally out of scope here. The
|
||||
# Docker suite (tests/e2e/scenarios) stays available for local debugging.
|
||||
# `-k` keeps going after a failing scenario so the logs cover all of
|
||||
# them, while make still exits non-zero if any scenario failed.
|
||||
- name: Run mock scenarios
|
||||
run: make -k e2e_mock
|
||||
- name: Upload logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-logs
|
||||
path: /tmp/e2e-mock-*.log
|
||||
if-no-files-found: ignore
|
||||
@@ -1,5 +1,8 @@
|
||||
name: Main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -12,8 +15,13 @@ jobs:
|
||||
name: Main Process
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GO_VERSION: 1.23
|
||||
# Keep in sync with go.mod. Capped at 1.22 because the plugin is run by
|
||||
# yaegi (bundled in Traefik) and even Traefik v3.7.1 ships yaegi v0.16.1,
|
||||
# which only supports Go 1.22. Building on the floor makes go build / go
|
||||
# test reject newer stdlib before yaegi_test does.
|
||||
GO_VERSION: 1.22
|
||||
GOLANGCI_LINT_VERSION: v1.63.4
|
||||
# yaegi_test guard — pin to the version current Traefik bundles.
|
||||
YAEGI_VERSION: v0.16.1
|
||||
CGO_ENABLED: 0
|
||||
defaults:
|
||||
@@ -24,20 +32,20 @@ jobs:
|
||||
|
||||
# https://github.com/marketplace/actions/setup-go-environment
|
||||
- name: Set up Go ${{ env.GO_VERSION }}
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
# https://github.com/marketplace/actions/checkout
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: go/src/github.com/${{ github.repository }}
|
||||
fetch-depth: 0
|
||||
|
||||
# https://github.com/marketplace/actions/cache
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
@@ -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.1.17
|
||||
with:
|
||||
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||
env:
|
||||
RENOVATE_REPOSITORIES: ${{ github.repository }}
|
||||
RENOVATE_ONBOARDING: "false"
|
||||
RENOVATE_REQUIRE_CONFIG: "required"
|
||||
# The grouped "all" branch holds many upgrades; changelog/PR-body
|
||||
# rendering for it blew the default 4GB V8 heap (exit 134 OOM).
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
LOG_LEVEL: ${{ github.event.inputs.logLevel || 'info' }}
|
||||
@@ -5,3 +5,7 @@ conf
|
||||
db
|
||||
logs
|
||||
docker-compose.dev.yml
|
||||
|
||||
# Binary e2e suite working cache: Traefik binary + compiled mock. Persisted
|
||||
# across local runs; CI runs on fresh runners so it is recreated every time.
|
||||
tests/e2e/mock/.cache/
|
||||
|
||||
+3
-1
@@ -7,7 +7,7 @@ linters-settings:
|
||||
disable:
|
||||
- fieldalignment
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
min-complexity: 20
|
||||
goconst:
|
||||
min-len: 5
|
||||
min-occurrences: 4
|
||||
@@ -41,6 +41,7 @@ linters-settings:
|
||||
- $test
|
||||
allow:
|
||||
- $gostd
|
||||
- github.com/maxlerebourg/simpleredis
|
||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
|
||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
|
||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
|
||||
@@ -73,6 +74,7 @@ linters:
|
||||
- gci
|
||||
- mnd
|
||||
- exportloopref
|
||||
- contextcheck
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-same-issues: 0
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
.PHONY: lint test vendor clean
|
||||
.PHONY: lint test vendor clean e2e_mock
|
||||
|
||||
export GO111MODULE=on
|
||||
|
||||
# Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs.
|
||||
# The local Docker suite (make e2e) lives in a separate PR/branch.
|
||||
E2E_MOCK_SCENARIOS := $(notdir $(wildcard tests/e2e/mock/scenarios/*))
|
||||
|
||||
default: lint test
|
||||
|
||||
lint:
|
||||
@@ -13,6 +17,11 @@ test:
|
||||
yaegi_test:
|
||||
yaegi test -v .
|
||||
|
||||
e2e_mock: $(addprefix e2e_mock_,$(E2E_MOCK_SCENARIOS))
|
||||
|
||||
e2e_mock_%:
|
||||
bash ./tests/e2e/mock/scenarios/$*/run.sh
|
||||
|
||||
vendor:
|
||||
go mod vendor
|
||||
|
||||
@@ -41,13 +50,16 @@ run_tlsauth:
|
||||
docker compose -f examples/tls-auth/docker-compose.yml up -d --remove-orphans
|
||||
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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/appsec-enabled/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 docker-compose.local.yml down --remove-orphans
|
||||
docker compose -f docker-compose.yml down --remove-orphans
|
||||
@@ -111,4 +124,3 @@ show_metrics:
|
||||
|
||||
show_decisions:
|
||||
docker exec crowdsec cscli decisions list
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ 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/)
|
||||
- [custom/wicketkeeper](https://github.com/a-ve/wicketkeeper)
|
||||
|
||||
There are 5 operating modes (CrowdsecMode) for this plugin:
|
||||
|
||||
@@ -67,7 +68,7 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant CrowdsecLAPI
|
||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a Crowdsec Decision ?
|
||||
Destroy CrowdsecLAPI
|
||||
destroy CrowdsecLAPI
|
||||
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
||||
TraefikPlugin->>User: No, HTTP 403
|
||||
```
|
||||
@@ -81,9 +82,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant CrowdsecLAPI
|
||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||
Destroy CrowdsecLAPI
|
||||
destroy CrowdsecLAPI
|
||||
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
```
|
||||
@@ -104,10 +105,10 @@ sequenceDiagram
|
||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||
create participant CrowdsecLAPI
|
||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||
Destroy CrowdsecLAPI
|
||||
destroy CrowdsecLAPI
|
||||
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
||||
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Done
|
||||
TraefikPlugin->>User: No, HTTP 403
|
||||
```
|
||||
@@ -124,10 +125,10 @@ sequenceDiagram
|
||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||
create participant CrowdsecLAPI
|
||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||
Destroy CrowdsecLAPI
|
||||
destroy CrowdsecLAPI
|
||||
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
||||
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Done
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
@@ -144,11 +145,11 @@ sequenceDiagram
|
||||
participant TraefikPlugin
|
||||
participant CrowdsecLAPI
|
||||
TraefikPlugin->>CrowdsecLAPI: What are the current decisions
|
||||
Destroy CrowdsecLAPI
|
||||
destroy CrowdsecLAPI
|
||||
CrowdsecLAPI->>TraefikPlugin: Here is the list
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Store this list
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Done
|
||||
```
|
||||
|
||||
@@ -161,9 +162,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Yes a ban decision
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>User: No, HTTP 403
|
||||
```
|
||||
|
||||
@@ -176,9 +177,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
```
|
||||
@@ -194,11 +195,11 @@ sequenceDiagram
|
||||
participant TraefikPlugin
|
||||
participant CrowdsecCAPI
|
||||
TraefikPlugin->>CrowdsecCAPI: What are the current decisions from CAPI
|
||||
Destroy CrowdsecCAPI
|
||||
destroy CrowdsecCAPI
|
||||
CrowdsecCAPI->>TraefikPlugin: Here is the list
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Store this list
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Done
|
||||
```
|
||||
|
||||
@@ -211,9 +212,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Yes a ban decision
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>User: No, HTTP 403
|
||||
```
|
||||
|
||||
@@ -226,9 +227,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
```
|
||||
@@ -246,9 +247,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant CrowdsecAppSec
|
||||
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
||||
Destroy CrowdsecAppSec
|
||||
destroy CrowdsecAppSec
|
||||
CrowdsecAppSec-->>TraefikPlugin: Yes I think so
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>User: No, HTTP 403
|
||||
```
|
||||
|
||||
@@ -261,9 +262,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant CrowdsecAppSec
|
||||
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
||||
Destroy CrowdsecAppSec
|
||||
destroy CrowdsecAppSec
|
||||
CrowdsecAppSec-->>TraefikPlugin: No I don't think so
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
```
|
||||
@@ -284,12 +285,12 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Fine, done!
|
||||
create participant ProviderCaptcha
|
||||
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
||||
Destroy ProviderCaptcha
|
||||
destroy ProviderCaptcha
|
||||
ProviderCaptcha-->>TraefikPlugin: Yes
|
||||
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Done
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
```
|
||||
@@ -309,17 +310,16 @@ make run
|
||||
### Note
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Some of the behaviours and configuration parameters are shared globally across *all* crowdsec middlewares even if you declare different middlewares with different settings.
|
||||
> Some of the behaviours and configuration parameters are shared globally across _all_ crowdsec middlewares even if you declare different middlewares with different settings.
|
||||
>
|
||||
> **Cache is shared by all services**: This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP
|
||||
>
|
||||
> If you define different caches for different middlewares, only the first one to be instantiated will be bound to the crowdsec stream.
|
||||
>
|
||||
> Overall, this middleware is designed in such a way that **only one instance of the plugin is *possible*.** You can have multiple crowdsec middlewares in the same cluster, the key parameters must be aligned (MetricsUpdateIntervalSeconds, CrowdsecMode, CrowdsecAppsecEnabled, etc.)
|
||||
> Overall, this middleware is designed in such a way that **only one instance of the plugin is _possible_.** You can have multiple crowdsec middlewares in the same cluster, the key parameters must be aligned (MetricsUpdateIntervalSeconds, CrowdsecMode, CrowdsecAppsecEnabled, etc.)
|
||||
|
||||
> [!WARNING]
|
||||
> **Appsec maximum body limit is defaulted to 10MB**
|
||||
> *Be careful when you upgrade to >1.4.x*
|
||||
> **Appsec maximum body limit is defaulted to 10MB** > _Be careful when you upgrade to >1.4.x_
|
||||
|
||||
### Variables
|
||||
|
||||
@@ -329,8 +329,12 @@ make run
|
||||
- Enable the plugin
|
||||
- LogLevel
|
||||
- string
|
||||
- default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR`
|
||||
- default: `INFO`, expected values are: `DEBUG`, `INFO`, `WARN`, `ERROR`
|
||||
- Log are written to `stdout` / `stderr` or file if LogFilePath is provided
|
||||
- LogFormat
|
||||
- string
|
||||
- default: `common`, expected values are: `common`, `json`
|
||||
- Log format: `common` for traditional text logs, `json` for structured JSON logs
|
||||
- LogFilePath
|
||||
- string
|
||||
- default: ""
|
||||
@@ -350,7 +354,18 @@ make run
|
||||
- CrowdsecAppsecHost
|
||||
- string
|
||||
- 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: "/"
|
||||
@@ -367,6 +382,14 @@ make run
|
||||
- int64
|
||||
- default: 10485760 (= 10MB)
|
||||
- Transmit only the first number of bytes to Crowdsec Appsec Server.
|
||||
- CrowdsecAppsecUnreadableBodyBlock
|
||||
- bool
|
||||
- default: false
|
||||
- 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` (default) 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
|
||||
- string
|
||||
- default: `http`, expected values are: `http`, `https`
|
||||
@@ -389,7 +412,7 @@ make run
|
||||
- CrowdsecLapiTlsCertificateAuthority
|
||||
- string
|
||||
- default: ""
|
||||
- PEM-encoded Certificate Authority of the Crowdsec LAPI
|
||||
- PEM-encoded Certificate Authority used to verify the LAPI's server certificate. When empty (and `crowdsecLapiTlsInsecureVerify` is `false`), the host's system trust store is used.
|
||||
- CrowdsecLapiTlsCertificateBouncer
|
||||
- string
|
||||
- default: ""
|
||||
@@ -405,7 +428,7 @@ make run
|
||||
- RemediationHeadersCustomName
|
||||
- string
|
||||
- default: ""
|
||||
- Name of the header you want in response when request are cancelled (possible value of the header `ban` or `captcha`)
|
||||
- Name of the header you want in response when request are handled by plugin (possible value of the header `ban`, `captcha` or `solved-captcha`)
|
||||
- ForwardedHeadersCustomName
|
||||
- string
|
||||
- default: "X-Forwarded-For"
|
||||
@@ -421,7 +444,12 @@ make run
|
||||
- RedisCacheHost
|
||||
- string
|
||||
- default: "redis:6379"
|
||||
- hostname and port for the Redis service
|
||||
- hostname and port for the Redis write host (primary)
|
||||
- RedisCacheReadHosts
|
||||
- []string
|
||||
- default: []
|
||||
- List of Redis replica hostnames (host:port) to use for read operations. Reads are distributed round-robin across replicas. Falls back to RedisCacheHost when empty.
|
||||
- Note: when set, reads are not retried against RedisCacheHost (the primary) if the replicas are unreachable. With RedisCacheUnreachableBlock at its default (true), a replica outage will therefore block/delay requests even though the primary is healthy.
|
||||
- RedisCachePassword
|
||||
- string
|
||||
- default: ""
|
||||
@@ -446,6 +474,12 @@ make run
|
||||
- int64
|
||||
- default: 0
|
||||
- Used only in `stream` and `alone` mode, the maximum number of time we can not reach Crowdsec before blocking traffic (set -1 to never block)
|
||||
- StreamStartupBlock
|
||||
- bool
|
||||
- default: true
|
||||
- Used only in `stream` and `alone` mode, controls whether the initial stream update runs synchronously or asynchronously during plugin initialization
|
||||
- When `true`, plugin initialization waits for Crowdsec to be ready before serving traffic.
|
||||
- **Warning**: When `false`, all requests bypass remediation until the first stream sync completes — banned IPs will be allowed through during this window. Only disable when startup availability is more important than blocking at startup.
|
||||
- DefaultDecisionSeconds
|
||||
- int64
|
||||
- default: 60
|
||||
@@ -465,7 +499,19 @@ make run
|
||||
- Used only in `alone` mode, scenarios for Crowdsec CAPI
|
||||
- CaptchaProvider
|
||||
- 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
|
||||
- string
|
||||
- Site key for the captcha provider
|
||||
@@ -476,14 +522,18 @@ make run
|
||||
- int64
|
||||
- default: 1800 (= 30 minutes)
|
||||
- Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid
|
||||
- CaptchaHTMLFilePath
|
||||
- CaptchaFilePath
|
||||
- string
|
||||
- default: /captcha.html
|
||||
- Path where the captcha template is stored
|
||||
- BanHTMLFilePath
|
||||
- Path where the captcha template is stored. The Content-Type header is automatically inferred from the file extension.
|
||||
- BanFilePath
|
||||
- string
|
||||
- default: ""
|
||||
- Path where the ban html file is stored (default empty ""=disabled)
|
||||
- Path where the ban file is stored (default empty ""=disabled). The Content-Type header is automatically inferred from the file extension.
|
||||
- TraceHeadersCustomName
|
||||
- string
|
||||
- default: ""
|
||||
- Request Header name whose value to inject in ban HTML response (default empty ""=disabled)
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -505,7 +555,37 @@ experimental:
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Dynamic configuration
|
||||
# Simplified dynamic configuration
|
||||
|
||||
http:
|
||||
routers:
|
||||
my-router:
|
||||
rule: host(`whoami.localhost`)
|
||||
service: service-foo
|
||||
entryPoints:
|
||||
- web
|
||||
middlewares:
|
||||
- crowdsec
|
||||
|
||||
services:
|
||||
service-foo:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: http://127.0.0.1:5000
|
||||
|
||||
middlewares:
|
||||
crowdsec:
|
||||
plugin:
|
||||
bouncer:
|
||||
enabled: true
|
||||
logLevel: DEBUG
|
||||
crowdsecMode: live
|
||||
crowdsecLapiKey: privateKey-foo
|
||||
crowdsecLapiHost: crowdsec:8080
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Full dynamic configuration
|
||||
|
||||
http:
|
||||
routers:
|
||||
@@ -529,19 +609,23 @@ http:
|
||||
bouncer:
|
||||
enabled: false
|
||||
logLevel: DEBUG
|
||||
logFormat: common
|
||||
LogFilePath: ""
|
||||
updateIntervalSeconds: 60
|
||||
updateMaxFailure: 0
|
||||
streamStartupBlock: true
|
||||
defaultDecisionSeconds: 60
|
||||
remediationStatusCode: 403
|
||||
httpTimeoutSeconds: 10
|
||||
crowdsecMode: live
|
||||
crowdsecAppsecEnabled: false
|
||||
crowdsecAppsecScheme: ""
|
||||
crowdsecAppsecHost: crowdsec:7422
|
||||
crowdsecAppsecPath: "/"
|
||||
crowdsecAppsecFailureBlock: true
|
||||
crowdsecAppsecUnreachableBlock: true
|
||||
crowdsecAppsecBodyLimit: 10485760
|
||||
crowdsecAppsecUnreadableBodyBlock: false
|
||||
crowdsecLapiKey: privateKey-foo
|
||||
crowdsecLapiScheme: http
|
||||
crowdsecLapiHost: crowdsec:8080
|
||||
@@ -561,7 +645,10 @@ http:
|
||||
forwardedHeadersCustomName: X-Custom-Header
|
||||
remediationHeadersCustomName: cs-remediation
|
||||
redisCacheEnabled: false
|
||||
redisCacheHost: "redis:6379"
|
||||
redisCacheHost: "redis-primary:6379"
|
||||
redisCacheReadHosts:
|
||||
- "redis-replica-1:6379"
|
||||
- "redis-replica-2:6379"
|
||||
redisCachePassword: password
|
||||
redisCacheDatabase: "5"
|
||||
redisCacheUnreachableBlock: true
|
||||
@@ -590,12 +677,13 @@ http:
|
||||
captchaGracePeriodSeconds: 1800
|
||||
captchaHTMLFilePath: /captcha.html
|
||||
banHTMLFilePath: /ban.html
|
||||
traceHeadersCustomName: X-Request-ID
|
||||
metricsUpdateIntervalSeconds: 600
|
||||
```
|
||||
|
||||
#### Fill variable with value of file
|
||||
|
||||
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey`, `CaptchaSecretKey` and `RedisCachePassword` can be provided with the content as raw or through a file path that Traefik can read.
|
||||
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecAppsecTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CrowdsecAppsecKey`, `CaptchaSiteKey`, `CaptchaSecretKey` and `RedisCachePassword` can be provided with the content as raw or through a file path that Traefik can read.
|
||||
The file variable will be used as preference if both content and file are provided for the same variable.
|
||||
|
||||
Format is:
|
||||
@@ -652,12 +740,21 @@ A script is available to generate certificates in `examples/tls-auth/gencerts.sh
|
||||
|
||||
#### Use HTTPS to communicate with the LAPI
|
||||
|
||||
To communicate with the LAPI in HTTPS you need to either accept any certificates by setting the `crowdsecLapiTLSInsecureVerify` to true or add the CA used by the server certificate of Crowdsec using `crowdsecLapiTLSCertificateAuthority` or `crowdsecLapiTLSCertificateAuthorityFile`.
|
||||
Set the `crowdsecLapiScheme` to https.
|
||||
Set `crowdsecLapiScheme` to `https`. The plugin then validates Crowdsec's server certificate. Three options:
|
||||
|
||||
- **Publicly trusted certificate** (e.g. Let's Encrypt behind a reverse proxy): leave `crowdsecLapiTLSCertificateAuthority` empty and `crowdsecLapiTLSInsecureVerify` `false`. The plugin falls back to the host's system trust store (the `traefik` image ships `ca-certificates`).
|
||||
- **Private/self-signed CA**: set `crowdsecLapiTLSCertificateAuthority` (or `…File`) to the PEM-encoded CA that signed Crowdsec's server cert.
|
||||
- **Skip verification entirely** (not recommended for production): set `crowdsecLapiTLSInsecureVerify` to `true`.
|
||||
|
||||
Crowdsec must be listening in HTTPS for this to work.
|
||||
Please see the [tls-auth example](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/tls-auth/README.md) or the official documentation: [docs.crowdsec.net/docs/local_api/tls_auth/](https://docs.crowdsec.net/docs/local_api/tls_auth/)
|
||||
|
||||
#### Use HTTPS to communicate with the Appsec
|
||||
|
||||
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)
|
||||
|
||||
```bash
|
||||
@@ -690,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)
|
||||
|
||||
#### 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
|
||||
|
||||
Traefik also offers a developer mode that can be used for temporary testing of plugins not hosted on GitHub.
|
||||
|
||||
+171
-69
@@ -10,6 +10,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -62,7 +63,7 @@ const (
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var (
|
||||
isStartup = true
|
||||
isCrowdsecStreamStartup = true
|
||||
isCrowdsecStreamHealthy = true
|
||||
updateFailure int64
|
||||
streamTicker chan bool
|
||||
@@ -84,10 +85,13 @@ type Bouncer struct {
|
||||
|
||||
enabled bool
|
||||
appsecEnabled bool
|
||||
appsecScheme string
|
||||
appsecHost string
|
||||
appsecPath string
|
||||
appsecKey string
|
||||
appsecFailureBlock bool
|
||||
appsecUnreachableBlock bool
|
||||
appsecUnreadableBodyBlock bool
|
||||
appsecBodyLimit int64
|
||||
crowdsecScheme string
|
||||
crowdsecHost string
|
||||
@@ -106,22 +110,33 @@ type Bouncer struct {
|
||||
crowdsecStreamRoute string
|
||||
crowdsecHeader string
|
||||
redisUnreachableBlock bool
|
||||
banTemplateString string
|
||||
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 *logger.Log
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates the crowdsec bouncer plugin.
|
||||
//
|
||||
//nolint:gocyclo
|
||||
//nolint:nestif,gocyclo,gocognit,funlen,maintidx
|
||||
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
||||
config.LogLevel = strings.ToUpper(config.LogLevel)
|
||||
log := logger.New(config.LogLevel, config.LogFilePath)
|
||||
err := configuration.ValidateParams(config)
|
||||
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
|
||||
|
||||
if config.BanFilePath == "" && config.BanHTMLFilePath != "" {
|
||||
config.BanFilePath = config.BanHTMLFilePath
|
||||
}
|
||||
if config.CaptchaHTMLFilePath != "" {
|
||||
config.CaptchaFilePath = config.CaptchaHTMLFilePath
|
||||
}
|
||||
|
||||
err := configuration.ValidateParams(config, log)
|
||||
if err != nil {
|
||||
log.Error("New:validateParams " + err.Error())
|
||||
return nil, err
|
||||
@@ -130,6 +145,23 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
serverChecker, _ := ip.NewChecker(log, config.ForwardedHeadersTrustedIPs)
|
||||
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
|
||||
crowdsecStreamRoute := ""
|
||||
crowdsecHeader := ""
|
||||
@@ -139,36 +171,32 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
config.CrowdsecLapiScheme = configuration.HTTPS
|
||||
config.CrowdsecLapiHost = crowdsecCapiHost
|
||||
config.CrowdsecLapiPath = "/"
|
||||
config.CrowdsecAppsecEnabled = false
|
||||
config.UpdateIntervalSeconds = 7200 // 2 hours
|
||||
crowdsecStreamRoute = crowdsecCapiStreamRoute
|
||||
crowdsecHeader = crowdsecCapiHeader
|
||||
} else {
|
||||
crowdsecStreamRoute = crowdsecLapiStreamRoute
|
||||
crowdsecHeader = crowdsecLapiHeader
|
||||
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log)
|
||||
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log, false)
|
||||
if err != nil {
|
||||
log.Error("New:getTLSConfigCrowdsec fail to get tlsConfig " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
apiKey, errAPIKey := configuration.GetVariable(config, "CrowdsecLapiKey")
|
||||
if errAPIKey != nil && len(tlsConfig.Certificates) == 0 {
|
||||
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errAPIKey.Error())
|
||||
return nil, errAPIKey
|
||||
apiKey, errKey := configuration.GetVariable(config, "CrowdsecLapiKey")
|
||||
if errKey != nil && len(tlsConfig.Certificates) == 0 {
|
||||
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errKey.Error())
|
||||
return nil, errKey
|
||||
}
|
||||
config.CrowdsecLapiKey = apiKey
|
||||
if config.CrowdsecAppsecKey == "" {
|
||||
config.CrowdsecAppsecKey = apiKey
|
||||
}
|
||||
}
|
||||
|
||||
var banTemplateString string
|
||||
if config.BanHTMLFilePath != "" {
|
||||
var buf bytes.Buffer
|
||||
banTemplate, _ := configuration.GetHTMLTemplate(config.BanHTMLFilePath)
|
||||
err = banTemplate.Execute(&buf, nil)
|
||||
if err != nil {
|
||||
log.Error("New:banTemplate is bad formatted " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
banTemplateString = buf.String()
|
||||
var banTemplate *template.Template
|
||||
var banTemplateContentType string
|
||||
if config.BanFilePath != "" {
|
||||
banTemplate, banTemplateContentType, _ = configuration.GetTemplate(config.BanFilePath)
|
||||
}
|
||||
|
||||
bouncer := &Bouncer{
|
||||
@@ -179,10 +207,13 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
enabled: config.Enabled,
|
||||
crowdsecMode: config.CrowdsecMode,
|
||||
appsecEnabled: config.CrowdsecAppsecEnabled,
|
||||
appsecScheme: config.CrowdsecAppsecScheme,
|
||||
appsecHost: config.CrowdsecAppsecHost,
|
||||
appsecPath: config.CrowdsecAppsecPath,
|
||||
appsecKey: config.CrowdsecAppsecKey,
|
||||
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
||||
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
||||
appsecUnreadableBodyBlock: config.CrowdsecAppsecUnreadableBodyBlock,
|
||||
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
|
||||
crowdsecScheme: config.CrowdsecLapiScheme,
|
||||
crowdsecHost: config.CrowdsecLapiHost,
|
||||
@@ -198,7 +229,9 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
defaultDecisionTimeout: config.DefaultDecisionSeconds,
|
||||
remediationStatusCode: config.RemediationStatusCode,
|
||||
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
||||
banTemplateString: banTemplateString,
|
||||
banTemplate: banTemplate,
|
||||
banTemplateContentType: banTemplateContentType,
|
||||
traceCustomHeader: config.TraceHeadersCustomName,
|
||||
crowdsecStreamRoute: crowdsecStreamRoute,
|
||||
crowdsecHeader: crowdsecHeader,
|
||||
log: log,
|
||||
@@ -216,6 +249,14 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
},
|
||||
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{},
|
||||
captchaClient: &captcha.Client{},
|
||||
}
|
||||
@@ -227,6 +268,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
log,
|
||||
config.RedisCacheEnabled,
|
||||
config.RedisCacheHost,
|
||||
config.RedisCacheReadHosts,
|
||||
config.RedisCachePassword,
|
||||
config.RedisCacheDatabase,
|
||||
)
|
||||
@@ -240,13 +282,18 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||
},
|
||||
config.CaptchaProvider,
|
||||
config.CaptchaCustomJsURL,
|
||||
config.CaptchaCustomKey,
|
||||
config.CaptchaCustomResponse,
|
||||
config.CaptchaCustomValidateURL,
|
||||
config.CaptchaSiteKey,
|
||||
config.CaptchaSecretKey,
|
||||
config.RemediationHeadersCustomName,
|
||||
config.CaptchaHTMLFilePath,
|
||||
config.CaptchaFilePath,
|
||||
config.CaptchaGracePeriodSeconds,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error("CaptchaClient not valid " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -257,8 +304,11 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if config.StreamStartupBlock {
|
||||
handleStreamTicker(bouncer)
|
||||
isStartup = false
|
||||
} else {
|
||||
go handleStreamTicker(bouncer)
|
||||
}
|
||||
streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() {
|
||||
handleStreamTicker(bouncer)
|
||||
})
|
||||
@@ -267,7 +317,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
// Start metrics ticker if not already running
|
||||
if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 {
|
||||
lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker
|
||||
handleMetricsTicker(bouncer)
|
||||
go handleMetricsTicker(bouncer)
|
||||
metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() {
|
||||
handleMetricsTicker(bouncer)
|
||||
})
|
||||
@@ -280,7 +330,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
|
||||
// ServeHTTP principal function of plugin.
|
||||
//
|
||||
//nolint:nestif,gocyclo
|
||||
//nolint:nestif
|
||||
func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
if !bouncer.enabled {
|
||||
bouncer.next.ServeHTTP(rw, req)
|
||||
@@ -291,13 +341,13 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.forwardedCustomHeader)
|
||||
if err != nil {
|
||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error()))
|
||||
handleBanServeHTTP(bouncer, rw)
|
||||
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||
return
|
||||
}
|
||||
isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP)
|
||||
if err != nil {
|
||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error()))
|
||||
handleBanServeHTTP(bouncer, rw)
|
||||
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||
return
|
||||
}
|
||||
// if our IP is in the trusted list we bypass the next checks
|
||||
@@ -308,7 +358,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
if bouncer.crowdsecMode == configuration.AppsecMode {
|
||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -320,20 +370,20 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
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))
|
||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||
return
|
||||
}
|
||||
if cacheErrString != cache.CacheMiss {
|
||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
|
||||
handleBanServeHTTP(bouncer, rw)
|
||||
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
|
||||
if value == cache.NoBannedValue {
|
||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||
} else {
|
||||
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
|
||||
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -342,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.
|
||||
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
|
||||
if isCrowdsecStreamHealthy {
|
||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||
} else {
|
||||
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 {
|
||||
value, err := handleNoStreamCache(bouncer, remoteIP)
|
||||
if err != nil {
|
||||
bouncer.log.Debug("handleNoStreamCache:crowdsecQuery " + err.Error())
|
||||
}
|
||||
if value == cache.NoBannedValue {
|
||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,43 +440,56 @@ type Login struct {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
atomic.AddInt64(&blockedRequests, 1)
|
||||
|
||||
if bouncer.remediationCustomHeader != "" {
|
||||
rw.Header().Set(bouncer.remediationCustomHeader, "ban")
|
||||
}
|
||||
if bouncer.banTemplateString == "" {
|
||||
rw.Header().Set("Content-Type", bouncer.banTemplateContentType)
|
||||
rw.WriteHeader(bouncer.remediationStatusCode)
|
||||
if bouncer.banTemplate == nil || req.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
rw.WriteHeader(bouncer.remediationStatusCode)
|
||||
_, err := fmt.Fprint(rw, bouncer.banTemplateString)
|
||||
if err != nil {
|
||||
bouncer.log.Error("handleBanServeHTTP could not write template to ResponseWriter")
|
||||
templateData := map[string]string{
|
||||
"RemediationReason": reason,
|
||||
"ClientIP": remoteIP,
|
||||
}
|
||||
|
||||
if bouncer.traceCustomHeader != "" {
|
||||
headerVal := req.Header.Get(bouncer.traceCustomHeader)
|
||||
|
||||
if headerVal != "" {
|
||||
templateData["TraceID"] = headerVal
|
||||
}
|
||||
}
|
||||
|
||||
func handleRemediationServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) {
|
||||
err := bouncer.banTemplate.Execute(rw, templateData)
|
||||
|
||||
if err != nil {
|
||||
bouncer.log.Warn("handleBanServeHTTP could not write template to ResponseWriter: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue {
|
||||
if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue && req.Method != http.MethodHead {
|
||||
if bouncer.captchaClient.Check(remoteIP) {
|
||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||
return
|
||||
}
|
||||
atomic.AddInt64(&blockedRequests, 1) // If we serve a captcha that should count as a dropped request.
|
||||
bouncer.captchaClient.ServeHTTP(rw, req, remoteIP)
|
||||
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 err := appsecQuery(bouncer, remoteIP, req); err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -432,7 +498,7 @@ func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWrit
|
||||
|
||||
func handleStreamTicker(bouncer *Bouncer) {
|
||||
if err := handleStreamCache(bouncer); err != nil {
|
||||
bouncer.log.Debug(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
|
||||
bouncer.log.Warn(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
|
||||
if bouncer.updateMaxFailure != -1 && updateFailure >= bouncer.updateMaxFailure && isCrowdsecStreamHealthy {
|
||||
isCrowdsecStreamHealthy = false
|
||||
bouncer.log.Error(fmt.Sprintf("handleStreamTicker:error updateFailure:%d %s", updateFailure, err.Error()))
|
||||
@@ -450,7 +516,7 @@ func handleMetricsTicker(bouncer *Bouncer) {
|
||||
}
|
||||
}
|
||||
|
||||
func startTicker(name string, updateInterval int64, log *logger.Log, work func()) chan bool {
|
||||
func startTicker(name string, updateInterval int64, log *slog.Logger, work func()) chan bool {
|
||||
ticker := time.NewTicker(time.Duration(updateInterval) * time.Second)
|
||||
stop := make(chan bool, 1)
|
||||
go func() {
|
||||
@@ -517,9 +583,9 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
|
||||
case "captcha":
|
||||
value = cache.CaptchaValue
|
||||
default:
|
||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
||||
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
|
||||
}
|
||||
if isLiveMode {
|
||||
if isLiveMode && bouncer.defaultDecisionTimeout > 0 {
|
||||
durationSecond := int64(duration.Seconds())
|
||||
if bouncer.defaultDecisionTimeout < durationSecond {
|
||||
durationSecond = bouncer.defaultDecisionTimeout
|
||||
@@ -553,11 +619,11 @@ func getToken(bouncer *Bouncer) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("getToken:parsingBody %w", err)
|
||||
}
|
||||
if login.Code == 200 && len(login.Token) > 0 {
|
||||
if login.Code == http.StatusOK && len(login.Token) > 0 {
|
||||
bouncer.crowdsecKey = login.Token
|
||||
bouncer.log.Debug(fmt.Sprintf("getToken statusCode:%d", login.Code))
|
||||
return nil
|
||||
}
|
||||
bouncer.log.Warn(fmt.Sprintf("getToken statusCode:%d", login.Code))
|
||||
return fmt.Errorf("getToken statusCode:%d", login.Code)
|
||||
}
|
||||
|
||||
@@ -569,6 +635,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
||||
_, err := bouncer.cacheClient.Get(cacheTimeoutKey)
|
||||
if err == nil {
|
||||
bouncer.log.Debug("handleStreamCache:alreadyUpdated")
|
||||
isCrowdsecStreamStartup = false
|
||||
return nil
|
||||
}
|
||||
if err.Error() != cache.CacheMiss {
|
||||
@@ -579,7 +646,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
||||
Scheme: bouncer.crowdsecScheme,
|
||||
Host: bouncer.crowdsecHost,
|
||||
Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute,
|
||||
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup),
|
||||
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isCrowdsecStreamStartup),
|
||||
}
|
||||
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil)
|
||||
if err != nil {
|
||||
@@ -600,7 +667,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
||||
case "captcha":
|
||||
value = cache.CaptchaValue
|
||||
default:
|
||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
||||
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
|
||||
}
|
||||
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
|
||||
}
|
||||
@@ -609,9 +676,16 @@ func handleStreamCache(bouncer *Bouncer) error {
|
||||
bouncer.cacheClient.Delete(decision.Value)
|
||||
}
|
||||
bouncer.log.Debug("handleStreamCache:updated")
|
||||
isCrowdsecStreamStartup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func isReverseProxyError(statusCode int) bool {
|
||||
return statusCode == http.StatusBadGateway ||
|
||||
statusCode == http.StatusServiceUnavailable ||
|
||||
statusCode == http.StatusGatewayTimeout
|
||||
}
|
||||
|
||||
func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, error) {
|
||||
var req *http.Request
|
||||
if len(data) > 0 {
|
||||
@@ -619,11 +693,11 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
|
||||
} else {
|
||||
req, _ = http.NewRequest(http.MethodGet, stringURL, nil)
|
||||
}
|
||||
req.Header.Add(bouncer.crowdsecHeader, bouncer.crowdsecKey)
|
||||
req.Header.Add("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/1.X.X")
|
||||
req.Header.Set(bouncer.crowdsecHeader, bouncer.crowdsecKey)
|
||||
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||
|
||||
res, err := bouncer.httpClient.Do(req)
|
||||
if err != nil {
|
||||
if err != nil || isReverseProxyError(res.StatusCode) {
|
||||
return nil, fmt.Errorf("crowdsecQuery:unreachable url:%s %w", stringURL, err)
|
||||
}
|
||||
defer func() {
|
||||
@@ -651,14 +725,41 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// isBodyUnreadable reports whether the request body cannot be buffered before
|
||||
// forwarding it to the Appsec component. An HTTP/2 or HTTP/3 request without a
|
||||
// Content-Length (typically a bidirectional gRPC stream) keeps its body open
|
||||
// for the whole life of the stream and never reaches EOF, so reading it with
|
||||
// io.ReadAll would block until the request times out and is wrongly turned into
|
||||
// a 403. This mirrors the reference lua-cs-bouncer behavior, which refuses to
|
||||
// read the body of an HTTP/2+ request that has no Content-Length.
|
||||
func isBodyUnreadable(httpReq *http.Request) bool {
|
||||
return httpReq.Body != nil && httpReq.Body != http.NoBody && httpReq.ProtoMajor >= 2 && httpReq.ContentLength < 0
|
||||
}
|
||||
|
||||
// isMethodWithBody used only when isBodyUnreadable returns true but the request method can't have body.
|
||||
func isMethodWithBody(method string) bool {
|
||||
switch method {
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||
routeURL := url.URL{
|
||||
Scheme: bouncer.crowdsecScheme,
|
||||
Scheme: bouncer.appsecScheme,
|
||||
Host: bouncer.appsecHost,
|
||||
Path: bouncer.appsecPath,
|
||||
}
|
||||
var req *http.Request
|
||||
if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil && httpReq.ContentLength > 0 {
|
||||
switch {
|
||||
case isBodyUnreadable(httpReq):
|
||||
if bouncer.appsecUnreadableBodyBlock && isMethodWithBody(httpReq.Method) {
|
||||
return errors.New("appsecQuery:unreadableBody dropped")
|
||||
}
|
||||
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
|
||||
case bouncer.appsecBodyLimit > 0 && httpReq.Body != nil:
|
||||
var bodyBuffer bytes.Buffer
|
||||
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
|
||||
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
|
||||
@@ -669,7 +770,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||
// Conserve body intact after reading it for other middlewares and service
|
||||
httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body))
|
||||
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
|
||||
} else {
|
||||
default:
|
||||
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
|
||||
}
|
||||
|
||||
@@ -678,15 +779,16 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||
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(crowdsecAppsecVerbHeader, httpReq.Method)
|
||||
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
|
||||
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
|
||||
req.Header.Set(crowdsecAppsecUserAgent, httpReq.Header.Get("User-Agent"))
|
||||
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||
|
||||
res, err := bouncer.httpClient.Do(req)
|
||||
if err != nil {
|
||||
res, err := bouncer.httpAppsecClient.Do(req)
|
||||
if err != nil || isReverseProxyError(res.StatusCode) {
|
||||
bouncer.log.Error("appsecQuery:unreachable")
|
||||
if bouncer.appsecUnreachableBlock {
|
||||
return fmt.Errorf("appsecQuery:unreachable %w", err)
|
||||
@@ -725,7 +827,7 @@ func reportMetrics(bouncer *Bouncer) error {
|
||||
metrics := map[string]interface{}{
|
||||
"remediation_components": []map[string]interface{}{
|
||||
{
|
||||
"version": "1.X.X",
|
||||
"version": pluginVersion,
|
||||
"type": "bouncer",
|
||||
"name": "traefik_plugin",
|
||||
"metrics": []map[string]interface{}{
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+369
@@ -2,15 +2,20 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
||||
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
func TestServeHTTP(t *testing.T) {
|
||||
@@ -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,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.6"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -16,8 +16,8 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- logs-local:/var/log/traefik
|
||||
- './ban.html:/ban.html:ro'
|
||||
- './captcha.html:/captcha.html:ro'
|
||||
- "./ban.html:/ban.html:ro"
|
||||
- "./captcha.html:/captcha.html:ro"
|
||||
- ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
ports:
|
||||
- 8000:80
|
||||
@@ -52,6 +52,7 @@ services:
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=stream"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.ForwardedHeadersTrustedIPs=172.21.0.1/8"
|
||||
|
||||
bar2:
|
||||
image: traefik/whoami
|
||||
@@ -79,7 +80,7 @@ services:
|
||||
- "traefik.http.routers.router-bar3.entrypoints=web"
|
||||
- "traefik.http.routers.router-bar3.middlewares=crowdsec2@docker"
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.8
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.6"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.6.0"
|
||||
volumes:
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
# - './ban.html:/ban.html:ro'
|
||||
@@ -59,7 +59,7 @@ services:
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.8
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
+3
-7
@@ -1,8 +1,6 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.6"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -15,7 +13,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.6.0"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -48,10 +46,8 @@ services:
|
||||
# Define AppSec host and port informations
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||
|
||||
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
cloudflare:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.6"
|
||||
container_name: "cloudflare"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
- 8080:8080
|
||||
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.6"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
- "--entrypoints.web.forwardedheaders.trustedips=172.21.0.5"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.6.0"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- logs-traefik:/var/log/traefik
|
||||
@@ -79,7 +79,7 @@ services:
|
||||
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -20,7 +20,7 @@ For now 3 captcha providers are supported:
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSiteKey=FIXME"
|
||||
# Define captcha secret key
|
||||
- "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"
|
||||
# Define captcha HTML file path
|
||||
- "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.
|
||||
|
||||
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
|
||||
...
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.6.8
|
||||
volumes:
|
||||
# For captcha and ban mixed decision
|
||||
- './profiles.yaml:/etc/crowdsec/profiles.yaml:ro'
|
||||
@@ -100,9 +100,9 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Does the user IP has a crowdsec decision ?
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
```
|
||||
@@ -121,12 +121,12 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Fine, done!
|
||||
create participant ProviderCaptcha
|
||||
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
||||
Destroy ProviderCaptcha
|
||||
destroy ProviderCaptcha
|
||||
ProviderCaptcha-->>TraefikPlugin: Yes
|
||||
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Done
|
||||
Destroy TraefikPlugin
|
||||
destroy TraefikPlugin
|
||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||
Webserver->>User: HTTP Response
|
||||
```
|
||||
@@ -140,7 +140,7 @@ sequenceDiagram
|
||||
User->>TraefikPlugin: Can I access that webpage
|
||||
create participant PluginCache
|
||||
TraefikPlugin-->>PluginCache: Does the User IP has a Crowdsec Decision ?
|
||||
Destroy PluginCache
|
||||
destroy PluginCache
|
||||
PluginCache-->>TraefikPlugin: Yes a ban Decision
|
||||
TraefikPlugin->>User: No, HTTP 403
|
||||
```
|
||||
|
||||
@@ -293,7 +293,7 @@
|
||||
</svg>
|
||||
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
|
||||
</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>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.6"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.6.0"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -9,11 +9,11 @@ This can be usefull as some browser (Firefox for instance) return a 403 blank we
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
# Define ban HTML file path
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
|
||||
# Define ban file path
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html"
|
||||
```
|
||||
|
||||
The ban HTML file must be present in the Traefik container (bind mounted or added during a custom build).
|
||||
The ban file must be present in the Traefik container (bind mounted or added during a custom build).
|
||||
It is not directly accessible from Traefik even when importing the plugin, so [download](https://raw.githubusercontent.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/master/ban.html) it locally to expose it to Traefik.
|
||||
|
||||
```yaml
|
||||
@@ -45,3 +45,15 @@ To play the demo environment run:
|
||||
```bash
|
||||
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:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.6"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.6.0"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -42,11 +42,11 @@ services:
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||
# Define ban HTML file path
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
|
||||
# Define ban file path
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -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.6"
|
||||
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.6.0"
|
||||
# - "--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:
|
||||
tag: v1.6.1-2
|
||||
tag: v1.7.8-2
|
||||
|
||||
agent:
|
||||
acquisition:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
image:
|
||||
tag: v3.0.0
|
||||
tag: v3.7.6
|
||||
|
||||
logs:
|
||||
general:
|
||||
@@ -15,4 +15,4 @@ experimental:
|
||||
plugins:
|
||||
bouncer:
|
||||
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
version: "v1.3.0"
|
||||
version: "v1.6.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.6"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.6.0"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -87,7 +87,7 @@ services:
|
||||
- "traefik.enable=false"
|
||||
|
||||
redis-secure:
|
||||
image: "redis:7.0.12-alpine"
|
||||
image: "redis:8.8.0-alpine"
|
||||
container_name: "redis-secure"
|
||||
hostname: redis-secure
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.6"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.6.0"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM ubuntu:24.04
|
||||
FROM ubuntu:26.04
|
||||
|
||||
RUN apt-get update && apt-get install -y curl wget
|
||||
RUN VERSION=$(curl --silent "https://api.github.com/repos/cloudflare/cfssl/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') && VNUMBER=${VERSION#"v"} && wget https://github.com/cloudflare/cfssl/releases/download/${VERSION}/cfssl_${VNUMBER}_linux_amd64 -O cfssl && chmod +x cfssl && mv cfssl /usr/local/bin
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Example
|
||||
|
||||
## Using https communication and tls authentication with Crowdsec
|
||||
|
||||
##### Summary
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
|
||||
You can provide the Certificate Authority using:
|
||||
* A file path readable by Traefik
|
||||
|
||||
- A file path readable by Traefik
|
||||
|
||||
```yaml
|
||||
http:
|
||||
middlewares:
|
||||
@@ -26,9 +30,11 @@ http:
|
||||
bouncer:
|
||||
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
|
||||
|
||||
```yaml
|
||||
http:
|
||||
middlewares:
|
||||
@@ -44,7 +50,9 @@ http:
|
||||
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
|
||||
-----END CERTIFICATE-----
|
||||
```
|
||||
|
||||
In a dynamic configuration of a provider (ex docker) as a Label
|
||||
|
||||
```yaml
|
||||
services:
|
||||
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.
|
||||
|
||||
Access to a route that communicate via https and authenticate with API-key:
|
||||
|
||||
```
|
||||
curl http://localhost:8000/foo
|
||||
```
|
||||
|
||||
Access to a route that communicate via https and authenticate with a client certificate:
|
||||
|
||||
```
|
||||
curl http://localhost:8000/bar
|
||||
```
|
||||
|
||||
Access to the traefik dashboard
|
||||
|
||||
```
|
||||
curl http://localhost:8080/dashboard/#/
|
||||
```
|
||||
|
||||
To play the demo environnement run:
|
||||
|
||||
```bash
|
||||
make run_tlsauth
|
||||
```
|
||||
|
||||
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
|
||||
labels:
|
||||
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:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.6"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,15 +13,13 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.6.0"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./LAPIKEY:/etc/traefik/LAPIKEY:ro
|
||||
- logs-tls-auth:/var/log/traefik
|
||||
- crowdsec-certs-tls-auth:/etc/traefik/crowdsec-certs
|
||||
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
|
||||
ports:
|
||||
- 8000:80
|
||||
- 8080:8080
|
||||
@@ -53,27 +51,34 @@ services:
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "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.middlewares=crowdsec@docker"
|
||||
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||
- "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.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.crowdsecLapiTLSCertificateBouncerFile=/etc/traefik/crowdsec-certs/bouncer.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:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
COLLECTIONS: crowdsecurity/traefik
|
||||
CUSTOM_HOSTNAME: crowdsec
|
||||
# whoami-foo is authenticating with api key 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
|
||||
USE_TLS: "true"
|
||||
CERT_FILE: "/etc/crowdsec/certs/server.pem"
|
||||
@@ -88,6 +93,7 @@ services:
|
||||
# DISABLE_AGENT: "true"
|
||||
# Disabled for the examples
|
||||
DISABLE_ONLINE_API: "true"
|
||||
COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
|
||||
volumes:
|
||||
- ./config/acquis.yaml:/etc/crowdsec/acquis.yaml
|
||||
# - ./config/config.yaml:/etc/crowdsec/config_local.yaml
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/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" && \
|
||||
# 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" && \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.7.6"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.6.0"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -65,7 +65,7 @@ services:
|
||||
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
|
||||
go 1.22
|
||||
go 1.22.12
|
||||
|
||||
require (
|
||||
github.com/leprosus/golang-ttl-map v1.1.7
|
||||
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/go.mod h1:4QWHJPeVBbrkhOhXdhCv9IEiyj/YzkO04/iexy4vSe0=
|
||||
github.com/maxlerebourg/simpleredis v1.0.11 h1:B33TUeIrHtJH2/Qj2bRdU+UZ1BvZwFyP55JWMxHirWg=
|
||||
github.com/maxlerebourg/simpleredis v1.0.11/go.mod h1:lT4LX02SOsE9PxUcSrz1QW5ZnO86gPbaiYBxmtcXEls=
|
||||
github.com/maxlerebourg/simpleredis v1.0.12 h1:VsJpk2l8U9QqxOWbYnEXbrs7iSNZFm2fNmFvlxYlQNk=
|
||||
github.com/maxlerebourg/simpleredis v1.0.12/go.mod h1:lT4LX02SOsE9PxUcSrz1QW5ZnO86gPbaiYBxmtcXEls=
|
||||
|
||||
Vendored
+44
-26
@@ -5,11 +5,11 @@ package cache
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
|
||||
ttl_map "github.com/leprosus/golang-ttl-map"
|
||||
simpleredis "github.com/maxlerebourg/simpleredis"
|
||||
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -28,10 +28,7 @@ const (
|
||||
)
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var (
|
||||
redis simpleredis.SimpleRedis
|
||||
cache = ttl_map.New()
|
||||
)
|
||||
var cache = ttl_map.New()
|
||||
|
||||
type localCache struct{}
|
||||
|
||||
@@ -53,33 +50,48 @@ func (localCache) delete(key string) {
|
||||
}
|
||||
|
||||
type redisCache struct {
|
||||
log *logger.Log
|
||||
log *slog.Logger
|
||||
writer simpleredis.SimpleRedis
|
||||
readers []simpleredis.SimpleRedis
|
||||
counter atomic.Uint64
|
||||
}
|
||||
|
||||
func (redisCache) get(key string) (string, error) {
|
||||
value, err := redis.Get(key)
|
||||
valueString := string(value)
|
||||
if err == nil && len(valueString) > 0 {
|
||||
return valueString, nil
|
||||
func (rc *redisCache) nextReader() *simpleredis.SimpleRedis {
|
||||
n := len(rc.readers)
|
||||
if n == 0 {
|
||||
return &rc.writer
|
||||
}
|
||||
errRedisMessage := err.Error()
|
||||
if errRedisMessage == simpleredis.RedisMiss {
|
||||
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)
|
||||
}
|
||||
if errRedisMessage == simpleredis.RedisUnreachable {
|
||||
case simpleredis.RedisUnreachable:
|
||||
return "", errors.New(CacheUnreachable)
|
||||
}
|
||||
default:
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
valueString := string(value)
|
||||
if len(valueString) > 0 {
|
||||
return valueString, nil
|
||||
}
|
||||
return "", errors.New(CacheMiss)
|
||||
}
|
||||
|
||||
func (rc redisCache) set(key, value string, duration int64) {
|
||||
if err := redis.Set(key, []byte(value), duration); err != nil {
|
||||
func (rc *redisCache) set(key, value string, duration int64) {
|
||||
if err := rc.writer.Set(key, []byte(value), duration); err != nil {
|
||||
rc.log.Error("cache:setDecisionRedisCache" + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (rc redisCache) delete(key string) {
|
||||
if err := redis.Del(key); err != nil {
|
||||
func (rc *redisCache) delete(key string) {
|
||||
if err := rc.writer.Del(key); err != nil {
|
||||
rc.log.Error("cache:deleteDecisionRedisCache " + err.Error())
|
||||
}
|
||||
}
|
||||
@@ -93,19 +105,25 @@ type cacheInterface interface {
|
||||
// Client Cache client.
|
||||
type Client struct {
|
||||
cache cacheInterface
|
||||
log *logger.Log
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New Initialize cache client.
|
||||
func (c *Client) New(log *logger.Log, isRedis bool, host, pass, database string) {
|
||||
func (c *Client) New(log *slog.Logger, isRedis bool, writeHost string, readHosts []string, pass, database string) {
|
||||
c.log = log
|
||||
if isRedis {
|
||||
redis.Init(host, pass, database)
|
||||
c.cache = &redisCache{log: log}
|
||||
rc := &redisCache{log: log}
|
||||
rc.writer.Init(writeHost, pass, database)
|
||||
for _, h := range readHosts {
|
||||
var r simpleredis.SimpleRedis
|
||||
r.Init(h, pass, database)
|
||||
rc.readers = append(rc.readers, r)
|
||||
}
|
||||
c.cache = rc
|
||||
} else {
|
||||
c.cache = &localCache{}
|
||||
}
|
||||
c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v", isRedis))
|
||||
c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v writeHost:%v readHosts:%v", isRedis, writeHost, readHosts))
|
||||
}
|
||||
|
||||
// Delete delete decision in cache.
|
||||
|
||||
Vendored
+38
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
simpleredis "github.com/maxlerebourg/simpleredis"
|
||||
)
|
||||
|
||||
func Test_Get(t *testing.T) {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+32
-18
@@ -4,69 +4,80 @@ package captcha
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
||||
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
// Client Captcha client.
|
||||
type Client struct {
|
||||
Valid bool
|
||||
provider string
|
||||
siteKey string
|
||||
secretKey string
|
||||
remediationCustomHeader string
|
||||
gracePeriodSeconds int64
|
||||
captchaTemplate *template.Template
|
||||
templateContentType string
|
||||
template *template.Template
|
||||
cacheClient *cache.Client
|
||||
httpClient *http.Client
|
||||
log *logger.Log
|
||||
log *slog.Logger
|
||||
infoProvider *infoProvider
|
||||
}
|
||||
|
||||
// Information for self-hosted provider.
|
||||
type infoProvider struct {
|
||||
js string
|
||||
key string
|
||||
response string
|
||||
validate string
|
||||
}
|
||||
|
||||
var (
|
||||
//nolint:gochecknoglobals
|
||||
captcha = map[string]infoProvider{
|
||||
var infoProviders = map[string]*infoProvider{
|
||||
configuration.HcaptchaProvider: {
|
||||
js: "https://hcaptcha.com/1/api.js",
|
||||
key: "h-captcha",
|
||||
response: "h-captcha-response",
|
||||
validate: "https://api.hcaptcha.com/siteverify",
|
||||
},
|
||||
configuration.RecaptchaProvider: {
|
||||
js: "https://www.google.com/recaptcha/api.js",
|
||||
key: "g-recaptcha",
|
||||
response: "g-recaptcha-response",
|
||||
validate: "https://www.google.com/recaptcha/api/siteverify",
|
||||
},
|
||||
configuration.TurnstileProvider: {
|
||||
js: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||
key: "cf-turnstile",
|
||||
response: "cf-turnstile-response",
|
||||
validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// New Initialize captcha client.
|
||||
func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
||||
func (c *Client) New(log *slog.Logger, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
||||
c.Valid = provider != ""
|
||||
if !c.Valid {
|
||||
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.secretKey = secretKey
|
||||
c.provider = provider
|
||||
c.remediationCustomHeader = remediationCustomHeader
|
||||
html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
|
||||
c.captchaTemplate = html
|
||||
template, contentType, _ := configuration.GetTemplate(captchaTemplatePath)
|
||||
c.template = template
|
||||
c.templateContentType = contentType
|
||||
c.gracePeriodSeconds = gracePeriodSeconds
|
||||
c.log = log
|
||||
c.httpClient = httpClient
|
||||
@@ -85,18 +96,21 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
|
||||
if valid {
|
||||
c.log.Debug("captcha:ServeHTTP captcha:valid")
|
||||
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)
|
||||
return
|
||||
}
|
||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
rw.Header().Set("Content-Type", c.templateContentType)
|
||||
if c.remediationCustomHeader != "" {
|
||||
rw.Header().Set(c.remediationCustomHeader, "captcha")
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
err = c.captchaTemplate.Execute(rw, map[string]string{
|
||||
err = c.template.Execute(rw, map[string]string{
|
||||
"SiteKey": c.siteKey,
|
||||
"FrontendJS": captcha[c.provider].js,
|
||||
"FrontendKey": captcha[c.provider].key,
|
||||
"FrontendJS": c.infoProvider.js,
|
||||
"FrontendKey": c.infoProvider.key,
|
||||
})
|
||||
if err != nil {
|
||||
c.log.Info("captcha:ServeHTTP captchaTemplateServe " + err.Error())
|
||||
@@ -121,7 +135,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
|
||||
c.log.Debug("captcha:Validate invalid method: " + r.Method)
|
||||
return false, nil
|
||||
}
|
||||
var response = r.FormValue(captcha[c.provider].key + "-response")
|
||||
var response = r.FormValue(c.infoProvider.response)
|
||||
if response == "" {
|
||||
c.log.Debug("captcha:Validate no captcha response found in request")
|
||||
return false, nil
|
||||
@@ -129,7 +143,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
|
||||
var body = url.Values{}
|
||||
body.Add("secret", c.secretKey)
|
||||
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 {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
// Enums for crowdsec mode.
|
||||
@@ -30,23 +30,40 @@ const (
|
||||
HTTP = "http"
|
||||
LogDEBUG = "DEBUG"
|
||||
LogINFO = "INFO"
|
||||
LogWARN = "WARN"
|
||||
LogERROR = "ERROR"
|
||||
ReasonTECH = "TECHNICAL_ISSUE"
|
||||
ReasonLAPI = "LAPI"
|
||||
ReasonAPPSEC = "APPSEC"
|
||||
HcaptchaProvider = "hcaptcha"
|
||||
RecaptchaProvider = "recaptcha"
|
||||
TurnstileProvider = "turnstile"
|
||||
CustomProvider = "custom"
|
||||
)
|
||||
|
||||
// Config the plugin configuration.
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
LogFormat string `json:"logFormat,omitempty"`
|
||||
LogFilePath string `json:"logFilePath,omitempty"`
|
||||
CrowdsecMode string `json:"crowdsecMode,omitempty"`
|
||||
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
|
||||
CrowdsecAppsecScheme string `json:"crowdsecAppsecScheme,omitempty"`
|
||||
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
|
||||
CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"`
|
||||
CrowdsecAppsecKey string `json:"crowdsecAppsecKey,omitempty"`
|
||||
CrowdsecAppsecKeyFile string `json:"crowdsecAppsecKeyFile,omitempty"`
|
||||
CrowdsecAppsecTLSInsecureVerify bool `json:"crowdsecAppsecTlsInsecureVerify,omitempty"`
|
||||
CrowdsecAppsecTLSCertificateAuthority string `json:"crowdsecAppsecTlsCertificateAuthority,omitempty"`
|
||||
CrowdsecAppsecTLSCertificateAuthorityFile string `json:"crowdsecAppsecTlsCertificateAuthorityFile,omitempty"`
|
||||
CrowdsecAppsecTLSCertificateBouncer string `json:"crowdsecAppsecTlsCertificateBouncer,omitempty"`
|
||||
CrowdsecAppsecTLSCertificateBouncerFile string `json:"crowdsecAppsecTlsCertificateBouncerFile,omitempty"`
|
||||
CrowdsecAppsecTLSCertificateBouncerKey string `json:"crowdsecAppsecTlsCertificateBouncerKey,omitempty"`
|
||||
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
|
||||
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
||||
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
||||
CrowdsecAppsecUnreadableBodyBlock bool `json:"crowdsecAppsecUnreadableBodyBlock,omitempty"`
|
||||
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
|
||||
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
|
||||
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
|
||||
@@ -68,22 +85,31 @@ type Config struct {
|
||||
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
||||
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
|
||||
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
|
||||
StreamStartupBlock bool `json:"streamStartupBlock,omitempty"`
|
||||
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
||||
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
|
||||
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
||||
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"`
|
||||
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
|
||||
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
|
||||
BanFilePath string `json:"banFilePath,omitempty"`
|
||||
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
|
||||
CaptchaFilePath string `json:"captchaFilePath,omitempty"`
|
||||
CaptchaProvider string `json:"captchaProvider,omitempty"`
|
||||
CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"`
|
||||
CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"`
|
||||
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"`
|
||||
@@ -105,14 +131,19 @@ func New() *Config {
|
||||
return &Config{
|
||||
Enabled: false,
|
||||
LogLevel: LogINFO,
|
||||
LogFormat: "common",
|
||||
LogFilePath: "",
|
||||
CrowdsecMode: LiveMode,
|
||||
CrowdsecAppsecEnabled: false,
|
||||
CrowdsecAppsecHost: "crowdsec:7422",
|
||||
CrowdsecAppsecPath: "/",
|
||||
CrowdsecAppsecFailureBlock: true,
|
||||
CrowdsecAppsecUnreachableBlock: true,
|
||||
CrowdsecAppsecUnreadableBodyBlock: true,
|
||||
CrowdsecAppsecBodyLimit: 10485760,
|
||||
CrowdsecAppsecScheme: "",
|
||||
CrowdsecAppsecHost: "crowdsec:7422",
|
||||
CrowdsecAppsecPath: "/",
|
||||
CrowdsecAppsecKey: "",
|
||||
CrowdsecAppsecTLSInsecureVerify: false,
|
||||
CrowdsecLapiScheme: HTTP,
|
||||
CrowdsecLapiHost: "crowdsec:8080",
|
||||
CrowdsecLapiPath: "/",
|
||||
@@ -121,21 +152,28 @@ func New() *Config {
|
||||
UpdateIntervalSeconds: 60,
|
||||
MetricsUpdateIntervalSeconds: 600,
|
||||
UpdateMaxFailure: 0,
|
||||
StreamStartupBlock: true,
|
||||
DefaultDecisionSeconds: 60,
|
||||
RemediationStatusCode: http.StatusForbidden,
|
||||
HTTPTimeoutSeconds: 10,
|
||||
CaptchaProvider: "",
|
||||
CaptchaCustomJsURL: "",
|
||||
CaptchaCustomValidateURL: "",
|
||||
CaptchaCustomKey: "",
|
||||
CaptchaCustomResponse: "",
|
||||
CaptchaSiteKey: "",
|
||||
CaptchaSecretKey: "",
|
||||
CaptchaGracePeriodSeconds: 1800,
|
||||
CaptchaHTMLFilePath: "/captcha.html",
|
||||
BanHTMLFilePath: "",
|
||||
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,
|
||||
@@ -169,37 +207,63 @@ func GetVariable(config *Config, key string) (string, error) {
|
||||
return strings.TrimSpace(value), nil
|
||||
}
|
||||
|
||||
// GetHTMLTemplate get compiled HTML template.
|
||||
func GetHTMLTemplate(path string) (*template.Template, error) {
|
||||
var err error
|
||||
func getContentTypeFromPath(path string) string {
|
||||
if path == "" {
|
||||
return nil, errors.New("no html template provided")
|
||||
return ""
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
contentTypeMap := map[string]string{
|
||||
".html": "text/html; charset=utf-8",
|
||||
".htm": "text/html; charset=utf-8",
|
||||
".json": "application/json",
|
||||
".txt": "text/plain",
|
||||
".xml": "application/xml",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
}
|
||||
if contentType, ok := contentTypeMap[ext]; ok {
|
||||
return contentType
|
||||
}
|
||||
// Default to HTML for backward compatibility
|
||||
return "text/html; charset=utf-8"
|
||||
}
|
||||
|
||||
// GetTemplate get compiled template with {{ and }} delimiters.
|
||||
// Uses text/template for all file types to avoid HTML escaping issues.
|
||||
func GetTemplate(path string) (*template.Template, string, error) {
|
||||
if path == "" {
|
||||
return nil, "", errors.New("no template file provided")
|
||||
}
|
||||
contentType := getContentTypeFromPath(path)
|
||||
//nolint:gosec
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
html := string(b)
|
||||
compiledTemplate, err := template.New("html").Parse(html)
|
||||
content := string(b)
|
||||
compiledTemplate, err := template.New(filepath.Base(path)).Delims("{{", "}}").Parse(content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("impossible to compile html template: %w", err)
|
||||
return nil, "", fmt.Errorf("impossible to compile template %s: %w", path, err)
|
||||
}
|
||||
return compiledTemplate, nil
|
||||
return compiledTemplate, contentType, nil
|
||||
}
|
||||
|
||||
// ValidateParams validate all the param gave by user.
|
||||
//
|
||||
//nolint:gocyclo,gocognit
|
||||
func ValidateParams(config *Config) error {
|
||||
//nolint:gocyclo,gocognit,nestif
|
||||
func ValidateParams(config *Config, log *slog.Logger) error {
|
||||
if err := validateParamsRequired(config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
||||
if err := validateCaptcha(config); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -224,12 +288,14 @@ func ValidateParams(config *Config) error {
|
||||
if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := GetHTMLTemplate(config.CaptchaHTMLFilePath); err != nil {
|
||||
if config.CaptchaFilePath != "" {
|
||||
if _, _, err := GetTemplate(config.CaptchaFilePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if config.BanHTMLFilePath != "" {
|
||||
if _, err := GetHTMLTemplate(config.BanHTMLFilePath); err != nil {
|
||||
}
|
||||
if config.BanFilePath != "" {
|
||||
if _, _, err := GetTemplate(config.BanFilePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -246,6 +312,10 @@ func ValidateParams(config *Config) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appsecKey, err := GetVariable(config, "CrowdsecAppsecKey")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -254,12 +324,21 @@ func ValidateParams(config *Config) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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")
|
||||
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -273,8 +352,8 @@ func ValidateParams(config *Config) error {
|
||||
|
||||
// Check logging configuration
|
||||
// to upper allow of anycase of log level
|
||||
if !contains([]string{LogERROR, LogDEBUG, LogINFO}, strings.ToUpper(config.LogLevel)) {
|
||||
return fmt.Errorf("LogLevel should be one of (%s,%s,%s)", LogDEBUG, LogINFO, LogERROR)
|
||||
if !contains([]string{LogDEBUG, LogINFO, LogWARN, LogERROR}, strings.ToUpper(config.LogLevel)) {
|
||||
return fmt.Errorf("LogLevel should be one of (%s,%s,%s,%s)", LogDEBUG, LogINFO, LogWARN, LogERROR)
|
||||
}
|
||||
if config.LogFilePath != "" {
|
||||
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
@@ -298,10 +377,10 @@ func validateURL(variable, scheme, host, path string) error {
|
||||
// field name. RFC 7230 says:
|
||||
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
|
||||
// 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 !#$%&'*+-.^_`|~=/]*$")
|
||||
if !reg.MatchString(lapiKey) {
|
||||
return fmt.Errorf("CrowdsecLapiKey doesn't valid this regexp: '/%s/'", reg.String())
|
||||
if !reg.MatchString(key) {
|
||||
return fmt.Errorf("%s doesn't validate this regexp: '/%s/'", paramName, reg.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -312,7 +391,8 @@ func validateParamsTLS(config *Config) error {
|
||||
return err
|
||||
}
|
||||
if certAuth == "" {
|
||||
return errors.New("CrowdsecLapiTLSCertificateAuthority must be specified when CrowdsecLapiScheme='https' and CrowdsecLapiTLSInsecureVerify=false")
|
||||
// No custom CA — runtime will fall back to the system trust store.
|
||||
return nil
|
||||
}
|
||||
tlsConfig := new(tls.Config)
|
||||
tlsConfig.RootCAs = x509.NewCertPool()
|
||||
@@ -322,15 +402,33 @@ func validateParamsTLS(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateParamsIPs(listIP []string, key string) error {
|
||||
func validateParamsIPs(log *slog.Logger, listIP []string, key string) error {
|
||||
if len(listIP) > 0 {
|
||||
if _, err := ip.NewChecker(logger.New(LogINFO, ""), listIP); err != nil {
|
||||
if _, err := ip.NewChecker(log, listIP); err != nil {
|
||||
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
requiredStrings := map[string]string{
|
||||
"CrowdsecLapiScheme": config.CrowdsecLapiScheme,
|
||||
@@ -339,7 +437,7 @@ func validateParamsRequired(config *Config) error {
|
||||
}
|
||||
for key, val := range requiredStrings {
|
||||
if len(val) == 0 {
|
||||
return fmt.Errorf("%v: cannot be empty", key)
|
||||
return errors.New(key + ": cannot be empty")
|
||||
}
|
||||
}
|
||||
requiredInt0 := map[string]int64{
|
||||
@@ -348,7 +446,7 @@ func validateParamsRequired(config *Config) error {
|
||||
}
|
||||
for key, val := range requiredInt0 {
|
||||
if val < 0 {
|
||||
return fmt.Errorf("%v: cannot be less than 0", key)
|
||||
return errors.New(key + ": cannot be less than 0")
|
||||
}
|
||||
}
|
||||
requiredInt1 := map[string]int64{
|
||||
@@ -359,7 +457,7 @@ func validateParamsRequired(config *Config) error {
|
||||
}
|
||||
for key, val := range requiredInt1 {
|
||||
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 {
|
||||
@@ -378,47 +476,46 @@ func validateParamsRequired(config *Config) error {
|
||||
if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) {
|
||||
return errors.New("CrowdsecLapiScheme: must be one of 'http' or 'https'")
|
||||
}
|
||||
if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider}, config.CaptchaProvider) {
|
||||
return errors.New("CaptchaProvider: must be one of 'hcaptcha', 'recaptcha' or 'turnstile'")
|
||||
if !contains([]string{HTTP, HTTPS, ""}, config.CrowdsecAppsecScheme) {
|
||||
return errors.New("CrowdsecAppsecScheme: must be one of 'http' or 'https'")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTLSConfigCrowdsec get TLS config from Config.
|
||||
//
|
||||
//nolint:nestif
|
||||
func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error) {
|
||||
func getTLSConfig(config *Config, log *slog.Logger, prefix, scheme string, insecureVerify bool) (*tls.Config, error) {
|
||||
tlsConfig := new(tls.Config)
|
||||
tlsConfig.RootCAs = x509.NewCertPool()
|
||||
//nolint:gocritic
|
||||
if config.CrowdsecLapiScheme != HTTPS {
|
||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiScheme https:no")
|
||||
if scheme != HTTPS {
|
||||
log.Debug("getTLSConfig:" + prefix + "Scheme https:no")
|
||||
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
|
||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSInsecureVerify tlsInsecure:true")
|
||||
// If we return here and still want to use client auth this won't work
|
||||
// return tlsConfig, nil
|
||||
log.Debug("getTLSConfig:" + prefix + "TLSInsecureVerify tlsInsecure:true")
|
||||
} else {
|
||||
certAuthority, err := GetVariable(config, "CrowdsecLapiTLSCertificateAuthority")
|
||||
certAuthority, err := GetVariable(config, prefix+"TLSCertificateAuthority")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(certAuthority) > 0 {
|
||||
tlsConfig.RootCAs = x509.NewCertPool()
|
||||
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
||||
// here we return because if CrowdsecLapiTLSInsecureVerify is false
|
||||
// and CA not load, we can't communicate with https
|
||||
return nil, errors.New("getTLSConfigCrowdsec:cannot load CA and verify cert is enabled")
|
||||
return nil, errors.New("getTLSConfig:" + prefix + " cannot load CA and verify cert is enabled")
|
||||
}
|
||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSCertificateAuthority CA added successfully")
|
||||
log.Debug("getTLSConfig:" + prefix + "TLSCertificateAuthority CA added successfully")
|
||||
} else {
|
||||
log.Debug("getTLSConfig:" + prefix + " no CA provided, using system trust store")
|
||||
}
|
||||
}
|
||||
|
||||
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
||||
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certBouncerKey, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncerKey")
|
||||
certBouncerKey, err := GetVariable(config, prefix+"TLSCertificateBouncerKey")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -433,3 +530,14 @@ func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error)
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
// validPEM is a minimal self-signed certificate accepted by AppendCertsFromPEM,
|
||||
// shared by the TLS tests below.
|
||||
const validPEM = `-----BEGIN CERTIFICATE-----
|
||||
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
|
||||
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
|
||||
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
|
||||
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
|
||||
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
|
||||
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
|
||||
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
|
||||
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
|
||||
6MF9+Yw1Yy0t
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
func getMinimalConfig() *Config {
|
||||
cfg := New()
|
||||
cfg.CrowdsecLapiKey = "test"
|
||||
@@ -72,6 +84,7 @@ func Test_GetVariable(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_ValidateParams(t *testing.T) {
|
||||
log := logger.New("INFO", "")
|
||||
cfg1 := New()
|
||||
cfg1.CrowdsecLapiKey = "test\n\n"
|
||||
cfg2 := New()
|
||||
@@ -110,14 +123,14 @@ func Test_ValidateParams(t *testing.T) {
|
||||
{name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true},
|
||||
// HTTPS enabled
|
||||
{name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false},
|
||||
{name: "Not validate https without cert authority", args: args{config: cfg7}, wantErr: true},
|
||||
{name: "Validate https without cert authority (falls back to system trust store)", args: args{config: cfg7}, wantErr: false},
|
||||
{name: "Valid log level uppercase INFO", args: args{config: cfg8}, wantErr: false},
|
||||
{name: "Valid log level lowercase info", args: args{config: cfg9}, wantErr: false},
|
||||
{name: "Invalid log level Warning", args: args{config: cfg10}, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := ValidateParams(tt.args.config); (err != nil) != tt.wantErr {
|
||||
if err := ValidateParams(tt.args.config, log); (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
@@ -125,19 +138,24 @@ func Test_ValidateParams(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_validateParamsTLS(t *testing.T) {
|
||||
type args struct {
|
||||
config *Config
|
||||
}
|
||||
cfgEmpty := getMinimalConfig()
|
||||
cfgValid := getMinimalConfig()
|
||||
cfgValid.CrowdsecLapiTLSCertificateAuthority = validPEM
|
||||
cfgInvalidCA := getMinimalConfig()
|
||||
cfgInvalidCA.CrowdsecLapiTLSCertificateAuthority = "not a pem"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
config *Config
|
||||
wantErr bool
|
||||
}{
|
||||
// TODO: Add test cases.
|
||||
{name: "Empty CA is accepted (system trust store used at runtime)", config: cfgEmpty, wantErr: false},
|
||||
{name: "Valid PEM CA is accepted", config: cfgValid, wantErr: false},
|
||||
{name: "Invalid CA is rejected", config: cfgInvalidCA, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := validateParamsTLS(tt.args.config); (err != nil) != tt.wantErr {
|
||||
if err := validateParamsTLS(tt.config); (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateParamsTLS() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
@@ -145,6 +163,7 @@ func Test_validateParamsTLS(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_validateParamsIPs(t *testing.T) {
|
||||
log := logger.New("INFO", "")
|
||||
type args struct {
|
||||
listIP []string
|
||||
key string
|
||||
@@ -164,7 +183,7 @@ func Test_validateParamsIPs(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := validateParamsIPs(tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
|
||||
if err := validateParamsIPs(log, tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
@@ -206,22 +225,23 @@ func Test_validateParamsRequired(t *testing.T) {
|
||||
func Test_validateParamsAPIKey(t *testing.T) {
|
||||
type args struct {
|
||||
lapiKey string
|
||||
paramName string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~"}, wantErr: false},
|
||||
{name: "Not validate a @", args: args{lapiKey: "test@"}, wantErr: true},
|
||||
{name: "Not validate a (", args: args{lapiKey: "test("}, wantErr: true},
|
||||
{name: "Not validate a [", args: args{lapiKey: "test["}, wantErr: true},
|
||||
{name: "Not validate a ?", args: args{lapiKey: "test?"}, wantErr: true},
|
||||
{name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n"}, wantErr: true},
|
||||
{name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~", paramName: "CrowdsecParamName"}, wantErr: false},
|
||||
{name: "Not validate a @", args: args{lapiKey: "test@", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||
{name: "Not validate a (", args: args{lapiKey: "test(", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||
{name: "Not validate a [", args: args{lapiKey: "test[", paramName: "CrowdsecParamName"}, 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", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -229,26 +249,79 @@ func Test_validateParamsAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
||||
type args struct {
|
||||
config *Config
|
||||
}
|
||||
log := logger.New("INFO", "")
|
||||
|
||||
httpCfg := getMinimalConfig()
|
||||
httpCfg.CrowdsecLapiScheme = HTTP
|
||||
|
||||
httpsSystemCA := getMinimalConfig()
|
||||
httpsSystemCA.CrowdsecLapiScheme = HTTPS
|
||||
|
||||
httpsCustomCA := getMinimalConfig()
|
||||
httpsCustomCA.CrowdsecLapiScheme = HTTPS
|
||||
httpsCustomCA.CrowdsecLapiTLSCertificateAuthority = validPEM
|
||||
|
||||
httpsInsecure := getMinimalConfig()
|
||||
httpsInsecure.CrowdsecLapiScheme = HTTPS
|
||||
httpsInsecure.CrowdsecLapiTLSInsecureVerify = true
|
||||
|
||||
httpsBadCA := getMinimalConfig()
|
||||
httpsBadCA.CrowdsecLapiScheme = HTTPS
|
||||
httpsBadCA.CrowdsecLapiTLSCertificateAuthority = "not a pem"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *tls.Config
|
||||
config *Config
|
||||
wantErr bool
|
||||
wantRootCAsNil bool
|
||||
wantInsecureSkip bool
|
||||
}{
|
||||
// TODO: Add test cases.
|
||||
{name: "HTTP scheme returns empty tls.Config", config: httpCfg, wantRootCAsNil: true},
|
||||
{name: "HTTPS without CA leaves RootCAs nil (system trust store)", config: httpsSystemCA, wantRootCAsNil: true},
|
||||
{name: "HTTPS with custom CA populates RootCAs", config: httpsCustomCA, wantRootCAsNil: false},
|
||||
{name: "HTTPS with insecure verify sets InsecureSkipVerify", config: httpsInsecure, wantRootCAsNil: true, wantInsecureSkip: true},
|
||||
{name: "HTTPS with garbage CA is rejected", config: httpsBadCA, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO", ""))
|
||||
got, err := GetTLSConfigCrowdsec(tt.config, log, false)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
||||
t.Errorf("GetTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("getTLSConfigCrowdsec() = %v, want %v", got, tt.want)
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
if (got.RootCAs == nil) != tt.wantRootCAsNil {
|
||||
t.Errorf("GetTLSConfigCrowdsec() RootCAs nil = %v, want nil = %v", got.RootCAs == nil, tt.wantRootCAsNil)
|
||||
}
|
||||
if got.InsecureSkipVerify != tt.wantInsecureSkip {
|
||||
t.Errorf("GetTLSConfigCrowdsec() InsecureSkipVerify = %v, want %v", got.InsecureSkipVerify, tt.wantInsecureSkip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getContentTypeFromPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{name: "HTML file with .html extension", path: "/ban.html", expected: "text/html; charset=utf-8"},
|
||||
{name: "JSON file", path: "/ban.json", expected: "application/json"},
|
||||
{name: "Text file", path: "/ban.txt", expected: "text/plain"},
|
||||
{name: "File with mixed case extension", path: "/ban.HtMl", expected: "text/html; charset=utf-8"},
|
||||
{name: "Unknown extension defaults to HTML", path: "/ban.xyz", expected: "text/html; charset=utf-8"},
|
||||
{name: "File without extension", path: "/ban", expected: "text/html; charset=utf-8"},
|
||||
{name: "Empty path", path: "", expected: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := getContentTypeFromPath(tt.path)
|
||||
if got != tt.expected {
|
||||
t.Errorf("GetContentTypeFromPath(%q) = %q, want %q", tt.path, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+2
-3
@@ -5,11 +5,10 @@ package ip
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
// CHECKER
|
||||
@@ -21,7 +20,7 @@ type Checker struct {
|
||||
}
|
||||
|
||||
// NewChecker builds a new Checker given a list of CIDR-Strings to trusted IPs.
|
||||
func NewChecker(log *logger.Log, trustedIPs []string) (*Checker, error) {
|
||||
func NewChecker(log *slog.Logger, trustedIPs []string) (*Checker, error) {
|
||||
checker := &Checker{}
|
||||
|
||||
for _, ipMaskRaw := range trustedIPs {
|
||||
|
||||
+67
-54
@@ -1,79 +1,92 @@
|
||||
// Package logger implements utility routines to write to stdout and stderr.
|
||||
// It supports trace, debug, info and error level
|
||||
// It supports trace, debug, info, warn and error level using Go's standard log/slog
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Log Logger struct.
|
||||
type Log struct {
|
||||
logError *log.Logger
|
||||
logInfo *log.Logger
|
||||
logDebug *log.Logger
|
||||
// Custom log levels following slog best practices.
|
||||
const (
|
||||
LevelDebug = slog.LevelDebug
|
||||
LevelInfo = slog.LevelInfo
|
||||
LevelWarn = slog.LevelWarn
|
||||
LevelError = slog.LevelError
|
||||
)
|
||||
|
||||
// New creates a Log wrapper with default format (common).
|
||||
func New(logLevel string, logFilePath string) *slog.Logger {
|
||||
return NewWithFormat(logLevel, logFilePath, "common")
|
||||
}
|
||||
|
||||
// New Set Default log level to info in case log level to defined.
|
||||
func New(logLevel string, logFilePath string) *Log {
|
||||
// Initialize loggers with discard output
|
||||
logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
||||
logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
||||
logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
||||
// NewWithFormat creates a Log wrapper with specified format (common or json).
|
||||
func NewWithFormat(logLevel, logFilePath, logFormat string) *slog.Logger {
|
||||
// Determine log level
|
||||
var level slog.Level
|
||||
switch logLevel {
|
||||
case "ERROR":
|
||||
level = LevelError
|
||||
case "WARN":
|
||||
level = LevelWarn
|
||||
case "INFO":
|
||||
level = LevelInfo
|
||||
case "DEBUG":
|
||||
level = LevelDebug
|
||||
default:
|
||||
// Default to INFO level
|
||||
level = LevelInfo
|
||||
}
|
||||
|
||||
// we initialize logger to STDOUT/STDERR first so if the file logger cannot be initialized we can inform the user
|
||||
output := os.Stdout
|
||||
errorOutput := os.Stderr
|
||||
|
||||
// prepare file logging if specified
|
||||
// Set output destination
|
||||
var output *os.File
|
||||
if logFilePath != "" {
|
||||
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err == nil {
|
||||
output = logFile
|
||||
errorOutput = logFile
|
||||
} else {
|
||||
_ = fmt.Errorf("LogFilePath is not writable %w", err)
|
||||
// Fall back to stdout and log the error
|
||||
output = os.Stdout
|
||||
slog.Warn("LogFilePath is not writable, using stdout", "error", err)
|
||||
}
|
||||
} else {
|
||||
output = os.Stdout
|
||||
}
|
||||
|
||||
// Set error logger output
|
||||
logError.SetOutput(errorOutput)
|
||||
|
||||
// Configure log levels
|
||||
switch logLevel {
|
||||
case "ERROR":
|
||||
// Only error logging is enabled
|
||||
case "INFO":
|
||||
logInfo.SetOutput(output)
|
||||
case "DEBUG":
|
||||
logInfo.SetOutput(output)
|
||||
logDebug.SetOutput(output)
|
||||
// Create handler based on format with custom level names
|
||||
var handler slog.Handler
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: level,
|
||||
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||||
// Customize level names to match our expected format
|
||||
if a.Key == slog.LevelKey {
|
||||
lvl, ok := a.Value.Any().(slog.Level)
|
||||
if !ok {
|
||||
return a
|
||||
}
|
||||
switch {
|
||||
case lvl < LevelInfo:
|
||||
a.Value = slog.StringValue("DEBUG")
|
||||
case lvl < LevelWarn:
|
||||
a.Value = slog.StringValue("INFO")
|
||||
case lvl < LevelError:
|
||||
a.Value = slog.StringValue("WARN")
|
||||
default:
|
||||
// Default to INFO level
|
||||
logInfo.SetOutput(output)
|
||||
a.Value = slog.StringValue("ERROR")
|
||||
}
|
||||
}
|
||||
return a
|
||||
},
|
||||
}
|
||||
|
||||
return &Log{
|
||||
logError: logError,
|
||||
logInfo: logInfo,
|
||||
logDebug: logDebug,
|
||||
}
|
||||
if logFormat == "json" {
|
||||
handler = slog.NewJSONHandler(output, opts)
|
||||
} else {
|
||||
// Common format (default)
|
||||
handler = slog.NewTextHandler(output, opts)
|
||||
}
|
||||
|
||||
// Info log to Stdout.
|
||||
func (l *Log) Info(str string) {
|
||||
l.logInfo.Printf("%s", str)
|
||||
}
|
||||
|
||||
// Debug log to Stdout.
|
||||
func (l *Log) Debug(str string) {
|
||||
l.logDebug.Printf("%s", str)
|
||||
}
|
||||
|
||||
// Error log to Stderr.
|
||||
func (l *Log) Error(str string) {
|
||||
l.logError.Printf("%s", str)
|
||||
// Create logger with component attribute
|
||||
return slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
logLevel string
|
||||
}{
|
||||
{name: "ERROR level", logLevel: "ERROR"},
|
||||
{name: "WARN level", logLevel: "WARN"},
|
||||
{name: "INFO level", logLevel: "INFO"},
|
||||
{name: "DEBUG level", logLevel: "DEBUG"},
|
||||
{name: "Default level (INFO)", logLevel: "INVALID"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
logger := New(tt.logLevel, "")
|
||||
|
||||
// Verify logger is created
|
||||
if logger == nil {
|
||||
t.Fatal("Expected logger to be created, got nil")
|
||||
}
|
||||
|
||||
// Verify it's a slog.Logger (we can call methods on it)
|
||||
logger.Info("test initialization")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLogFormat(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Create a logger with JSON handler to capture output
|
||||
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||
|
||||
testMessage := "json test message"
|
||||
logger.Info(testMessage)
|
||||
|
||||
output := buf.String()
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("Expected 1 log line, got %d", len(lines))
|
||||
}
|
||||
|
||||
// Verify it's valid JSON
|
||||
var logEntry map[string]interface{}
|
||||
err := json.Unmarshal([]byte(lines[0]), &logEntry)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected valid JSON output, got error: %v, output: %s", err, output)
|
||||
}
|
||||
|
||||
// Verify JSON structure
|
||||
if logEntry["level"] != "INFO" {
|
||||
t.Errorf("Expected level 'INFO', got '%v'", logEntry["level"])
|
||||
}
|
||||
if logEntry["msg"] != testMessage {
|
||||
t.Errorf("Expected message '%s', got '%v'", testMessage, logEntry["msg"])
|
||||
}
|
||||
if logEntry["time"] == nil {
|
||||
t.Error("Expected timestamp to be set")
|
||||
}
|
||||
if logEntry["component"] != "CrowdsecBouncerTraefikPlugin" {
|
||||
t.Errorf("Expected component 'CrowdsecBouncerTraefikPlugin', got '%v'", logEntry["component"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommonLogFormat(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Create a logger with text handler to capture output
|
||||
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||
|
||||
testMessage := "common test message"
|
||||
logger.Info(testMessage)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Verify common format (should contain level and message)
|
||||
if !strings.Contains(output, "level=INFO") {
|
||||
t.Error("Expected common format with INFO level")
|
||||
}
|
||||
if !strings.Contains(output, testMessage) {
|
||||
t.Error("Expected test message in common format")
|
||||
}
|
||||
if !strings.Contains(output, "component=CrowdsecBouncerTraefikPlugin") {
|
||||
t.Error("Expected component field in common format")
|
||||
}
|
||||
|
||||
// Should NOT be JSON (should be slog text format)
|
||||
var logEntry map[string]interface{}
|
||||
err := json.Unmarshal([]byte(strings.TrimSpace(output)), &logEntry)
|
||||
if err == nil {
|
||||
t.Error("Expected common format (not JSON), but got valid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorLevel(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Create a logger with ERROR level to capture output
|
||||
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelError})
|
||||
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||
|
||||
testMessage := "error only test"
|
||||
|
||||
// Test all log methods
|
||||
logger.Error(testMessage)
|
||||
logger.Warn(testMessage) // Should not appear
|
||||
logger.Info(testMessage) // Should not appear
|
||||
logger.Debug(testMessage) // Should not appear
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Only ERROR should appear
|
||||
if !strings.Contains(output, "level=ERROR") {
|
||||
t.Error("Expected ERROR message to appear")
|
||||
}
|
||||
|
||||
// Other levels should NOT appear
|
||||
unwantedLevels := []string{"level=WARN", "level=INFO", "level=DEBUG"}
|
||||
for _, level := range unwantedLevels {
|
||||
if strings.Contains(output, level) {
|
||||
t.Errorf("Unexpected %s message appeared at ERROR level", level)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify only one message appears
|
||||
messageCount := strings.Count(output, testMessage)
|
||||
if messageCount != 1 {
|
||||
t.Errorf("Expected 1 occurrence of test message at ERROR level, got %d", messageCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidLogFile(t *testing.T) {
|
||||
// Try to create logger with invalid file path
|
||||
logger := New("INFO", "/invalid/path/that/does/not/exist/test.log")
|
||||
|
||||
// Logger should still be created (falls back to stdout)
|
||||
if logger == nil {
|
||||
t.Fatal("Expected logger to be created even with invalid file path")
|
||||
}
|
||||
|
||||
// Should not panic when logging
|
||||
logger.Info("test message")
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"$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 version.go (pluginVersion)",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^version\\.go$/"],
|
||||
"matchStrings": [
|
||||
"pluginVersion\\s*=\\s*\"(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)\""
|
||||
],
|
||||
"depNameTemplate": "maxlerebourg/crowdsec-bouncer-traefik-plugin",
|
||||
"datasourceTemplate": "github-tags"
|
||||
},
|
||||
{
|
||||
"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.6}"
|
||||
|
||||
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)
|
||||
if strings.Contains(str, "-NOAUTH") {
|
||||
return redisCmd{Error: fmt.Errorf(RedisNoAuth)}
|
||||
} else if str != "$1" {
|
||||
} else if str == "$-1" {
|
||||
return redisCmd{Error: fmt.Errorf(RedisMiss)}
|
||||
}
|
||||
read, _ = reader.ReadLineBytes()
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
# github.com/leprosus/golang-ttl-map v1.1.7
|
||||
## explicit; go 1.15
|
||||
github.com/leprosus/golang-ttl-map
|
||||
# github.com/maxlerebourg/simpleredis v1.0.11
|
||||
# github.com/maxlerebourg/simpleredis v1.0.12
|
||||
## explicit; go 1.22
|
||||
github.com/maxlerebourg/simpleredis
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||
|
||||
// pluginVersion is updated automatically by the release workflow and Renovate.
|
||||
var pluginVersion = "v1.6.0" //nolint:gochecknoglobals
|
||||
Reference in New Issue
Block a user