mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
Compare commits
33
Commits
v1.4.3
...
v1.7.0-alpha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d32f271195 | ||
|
|
1c1672c856 | ||
|
|
21895fbb9d | ||
|
|
7c73cb38dd | ||
|
|
f4dcd933c8 | ||
|
|
67b33dcf13 | ||
|
|
661a89ea9c | ||
|
|
14b9c47ab2 | ||
|
|
f5d580578c | ||
|
|
71d845faae | ||
|
|
0d8fd2a7a9 | ||
|
|
1f6a8991c8 | ||
|
|
7f776fe0fe | ||
|
|
e54c1d5c4f | ||
|
|
c2bbc4dac5 | ||
|
|
889c5b55fe | ||
|
|
efb3a67019 | ||
|
|
0780027252 | ||
|
|
892909b9b8 | ||
|
|
c26923dee5 | ||
|
|
a9d83f2097 | ||
|
|
e20ccc5d0c | ||
|
|
50beb4294f | ||
|
|
4ab4f3f183 | ||
|
|
2aac531ea7 | ||
|
|
e8e60c958f | ||
|
|
a2d3708bc3 | ||
|
|
65a2f79fb3 | ||
|
|
a2ecc95dc9 | ||
|
|
7c4f5163e9 | ||
|
|
734975c206 | ||
|
|
7397834e58 | ||
|
|
84a5674b14 |
@@ -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
|
name: Main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -24,20 +27,20 @@ jobs:
|
|||||||
|
|
||||||
# https://github.com/marketplace/actions/setup-go-environment
|
# https://github.com/marketplace/actions/setup-go-environment
|
||||||
- name: Set up Go ${{ env.GO_VERSION }}
|
- name: Set up Go ${{ env.GO_VERSION }}
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
# https://github.com/marketplace/actions/checkout
|
# https://github.com/marketplace/actions/checkout
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
path: go/src/github.com/${{ github.repository }}
|
path: go/src/github.com/${{ github.repository }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# https://github.com/marketplace/actions/cache
|
# https://github.com/marketplace/actions/cache
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: ${{ github.workspace }}/go/pkg/mod
|
path: ${{ github.workspace }}/go/pkg/mod
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: Release Version Update
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-version:
|
||||||
|
name: Update version in source
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
TAG="${{ github.event.release.tag_name }}"
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Update version in version.go
|
||||||
|
run: |
|
||||||
|
sed -i 's/pluginVersion = "[^"]*"/pluginVersion = "'"${{ steps.get_version.outputs.version }}"'"/' version.go
|
||||||
|
cat version.go
|
||||||
|
|
||||||
|
- name: Commit, push, and retag
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add version.go
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "Version already up to date, nothing to commit"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "⬆️ chore: bump version to ${{ steps.get_version.outputs.version }}"
|
||||||
|
git push origin main
|
||||||
|
# Move the release tag to include the version update
|
||||||
|
git tag -f "${{ steps.get_version.outputs.tag }}"
|
||||||
|
git push -f origin "${{ steps.get_version.outputs.tag }}"
|
||||||
@@ -5,3 +5,7 @@ conf
|
|||||||
db
|
db
|
||||||
logs
|
logs
|
||||||
docker-compose.dev.yml
|
docker-compose.dev.yml
|
||||||
|
|
||||||
|
# Binary e2e suite working cache: Traefik binary + compiled mock. Persisted
|
||||||
|
# across local runs; CI runs on fresh runners so it is recreated every time.
|
||||||
|
tests/e2e/mock/.cache/
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,7 @@ linters-settings:
|
|||||||
disable:
|
disable:
|
||||||
- fieldalignment
|
- fieldalignment
|
||||||
gocyclo:
|
gocyclo:
|
||||||
min-complexity: 15
|
min-complexity: 20
|
||||||
goconst:
|
goconst:
|
||||||
min-len: 5
|
min-len: 5
|
||||||
min-occurrences: 4
|
min-occurrences: 4
|
||||||
@@ -73,6 +73,7 @@ linters:
|
|||||||
- gci
|
- gci
|
||||||
- mnd
|
- mnd
|
||||||
- exportloopref
|
- exportloopref
|
||||||
|
- contextcheck
|
||||||
issues:
|
issues:
|
||||||
exclude-use-default: false
|
exclude-use-default: false
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
.PHONY: lint test vendor clean
|
.PHONY: lint test vendor clean e2e_mock
|
||||||
|
|
||||||
export GO111MODULE=on
|
export GO111MODULE=on
|
||||||
|
|
||||||
|
# Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs.
|
||||||
|
# The local Docker suite (make e2e) lives in a separate PR/branch.
|
||||||
|
E2E_MOCK_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec tls-system-ca
|
||||||
|
|
||||||
default: lint test
|
default: lint test
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@@ -13,6 +17,11 @@ test:
|
|||||||
yaegi_test:
|
yaegi_test:
|
||||||
yaegi test -v .
|
yaegi test -v .
|
||||||
|
|
||||||
|
e2e_mock: $(addprefix e2e_mock_,$(E2E_MOCK_SCENARIOS))
|
||||||
|
|
||||||
|
e2e_mock_%:
|
||||||
|
./tests/e2e/mock/scenarios/$*/run.sh
|
||||||
|
|
||||||
vendor:
|
vendor:
|
||||||
go mod vendor
|
go mod vendor
|
||||||
|
|
||||||
@@ -41,13 +50,16 @@ run_tlsauth:
|
|||||||
docker compose -f examples/tls-auth/docker-compose.yml up -d --remove-orphans
|
docker compose -f examples/tls-auth/docker-compose.yml up -d --remove-orphans
|
||||||
|
|
||||||
run_appsec:
|
run_appsec:
|
||||||
docker compose -f examples/appsec-enabled/docker-compose.yml up -d
|
docker compose -f examples/appsec-enabled/docker-compose.yml up -d --remove-orphans
|
||||||
|
|
||||||
|
run_custom_captcha:
|
||||||
|
docker compose -f examples/custom-captcha/docker-compose.yml up -d --remove-orphans
|
||||||
|
|
||||||
run_captcha:
|
run_captcha:
|
||||||
docker compose -f examples/captcha/docker-compose.yml up -d
|
docker compose -f examples/captcha/docker-compose.yml up -d --remove-orphans
|
||||||
|
|
||||||
run_custom_ban_page:
|
run_custom_ban_page:
|
||||||
docker compose -f examples/custom-ban-page/docker-compose.yml up -d
|
docker compose -f examples/custom-ban-page/docker-compose.yml up -d --remove-orphans
|
||||||
|
|
||||||
run:
|
run:
|
||||||
docker compose -f docker-compose.yml up -d --remove-orphans
|
docker compose -f docker-compose.yml up -d --remove-orphans
|
||||||
@@ -98,6 +110,7 @@ clean_all_docker:
|
|||||||
docker compose -f examples/tls-auth/docker-compose.yml down --remove-orphans
|
docker compose -f examples/tls-auth/docker-compose.yml down --remove-orphans
|
||||||
docker compose -f examples/appsec-enabled/docker-compose.yml down --remove-orphans
|
docker compose -f examples/appsec-enabled/docker-compose.yml down --remove-orphans
|
||||||
docker compose -f examples/captcha/docker-compose.yml down --remove-orphans
|
docker compose -f examples/captcha/docker-compose.yml down --remove-orphans
|
||||||
|
docker compose -f examples/custom-captcha/docker-compose.yml down --remove-orphans
|
||||||
docker compose -f examples/custom-ban-page/docker-compose.yml down --remove-orphans
|
docker compose -f examples/custom-ban-page/docker-compose.yml down --remove-orphans
|
||||||
docker compose -f docker-compose.local.yml down --remove-orphans
|
docker compose -f docker-compose.local.yml down --remove-orphans
|
||||||
docker compose -f docker-compose.yml down --remove-orphans
|
docker compose -f docker-compose.yml down --remove-orphans
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ The following captcha providers are supported now:
|
|||||||
- [hcaptcha](https://www.hcaptcha.com/)
|
- [hcaptcha](https://www.hcaptcha.com/)
|
||||||
- [recaptcha](https://www.google.com/recaptcha/about/)
|
- [recaptcha](https://www.google.com/recaptcha/about/)
|
||||||
- [turnstile](https://www.cloudflare.com/products/turnstile/)
|
- [turnstile](https://www.cloudflare.com/products/turnstile/)
|
||||||
|
- [custom/wicketkeeper](https://github.com/a-ve/wicketkeeper)
|
||||||
|
|
||||||
There are 5 operating modes (CrowdsecMode) for this plugin:
|
There are 5 operating modes (CrowdsecMode) for this plugin:
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant CrowdsecLAPI
|
create participant CrowdsecLAPI
|
||||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a Crowdsec Decision ?
|
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a Crowdsec Decision ?
|
||||||
Destroy CrowdsecLAPI
|
destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
@@ -81,9 +82,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant CrowdsecLAPI
|
create participant CrowdsecLAPI
|
||||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||||
Destroy CrowdsecLAPI
|
destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -104,10 +105,10 @@ sequenceDiagram
|
|||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
create participant CrowdsecLAPI
|
create participant CrowdsecLAPI
|
||||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||||
Destroy CrowdsecLAPI
|
destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
||||||
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
@@ -124,10 +125,10 @@ sequenceDiagram
|
|||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
create participant CrowdsecLAPI
|
create participant CrowdsecLAPI
|
||||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||||
Destroy CrowdsecLAPI
|
destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
||||||
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
@@ -144,11 +145,11 @@ sequenceDiagram
|
|||||||
participant TraefikPlugin
|
participant TraefikPlugin
|
||||||
participant CrowdsecLAPI
|
participant CrowdsecLAPI
|
||||||
TraefikPlugin->>CrowdsecLAPI: What are the current decisions
|
TraefikPlugin->>CrowdsecLAPI: What are the current decisions
|
||||||
Destroy CrowdsecLAPI
|
destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI->>TraefikPlugin: Here is the list
|
CrowdsecLAPI->>TraefikPlugin: Here is the list
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Store this list
|
TraefikPlugin-->>PluginCache: Store this list
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -161,9 +162,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Yes a ban decision
|
PluginCache-->>TraefikPlugin: Yes a ban decision
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -176,9 +177,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -194,11 +195,11 @@ sequenceDiagram
|
|||||||
participant TraefikPlugin
|
participant TraefikPlugin
|
||||||
participant CrowdsecCAPI
|
participant CrowdsecCAPI
|
||||||
TraefikPlugin->>CrowdsecCAPI: What are the current decisions from CAPI
|
TraefikPlugin->>CrowdsecCAPI: What are the current decisions from CAPI
|
||||||
Destroy CrowdsecCAPI
|
destroy CrowdsecCAPI
|
||||||
CrowdsecCAPI->>TraefikPlugin: Here is the list
|
CrowdsecCAPI->>TraefikPlugin: Here is the list
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Store this list
|
TraefikPlugin-->>PluginCache: Store this list
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -211,9 +212,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Yes a ban decision
|
PluginCache-->>TraefikPlugin: Yes a ban decision
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -226,9 +227,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -246,9 +247,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant CrowdsecAppSec
|
create participant CrowdsecAppSec
|
||||||
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
||||||
Destroy CrowdsecAppSec
|
destroy CrowdsecAppSec
|
||||||
CrowdsecAppSec-->>TraefikPlugin: Yes I think so
|
CrowdsecAppSec-->>TraefikPlugin: Yes I think so
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -261,9 +262,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant CrowdsecAppSec
|
create participant CrowdsecAppSec
|
||||||
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
||||||
Destroy CrowdsecAppSec
|
destroy CrowdsecAppSec
|
||||||
CrowdsecAppSec-->>TraefikPlugin: No I don't think so
|
CrowdsecAppSec-->>TraefikPlugin: No I don't think so
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -284,12 +285,12 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Fine, done!
|
User->>TraefikPlugin: Fine, done!
|
||||||
create participant ProviderCaptcha
|
create participant ProviderCaptcha
|
||||||
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
||||||
Destroy ProviderCaptcha
|
destroy ProviderCaptcha
|
||||||
ProviderCaptcha-->>TraefikPlugin: Yes
|
ProviderCaptcha-->>TraefikPlugin: Yes
|
||||||
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -308,12 +309,17 @@ make run
|
|||||||
|
|
||||||
### Note
|
### Note
|
||||||
|
|
||||||
**/!\ Cache is shared by all services**
|
> [!IMPORTANT]
|
||||||
_This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP_
|
> Some of the behaviours and configuration parameters are shared globally across _all_ crowdsec middlewares even if you declare different middlewares with different settings.
|
||||||
Only one instance of the plugin is _possible_.
|
>
|
||||||
|
> **Cache is shared by all services**: This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP
|
||||||
|
>
|
||||||
|
> If you define different caches for different middlewares, only the first one to be instantiated will be bound to the crowdsec stream.
|
||||||
|
>
|
||||||
|
> Overall, this middleware is designed in such a way that **only one instance of the plugin is _possible_.** You can have multiple crowdsec middlewares in the same cluster, the key parameters must be aligned (MetricsUpdateIntervalSeconds, CrowdsecMode, CrowdsecAppsecEnabled, etc.)
|
||||||
|
|
||||||
**/!\ Appsec maximum body limit is defaulted to 10MB**
|
> [!WARNING]
|
||||||
_By 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
|
### Variables
|
||||||
|
|
||||||
@@ -323,12 +329,21 @@ _By careful when you upgrade to >1.4.x_
|
|||||||
- Enable the plugin
|
- Enable the plugin
|
||||||
- LogLevel
|
- LogLevel
|
||||||
- string
|
- string
|
||||||
- default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR`
|
- default: `INFO`, expected values are: `DEBUG`, `INFO`, `WARN`, `ERROR`
|
||||||
- Log are written to `stdout` / `stderr` of file if LogFilePath is provided
|
- 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
|
- LogFilePath
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- File Path to write logs, must be writable by Traefik, Log rotation may require a restart of traefik
|
- File Path to write logs, must be writable by Traefik, Log rotation may require a restart of traefik
|
||||||
|
- MetricsUpdateIntervalSeconds
|
||||||
|
- int64
|
||||||
|
- default: 600
|
||||||
|
- Interval in seconds between metrics updates to Crowdsec
|
||||||
|
- If set to zero or less, metrics collection is disabled
|
||||||
- CrowdsecMode
|
- CrowdsecMode
|
||||||
- string
|
- string
|
||||||
- default: `live`, expected values are: `none`, `live`, `stream`, `alone`, `appsec`
|
- default: `live`, expected values are: `none`, `live`, `stream`, `alone`, `appsec`
|
||||||
@@ -339,7 +354,18 @@ _By careful when you upgrade to >1.4.x_
|
|||||||
- CrowdsecAppsecHost
|
- CrowdsecAppsecHost
|
||||||
- string
|
- string
|
||||||
- default: "crowdsec:7422"
|
- default: "crowdsec:7422"
|
||||||
- Crowdsec Appsec Server available on which host and port. The scheme will be handled by the CrowdsecLapiScheme var.
|
- Crowdsec Appsec Server available on which host and port.
|
||||||
|
- CrowdsecAppsecTlsInsecureVerify
|
||||||
|
- bool
|
||||||
|
- default: false
|
||||||
|
- Disable verification of certificate presented by Appsec
|
||||||
|
- CrowdsecAppsecTlsCertificateAuthority
|
||||||
|
- string
|
||||||
|
- default: ""
|
||||||
|
- PEM-encoded Certificate Authority used to verify Appsec's server certificate. When empty (and `crowdsecAppsecTlsInsecureVerify` is `false`), the host's system trust store is used.
|
||||||
|
- CrowdsecAppsecScheme
|
||||||
|
- string
|
||||||
|
- default: value of `CrowdsecLapiScheme`, expected values are: `http`, `https`
|
||||||
- CrowdsecAppsecPath
|
- CrowdsecAppsecPath
|
||||||
- string
|
- string
|
||||||
- default: "/"
|
- default: "/"
|
||||||
@@ -356,6 +382,14 @@ _By careful when you upgrade to >1.4.x_
|
|||||||
- int64
|
- int64
|
||||||
- default: 10485760 (= 10MB)
|
- default: 10485760 (= 10MB)
|
||||||
- Transmit only the first number of bytes to Crowdsec Appsec Server.
|
- Transmit only the first number of bytes to Crowdsec Appsec Server.
|
||||||
|
- CrowdsecAppsecUnreadableBodyBlock
|
||||||
|
- bool
|
||||||
|
- default: 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
|
- CrowdsecLapiScheme
|
||||||
- string
|
- string
|
||||||
- default: `http`, expected values are: `http`, `https`
|
- default: `http`, expected values are: `http`, `https`
|
||||||
@@ -378,7 +412,7 @@ _By careful when you upgrade to >1.4.x_
|
|||||||
- CrowdsecLapiTlsCertificateAuthority
|
- CrowdsecLapiTlsCertificateAuthority
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- PEM-encoded Certificate Authority of the Crowdsec LAPI
|
- PEM-encoded Certificate Authority used to verify the LAPI's server certificate. When empty (and `crowdsecLapiTlsInsecureVerify` is `false`), the host's system trust store is used.
|
||||||
- CrowdsecLapiTlsCertificateBouncer
|
- CrowdsecLapiTlsCertificateBouncer
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
@@ -394,7 +428,7 @@ _By careful when you upgrade to >1.4.x_
|
|||||||
- RemediationHeadersCustomName
|
- RemediationHeadersCustomName
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- Name of the header you want in response when request are cancelled (possible value of the header `ban` or `captcha`)
|
- Name of the header you want in response when request are handled by plugin (possible value of the header `ban`, `captcha` or `solved-captcha`)
|
||||||
- ForwardedHeadersCustomName
|
- ForwardedHeadersCustomName
|
||||||
- string
|
- string
|
||||||
- default: "X-Forwarded-For"
|
- default: "X-Forwarded-For"
|
||||||
@@ -435,6 +469,12 @@ _By careful when you upgrade to >1.4.x_
|
|||||||
- int64
|
- int64
|
||||||
- default: 0
|
- default: 0
|
||||||
- Used only in `stream` and `alone` mode, the maximum number of time we can not reach Crowdsec before blocking traffic (set -1 to never block)
|
- Used only in `stream` and `alone` mode, the maximum number of time we can not reach Crowdsec before blocking traffic (set -1 to never block)
|
||||||
|
- StreamStartupBlock
|
||||||
|
- bool
|
||||||
|
- default: true
|
||||||
|
- Used only in `stream` and `alone` mode, controls whether the initial stream update runs synchronously or asynchronously during plugin initialization
|
||||||
|
- When `true`, plugin initialization waits for Crowdsec to be ready before serving traffic.
|
||||||
|
- **Warning**: When `false`, all requests bypass remediation until the first stream sync completes — banned IPs will be allowed through during this window. Only disable when startup availability is more important than blocking at startup.
|
||||||
- DefaultDecisionSeconds
|
- DefaultDecisionSeconds
|
||||||
- int64
|
- int64
|
||||||
- default: 60
|
- default: 60
|
||||||
@@ -454,7 +494,19 @@ _By careful when you upgrade to >1.4.x_
|
|||||||
- Used only in `alone` mode, scenarios for Crowdsec CAPI
|
- Used only in `alone` mode, scenarios for Crowdsec CAPI
|
||||||
- CaptchaProvider
|
- CaptchaProvider
|
||||||
- string
|
- string
|
||||||
- Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile`
|
- Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile` or `custom`
|
||||||
|
- CaptchaCustomJsURL
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, URL used to load the challenge in the HTML (in case of hcaptcha: `https://hcaptcha.com/1/api.js`)
|
||||||
|
- CaptchaCustomValidateURL
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, URL used to validate the challenge (in case of hcaptcha: `https://api.hcaptcha.com/siteverify`)
|
||||||
|
- CaptchaCustomKey
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, used to set class name of the div used by captcha provider (in case of hcaptcha: `h-captcha`)
|
||||||
|
- CaptchaCustomResponse
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, used to set the field in the POST body from the captcha.html to Traefik (in case of hcaptcha: `h-captcha-response`)
|
||||||
- CaptchaSiteKey
|
- CaptchaSiteKey
|
||||||
- string
|
- string
|
||||||
- Site key for the captcha provider
|
- Site key for the captcha provider
|
||||||
@@ -465,14 +517,18 @@ _By careful when you upgrade to >1.4.x_
|
|||||||
- int64
|
- int64
|
||||||
- default: 1800 (= 30 minutes)
|
- default: 1800 (= 30 minutes)
|
||||||
- Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid
|
- Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid
|
||||||
- CaptchaHTMLFilePath
|
- CaptchaFilePath
|
||||||
- string
|
- string
|
||||||
- default: /captcha.html
|
- default: /captcha.html
|
||||||
- Path where the captcha template is stored
|
- Path where the captcha template is stored. The Content-Type header is automatically inferred from the file extension.
|
||||||
- BanHTMLFilePath
|
- BanFilePath
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- Path where the ban html file is stored (default empty ""=disabled)
|
- Path where the ban file is stored (default empty ""=disabled). The Content-Type header is automatically inferred from the file extension.
|
||||||
|
- TraceHeadersCustomName
|
||||||
|
- string
|
||||||
|
- default: ""
|
||||||
|
- Request Header name whose value to inject in ban HTML response (default empty ""=disabled)
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
@@ -494,7 +550,37 @@ experimental:
|
|||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Dynamic configuration
|
# Simplified dynamic configuration
|
||||||
|
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
my-router:
|
||||||
|
rule: host(`whoami.localhost`)
|
||||||
|
service: service-foo
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
middlewares:
|
||||||
|
- crowdsec
|
||||||
|
|
||||||
|
services:
|
||||||
|
service-foo:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: http://127.0.0.1:5000
|
||||||
|
|
||||||
|
middlewares:
|
||||||
|
crowdsec:
|
||||||
|
plugin:
|
||||||
|
bouncer:
|
||||||
|
enabled: true
|
||||||
|
logLevel: DEBUG
|
||||||
|
crowdsecMode: live
|
||||||
|
crowdsecLapiKey: privateKey-foo
|
||||||
|
crowdsecLapiHost: crowdsec:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Full dynamic configuration
|
||||||
|
|
||||||
http:
|
http:
|
||||||
routers:
|
routers:
|
||||||
@@ -518,19 +604,23 @@ http:
|
|||||||
bouncer:
|
bouncer:
|
||||||
enabled: false
|
enabled: false
|
||||||
logLevel: DEBUG
|
logLevel: DEBUG
|
||||||
|
logFormat: common
|
||||||
LogFilePath: ""
|
LogFilePath: ""
|
||||||
updateIntervalSeconds: 60
|
updateIntervalSeconds: 60
|
||||||
updateMaxFailure: 0
|
updateMaxFailure: 0
|
||||||
|
streamStartupBlock: true
|
||||||
defaultDecisionSeconds: 60
|
defaultDecisionSeconds: 60
|
||||||
remediationStatusCode: 403
|
remediationStatusCode: 403
|
||||||
httpTimeoutSeconds: 10
|
httpTimeoutSeconds: 10
|
||||||
crowdsecMode: live
|
crowdsecMode: live
|
||||||
crowdsecAppsecEnabled: false
|
crowdsecAppsecEnabled: false
|
||||||
|
crowdsecAppsecScheme: ""
|
||||||
crowdsecAppsecHost: crowdsec:7422
|
crowdsecAppsecHost: crowdsec:7422
|
||||||
crowdsecAppsecPath: "/"
|
crowdsecAppsecPath: "/"
|
||||||
crowdsecAppsecFailureBlock: true
|
crowdsecAppsecFailureBlock: true
|
||||||
crowdsecAppsecUnreachableBlock: true
|
crowdsecAppsecUnreachableBlock: true
|
||||||
crowdsecAppsecBodyLimit: 10485760
|
crowdsecAppsecBodyLimit: 10485760
|
||||||
|
crowdsecAppsecUnreadableBodyBlock: false
|
||||||
crowdsecLapiKey: privateKey-foo
|
crowdsecLapiKey: privateKey-foo
|
||||||
crowdsecLapiScheme: http
|
crowdsecLapiScheme: http
|
||||||
crowdsecLapiHost: crowdsec:8080
|
crowdsecLapiHost: crowdsec:8080
|
||||||
@@ -579,11 +669,13 @@ http:
|
|||||||
captchaGracePeriodSeconds: 1800
|
captchaGracePeriodSeconds: 1800
|
||||||
captchaHTMLFilePath: /captcha.html
|
captchaHTMLFilePath: /captcha.html
|
||||||
banHTMLFilePath: /ban.html
|
banHTMLFilePath: /ban.html
|
||||||
|
traceHeadersCustomName: X-Request-ID
|
||||||
|
metricsUpdateIntervalSeconds: 600
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Fill variable with value of file
|
#### Fill variable with value of file
|
||||||
|
|
||||||
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey`, `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.
|
The file variable will be used as preference if both content and file are provided for the same variable.
|
||||||
|
|
||||||
Format is:
|
Format is:
|
||||||
@@ -640,12 +732,21 @@ A script is available to generate certificates in `examples/tls-auth/gencerts.sh
|
|||||||
|
|
||||||
#### Use HTTPS to communicate with the LAPI
|
#### Use HTTPS to communicate with the LAPI
|
||||||
|
|
||||||
To communicate with the LAPI in HTTPS you need to either accept any certificates by setting the `crowdsecLapiTLSInsecureVerify` to true or add the CA used by the server certificate of Crowdsec using `crowdsecLapiTLSCertificateAuthority` or `crowdsecLapiTLSCertificateAuthorityFile`.
|
Set `crowdsecLapiScheme` to `https`. The plugin then validates Crowdsec's server certificate. Three options:
|
||||||
Set the `crowdsecLapiScheme` to https.
|
|
||||||
|
- **Publicly trusted certificate** (e.g. Let's Encrypt behind a reverse proxy): leave `crowdsecLapiTLSCertificateAuthority` empty and `crowdsecLapiTLSInsecureVerify` `false`. The plugin falls back to the host's system trust store (the `traefik` image ships `ca-certificates`).
|
||||||
|
- **Private/self-signed CA**: set `crowdsecLapiTLSCertificateAuthority` (or `…File`) to the PEM-encoded CA that signed Crowdsec's server cert.
|
||||||
|
- **Skip verification entirely** (not recommended for production): set `crowdsecLapiTLSInsecureVerify` to `true`.
|
||||||
|
|
||||||
Crowdsec must be listening in HTTPS for this to work.
|
Crowdsec must be listening in HTTPS for this to work.
|
||||||
Please see the [tls-auth example](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/tls-auth/README.md) or the official documentation: [docs.crowdsec.net/docs/local_api/tls_auth/](https://docs.crowdsec.net/docs/local_api/tls_auth/)
|
Please see the [tls-auth example](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/tls-auth/README.md) or the official documentation: [docs.crowdsec.net/docs/local_api/tls_auth/](https://docs.crowdsec.net/docs/local_api/tls_auth/)
|
||||||
|
|
||||||
|
#### Use HTTPS to communicate with the Appsec
|
||||||
|
|
||||||
|
Set `crowdsecAppsecScheme` to `https`. Same three options as for the LAPI, prefixed `crowdsecAppsec…` instead of `crowdsecLapi…`: empty CA + secure verify falls back to the system trust store, a custom CA pins to your private PKI, and `crowdsecAppsecTLSInsecureVerify=true` skips verification altogether.
|
||||||
|
|
||||||
|
Currently AppSec does not support mTLS authentication for the AppSec Component.
|
||||||
|
|
||||||
#### Manually add an IP to the blocklist (for testing purposes)
|
#### Manually add an IP to the blocklist (for testing purposes)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -678,6 +779,8 @@ docker exec crowdsec cscli decisions remove --ip 10.0.0.10 -t captcha
|
|||||||
|
|
||||||
#### 10. Using Traefik with Custom Ban HTML Page [examples/custom-ban-page/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/custom-ban-page/README.md)
|
#### 10. Using Traefik with Custom Ban HTML Page [examples/custom-ban-page/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/custom-ban-page/README.md)
|
||||||
|
|
||||||
|
#### 11. Using Traefik with Custom Captcha Whiketkeeper[examples/custom-captcha/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/custom-captcha/README.md)
|
||||||
|
|
||||||
### Local Mode
|
### Local Mode
|
||||||
|
|
||||||
Traefik also offers a developer mode that can be used for temporary testing of plugins not hosted on GitHub.
|
Traefik also offers a developer mode that can be used for temporary testing of plugins not hosted on GitHub.
|
||||||
|
|||||||
+347
-143
@@ -10,9 +10,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ const (
|
|||||||
crowdsecLapiHeader = "X-Api-Key"
|
crowdsecLapiHeader = "X-Api-Key"
|
||||||
crowdsecLapiRoute = "v1/decisions"
|
crowdsecLapiRoute = "v1/decisions"
|
||||||
crowdsecLapiStreamRoute = "v1/decisions/stream"
|
crowdsecLapiStreamRoute = "v1/decisions/stream"
|
||||||
|
crowdsecLapiMetricsRoute = "v1/usage-metrics"
|
||||||
crowdsecCapiHost = "api.crowdsec.net"
|
crowdsecCapiHost = "api.crowdsec.net"
|
||||||
crowdsecCapiHeader = "Authorization"
|
crowdsecCapiHeader = "Authorization"
|
||||||
crowdsecCapiLoginRoute = "v2/watchers/login"
|
crowdsecCapiLoginRoute = "v2/watchers/login"
|
||||||
@@ -40,12 +44,32 @@ const (
|
|||||||
cacheTimeoutKey = "updated"
|
cacheTimeoutKey = "updated"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ##############################################################
|
||||||
|
// Important: traefik creates an instance of the bouncer per route.
|
||||||
|
// We rely on globals (both here and in the memory cache) to share info between
|
||||||
|
// routes. This means that some of the plugins parameters will only work "once"
|
||||||
|
// and will take the values of the first middleware that was instantiated even
|
||||||
|
// if you have different middlewares with different parameters. This design
|
||||||
|
// makes it impossible to have multiple crowdsec implementations per cluster (unless you have multiple traefik deployments in it)
|
||||||
|
// - updateInterval
|
||||||
|
// - updateMaxFailure
|
||||||
|
// - defaultDecisionTimeout
|
||||||
|
// - redisUnreachableBlock
|
||||||
|
// - appsecEnabled
|
||||||
|
// - appsecHost
|
||||||
|
// - metricsUpdateIntervalSeconds
|
||||||
|
// - others...
|
||||||
|
// ###################################
|
||||||
|
|
||||||
//nolint:gochecknoglobals
|
//nolint:gochecknoglobals
|
||||||
var (
|
var (
|
||||||
isStartup = true
|
isCrowdsecStreamStartup = true
|
||||||
isCrowdsecStreamHealthy = true
|
isCrowdsecStreamHealthy = true
|
||||||
updateFailure = 0
|
updateFailure int64
|
||||||
ticker chan bool
|
streamTicker chan bool
|
||||||
|
metricsTicker chan bool
|
||||||
|
lastMetricsPush time.Time
|
||||||
|
blockedRequests int64
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateConfig creates the default plugin configuration.
|
// CreateConfig creates the default plugin configuration.
|
||||||
@@ -59,44 +83,60 @@ type Bouncer struct {
|
|||||||
name string
|
name string
|
||||||
template *template.Template
|
template *template.Template
|
||||||
|
|
||||||
enabled bool
|
enabled bool
|
||||||
appsecEnabled bool
|
appsecEnabled bool
|
||||||
appsecHost string
|
appsecScheme string
|
||||||
appsecPath string
|
appsecHost string
|
||||||
appsecFailureBlock bool
|
appsecPath string
|
||||||
appsecUnreachableBlock bool
|
appsecKey string
|
||||||
appsecBodyLimit int64
|
appsecFailureBlock bool
|
||||||
crowdsecScheme string
|
appsecUnreachableBlock bool
|
||||||
crowdsecHost string
|
appsecUnreadableBodyBlock bool
|
||||||
crowdsecPath string
|
appsecBodyLimit int64
|
||||||
crowdsecKey string
|
crowdsecScheme string
|
||||||
crowdsecMode string
|
crowdsecHost string
|
||||||
crowdsecMachineID string
|
crowdsecPath string
|
||||||
crowdsecPassword string
|
crowdsecKey string
|
||||||
crowdsecScenarios []string
|
crowdsecMode string
|
||||||
updateInterval int64
|
crowdsecMachineID string
|
||||||
updateMaxFailure int
|
crowdsecPassword string
|
||||||
defaultDecisionTimeout int64
|
crowdsecScenarios []string
|
||||||
remediationStatusCode int
|
updateInterval int64
|
||||||
remediationCustomHeader string
|
updateMaxFailure int64
|
||||||
forwardedCustomHeader string
|
defaultDecisionTimeout int64
|
||||||
crowdsecStreamRoute string
|
remediationStatusCode int
|
||||||
crowdsecHeader string
|
remediationCustomHeader string
|
||||||
redisUnreachableBlock bool
|
forwardedCustomHeader string
|
||||||
banTemplateString string
|
crowdsecStreamRoute string
|
||||||
clientPoolStrategy *ip.PoolStrategy
|
crowdsecHeader string
|
||||||
serverPoolStrategy *ip.PoolStrategy
|
redisUnreachableBlock bool
|
||||||
httpClient *http.Client
|
banTemplate *template.Template
|
||||||
cacheClient *cache.Client
|
banTemplateContentType string
|
||||||
captchaClient *captcha.Client
|
traceCustomHeader string
|
||||||
log *logger.Log
|
clientPoolStrategy *ip.PoolStrategy
|
||||||
|
serverPoolStrategy *ip.PoolStrategy
|
||||||
|
httpClient *http.Client
|
||||||
|
httpAppsecClient *http.Client
|
||||||
|
cacheClient *cache.Client
|
||||||
|
captchaClient *captcha.Client
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates the crowdsec bouncer plugin.
|
// New creates the crowdsec bouncer plugin.
|
||||||
|
//
|
||||||
|
//nolint:nestif,gocyclo,gocognit,funlen,maintidx
|
||||||
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
||||||
config.LogLevel = strings.ToUpper(config.LogLevel)
|
config.LogLevel = strings.ToUpper(config.LogLevel)
|
||||||
log := logger.New(config.LogLevel, config.LogFilePath)
|
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
|
||||||
err := configuration.ValidateParams(config)
|
|
||||||
|
if config.BanFilePath == "" && config.BanHTMLFilePath != "" {
|
||||||
|
config.BanFilePath = config.BanHTMLFilePath
|
||||||
|
}
|
||||||
|
if config.CaptchaHTMLFilePath != "" {
|
||||||
|
config.CaptchaFilePath = config.CaptchaHTMLFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
err := configuration.ValidateParams(config, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("New:validateParams " + err.Error())
|
log.Error("New:validateParams " + err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -105,6 +145,23 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
serverChecker, _ := ip.NewChecker(log, config.ForwardedHeadersTrustedIPs)
|
serverChecker, _ := ip.NewChecker(log, config.ForwardedHeadersTrustedIPs)
|
||||||
clientChecker, _ := ip.NewChecker(log, config.ClientTrustedIPs)
|
clientChecker, _ := ip.NewChecker(log, config.ClientTrustedIPs)
|
||||||
|
|
||||||
|
var tlsAppsecConfig *tls.Config
|
||||||
|
if config.CrowdsecAppsecEnabled {
|
||||||
|
tlsAppsecConfig, err = configuration.GetTLSConfigCrowdsec(config, log, true)
|
||||||
|
if config.CrowdsecAppsecScheme == "" {
|
||||||
|
config.CrowdsecAppsecScheme = config.CrowdsecLapiScheme
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error("New:getTLSConfigCrowdsec fail to get tlsAppsecConfig " + err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
apiAppsecKey, errAppsecKey := configuration.GetVariable(config, "CrowdsecAppsecKey")
|
||||||
|
if errAppsecKey != nil && len(tlsAppsecConfig.Certificates) == 0 {
|
||||||
|
log.Info("New:crowdsecLapiKey fail to get CrowdsecAppsecKey and no client certificate setup " + errAppsecKey.Error())
|
||||||
|
}
|
||||||
|
config.CrowdsecAppsecKey = apiAppsecKey
|
||||||
|
}
|
||||||
|
|
||||||
var tlsConfig *tls.Config
|
var tlsConfig *tls.Config
|
||||||
crowdsecStreamRoute := ""
|
crowdsecStreamRoute := ""
|
||||||
crowdsecHeader := ""
|
crowdsecHeader := ""
|
||||||
@@ -114,36 +171,32 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
config.CrowdsecLapiScheme = configuration.HTTPS
|
config.CrowdsecLapiScheme = configuration.HTTPS
|
||||||
config.CrowdsecLapiHost = crowdsecCapiHost
|
config.CrowdsecLapiHost = crowdsecCapiHost
|
||||||
config.CrowdsecLapiPath = "/"
|
config.CrowdsecLapiPath = "/"
|
||||||
config.CrowdsecAppsecEnabled = false
|
|
||||||
config.UpdateIntervalSeconds = 7200 // 2 hours
|
config.UpdateIntervalSeconds = 7200 // 2 hours
|
||||||
crowdsecStreamRoute = crowdsecCapiStreamRoute
|
crowdsecStreamRoute = crowdsecCapiStreamRoute
|
||||||
crowdsecHeader = crowdsecCapiHeader
|
crowdsecHeader = crowdsecCapiHeader
|
||||||
} else {
|
} else {
|
||||||
crowdsecStreamRoute = crowdsecLapiStreamRoute
|
crowdsecStreamRoute = crowdsecLapiStreamRoute
|
||||||
crowdsecHeader = crowdsecLapiHeader
|
crowdsecHeader = crowdsecLapiHeader
|
||||||
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log)
|
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("New:getTLSConfigCrowdsec fail to get tlsConfig " + err.Error())
|
log.Error("New:getTLSConfigCrowdsec fail to get tlsConfig " + err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
apiKey, errAPIKey := configuration.GetVariable(config, "CrowdsecLapiKey")
|
apiKey, errKey := configuration.GetVariable(config, "CrowdsecLapiKey")
|
||||||
if errAPIKey != nil && len(tlsConfig.Certificates) == 0 {
|
if errKey != nil && len(tlsConfig.Certificates) == 0 {
|
||||||
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errAPIKey.Error())
|
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errKey.Error())
|
||||||
return nil, errAPIKey
|
return nil, errKey
|
||||||
}
|
}
|
||||||
config.CrowdsecLapiKey = apiKey
|
config.CrowdsecLapiKey = apiKey
|
||||||
|
if config.CrowdsecAppsecKey == "" {
|
||||||
|
config.CrowdsecAppsecKey = apiKey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var banTemplateString string
|
var banTemplate *template.Template
|
||||||
if config.BanHTMLFilePath != "" {
|
var banTemplateContentType string
|
||||||
var buf bytes.Buffer
|
if config.BanFilePath != "" {
|
||||||
banTemplate, _ := configuration.GetHTMLTemplate(config.BanHTMLFilePath)
|
banTemplate, banTemplateContentType, _ = configuration.GetTemplate(config.BanFilePath)
|
||||||
err = banTemplate.Execute(&buf, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("New:banTemplate is bad formatted " + err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
banTemplateString = buf.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bouncer := &Bouncer{
|
bouncer := &Bouncer{
|
||||||
@@ -151,32 +204,37 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
name: name,
|
name: name,
|
||||||
template: template.New("CrowdsecBouncer").Delims("[[", "]]"),
|
template: template.New("CrowdsecBouncer").Delims("[[", "]]"),
|
||||||
|
|
||||||
enabled: config.Enabled,
|
enabled: config.Enabled,
|
||||||
crowdsecMode: config.CrowdsecMode,
|
crowdsecMode: config.CrowdsecMode,
|
||||||
appsecEnabled: config.CrowdsecAppsecEnabled,
|
appsecEnabled: config.CrowdsecAppsecEnabled,
|
||||||
appsecHost: config.CrowdsecAppsecHost,
|
appsecScheme: config.CrowdsecAppsecScheme,
|
||||||
appsecPath: config.CrowdsecAppsecPath,
|
appsecHost: config.CrowdsecAppsecHost,
|
||||||
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
appsecPath: config.CrowdsecAppsecPath,
|
||||||
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
appsecKey: config.CrowdsecAppsecKey,
|
||||||
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
|
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
||||||
crowdsecScheme: config.CrowdsecLapiScheme,
|
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
||||||
crowdsecHost: config.CrowdsecLapiHost,
|
appsecUnreadableBodyBlock: config.CrowdsecAppsecUnreadableBodyBlock,
|
||||||
crowdsecPath: config.CrowdsecLapiPath,
|
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
|
||||||
crowdsecKey: config.CrowdsecLapiKey,
|
crowdsecScheme: config.CrowdsecLapiScheme,
|
||||||
crowdsecMachineID: config.CrowdsecCapiMachineID,
|
crowdsecHost: config.CrowdsecLapiHost,
|
||||||
crowdsecPassword: config.CrowdsecCapiPassword,
|
crowdsecPath: config.CrowdsecLapiPath,
|
||||||
crowdsecScenarios: config.CrowdsecCapiScenarios,
|
crowdsecKey: config.CrowdsecLapiKey,
|
||||||
updateInterval: config.UpdateIntervalSeconds,
|
crowdsecMachineID: config.CrowdsecCapiMachineID,
|
||||||
updateMaxFailure: config.UpdateMaxFailure,
|
crowdsecPassword: config.CrowdsecCapiPassword,
|
||||||
remediationCustomHeader: config.RemediationHeadersCustomName,
|
crowdsecScenarios: config.CrowdsecCapiScenarios,
|
||||||
forwardedCustomHeader: config.ForwardedHeadersCustomName,
|
updateInterval: config.UpdateIntervalSeconds,
|
||||||
defaultDecisionTimeout: config.DefaultDecisionSeconds,
|
updateMaxFailure: config.UpdateMaxFailure,
|
||||||
remediationStatusCode: config.RemediationStatusCode,
|
remediationCustomHeader: config.RemediationHeadersCustomName,
|
||||||
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
forwardedCustomHeader: config.ForwardedHeadersCustomName,
|
||||||
banTemplateString: banTemplateString,
|
defaultDecisionTimeout: config.DefaultDecisionSeconds,
|
||||||
crowdsecStreamRoute: crowdsecStreamRoute,
|
remediationStatusCode: config.RemediationStatusCode,
|
||||||
crowdsecHeader: crowdsecHeader,
|
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
||||||
log: log,
|
banTemplate: banTemplate,
|
||||||
|
banTemplateContentType: banTemplateContentType,
|
||||||
|
traceCustomHeader: config.TraceHeadersCustomName,
|
||||||
|
crowdsecStreamRoute: crowdsecStreamRoute,
|
||||||
|
crowdsecHeader: crowdsecHeader,
|
||||||
|
log: log,
|
||||||
serverPoolStrategy: &ip.PoolStrategy{
|
serverPoolStrategy: &ip.PoolStrategy{
|
||||||
Checker: serverChecker,
|
Checker: serverChecker,
|
||||||
},
|
},
|
||||||
@@ -191,6 +249,14 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
},
|
},
|
||||||
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||||
},
|
},
|
||||||
|
httpAppsecClient: &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
MaxIdleConns: 10,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
TLSClientConfig: tlsAppsecConfig,
|
||||||
|
},
|
||||||
|
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||||
|
},
|
||||||
cacheClient: &cache.Client{},
|
cacheClient: &cache.Client{},
|
||||||
captchaClient: &captcha.Client{},
|
captchaClient: &captcha.Client{},
|
||||||
}
|
}
|
||||||
@@ -215,29 +281,47 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||||
},
|
},
|
||||||
config.CaptchaProvider,
|
config.CaptchaProvider,
|
||||||
|
config.CaptchaCustomJsURL,
|
||||||
|
config.CaptchaCustomKey,
|
||||||
|
config.CaptchaCustomResponse,
|
||||||
|
config.CaptchaCustomValidateURL,
|
||||||
config.CaptchaSiteKey,
|
config.CaptchaSiteKey,
|
||||||
config.CaptchaSecretKey,
|
config.CaptchaSecretKey,
|
||||||
config.RemediationHeadersCustomName,
|
config.RemediationHeadersCustomName,
|
||||||
config.CaptchaHTMLFilePath,
|
config.CaptchaFilePath,
|
||||||
config.CaptchaGracePeriodSeconds,
|
config.CaptchaGracePeriodSeconds,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("CaptchaClient not valid " + err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.CrowdsecMode == configuration.StreamMode || config.CrowdsecMode == configuration.AloneMode) && ticker == nil {
|
if (config.CrowdsecMode == configuration.StreamMode || config.CrowdsecMode == configuration.AloneMode) && streamTicker == nil {
|
||||||
if config.CrowdsecMode == configuration.AloneMode {
|
if config.CrowdsecMode == configuration.AloneMode {
|
||||||
if err := getToken(bouncer); err != nil {
|
if err := getToken(bouncer); err != nil {
|
||||||
bouncer.log.Error("New:getToken " + err.Error())
|
bouncer.log.Error("New:getToken " + err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handleStreamTicker(bouncer)
|
if config.StreamStartupBlock {
|
||||||
isStartup = false
|
handleStreamTicker(bouncer)
|
||||||
ticker = startTicker(config, log, func() {
|
} else {
|
||||||
|
go handleStreamTicker(bouncer)
|
||||||
|
}
|
||||||
|
streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() {
|
||||||
handleStreamTicker(bouncer)
|
handleStreamTicker(bouncer)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start metrics ticker if not already running
|
||||||
|
if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 {
|
||||||
|
lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker
|
||||||
|
go handleMetricsTicker(bouncer)
|
||||||
|
metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() {
|
||||||
|
handleMetricsTicker(bouncer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
bouncer.log.Debug("New initialized mode:" + config.CrowdsecMode)
|
bouncer.log.Debug("New initialized mode:" + config.CrowdsecMode)
|
||||||
|
|
||||||
return bouncer, nil
|
return bouncer, nil
|
||||||
@@ -245,7 +329,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
|
|
||||||
// ServeHTTP principal function of plugin.
|
// ServeHTTP principal function of plugin.
|
||||||
//
|
//
|
||||||
//nolint:nestif,gocyclo
|
//nolint:nestif
|
||||||
func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||||
if !bouncer.enabled {
|
if !bouncer.enabled {
|
||||||
bouncer.next.ServeHTTP(rw, req)
|
bouncer.next.ServeHTTP(rw, req)
|
||||||
@@ -256,13 +340,13 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.forwardedCustomHeader)
|
remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.forwardedCustomHeader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error()))
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error()))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP)
|
isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error()))
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error()))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// if our IP is in the trusted list we bypass the next checks
|
// if our IP is in the trusted list we bypass the next checks
|
||||||
@@ -273,7 +357,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if bouncer.crowdsecMode == configuration.AppsecMode {
|
if bouncer.crowdsecMode == configuration.AppsecMode {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,20 +369,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))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString))
|
||||||
if !bouncer.redisUnreachableBlock && cacheErrString == cache.CacheUnreachable {
|
if !bouncer.redisUnreachableBlock && cacheErrString == cache.CacheUnreachable {
|
||||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s redisUnreachable=true", remoteIP))
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s redisUnreachable=true", remoteIP))
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cacheErrString != cache.CacheMiss {
|
if cacheErrString != cache.CacheMiss {
|
||||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
|
||||||
if value == cache.NoBannedValue {
|
if value == cache.NoBannedValue {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
} else {
|
} else {
|
||||||
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
|
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -307,18 +391,21 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
// Right here if we cannot join the stream we forbid the request to go on.
|
// Right here if we cannot join the stream we forbid the request to go on.
|
||||||
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
|
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
|
||||||
if isCrowdsecStreamHealthy {
|
if isCrowdsecStreamHealthy {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
} else {
|
} else {
|
||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
value, err := handleNoStreamCache(bouncer, remoteIP)
|
value, err := handleNoStreamCache(bouncer, remoteIP)
|
||||||
|
if err != nil {
|
||||||
|
bouncer.log.Debug("handleNoStreamCache:crowdsecQuery " + err.Error())
|
||||||
|
}
|
||||||
if value == cache.NoBannedValue {
|
if value == cache.NoBannedValue {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
} else {
|
} else {
|
||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error()))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error()))
|
||||||
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
|
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,40 +439,56 @@ type Login struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// To append Headers we need to call rw.WriteHeader after set any header.
|
// To append Headers we need to call rw.WriteHeader after set any header.
|
||||||
func handleBanServeHTTP(bouncer *Bouncer, rw http.ResponseWriter) {
|
func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP, reason string) {
|
||||||
|
atomic.AddInt64(&blockedRequests, 1)
|
||||||
|
|
||||||
if bouncer.remediationCustomHeader != "" {
|
if bouncer.remediationCustomHeader != "" {
|
||||||
rw.Header().Set(bouncer.remediationCustomHeader, "ban")
|
rw.Header().Set(bouncer.remediationCustomHeader, "ban")
|
||||||
}
|
}
|
||||||
if bouncer.banTemplateString == "" {
|
rw.Header().Set("Content-Type", bouncer.banTemplateContentType)
|
||||||
rw.WriteHeader(bouncer.remediationStatusCode)
|
rw.WriteHeader(bouncer.remediationStatusCode)
|
||||||
|
if bouncer.banTemplate == nil || req.Method == http.MethodHead {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
templateData := map[string]string{
|
||||||
rw.WriteHeader(bouncer.remediationStatusCode)
|
"RemediationReason": reason,
|
||||||
_, err := fmt.Fprint(rw, bouncer.banTemplateString)
|
"ClientIP": remoteIP,
|
||||||
|
}
|
||||||
|
|
||||||
|
if bouncer.traceCustomHeader != "" {
|
||||||
|
headerVal := req.Header.Get(bouncer.traceCustomHeader)
|
||||||
|
|
||||||
|
if headerVal != "" {
|
||||||
|
templateData["TraceID"] = headerVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := bouncer.banTemplate.Execute(rw, templateData)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bouncer.log.Error("handleBanServeHTTP could not write template to ResponseWriter")
|
bouncer.log.Warn("handleBanServeHTTP could not write template to ResponseWriter: " + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRemediationServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) {
|
func (bouncer *Bouncer) handleRemediationServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP, remediation string) {
|
||||||
bouncer.log.Debug(fmt.Sprintf("handleRemediationServeHTTP ip:%s remediation:%s", remoteIP, remediation))
|
bouncer.log.Debug(fmt.Sprintf("handleRemediationServeHTTP ip:%s remediation:%s", remoteIP, remediation))
|
||||||
if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue {
|
if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue && req.Method != http.MethodHead {
|
||||||
if bouncer.captchaClient.Check(remoteIP) {
|
if bouncer.captchaClient.Check(remoteIP) {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
atomic.AddInt64(&blockedRequests, 1) // If we serve a captcha that should count as a dropped request.
|
||||||
bouncer.captchaClient.ServeHTTP(rw, req, remoteIP)
|
bouncer.captchaClient.ServeHTTP(rw, req, remoteIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonLAPI)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWriter, req *http.Request) {
|
func (bouncer *Bouncer) handleNextServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP string) {
|
||||||
if bouncer.appsecEnabled {
|
if bouncer.appsecEnabled {
|
||||||
if err := appsecQuery(bouncer, remoteIP, req); err != nil {
|
if err := appsecQuery(bouncer, remoteIP, req); err != nil {
|
||||||
bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error()))
|
bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error()))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonAPPSEC)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,7 +497,7 @@ func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWrit
|
|||||||
|
|
||||||
func handleStreamTicker(bouncer *Bouncer) {
|
func handleStreamTicker(bouncer *Bouncer) {
|
||||||
if err := handleStreamCache(bouncer); err != nil {
|
if err := handleStreamCache(bouncer); err != nil {
|
||||||
bouncer.log.Debug(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
|
bouncer.log.Warn(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
|
||||||
if bouncer.updateMaxFailure != -1 && updateFailure >= bouncer.updateMaxFailure && isCrowdsecStreamHealthy {
|
if bouncer.updateMaxFailure != -1 && updateFailure >= bouncer.updateMaxFailure && isCrowdsecStreamHealthy {
|
||||||
isCrowdsecStreamHealthy = false
|
isCrowdsecStreamHealthy = false
|
||||||
bouncer.log.Error(fmt.Sprintf("handleStreamTicker:error updateFailure:%d %s", updateFailure, err.Error()))
|
bouncer.log.Error(fmt.Sprintf("handleStreamTicker:error updateFailure:%d %s", updateFailure, err.Error()))
|
||||||
@@ -406,11 +509,17 @@ func handleStreamTicker(bouncer *Bouncer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startTicker(config *configuration.Config, log *logger.Log, work func()) chan bool {
|
func handleMetricsTicker(bouncer *Bouncer) {
|
||||||
ticker := time.NewTicker(time.Duration(config.UpdateIntervalSeconds) * time.Second)
|
if err := reportMetrics(bouncer); err != nil {
|
||||||
|
bouncer.log.Error("handleMetricsTicker:reportMetrics " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTicker(name string, updateInterval int64, log *slog.Logger, work func()) chan bool {
|
||||||
|
ticker := time.NewTicker(time.Duration(updateInterval) * time.Second)
|
||||||
stop := make(chan bool, 1)
|
stop := make(chan bool, 1)
|
||||||
go func() {
|
go func() {
|
||||||
defer log.Debug("ticker:stopped")
|
defer log.Debug(name + "_ticker:stopped")
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
@@ -432,7 +541,7 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
|
|||||||
Path: bouncer.crowdsecPath + crowdsecLapiRoute,
|
Path: bouncer.crowdsecPath + crowdsecLapiRoute,
|
||||||
RawQuery: fmt.Sprintf("ip=%v", remoteIP),
|
RawQuery: fmt.Sprintf("ip=%v", remoteIP),
|
||||||
}
|
}
|
||||||
body, err := crowdsecQuery(bouncer, routeURL.String(), false)
|
body, err := crowdsecQuery(bouncer, routeURL.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cache.BannedValue, err
|
return cache.BannedValue, err
|
||||||
}
|
}
|
||||||
@@ -473,9 +582,9 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
|
|||||||
case "captcha":
|
case "captcha":
|
||||||
value = cache.CaptchaValue
|
value = cache.CaptchaValue
|
||||||
default:
|
default:
|
||||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
|
||||||
}
|
}
|
||||||
if isLiveMode {
|
if isLiveMode && bouncer.defaultDecisionTimeout > 0 {
|
||||||
durationSecond := int64(duration.Seconds())
|
durationSecond := int64(duration.Seconds())
|
||||||
if bouncer.defaultDecisionTimeout < durationSecond {
|
if bouncer.defaultDecisionTimeout < durationSecond {
|
||||||
durationSecond = bouncer.defaultDecisionTimeout
|
durationSecond = bouncer.defaultDecisionTimeout
|
||||||
@@ -491,7 +600,16 @@ func getToken(bouncer *Bouncer) error {
|
|||||||
Host: bouncer.crowdsecHost,
|
Host: bouncer.crowdsecHost,
|
||||||
Path: crowdsecCapiLoginRoute,
|
Path: crowdsecCapiLoginRoute,
|
||||||
}
|
}
|
||||||
body, err := crowdsecQuery(bouncer, loginURL.String(), true)
|
|
||||||
|
// Move the login-specific payload here
|
||||||
|
loginData := []byte(fmt.Sprintf(
|
||||||
|
`{"machine_id": "%v","password": "%v","scenarios": ["%v"]}`,
|
||||||
|
bouncer.crowdsecMachineID,
|
||||||
|
bouncer.crowdsecPassword,
|
||||||
|
strings.Join(bouncer.crowdsecScenarios, `","`),
|
||||||
|
))
|
||||||
|
|
||||||
|
body, err := crowdsecQuery(bouncer, loginURL.String(), loginData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -500,11 +618,11 @@ func getToken(bouncer *Bouncer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getToken:parsingBody %w", err)
|
return fmt.Errorf("getToken:parsingBody %w", err)
|
||||||
}
|
}
|
||||||
if login.Code == 200 && len(login.Token) > 0 {
|
if login.Code == http.StatusOK && len(login.Token) > 0 {
|
||||||
bouncer.crowdsecKey = login.Token
|
bouncer.crowdsecKey = login.Token
|
||||||
bouncer.log.Debug(fmt.Sprintf("getToken statusCode:%d", login.Code))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
bouncer.log.Warn(fmt.Sprintf("getToken statusCode:%d", login.Code))
|
||||||
return fmt.Errorf("getToken statusCode:%d", login.Code)
|
return fmt.Errorf("getToken statusCode:%d", login.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,6 +634,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
_, err := bouncer.cacheClient.Get(cacheTimeoutKey)
|
_, err := bouncer.cacheClient.Get(cacheTimeoutKey)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
bouncer.log.Debug("handleStreamCache:alreadyUpdated")
|
bouncer.log.Debug("handleStreamCache:alreadyUpdated")
|
||||||
|
isCrowdsecStreamStartup = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err.Error() != cache.CacheMiss {
|
if err.Error() != cache.CacheMiss {
|
||||||
@@ -526,9 +645,9 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
Scheme: bouncer.crowdsecScheme,
|
Scheme: bouncer.crowdsecScheme,
|
||||||
Host: bouncer.crowdsecHost,
|
Host: bouncer.crowdsecHost,
|
||||||
Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute,
|
Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute,
|
||||||
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup),
|
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isCrowdsecStreamStartup),
|
||||||
}
|
}
|
||||||
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), false)
|
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -547,7 +666,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
case "captcha":
|
case "captcha":
|
||||||
value = cache.CaptchaValue
|
value = cache.CaptchaValue
|
||||||
default:
|
default:
|
||||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
|
||||||
}
|
}
|
||||||
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
|
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
|
||||||
}
|
}
|
||||||
@@ -556,27 +675,28 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
bouncer.cacheClient.Delete(decision.Value)
|
bouncer.cacheClient.Delete(decision.Value)
|
||||||
}
|
}
|
||||||
bouncer.log.Debug("handleStreamCache:updated")
|
bouncer.log.Debug("handleStreamCache:updated")
|
||||||
|
isCrowdsecStreamStartup = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, error) {
|
func isReverseProxyError(statusCode int) bool {
|
||||||
|
return statusCode == http.StatusBadGateway ||
|
||||||
|
statusCode == http.StatusServiceUnavailable ||
|
||||||
|
statusCode == http.StatusGatewayTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, error) {
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
if isPost {
|
if len(data) > 0 {
|
||||||
data := []byte(fmt.Sprintf(
|
|
||||||
`{"machine_id": "%v","password": "%v","scenarios": ["%v"]}`,
|
|
||||||
bouncer.crowdsecMachineID,
|
|
||||||
bouncer.crowdsecPassword,
|
|
||||||
strings.Join(bouncer.crowdsecScenarios, `","`),
|
|
||||||
))
|
|
||||||
req, _ = http.NewRequest(http.MethodPost, stringURL, bytes.NewBuffer(data))
|
req, _ = http.NewRequest(http.MethodPost, stringURL, bytes.NewBuffer(data))
|
||||||
} else {
|
} else {
|
||||||
req, _ = http.NewRequest(http.MethodGet, stringURL, nil)
|
req, _ = http.NewRequest(http.MethodGet, stringURL, nil)
|
||||||
}
|
}
|
||||||
req.Header.Add(bouncer.crowdsecHeader, bouncer.crowdsecKey)
|
req.Header.Set(bouncer.crowdsecHeader, bouncer.crowdsecKey)
|
||||||
req.Header.Add("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/1.X.X")
|
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||||
|
|
||||||
res, err := bouncer.httpClient.Do(req)
|
res, err := bouncer.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil || isReverseProxyError(res.StatusCode) {
|
||||||
return nil, fmt.Errorf("crowdsecQuery:unreachable url:%s %w", stringURL, err)
|
return nil, fmt.Errorf("crowdsecQuery:unreachable url:%s %w", stringURL, err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -588,27 +708,48 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, err
|
|||||||
if errToken := getToken(bouncer); errToken != nil {
|
if errToken := getToken(bouncer); errToken != nil {
|
||||||
return nil, fmt.Errorf("crowdsecQuery:renewToken url:%s %w", stringURL, errToken)
|
return nil, fmt.Errorf("crowdsecQuery:renewToken url:%s %w", stringURL, errToken)
|
||||||
}
|
}
|
||||||
return crowdsecQuery(bouncer, stringURL, false)
|
return crowdsecQuery(bouncer, stringURL, nil)
|
||||||
}
|
}
|
||||||
if res.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("crowdsecQuery url:%s, statusCode:%d", stringURL, res.StatusCode)
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(res.Body)
|
|
||||||
|
|
||||||
|
// Check if the status code starts with 2
|
||||||
|
statusStr := strconv.Itoa(res.StatusCode)
|
||||||
|
if len(statusStr) < 1 || statusStr[0] != '2' {
|
||||||
|
return nil, fmt.Errorf("crowdsecQuery method:%s url:%s, statusCode:%d (expected: 2xx)", req.Method, stringURL, res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("crowdsecQuery:readBody %w", err)
|
return nil, fmt.Errorf("crowdsecQuery:readBody %w", err)
|
||||||
}
|
}
|
||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isBodyUnreadable reports whether the request body cannot be buffered before
|
||||||
|
// forwarding it to the Appsec component. An HTTP/2 or HTTP/3 request without a
|
||||||
|
// Content-Length (typically a bidirectional gRPC stream) keeps its body open
|
||||||
|
// for the whole life of the stream and never reaches EOF, so reading it with
|
||||||
|
// io.ReadAll would block until the request times out and is wrongly turned into
|
||||||
|
// a 403. This mirrors the reference lua-cs-bouncer behavior, which refuses to
|
||||||
|
// read the body of an HTTP/2+ request that has no Content-Length.
|
||||||
|
func isBodyUnreadable(httpReq *http.Request) bool {
|
||||||
|
return httpReq.Body != nil && httpReq.ProtoMajor >= 2 && httpReq.ContentLength < 0
|
||||||
|
}
|
||||||
|
|
||||||
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||||
routeURL := url.URL{
|
routeURL := url.URL{
|
||||||
Scheme: bouncer.crowdsecScheme,
|
Scheme: bouncer.appsecScheme,
|
||||||
Host: bouncer.appsecHost,
|
Host: bouncer.appsecHost,
|
||||||
Path: bouncer.appsecPath,
|
Path: bouncer.appsecPath,
|
||||||
}
|
}
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil && httpReq.ContentLength > 0 {
|
switch {
|
||||||
|
case isBodyUnreadable(httpReq):
|
||||||
|
if bouncer.appsecUnreadableBodyBlock {
|
||||||
|
// The caller (handleNextServeHTTP) logs this returned error with the IP.
|
||||||
|
return errors.New("appsecQuery:unreadableBody dropped")
|
||||||
|
}
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
|
||||||
|
case bouncer.appsecBodyLimit > 0 && httpReq.Body != nil:
|
||||||
var bodyBuffer bytes.Buffer
|
var bodyBuffer bytes.Buffer
|
||||||
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
|
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
|
||||||
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
|
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
|
||||||
@@ -619,7 +760,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
|||||||
// Conserve body intact after reading it for other middlewares and service
|
// Conserve body intact after reading it for other middlewares and service
|
||||||
httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body))
|
httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body))
|
||||||
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
|
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
|
||||||
} else {
|
default:
|
||||||
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
|
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,15 +769,16 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
|||||||
req.Header.Add(key, value)
|
req.Header.Add(key, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
req.Header.Set(crowdsecAppsecHeader, bouncer.crowdsecKey)
|
req.Header.Set(crowdsecAppsecHeader, bouncer.appsecKey)
|
||||||
req.Header.Set(crowdsecAppsecIPHeader, ip)
|
req.Header.Set(crowdsecAppsecIPHeader, ip)
|
||||||
req.Header.Set(crowdsecAppsecVerbHeader, httpReq.Method)
|
req.Header.Set(crowdsecAppsecVerbHeader, httpReq.Method)
|
||||||
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
|
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
|
||||||
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
|
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
|
||||||
req.Header.Set(crowdsecAppsecUserAgent, httpReq.Header.Get("User-Agent"))
|
req.Header.Set(crowdsecAppsecUserAgent, httpReq.Header.Get("User-Agent"))
|
||||||
|
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||||
|
|
||||||
res, err := bouncer.httpClient.Do(req)
|
res, err := bouncer.httpAppsecClient.Do(req)
|
||||||
if err != nil {
|
if err != nil || isReverseProxyError(res.StatusCode) {
|
||||||
bouncer.log.Error("appsecQuery:unreachable")
|
bouncer.log.Error("appsecQuery:unreachable")
|
||||||
if bouncer.appsecUnreachableBlock {
|
if bouncer.appsecUnreachableBlock {
|
||||||
return fmt.Errorf("appsecQuery:unreachable %w", err)
|
return fmt.Errorf("appsecQuery:unreachable %w", err)
|
||||||
@@ -664,3 +806,65 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reportMetrics(bouncer *Bouncer) error {
|
||||||
|
now := time.Now()
|
||||||
|
currentCount := atomic.LoadInt64(&blockedRequests)
|
||||||
|
windowSizeSeconds := int(now.Sub(lastMetricsPush).Seconds())
|
||||||
|
|
||||||
|
bouncer.log.Debug(fmt.Sprintf("reportMetrics: blocked_requests=%d window_size=%ds", currentCount, windowSizeSeconds))
|
||||||
|
|
||||||
|
metrics := map[string]interface{}{
|
||||||
|
"remediation_components": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"version": pluginVersion,
|
||||||
|
"type": "bouncer",
|
||||||
|
"name": "traefik_plugin",
|
||||||
|
"metrics": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"items": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "dropped",
|
||||||
|
"value": currentCount,
|
||||||
|
"unit": "request",
|
||||||
|
"labels": map[string]string{
|
||||||
|
"type": "traefik_plugin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"meta": map[string]interface{}{
|
||||||
|
"window_size_seconds": windowSizeSeconds,
|
||||||
|
"utc_now_timestamp": now.Unix(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"utc_startup_timestamp": time.Now().Unix(),
|
||||||
|
"feature_flags": []string{},
|
||||||
|
"os": map[string]string{
|
||||||
|
"name": "unknown",
|
||||||
|
"version": "unknown",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(metrics)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reportMetrics:marshal %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metricsURL := url.URL{
|
||||||
|
Scheme: bouncer.crowdsecScheme,
|
||||||
|
Host: bouncer.crowdsecHost,
|
||||||
|
Path: bouncer.crowdsecPath + crowdsecLapiMetricsRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = crowdsecQuery(bouncer, metricsURL.String(), data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reportMetrics:query %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.StoreInt64(&blockedRequests, 0)
|
||||||
|
lastMetricsPush = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getTestConfig returns a minimal valid configuration for testing.
|
||||||
|
// Override specific fields by modifying the returned config.
|
||||||
|
func getTestConfig() *configuration.Config {
|
||||||
|
return &configuration.Config{
|
||||||
|
Enabled: true,
|
||||||
|
LogLevel: "INFO",
|
||||||
|
LogFormat: "common",
|
||||||
|
LogFilePath: "",
|
||||||
|
CrowdsecMode: "none",
|
||||||
|
CrowdsecLapiKey: "test-key",
|
||||||
|
CrowdsecLapiHost: "localhost",
|
||||||
|
CrowdsecLapiScheme: "http",
|
||||||
|
UpdateIntervalSeconds: 60,
|
||||||
|
DefaultDecisionSeconds: 60,
|
||||||
|
HTTPTimeoutSeconds: 10,
|
||||||
|
ForwardedHeadersTrustedIPs: []string{"127.0.0.1"},
|
||||||
|
ForwardedHeadersCustomName: "",
|
||||||
|
RemediationStatusCode: 403,
|
||||||
|
BanFilePath: "",
|
||||||
|
RemediationHeadersCustomName: "",
|
||||||
|
CaptchaProvider: "",
|
||||||
|
CaptchaSiteKey: "",
|
||||||
|
CaptchaSecretKey: "",
|
||||||
|
CaptchaGracePeriodSeconds: 1,
|
||||||
|
CaptchaFilePath: "",
|
||||||
|
RedisCacheEnabled: false,
|
||||||
|
RedisCacheHost: "",
|
||||||
|
RedisCachePassword: "",
|
||||||
|
RedisCacheDatabase: "",
|
||||||
|
RedisCacheUnreachableBlock: false,
|
||||||
|
CrowdsecAppsecEnabled: false,
|
||||||
|
CrowdsecAppsecHost: "",
|
||||||
|
CrowdsecAppsecPath: "",
|
||||||
|
CrowdsecAppsecFailureBlock: false,
|
||||||
|
CrowdsecAppsecUnreachableBlock: false,
|
||||||
|
CrowdsecLapiTLSInsecureVerify: true,
|
||||||
|
CrowdsecLapiTLSCertificateBouncer: "",
|
||||||
|
CrowdsecLapiTLSCertificateBouncerKey: "",
|
||||||
|
CrowdsecCapiMachineID: "",
|
||||||
|
CrowdsecCapiPassword: "",
|
||||||
|
CrowdsecCapiScenarios: []string{},
|
||||||
|
UpdateMaxFailure: 0,
|
||||||
|
MetricsUpdateIntervalSeconds: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create and execute a bouncer request for testing
|
||||||
|
func createAndExecuteBouncerRequest(t *testing.T, config *configuration.Config) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Create a mock next handler
|
||||||
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("OK"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create the bouncer plugin (this will initialize the logger with file output)
|
||||||
|
bouncerHandler, err := New(context.Background(), nextHandler, config, "test-bouncer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create bouncer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test request to trigger logging
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
|
||||||
|
req.RemoteAddr = "192.168.1.100:12345" // Use a non-trusted IP to trigger logging
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Process the request (this should generate log entries)
|
||||||
|
bouncerHandler.ServeHTTP(rw, req)
|
||||||
|
|
||||||
|
// Give a moment for log writes to complete
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to parse log file and extract found levels
|
||||||
|
func parseLogFileAndExtractLevels(t *testing.T, logFile string) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Verify the log file was created and contains entries
|
||||||
|
if _, statErr := os.Stat(logFile); os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("Log file was not created: %s", logFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the log file content
|
||||||
|
// #nosec G304 - logFile is a test-generated temporary file path
|
||||||
|
logContent, err := os.ReadFile(logFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read log file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logString := string(logContent)
|
||||||
|
if len(logString) == 0 {
|
||||||
|
return make(map[string]bool) // Return empty map for empty log files
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and verify JSON log entries
|
||||||
|
lines := strings.Split(strings.TrimSpace(logString), "\n")
|
||||||
|
foundLevels := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var logEntry map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
|
||||||
|
t.Errorf("Invalid JSON log entry: %s, error: %v", line, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify required fields
|
||||||
|
validateLogEntry(t, logEntry)
|
||||||
|
|
||||||
|
// Track log levels we've seen
|
||||||
|
if level, ok := logEntry["level"].(string); ok {
|
||||||
|
foundLevels[level] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return foundLevels
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to validate log entry structure
|
||||||
|
func validateLogEntry(t *testing.T, logEntry map[string]interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if logEntry["time"] == nil {
|
||||||
|
t.Error("Log entry missing 'time' field")
|
||||||
|
}
|
||||||
|
if logEntry["level"] == nil {
|
||||||
|
t.Error("Log entry missing 'level' field")
|
||||||
|
}
|
||||||
|
if logEntry["msg"] == nil {
|
||||||
|
t.Error("Log entry missing 'msg' field")
|
||||||
|
}
|
||||||
|
if logEntry["component"] != "CrowdsecBouncerTraefikPlugin" {
|
||||||
|
t.Errorf("Expected component 'CrowdsecBouncerTraefikPlugin', got %v", logEntry["component"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to verify expected and forbidden log levels
|
||||||
|
func verifyLogLevels(t *testing.T, foundLevels map[string]bool, expectedLevels, forbiddenLevels []string, logLevel string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Handle case where no logs are expected
|
||||||
|
if len(expectedLevels) == 0 {
|
||||||
|
if len(foundLevels) > 0 {
|
||||||
|
t.Errorf("Expected no logs at %s level, but found: %v", logLevel, foundLevels)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Verify we got some log entries
|
||||||
|
if len(foundLevels) == 0 {
|
||||||
|
t.Fatal("No valid log entries found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify expected levels are present
|
||||||
|
for _, expectedLevel := range expectedLevels {
|
||||||
|
if !foundLevels[expectedLevel] {
|
||||||
|
t.Errorf("Expected to find %s level logs, but didn't. Found levels: %v", expectedLevel, foundLevels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify forbidden levels are NOT present
|
||||||
|
for _, forbiddenLevel := range forbiddenLevels {
|
||||||
|
if foundLevels[forbiddenLevel] {
|
||||||
|
t.Errorf("Found forbidden %s level logs at %s level. Found levels: %v", forbiddenLevel, logLevel, foundLevels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBouncerFileLoggingLevels(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
logLevel string
|
||||||
|
expectedLevels []string // Levels that should appear
|
||||||
|
forbiddenLevels []string // Levels that should NOT appear
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "DEBUG level should show DEBUG only",
|
||||||
|
logLevel: "DEBUG",
|
||||||
|
expectedLevels: []string{"DEBUG"},
|
||||||
|
forbiddenLevels: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "INFO level should show no logs (bouncer doesn't generate INFO during normal operation)",
|
||||||
|
logLevel: "INFO",
|
||||||
|
expectedLevels: []string{}, // No logs expected for normal operation
|
||||||
|
forbiddenLevels: []string{"DEBUG"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create temporary directory for log file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
logFile := filepath.Join(tmpDir, "bouncer.log")
|
||||||
|
|
||||||
|
// Get test config and override specific fields
|
||||||
|
config := getTestConfig()
|
||||||
|
config.LogLevel = tt.logLevel
|
||||||
|
config.LogFormat = "json" // Use JSON format for easier parsing
|
||||||
|
config.LogFilePath = logFile
|
||||||
|
|
||||||
|
// Create and execute bouncer request
|
||||||
|
createAndExecuteBouncerRequest(t, config)
|
||||||
|
|
||||||
|
// Parse log file and extract found levels
|
||||||
|
foundLevels := parseLogFileAndExtractLevels(t, logFile)
|
||||||
|
|
||||||
|
// Handle empty log files for higher log levels (expected behavior)
|
||||||
|
if len(foundLevels) == 0 && len(tt.expectedLevels) > 0 {
|
||||||
|
t.Fatalf("Expected log entries but log file is empty for level %s", tt.logLevel)
|
||||||
|
}
|
||||||
|
if len(foundLevels) == 0 {
|
||||||
|
// Empty file is expected for this log level
|
||||||
|
t.Logf("LogLevel %s: No logs generated (expected behavior)", tt.logLevel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify expected and forbidden log levels
|
||||||
|
verifyLogLevels(t, foundLevels, tt.expectedLevels, tt.forbiddenLevels, tt.logLevel)
|
||||||
|
|
||||||
|
t.Logf("LogLevel %s: Successfully logged to file with levels: %v", tt.logLevel, foundLevels)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBouncerFileLoggingCommonFormat(t *testing.T) {
|
||||||
|
// Create temporary directory for log file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
logFile := filepath.Join(tmpDir, "bouncer-common.log")
|
||||||
|
|
||||||
|
// Get test config and override specific fields
|
||||||
|
config := getTestConfig()
|
||||||
|
config.LogLevel = "DEBUG"
|
||||||
|
config.LogFormat = "common" // Use common format
|
||||||
|
config.LogFilePath = logFile
|
||||||
|
|
||||||
|
// Create a mock next handler
|
||||||
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("OK"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create the bouncer plugin
|
||||||
|
bouncerHandler, err := New(context.Background(), nextHandler, config, "test-bouncer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create bouncer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test request to trigger logging
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
|
||||||
|
req.RemoteAddr = "192.168.1.100:12345"
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Process the request
|
||||||
|
bouncerHandler.ServeHTTP(rw, req)
|
||||||
|
|
||||||
|
// Give a moment for log writes to complete
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify the log file was created and contains entries
|
||||||
|
if _, statErr := os.Stat(logFile); os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("Log file was not created: %s", logFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the log file content
|
||||||
|
// #nosec G304 - logFile is a test-generated temporary file path
|
||||||
|
logContent, err := os.ReadFile(logFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read log file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logString := string(logContent)
|
||||||
|
if len(logString) == 0 {
|
||||||
|
t.Fatal("Log file is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify common format structure
|
||||||
|
lines := strings.Split(strings.TrimSpace(logString), "\n")
|
||||||
|
foundDebug := false
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common format should contain time, level, msg, and component
|
||||||
|
if strings.Contains(line, "level=DEBUG") {
|
||||||
|
foundDebug = true
|
||||||
|
}
|
||||||
|
if !strings.Contains(line, "component=CrowdsecBouncerTraefikPlugin") {
|
||||||
|
t.Errorf("Log line missing component field: %s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should see DEBUG level logs since we set LogLevel to DEBUG
|
||||||
|
if !foundDebug {
|
||||||
|
t.Errorf("Expected to find DEBUG level logs in common format. Log content:\n%s", logString)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Successfully logged to file %s in common format with %d lines", logFile, len(lines))
|
||||||
|
}
|
||||||
+329
-2
@@ -2,15 +2,19 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
||||||
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||||
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
||||||
|
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestServeHTTP(t *testing.T) {
|
func TestServeHTTP(t *testing.T) {
|
||||||
@@ -163,7 +167,7 @@ func Test_crowdsecQuery(t *testing.T) {
|
|||||||
type args struct {
|
type args struct {
|
||||||
bouncer *Bouncer
|
bouncer *Bouncer
|
||||||
stringURL string
|
stringURL string
|
||||||
isPost bool
|
data []byte
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -175,7 +179,7 @@ func Test_crowdsecQuery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := crowdsecQuery(tt.args.bouncer, tt.args.stringURL, tt.args.isPost)
|
got, err := crowdsecQuery(tt.args.bouncer, tt.args.stringURL, tt.args.data)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("crowdsecQuery() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("crowdsecQuery() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
return
|
return
|
||||||
@@ -186,3 +190,326 @@ 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) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
protoMajor int
|
||||||
|
contentLength int64
|
||||||
|
hasBody bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "http2 grpc stream without content-length", protoMajor: 2, contentLength: -1, hasBody: true, want: true},
|
||||||
|
{name: "http3 stream without content-length", protoMajor: 3, contentLength: -1, hasBody: true, want: true},
|
||||||
|
{name: "http2 with content-length", protoMajor: 2, contentLength: 42, hasBody: true, want: false},
|
||||||
|
{name: "http1.1 chunked without content-length", protoMajor: 1, contentLength: -1, hasBody: true, want: false},
|
||||||
|
{name: "http2 without body", protoMajor: 2, contentLength: -1, hasBody: false, 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
|
||||||
|
if tt.hasBody {
|
||||||
|
req.Body = http.NoBody
|
||||||
|
} else {
|
||||||
|
req.Body = nil
|
||||||
|
}
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.5.0"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -16,8 +16,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- logs-local:/var/log/traefik
|
- logs-local:/var/log/traefik
|
||||||
- './ban.html:/ban.html:ro'
|
- "./ban.html:/ban.html:ro"
|
||||||
- './captcha.html:/captcha.html:ro'
|
- "./captcha.html:/captcha.html:ro"
|
||||||
- ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
- ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
ports:
|
ports:
|
||||||
- 8000:80
|
- 8000:80
|
||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
- "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
|
- "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
|
||||||
- "traefik.http.services.service-foo.loadbalancer.server.port=80"
|
- "traefik.http.services.service-foo.loadbalancer.server.port=80"
|
||||||
|
|
||||||
whoami2:
|
bar:
|
||||||
image: traefik/whoami
|
image: traefik/whoami
|
||||||
container_name: "simple-service-bar"
|
container_name: "simple-service-bar"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -48,12 +48,39 @@ services:
|
|||||||
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.metricsupdateintervalseconds=15"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=stream"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=stream"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.ForwardedHeadersTrustedIPs=172.21.0.1/8"
|
||||||
|
|
||||||
|
bar2:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: "simple-service-bar2"
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.router-bar2.rule=PathPrefix(`/bar2`)"
|
||||||
|
- "traefik.http.routers.router-bar2.entrypoints=web"
|
||||||
|
- "traefik.http.routers.router-bar2.middlewares=crowdsec2@docker"
|
||||||
|
- "traefik.http.services.service-bar2.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.enabled=true"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.loglevel=DEBUG"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.crowdsecmode=stream"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.updateintervalseconds=10"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.updatemaxfailure=-1"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
|
||||||
|
bar3:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: "simple-service-bar3"
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.router-bar3.rule=PathPrefix(`/bar3`)"
|
||||||
|
- "traefik.http.routers.router-bar3.entrypoints=web"
|
||||||
|
- "traefik.http.routers.router-bar3.middlewares=crowdsec2@docker"
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.6.8
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -67,7 +94,6 @@ services:
|
|||||||
- crowdsec-config-local:/etc/crowdsec/
|
- crowdsec-config-local:/etc/crowdsec/
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=false"
|
- "traefik.enable=false"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
logs-local:
|
logs-local:
|
||||||
crowdsec-db-local:
|
crowdsec-db-local:
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@ services:
|
|||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.6.8
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+2
-6
@@ -1,8 +1,6 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.5.0"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -15,7 +13,7 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.5.0"
|
||||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
@@ -48,8 +46,6 @@ services:
|
|||||||
# Define AppSec host and port informations
|
# Define AppSec host and port informations
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
@@ -20,7 +20,7 @@ For now 3 captcha providers are supported:
|
|||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSiteKey=FIXME"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSiteKey=FIXME"
|
||||||
# Define captcha secret key
|
# Define captcha secret key
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSecretKey=FIXME"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSecretKey=FIXME"
|
||||||
# Define captcha grade period seconds
|
# Define captcha grace period seconds
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800"
|
||||||
# Define captcha HTML file path
|
# Define captcha HTML file path
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||||
@@ -47,7 +47,7 @@ To instruct Crowdsec to use captcha remediation, change the `/etc/crowdsec/profi
|
|||||||
- Return a captcha decision the first X times and then a ban decision.
|
- Return a captcha decision the first X times and then a ban decision.
|
||||||
|
|
||||||
The second mode could be used to prevent repeated malicious activity.
|
The second mode could be used to prevent repeated malicious activity.
|
||||||
More information is available on configuring Crowdsec in the [official documentation](https://docs.crowdsec.net/docs/next/profiles/captcha_profile/).
|
More information is available on configuring Crowdsec in the [official documentation](https://docs.crowdsec.net/docs/next/local_api/profiles/captcha_profile/).
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
...
|
...
|
||||||
@@ -100,9 +100,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the user IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the user IP has a crowdsec decision ?
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -121,12 +121,12 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Fine, done!
|
User->>TraefikPlugin: Fine, done!
|
||||||
create participant ProviderCaptcha
|
create participant ProviderCaptcha
|
||||||
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
||||||
Destroy ProviderCaptcha
|
destroy ProviderCaptcha
|
||||||
ProviderCaptcha-->>TraefikPlugin: Yes
|
ProviderCaptcha-->>TraefikPlugin: Yes
|
||||||
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
Destroy TraefikPlugin
|
destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -140,7 +140,7 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a Crowdsec Decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a Crowdsec Decision ?
|
||||||
Destroy PluginCache
|
destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Yes a ban Decision
|
PluginCache-->>TraefikPlugin: Yes a ban Decision
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -293,7 +293,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
|
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
|
||||||
</div>
|
</div>
|
||||||
<form action="" method="POST" class="flex flex-col space-y-1" id="captcha-form">
|
<form action="" method="POST" class="flex flex-col items-center space-y-1" id="captcha-form">
|
||||||
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
|
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ This can be usefull as some browser (Firefox for instance) return a 403 blank we
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
labels:
|
labels:
|
||||||
# Define ban HTML file path
|
# Define ban file path
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html"
|
||||||
```
|
```
|
||||||
|
|
||||||
The ban HTML file must be present in the Traefik container (bind mounted or added during a custom build).
|
The ban file must be present in the Traefik container (bind mounted or added during a custom build).
|
||||||
It is not directly accessible from Traefik even when importing the plugin, so [download](https://raw.githubusercontent.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/master/ban.html) it locally to expose it to Traefik.
|
It is not directly accessible from Traefik even when importing the plugin, so [download](https://raw.githubusercontent.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/master/ban.html) it locally to expose it to Traefik.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -45,3 +45,15 @@ To play the demo environment run:
|
|||||||
```bash
|
```bash
|
||||||
make run_custom_ban_page
|
make run_custom_ban_page
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Another thing to note
|
||||||
|
In the html of the ban page, you can use:
|
||||||
|
- {{ .ClientIP }} to display the IP used to ban the request.
|
||||||
|
- {{ .RemediationReason }} that convert on runtime into why the ban page is served. It's an enum with "APPSEC", "LAPI", "TECHNICAL_ISSUE" and it is useful to help user understand why the request is blocked.
|
||||||
|
- {{ .CustomHeader }} value of the specified Request Header (for example X-Request-ID)
|
||||||
|
```
|
||||||
|
<script>var remediation = "{{ .RemediationReason }}"</script>
|
||||||
|
<script>var clientIp = "{{ .ClientIP }}"</script>
|
||||||
|
<script>var traceID = "{{ .TraceID }}"</script>
|
||||||
|
```
|
||||||
|
With the above tweak and some other js, you can customize your ban page on runtime.
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ services:
|
|||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
# Define ban HTML file path
|
# Define ban file path
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html"
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||||
|
|||||||
@@ -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.5.0"
|
||||||
|
container_name: "traefik"
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
# - "--log.level=DEBUG"
|
||||||
|
- "--accesslog"
|
||||||
|
- "--accesslog.filepath=/var/log/traefik/access.log"
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--providers.docker=true"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
- "--entrypoints.web.address=:80"
|
||||||
|
- "--entrypoints.web.forwardedheaders.trustedips=172.18.0.0/24"
|
||||||
|
|
||||||
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
- "--experimental.plugins.bouncer.version=v1.4.5"
|
||||||
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- logs-custom-captcha-enabled:/var/log/traefik
|
||||||
|
- "./captcha.html:/captcha.html"
|
||||||
|
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
ports:
|
||||||
|
- 8000:80
|
||||||
|
- 8080:8080
|
||||||
|
depends_on:
|
||||||
|
- crowdsec
|
||||||
|
|
||||||
|
whoami-foo:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: "simple-service-custom-captcha-foo"
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
# Definition of the router
|
||||||
|
- "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)"
|
||||||
|
- "traefik.http.routers.router-foo.entrypoints=web"
|
||||||
|
- "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
|
||||||
|
# Definition of the service
|
||||||
|
- "traefik.http.services.service-foo.loadbalancer.server.port=80"
|
||||||
|
# Definition of the middleware
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=none"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||||
|
|
||||||
|
# Choose captcha provider
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaProvider=custom"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaCustomJsURL=http://captcha.localhost:8000/fast.js"
|
||||||
|
# Inside Traefik container the plugin must be able to reach wicketkeeper service so we can go through a Traefik localhost
|
||||||
|
# domain which would resolve traefik itself and the port for the dashboard
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomValidateURL=http://wicketkeeper:8080/v0/siteverify"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomKey=wicketkeeper"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomResponse=wicketkeeper_solution"
|
||||||
|
# Define captcha grade period seconds
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=20"
|
||||||
|
# Define captcha HTML file path
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||||
|
|
||||||
|
crowdsec:
|
||||||
|
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||||
|
container_name: "crowdsec"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
COLLECTIONS: crowdsecurity/traefik
|
||||||
|
CUSTOM_HOSTNAME: crowdsec
|
||||||
|
BOUNCER_KEY_TRAEFIK_DEV: 40796d93c2958f9e58345514e67740e5
|
||||||
|
volumes:
|
||||||
|
# For captcha and ban mixed decision
|
||||||
|
- "./profiles.yaml:/etc/crowdsec/profiles.yaml:ro"
|
||||||
|
# For captcha only remediation
|
||||||
|
# - './profiles_captcha_only.yaml:/etc/crowdsec/profiles.yaml:ro'
|
||||||
|
- "./acquis.yaml:/etc/crowdsec/acquis.yaml:ro"
|
||||||
|
- logs-custom-captcha-enabled:/var/log/traefik:ro
|
||||||
|
- crowdsec-db-custom-captcha-enabled:/var/lib/crowdsec/data/
|
||||||
|
- crowdsec-config-custom-captcha-enabled:/etc/crowdsec/
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=false"
|
||||||
|
|
||||||
|
wicketkeeper:
|
||||||
|
image: ghcr.io/a-ve/wicketkeeper:latest
|
||||||
|
container_name: "wicketkeeper"
|
||||||
|
environment:
|
||||||
|
- LISTEN_PORT=8080
|
||||||
|
- REDIS_ADDR=redis:6379
|
||||||
|
- DIFFICULTY=4
|
||||||
|
- ALLOWED_ORIGINS=*
|
||||||
|
- PRIVATE_KEY_PATH=/data/wicketkeeper.key # To override in production environment
|
||||||
|
volumes:
|
||||||
|
- wicketkeeper-custom-captcha-enabled:/data
|
||||||
|
user: root
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
# Definition of the router
|
||||||
|
- "traefik.http.routers.router-wicketkeeper.rule=Host(`captcha.localhost`)"
|
||||||
|
- "traefik.http.routers.router-wicketkeeper.entrypoints=web"
|
||||||
|
# Definition of the service
|
||||||
|
- "traefik.http.services.service-wicketkeeper.loadbalancer.server.port=8080"
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis/redis-stack-server:latest
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
logs-custom-captcha-enabled:
|
||||||
|
wicketkeeper-custom-captcha-enabled:
|
||||||
|
crowdsec-db-custom-captcha-enabled:
|
||||||
|
crowdsec-config-custom-captcha-enabled:
|
||||||
@@ -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
|
||||||
+30
-10
@@ -1,7 +1,9 @@
|
|||||||
# Example
|
# Example
|
||||||
|
|
||||||
## Using https communication and tls authentication with Crowdsec
|
## Using https communication and tls authentication with Crowdsec
|
||||||
|
|
||||||
##### Summary
|
##### Summary
|
||||||
|
|
||||||
This example demonstrates the use of https between the Traefik plugin and the Crowdsec LAPI.
|
This example demonstrates the use of https between the Traefik plugin and the Crowdsec LAPI.
|
||||||
|
|
||||||
It is possible to communicate with the LAPI in https and still authenticate with API key.
|
It is possible to communicate with the LAPI in https and still authenticate with API key.
|
||||||
@@ -17,7 +19,9 @@ In that case the setting **crowdsecLapiTLSInsecureVerify** must be set to true.
|
|||||||
It is recommended to validate the certificate presented by Crowdsec LAPI using the Certificate Authority which created it.
|
It is recommended to validate the certificate presented by Crowdsec LAPI using the Certificate Authority which created it.
|
||||||
|
|
||||||
You can provide the Certificate Authority using:
|
You can provide the Certificate Authority using:
|
||||||
* A file path readable by Traefik
|
|
||||||
|
- A file path readable by Traefik
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
http:
|
http:
|
||||||
middlewares:
|
middlewares:
|
||||||
@@ -26,9 +30,11 @@ http:
|
|||||||
bouncer:
|
bouncer:
|
||||||
crowdsecLapiTlsCertificateAuthorityFile: /etc/traefik/certs/crowdsecCA.pem
|
crowdsecLapiTlsCertificateAuthorityFile: /etc/traefik/certs/crowdsecCA.pem
|
||||||
```
|
```
|
||||||
* The PEM encoded certificate as a text variable
|
|
||||||
|
- The PEM encoded certificate as a text variable
|
||||||
|
|
||||||
In the static file configuration of Traefik
|
In the static file configuration of Traefik
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
http:
|
http:
|
||||||
middlewares:
|
middlewares:
|
||||||
@@ -36,15 +42,17 @@ http:
|
|||||||
plugin:
|
plugin:
|
||||||
bouncer:
|
bouncer:
|
||||||
crowdsecLapiTlsCertificateAuthority: |-
|
crowdsecLapiTlsCertificateAuthority: |-
|
||||||
-----BEGIN CERTIFICATE-----
|
-----BEGIN CERTIFICATE-----
|
||||||
MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
|
MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
|
||||||
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
|
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
|
||||||
...
|
...
|
||||||
C6qNieSwcvWL7C03ri0DefTQMY54r5wP33QU5hJ71JoaZI3YTeT0Nf+NRL4hM++w
|
C6qNieSwcvWL7C03ri0DefTQMY54r5wP33QU5hJ71JoaZI3YTeT0Nf+NRL4hM++w
|
||||||
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
|
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
|
||||||
-----END CERTIFICATE-----
|
-----END CERTIFICATE-----
|
||||||
```
|
```
|
||||||
|
|
||||||
In a dynamic configuration of a provider (ex docker) as a Label
|
In a dynamic configuration of a provider (ex docker) as a Label
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
whoami-foo:
|
whoami-foo:
|
||||||
@@ -71,22 +79,34 @@ The service `whoami-foo` will authenticate with an **API key** over HTTPS after
|
|||||||
The service `whoami-bar` will authenticate with a **client certificate** signed by the CA.
|
The service `whoami-bar` will authenticate with a **client certificate** signed by the CA.
|
||||||
|
|
||||||
Access to a route that communicate via https and authenticate with API-key:
|
Access to a route that communicate via https and authenticate with API-key:
|
||||||
|
|
||||||
```
|
```
|
||||||
curl http://localhost:8000/foo
|
curl http://localhost:8000/foo
|
||||||
```
|
```
|
||||||
|
|
||||||
Access to a route that communicate via https and authenticate with a client certificate:
|
Access to a route that communicate via https and authenticate with a client certificate:
|
||||||
|
|
||||||
```
|
```
|
||||||
curl http://localhost:8000/bar
|
curl http://localhost:8000/bar
|
||||||
```
|
```
|
||||||
|
|
||||||
Access to the traefik dashboard
|
Access to the traefik dashboard
|
||||||
|
|
||||||
```
|
```
|
||||||
curl http://localhost:8080/dashboard/#/
|
curl http://localhost:8080/dashboard/#/
|
||||||
```
|
```
|
||||||
|
|
||||||
To play the demo environnement run:
|
To play the demo environnement run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make run_tlsauth
|
make run_tlsauth
|
||||||
```
|
```
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
> Traefik need to be restart if certificates are regenerated after his launch
|
|
||||||
|
> Traefik need to be restarted if certificates are regenerated after his launch, crowdsec also
|
||||||
|
|
||||||
|
## Separate LAPI and Appsec HTTP/S config
|
||||||
|
|
||||||
|
To separate TLS config for LAPI and Appsec, you can use all the TLS LAPI variable beginning with `CrowdsecLapi...` into `CrowdsecAppsec...`.
|
||||||
|
Don't forget to set `CrowdsecAppsecScheme: HTTP` or `HTTPS` to trigger the separate setup.
|
||||||
|
|||||||
@@ -2,3 +2,12 @@ filenames:
|
|||||||
- /var/log/traefik/access.log
|
- /var/log/traefik/access.log
|
||||||
labels:
|
labels:
|
||||||
type: traefik
|
type: traefik
|
||||||
|
---
|
||||||
|
listen_addr: 0.0.0.0:7422
|
||||||
|
appsec_config: crowdsecurity/virtual-patching
|
||||||
|
name: myAppSecComponent
|
||||||
|
source: appsec
|
||||||
|
labels:
|
||||||
|
type: appsec
|
||||||
|
cert_file: /etc/crowdsec/certs/server.pem
|
||||||
|
key_file: /etc/crowdsec/certs/server-key.pem
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.5.0"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -13,15 +13,13 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.5.0"
|
||||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- ./LAPIKEY:/etc/traefik/LAPIKEY:ro
|
|
||||||
- logs-tls-auth:/var/log/traefik
|
- logs-tls-auth:/var/log/traefik
|
||||||
- crowdsec-certs-tls-auth:/etc/traefik/crowdsec-certs
|
- crowdsec-certs-tls-auth:/etc/traefik/crowdsec-certs
|
||||||
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- 8000:80
|
- 8000:80
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
@@ -29,7 +27,7 @@ services:
|
|||||||
- crowdsec
|
- crowdsec
|
||||||
- gencert
|
- gencert
|
||||||
|
|
||||||
# Use HTTPS scheme but with lapikey authentication
|
# Use HTTPS scheme but with lapikey authentication
|
||||||
# whoami-foo:
|
# whoami-foo:
|
||||||
# image: traefik/whoami
|
# image: traefik/whoami
|
||||||
# container_name: "simple-service-foo"
|
# container_name: "simple-service-foo"
|
||||||
@@ -46,34 +44,41 @@ services:
|
|||||||
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
||||||
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
||||||
|
|
||||||
# Use HTTPS scheme with TLS cert authentication
|
# Use HTTPS scheme with TLS cert authentication
|
||||||
whoami-bar:
|
whoami-bar:
|
||||||
image: traefik/whoami
|
image: traefik/whoami
|
||||||
container_name: "simple-service-bar"
|
container_name: "simple-service-bar"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.router-bar.rule=Path(`/bar`)"
|
- "traefik.http.routers.router-bar.rule=PathPrefix(`/bar`)"
|
||||||
- "traefik.http.routers.router-bar.entrypoints=web"
|
- "traefik.http.routers.router-bar.entrypoints=web"
|
||||||
- "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
|
- "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
|
||||||
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecMode=none"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecscheme=https"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecAppsecTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
||||||
|
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapikey=40796d93c2958f9e58345514e67740e5="
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerFile=/etc/traefik/crowdsec-certs/bouncer.pem"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerFile=/etc/traefik/crowdsec-certs/bouncer.pem"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerKeyFile=/etc/traefik/crowdsec-certs/bouncer-key.pem"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerKeyFile=/etc/traefik/crowdsec-certs/bouncer-key.pem"
|
||||||
|
# Enable AppSec
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
||||||
|
# Define AppSec host and port informations
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:latest
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
COLLECTIONS: crowdsecurity/traefik
|
|
||||||
CUSTOM_HOSTNAME: crowdsec
|
CUSTOM_HOSTNAME: crowdsec
|
||||||
# whoami-foo is authenticating with api key over https
|
# whoami-foo is authenticating with api key over https
|
||||||
# whoami-bar is authenticating with tls cert over https
|
# whoami-bar is authenticating with tls cert over https
|
||||||
BOUNCER_KEY_TRAEFIK_FOO: 40796d93c2958f9e58345514e67740e5
|
BOUNCER_KEY_TRAEFIK_FOO: 40796d93c2958f9e58345514e67740e5=
|
||||||
LOCAL_API_URL: https://127.0.0.1:8080
|
LOCAL_API_URL: https://127.0.0.1:8080
|
||||||
USE_TLS: "true"
|
USE_TLS: "true"
|
||||||
CERT_FILE: "/etc/crowdsec/certs/server.pem"
|
CERT_FILE: "/etc/crowdsec/certs/server.pem"
|
||||||
@@ -88,6 +93,7 @@ services:
|
|||||||
# DISABLE_AGENT: "true"
|
# DISABLE_AGENT: "true"
|
||||||
# Disabled for the examples
|
# Disabled for the examples
|
||||||
DISABLE_ONLINE_API: "true"
|
DISABLE_ONLINE_API: "true"
|
||||||
|
COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/acquis.yaml:/etc/crowdsec/acquis.yaml
|
- ./config/acquis.yaml:/etc/crowdsec/acquis.yaml
|
||||||
# - ./config/config.yaml:/etc/crowdsec/config_local.yaml
|
# - ./config/config.yaml:/etc/crowdsec/config_local.yaml
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
stdout=/out/res.log
|
if [ -f "/out/inter-key.pem" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
cfssl gencert --initca /in/ca.json 2>${stdout} | cfssljson --bare "/out/ca" && \
|
cfssl gencert --initca /in/ca.json 2>${stdout} | cfssljson --bare "/out/ca" && \
|
||||||
# Generate an intermediate certificate that will be used to sign the client certificates
|
# Generate an intermediate certificate that will be used to sign the client certificates
|
||||||
cfssl gencert --initca /in/intermediate.json 2>${stdout} | cfssljson --bare "/out/inter" && \
|
cfssl gencert --initca /in/intermediate.json 2>${stdout} | cfssljson --bare "/out/inter" && \
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ go 1.22
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/leprosus/golang-ttl-map v1.1.7
|
github.com/leprosus/golang-ttl-map v1.1.7
|
||||||
github.com/maxlerebourg/simpleredis v1.0.11
|
github.com/maxlerebourg/simpleredis v1.0.12
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
github.com/leprosus/golang-ttl-map v1.1.7 h1:cF4AAFDDnJTFSV+/42sKLhmMluvLdRlCGS2UaifH6UM=
|
github.com/leprosus/golang-ttl-map v1.1.7 h1:cF4AAFDDnJTFSV+/42sKLhmMluvLdRlCGS2UaifH6UM=
|
||||||
github.com/leprosus/golang-ttl-map v1.1.7/go.mod h1:4QWHJPeVBbrkhOhXdhCv9IEiyj/YzkO04/iexy4vSe0=
|
github.com/leprosus/golang-ttl-map v1.1.7/go.mod h1:4QWHJPeVBbrkhOhXdhCv9IEiyj/YzkO04/iexy4vSe0=
|
||||||
github.com/maxlerebourg/simpleredis v1.0.11 h1:B33TUeIrHtJH2/Qj2bRdU+UZ1BvZwFyP55JWMxHirWg=
|
github.com/maxlerebourg/simpleredis v1.0.12 h1:VsJpk2l8U9QqxOWbYnEXbrs7iSNZFm2fNmFvlxYlQNk=
|
||||||
github.com/maxlerebourg/simpleredis v1.0.11/go.mod h1:lT4LX02SOsE9PxUcSrz1QW5ZnO86gPbaiYBxmtcXEls=
|
github.com/maxlerebourg/simpleredis v1.0.12/go.mod h1:lT4LX02SOsE9PxUcSrz1QW5ZnO86gPbaiYBxmtcXEls=
|
||||||
|
|||||||
Vendored
+4
-5
@@ -5,11 +5,10 @@ package cache
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
ttl_map "github.com/leprosus/golang-ttl-map"
|
ttl_map "github.com/leprosus/golang-ttl-map"
|
||||||
simpleredis "github.com/maxlerebourg/simpleredis"
|
simpleredis "github.com/maxlerebourg/simpleredis"
|
||||||
|
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -53,7 +52,7 @@ func (localCache) delete(key string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type redisCache struct {
|
type redisCache struct {
|
||||||
log *logger.Log
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (redisCache) get(key string) (string, error) {
|
func (redisCache) get(key string) (string, error) {
|
||||||
@@ -93,11 +92,11 @@ type cacheInterface interface {
|
|||||||
// Client Cache client.
|
// Client Cache client.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
cache cacheInterface
|
cache cacheInterface
|
||||||
log *logger.Log
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New Initialize cache client.
|
// New Initialize cache client.
|
||||||
func (c *Client) New(log *logger.Log, isRedis bool, host, pass, database string) {
|
func (c *Client) New(log *slog.Logger, isRedis bool, host, pass, database string) {
|
||||||
c.log = log
|
c.log = log
|
||||||
if isRedis {
|
if isRedis {
|
||||||
redis.Init(host, pass, database)
|
redis.Init(host, pass, database)
|
||||||
|
|||||||
+49
-35
@@ -4,69 +4,80 @@ package captcha
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
||||||
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client Captcha client.
|
// Client Captcha client.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Valid bool
|
Valid bool
|
||||||
provider string
|
|
||||||
siteKey string
|
siteKey string
|
||||||
secretKey string
|
secretKey string
|
||||||
remediationCustomHeader string
|
remediationCustomHeader string
|
||||||
gracePeriodSeconds int64
|
gracePeriodSeconds int64
|
||||||
captchaTemplate *template.Template
|
templateContentType string
|
||||||
|
template *template.Template
|
||||||
cacheClient *cache.Client
|
cacheClient *cache.Client
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
log *logger.Log
|
log *slog.Logger
|
||||||
|
infoProvider *infoProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Information for self-hosted provider.
|
||||||
type infoProvider struct {
|
type infoProvider struct {
|
||||||
js string
|
js string
|
||||||
key string
|
key string
|
||||||
|
response string
|
||||||
validate string
|
validate string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
//nolint:gochecknoglobals
|
||||||
//nolint:gochecknoglobals
|
var infoProviders = map[string]*infoProvider{
|
||||||
captcha = map[string]infoProvider{
|
configuration.HcaptchaProvider: {
|
||||||
configuration.HcaptchaProvider: {
|
js: "https://hcaptcha.com/1/api.js",
|
||||||
js: "https://hcaptcha.com/1/api.js",
|
key: "h-captcha",
|
||||||
key: "h-captcha",
|
response: "h-captcha-response",
|
||||||
validate: "https://api.hcaptcha.com/siteverify",
|
validate: "https://api.hcaptcha.com/siteverify",
|
||||||
},
|
},
|
||||||
configuration.RecaptchaProvider: {
|
configuration.RecaptchaProvider: {
|
||||||
js: "https://www.google.com/recaptcha/api.js",
|
js: "https://www.google.com/recaptcha/api.js",
|
||||||
key: "g-recaptcha",
|
key: "g-recaptcha",
|
||||||
validate: "https://www.google.com/recaptcha/api/siteverify",
|
response: "g-recaptcha-response",
|
||||||
},
|
validate: "https://www.google.com/recaptcha/api/siteverify",
|
||||||
configuration.TurnstileProvider: {
|
},
|
||||||
js: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
configuration.TurnstileProvider: {
|
||||||
key: "cf-turnstile",
|
js: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||||
validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
key: "cf-turnstile",
|
||||||
},
|
response: "cf-turnstile-response",
|
||||||
}
|
validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||||
)
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// New Initialize captcha client.
|
// New Initialize captcha client.
|
||||||
func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
func (c *Client) New(log *slog.Logger, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
||||||
c.Valid = provider != ""
|
c.Valid = provider != ""
|
||||||
if !c.Valid {
|
if !c.Valid {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
var info *infoProvider
|
||||||
|
if provider == configuration.CustomProvider {
|
||||||
|
info = &infoProvider{js: js, key: key, response: response, validate: validate}
|
||||||
|
} else {
|
||||||
|
info = infoProviders[provider]
|
||||||
|
}
|
||||||
|
c.infoProvider = info
|
||||||
c.siteKey = siteKey
|
c.siteKey = siteKey
|
||||||
c.secretKey = secretKey
|
c.secretKey = secretKey
|
||||||
c.provider = provider
|
|
||||||
c.remediationCustomHeader = remediationCustomHeader
|
c.remediationCustomHeader = remediationCustomHeader
|
||||||
html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
|
template, contentType, _ := configuration.GetTemplate(captchaTemplatePath)
|
||||||
c.captchaTemplate = html
|
c.template = template
|
||||||
|
c.templateContentType = contentType
|
||||||
c.gracePeriodSeconds = gracePeriodSeconds
|
c.gracePeriodSeconds = gracePeriodSeconds
|
||||||
c.log = log
|
c.log = log
|
||||||
c.httpClient = httpClient
|
c.httpClient = httpClient
|
||||||
@@ -85,18 +96,21 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
|
|||||||
if valid {
|
if valid {
|
||||||
c.log.Debug("captcha:ServeHTTP captcha:valid")
|
c.log.Debug("captcha:ServeHTTP captcha:valid")
|
||||||
c.cacheClient.Set(remoteIP+"_captcha", cache.CaptchaDoneValue, c.gracePeriodSeconds)
|
c.cacheClient.Set(remoteIP+"_captcha", cache.CaptchaDoneValue, c.gracePeriodSeconds)
|
||||||
|
if c.remediationCustomHeader != "" {
|
||||||
|
rw.Header().Set(c.remediationCustomHeader, "solved-captcha")
|
||||||
|
}
|
||||||
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
|
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
rw.Header().Set("Content-Type", c.templateContentType)
|
||||||
if c.remediationCustomHeader != "" {
|
if c.remediationCustomHeader != "" {
|
||||||
rw.Header().Set(c.remediationCustomHeader, "captcha")
|
rw.Header().Set(c.remediationCustomHeader, "captcha")
|
||||||
}
|
}
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
err = c.captchaTemplate.Execute(rw, map[string]string{
|
err = c.template.Execute(rw, map[string]string{
|
||||||
"SiteKey": c.siteKey,
|
"SiteKey": c.siteKey,
|
||||||
"FrontendJS": captcha[c.provider].js,
|
"FrontendJS": c.infoProvider.js,
|
||||||
"FrontendKey": captcha[c.provider].key,
|
"FrontendKey": c.infoProvider.key,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Info("captcha:ServeHTTP captchaTemplateServe " + err.Error())
|
c.log.Info("captcha:ServeHTTP captchaTemplateServe " + err.Error())
|
||||||
@@ -121,7 +135,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
|
|||||||
c.log.Debug("captcha:Validate invalid method: " + r.Method)
|
c.log.Debug("captcha:Validate invalid method: " + r.Method)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
var response = r.FormValue(captcha[c.provider].key + "-response")
|
var response = r.FormValue(c.infoProvider.response)
|
||||||
if response == "" {
|
if response == "" {
|
||||||
c.log.Debug("captcha:Validate no captcha response found in request")
|
c.log.Debug("captcha:Validate no captcha response found in request")
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -129,7 +143,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
|
|||||||
var body = url.Values{}
|
var body = url.Values{}
|
||||||
body.Add("secret", c.secretKey)
|
body.Add("secret", c.secretKey)
|
||||||
body.Add("response", response)
|
body.Add("response", response)
|
||||||
res, err := c.httpClient.PostForm(captcha[c.provider].validate, body)
|
res, err := c.httpClient.PostForm(c.infoProvider.validate, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
+257
-140
@@ -6,7 +6,7 @@ import (
|
|||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -14,9 +14,9 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Enums for crowdsec mode.
|
// Enums for crowdsec mode.
|
||||||
@@ -30,64 +30,90 @@ const (
|
|||||||
HTTP = "http"
|
HTTP = "http"
|
||||||
LogDEBUG = "DEBUG"
|
LogDEBUG = "DEBUG"
|
||||||
LogINFO = "INFO"
|
LogINFO = "INFO"
|
||||||
|
LogWARN = "WARN"
|
||||||
LogERROR = "ERROR"
|
LogERROR = "ERROR"
|
||||||
|
ReasonTECH = "TECHNICAL_ISSUE"
|
||||||
|
ReasonLAPI = "LAPI"
|
||||||
|
ReasonAPPSEC = "APPSEC"
|
||||||
HcaptchaProvider = "hcaptcha"
|
HcaptchaProvider = "hcaptcha"
|
||||||
RecaptchaProvider = "recaptcha"
|
RecaptchaProvider = "recaptcha"
|
||||||
TurnstileProvider = "turnstile"
|
TurnstileProvider = "turnstile"
|
||||||
|
CustomProvider = "custom"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config the plugin configuration.
|
// Config the plugin configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled bool `json:"enabled,omitempty"`
|
Enabled bool `json:"enabled,omitempty"`
|
||||||
LogLevel string `json:"logLevel,omitempty"`
|
LogLevel string `json:"logLevel,omitempty"`
|
||||||
LogFilePath string `json:"logFilePath,omitempty"`
|
LogFormat string `json:"logFormat,omitempty"`
|
||||||
CrowdsecMode string `json:"crowdsecMode,omitempty"`
|
LogFilePath string `json:"logFilePath,omitempty"`
|
||||||
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
|
CrowdsecMode string `json:"crowdsecMode,omitempty"`
|
||||||
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
|
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
|
||||||
CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"`
|
CrowdsecAppsecScheme string `json:"crowdsecAppsecScheme,omitempty"`
|
||||||
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
|
||||||
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"`
|
||||||
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
|
CrowdsecAppsecKey string `json:"crowdsecAppsecKey,omitempty"`
|
||||||
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
|
CrowdsecAppsecKeyFile string `json:"crowdsecAppsecKeyFile,omitempty"`
|
||||||
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
|
CrowdsecAppsecTLSInsecureVerify bool `json:"crowdsecAppsecTlsInsecureVerify,omitempty"`
|
||||||
CrowdsecLapiPath string `json:"crowdsecLapiPath,omitempty"`
|
CrowdsecAppsecTLSCertificateAuthority string `json:"crowdsecAppsecTlsCertificateAuthority,omitempty"`
|
||||||
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
|
CrowdsecAppsecTLSCertificateAuthorityFile string `json:"crowdsecAppsecTlsCertificateAuthorityFile,omitempty"`
|
||||||
CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncer string `json:"crowdsecAppsecTlsCertificateBouncer,omitempty"`
|
||||||
CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncerFile string `json:"crowdsecAppsecTlsCertificateBouncerFile,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateAuthority string `json:"crowdsecLapiTlsCertificateAuthority,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncerKey string `json:"crowdsecAppsecTlsCertificateBouncerKey,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateAuthorityFile string `json:"crowdsecLapiTlsCertificateAuthorityFile,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncer string `json:"crowdsecLapiTlsCertificateBouncer,omitempty"`
|
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncerFile string `json:"crowdsecLapiTlsCertificateBouncerFile,omitempty"`
|
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncerKey string `json:"crowdsecLapiTlsCertificateBouncerKey,omitempty"`
|
CrowdsecAppsecUnreadableBodyBlock bool `json:"crowdsecAppsecUnreadableBodyBlock,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncerKeyFile string `json:"crowdsecLapiTlsCertificateBouncerKeyFile,omitempty"`
|
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
|
||||||
CrowdsecCapiMachineID string `json:"crowdsecCapiMachineId,omitempty"`
|
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
|
||||||
CrowdsecCapiMachineIDFile string `json:"crowdsecCapiMachineIdFile,omitempty"`
|
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
|
||||||
CrowdsecCapiPassword string `json:"crowdsecCapiPassword,omitempty"`
|
CrowdsecLapiPath string `json:"crowdsecLapiPath,omitempty"`
|
||||||
CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"`
|
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
|
||||||
CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"`
|
CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"`
|
||||||
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"`
|
||||||
UpdateMaxFailure int `json:"updateMaxFailure,omitempty"`
|
CrowdsecLapiTLSCertificateAuthority string `json:"crowdsecLapiTlsCertificateAuthority,omitempty"`
|
||||||
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
CrowdsecLapiTLSCertificateAuthorityFile string `json:"crowdsecLapiTlsCertificateAuthorityFile,omitempty"`
|
||||||
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
|
CrowdsecLapiTLSCertificateBouncer string `json:"crowdsecLapiTlsCertificateBouncer,omitempty"`
|
||||||
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
CrowdsecLapiTLSCertificateBouncerFile string `json:"crowdsecLapiTlsCertificateBouncerFile,omitempty"`
|
||||||
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"`
|
CrowdsecLapiTLSCertificateBouncerKey string `json:"crowdsecLapiTlsCertificateBouncerKey,omitempty"`
|
||||||
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
|
CrowdsecLapiTLSCertificateBouncerKeyFile string `json:"crowdsecLapiTlsCertificateBouncerKeyFile,omitempty"`
|
||||||
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
|
CrowdsecCapiMachineID string `json:"crowdsecCapiMachineId,omitempty"`
|
||||||
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
|
CrowdsecCapiMachineIDFile string `json:"crowdsecCapiMachineIdFile,omitempty"`
|
||||||
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
|
CrowdsecCapiPassword string `json:"crowdsecCapiPassword,omitempty"`
|
||||||
RedisCacheHost string `json:"redisCacheHost,omitempty"`
|
CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"`
|
||||||
RedisCachePassword string `json:"redisCachePassword,omitempty"`
|
CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"`
|
||||||
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
|
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
||||||
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
|
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
|
||||||
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
|
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
|
||||||
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
|
StreamStartupBlock bool `json:"streamStartupBlock,omitempty"`
|
||||||
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
|
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
||||||
CaptchaProvider string `json:"captchaProvider,omitempty"`
|
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
|
||||||
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"`
|
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
||||||
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"`
|
TraceHeadersCustomName string `json:"traceHeadersCustomName,omitempty"`
|
||||||
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"`
|
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"`
|
||||||
CaptchaSecretKeyFile string `json:"captchaSecretKeyFile,omitempty"`
|
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
|
||||||
CaptchaGracePeriodSeconds int64 `json:"captchaGracePeriodSeconds,omitempty"`
|
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
|
||||||
|
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
|
||||||
|
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
|
||||||
|
RedisCacheHost string `json:"redisCacheHost,omitempty"`
|
||||||
|
RedisCachePassword string `json:"redisCachePassword,omitempty"`
|
||||||
|
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
|
||||||
|
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
|
||||||
|
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
|
||||||
|
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
|
||||||
|
BanFilePath string `json:"banFilePath,omitempty"`
|
||||||
|
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
|
||||||
|
CaptchaFilePath string `json:"captchaFilePath,omitempty"`
|
||||||
|
CaptchaProvider string `json:"captchaProvider,omitempty"`
|
||||||
|
CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"`
|
||||||
|
CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"`
|
||||||
|
CaptchaCustomKey string `json:"captchaCustomKey,omitempty"`
|
||||||
|
CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"`
|
||||||
|
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"`
|
||||||
|
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"`
|
||||||
|
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"`
|
||||||
|
CaptchaSecretKeyFile string `json:"captchaSecretKeyFile,omitempty"`
|
||||||
|
CaptchaGracePeriodSeconds int64 `json:"captchaGracePeriodSeconds,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func contains(source []string, target string) bool {
|
func contains(source []string, target string) bool {
|
||||||
@@ -102,41 +128,53 @@ func contains(source []string, target string) bool {
|
|||||||
// New creates the default plugin configuration.
|
// New creates the default plugin configuration.
|
||||||
func New() *Config {
|
func New() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
LogLevel: LogINFO,
|
LogLevel: LogINFO,
|
||||||
LogFilePath: "",
|
LogFormat: "common",
|
||||||
CrowdsecMode: LiveMode,
|
LogFilePath: "",
|
||||||
CrowdsecAppsecEnabled: false,
|
CrowdsecMode: LiveMode,
|
||||||
CrowdsecAppsecHost: "crowdsec:7422",
|
CrowdsecAppsecEnabled: false,
|
||||||
CrowdsecAppsecPath: "/",
|
CrowdsecAppsecFailureBlock: true,
|
||||||
CrowdsecAppsecFailureBlock: true,
|
CrowdsecAppsecUnreachableBlock: true,
|
||||||
CrowdsecAppsecUnreachableBlock: true,
|
CrowdsecAppsecUnreadableBodyBlock: true,
|
||||||
CrowdsecAppsecBodyLimit: 10485760,
|
CrowdsecAppsecBodyLimit: 10485760,
|
||||||
CrowdsecLapiScheme: HTTP,
|
CrowdsecAppsecScheme: "",
|
||||||
CrowdsecLapiHost: "crowdsec:8080",
|
CrowdsecAppsecHost: "crowdsec:7422",
|
||||||
CrowdsecLapiPath: "/",
|
CrowdsecAppsecPath: "/",
|
||||||
CrowdsecLapiKey: "",
|
CrowdsecAppsecKey: "",
|
||||||
CrowdsecLapiTLSInsecureVerify: false,
|
CrowdsecAppsecTLSInsecureVerify: false,
|
||||||
UpdateIntervalSeconds: 60,
|
CrowdsecLapiScheme: HTTP,
|
||||||
UpdateMaxFailure: 0,
|
CrowdsecLapiHost: "crowdsec:8080",
|
||||||
DefaultDecisionSeconds: 60,
|
CrowdsecLapiPath: "/",
|
||||||
RemediationStatusCode: http.StatusForbidden,
|
CrowdsecLapiKey: "",
|
||||||
HTTPTimeoutSeconds: 10,
|
CrowdsecLapiTLSInsecureVerify: false,
|
||||||
CaptchaProvider: "",
|
UpdateIntervalSeconds: 60,
|
||||||
CaptchaSiteKey: "",
|
MetricsUpdateIntervalSeconds: 600,
|
||||||
CaptchaSecretKey: "",
|
UpdateMaxFailure: 0,
|
||||||
CaptchaGracePeriodSeconds: 1800,
|
StreamStartupBlock: true,
|
||||||
CaptchaHTMLFilePath: "/captcha.html",
|
DefaultDecisionSeconds: 60,
|
||||||
BanHTMLFilePath: "",
|
RemediationStatusCode: http.StatusForbidden,
|
||||||
RemediationHeadersCustomName: "",
|
HTTPTimeoutSeconds: 10,
|
||||||
ForwardedHeadersCustomName: "X-Forwarded-For",
|
CaptchaProvider: "",
|
||||||
ForwardedHeadersTrustedIPs: []string{},
|
CaptchaCustomJsURL: "",
|
||||||
ClientTrustedIPs: []string{},
|
CaptchaCustomValidateURL: "",
|
||||||
RedisCacheEnabled: false,
|
CaptchaCustomKey: "",
|
||||||
RedisCacheHost: "redis:6379",
|
CaptchaCustomResponse: "",
|
||||||
RedisCachePassword: "",
|
CaptchaSiteKey: "",
|
||||||
RedisCacheDatabase: "",
|
CaptchaSecretKey: "",
|
||||||
RedisCacheUnreachableBlock: true,
|
CaptchaGracePeriodSeconds: 1800,
|
||||||
|
CaptchaFilePath: "/captcha.html",
|
||||||
|
BanFilePath: "",
|
||||||
|
TraceHeadersCustomName: "",
|
||||||
|
RemediationHeadersCustomName: "",
|
||||||
|
ForwardedHeadersCustomName: "X-Forwarded-For",
|
||||||
|
ForwardedHeadersTrustedIPs: []string{},
|
||||||
|
ClientTrustedIPs: []string{},
|
||||||
|
RedisCacheEnabled: false,
|
||||||
|
RedisCacheHost: "redis:6379",
|
||||||
|
RedisCachePassword: "",
|
||||||
|
RedisCacheDatabase: "",
|
||||||
|
RedisCacheUnreachableBlock: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,37 +205,63 @@ func GetVariable(config *Config, key string) (string, error) {
|
|||||||
return strings.TrimSpace(value), nil
|
return strings.TrimSpace(value), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHTMLTemplate get compiled HTML template.
|
func getContentTypeFromPath(path string) string {
|
||||||
func GetHTMLTemplate(path string) (*template.Template, error) {
|
|
||||||
var err error
|
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return nil, errors.New("no html template provided")
|
return ""
|
||||||
}
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
contentTypeMap := map[string]string{
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".htm": "text/html; charset=utf-8",
|
||||||
|
".json": "application/json",
|
||||||
|
".txt": "text/plain",
|
||||||
|
".xml": "application/xml",
|
||||||
|
".js": "application/javascript",
|
||||||
|
".css": "text/css",
|
||||||
|
}
|
||||||
|
if contentType, ok := contentTypeMap[ext]; ok {
|
||||||
|
return contentType
|
||||||
|
}
|
||||||
|
// Default to HTML for backward compatibility
|
||||||
|
return "text/html; charset=utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplate get compiled template with {{ and }} delimiters.
|
||||||
|
// Uses text/template for all file types to avoid HTML escaping issues.
|
||||||
|
func GetTemplate(path string) (*template.Template, string, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, "", errors.New("no template file provided")
|
||||||
|
}
|
||||||
|
contentType := getContentTypeFromPath(path)
|
||||||
//nolint:gosec
|
//nolint:gosec
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
html := string(b)
|
content := string(b)
|
||||||
compiledTemplate, err := template.New("html").Parse(html)
|
compiledTemplate, err := template.New(filepath.Base(path)).Delims("{{", "}}").Parse(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("impossible to compile html template: %w", err)
|
return nil, "", fmt.Errorf("impossible to compile template %s: %w", path, err)
|
||||||
}
|
}
|
||||||
return compiledTemplate, nil
|
return compiledTemplate, contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateParams validate all the param gave by user.
|
// ValidateParams validate all the param gave by user.
|
||||||
//
|
//
|
||||||
//nolint:gocyclo,gocognit
|
//nolint:gocyclo,gocognit,nestif
|
||||||
func ValidateParams(config *Config) error {
|
func ValidateParams(config *Config, log *slog.Logger) error {
|
||||||
if err := validateParamsRequired(config); err != nil {
|
if err := validateParamsRequired(config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
if err := validateCaptcha(config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := validateParamsIPs(config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
|
|
||||||
|
if err := validateParamsIPs(log, config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateParamsIPs(log, config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,12 +286,14 @@ func ValidateParams(config *Config) error {
|
|||||||
if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil {
|
if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := GetHTMLTemplate(config.CaptchaHTMLFilePath); err != nil {
|
if config.CaptchaFilePath != "" {
|
||||||
return err
|
if _, _, err := GetTemplate(config.CaptchaFilePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if config.BanHTMLFilePath != "" {
|
if config.BanFilePath != "" {
|
||||||
if _, err := GetHTMLTemplate(config.BanHTMLFilePath); err != nil {
|
if _, _, err := GetTemplate(config.BanFilePath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,6 +310,10 @@ func ValidateParams(config *Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
appsecKey, err := GetVariable(config, "CrowdsecAppsecKey")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -252,12 +322,21 @@ func ValidateParams(config *Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to either have crowdsecLapiKey defined or the BouncerCert and Bouncerkey
|
// We need to either have crowdsecLapiKey defined or the BouncerCert and Bouncerkey
|
||||||
if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") {
|
if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") && config.CrowdsecMode != AppsecMode {
|
||||||
return errors.New("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be all empty")
|
return errors.New("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be all empty")
|
||||||
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
|
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
|
||||||
lapiKey = strings.TrimSpace(lapiKey)
|
lapiKey = strings.TrimSpace(lapiKey)
|
||||||
if err = validateParamsAPIKey(lapiKey); err != nil {
|
if err = validateParamsAPIKey(lapiKey, "CrowdsecLapiKey"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CrowdsecAppsecKey if provided
|
||||||
|
if appsecKey != "" {
|
||||||
|
appsecKey = strings.TrimSpace(appsecKey)
|
||||||
|
if err = validateParamsAPIKey(appsecKey, "CrowdsecAppsecKey"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,8 +350,8 @@ func ValidateParams(config *Config) error {
|
|||||||
|
|
||||||
// Check logging configuration
|
// Check logging configuration
|
||||||
// to upper allow of anycase of log level
|
// to upper allow of anycase of log level
|
||||||
if !contains([]string{LogERROR, LogDEBUG, LogINFO}, strings.ToUpper(config.LogLevel)) {
|
if !contains([]string{LogDEBUG, LogINFO, LogWARN, LogERROR}, strings.ToUpper(config.LogLevel)) {
|
||||||
return fmt.Errorf("LogLevel should be one of (%s,%s,%s)", LogDEBUG, LogINFO, LogERROR)
|
return fmt.Errorf("LogLevel should be one of (%s,%s,%s,%s)", LogDEBUG, LogINFO, LogWARN, LogERROR)
|
||||||
}
|
}
|
||||||
if config.LogFilePath != "" {
|
if config.LogFilePath != "" {
|
||||||
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
@@ -296,10 +375,10 @@ func validateURL(variable, scheme, host, path string) error {
|
|||||||
// field name. RFC 7230 says:
|
// field name. RFC 7230 says:
|
||||||
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
|
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
|
||||||
// See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators
|
// See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators
|
||||||
func validateParamsAPIKey(lapiKey string) error {
|
func validateParamsAPIKey(key string, paramName string) error {
|
||||||
reg := regexp.MustCompile("^[a-zA-Z0-9 !#$%&'*+-.^_`|~=/]*$")
|
reg := regexp.MustCompile("^[a-zA-Z0-9 !#$%&'*+-.^_`|~=/]*$")
|
||||||
if !reg.MatchString(lapiKey) {
|
if !reg.MatchString(key) {
|
||||||
return fmt.Errorf("CrowdsecLapiKey doesn't valid this regexp: '/%s/'", reg.String())
|
return fmt.Errorf("%s doesn't validate this regexp: '/%s/'", paramName, reg.String())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -310,7 +389,8 @@ func validateParamsTLS(config *Config) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if certAuth == "" {
|
if certAuth == "" {
|
||||||
return errors.New("CrowdsecLapiTLSCertificateAuthority must be specified when CrowdsecLapiScheme='https' and CrowdsecLapiTLSInsecureVerify=false")
|
// No custom CA — runtime will fall back to the system trust store.
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
tlsConfig := new(tls.Config)
|
tlsConfig := new(tls.Config)
|
||||||
tlsConfig.RootCAs = x509.NewCertPool()
|
tlsConfig.RootCAs = x509.NewCertPool()
|
||||||
@@ -320,15 +400,33 @@ func validateParamsTLS(config *Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateParamsIPs(listIP []string, key string) error {
|
func validateParamsIPs(log *slog.Logger, listIP []string, key string) error {
|
||||||
if len(listIP) > 0 {
|
if len(listIP) > 0 {
|
||||||
if _, err := ip.NewChecker(logger.New(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 fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateCaptcha(config *Config) error {
|
||||||
|
if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider}, config.CaptchaProvider) {
|
||||||
|
return fmt.Errorf("CaptchaProvider: must be one of '%s', '%s', '%s' or '%s'", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider)
|
||||||
|
}
|
||||||
|
if config.CaptchaProvider == CustomProvider {
|
||||||
|
if config.CaptchaCustomKey == "" || config.CaptchaCustomResponse == "" || config.CaptchaCustomValidateURL == "" || config.CaptchaCustomJsURL == "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"CaptchaProvider: provider is custom, captchaCustom variables must be filled: CaptchaCustomKey:%s, CaptchaCustomResponse:%s, CaptchaCustomValidateURL:%s, CaptchaCustomJsURL:%s",
|
||||||
|
config.CaptchaCustomKey,
|
||||||
|
config.CaptchaCustomResponse,
|
||||||
|
config.CaptchaCustomValidateURL,
|
||||||
|
config.CaptchaCustomJsURL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateParamsRequired(config *Config) error {
|
func validateParamsRequired(config *Config) error {
|
||||||
requiredStrings := map[string]string{
|
requiredStrings := map[string]string{
|
||||||
"CrowdsecLapiScheme": config.CrowdsecLapiScheme,
|
"CrowdsecLapiScheme": config.CrowdsecLapiScheme,
|
||||||
@@ -337,18 +435,27 @@ func validateParamsRequired(config *Config) error {
|
|||||||
}
|
}
|
||||||
for key, val := range requiredStrings {
|
for key, val := range requiredStrings {
|
||||||
if len(val) == 0 {
|
if len(val) == 0 {
|
||||||
return fmt.Errorf("%v: cannot be empty", key)
|
return errors.New(key + ": cannot be empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requiredInt := map[string]int64{
|
requiredInt0 := map[string]int64{
|
||||||
|
"CrowdsecAppsecBodyLimit": config.CrowdsecAppsecBodyLimit,
|
||||||
|
"MetricsUpdateIntervalSeconds": config.MetricsUpdateIntervalSeconds,
|
||||||
|
}
|
||||||
|
for key, val := range requiredInt0 {
|
||||||
|
if val < 0 {
|
||||||
|
return errors.New(key + ": cannot be less than 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requiredInt1 := map[string]int64{
|
||||||
"UpdateIntervalSeconds": config.UpdateIntervalSeconds,
|
"UpdateIntervalSeconds": config.UpdateIntervalSeconds,
|
||||||
"DefaultDecisionSeconds": config.DefaultDecisionSeconds,
|
"DefaultDecisionSeconds": config.DefaultDecisionSeconds,
|
||||||
"HTTPTimeoutSeconds": config.HTTPTimeoutSeconds,
|
"HTTPTimeoutSeconds": config.HTTPTimeoutSeconds,
|
||||||
"CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds,
|
"CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds,
|
||||||
}
|
}
|
||||||
for key, val := range requiredInt {
|
for key, val := range requiredInt1 {
|
||||||
if val < 1 {
|
if val < 1 {
|
||||||
return fmt.Errorf("%v: cannot be less than 1", key)
|
return errors.New(key + ": cannot be less than 1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if config.UpdateMaxFailure < -1 {
|
if config.UpdateMaxFailure < -1 {
|
||||||
@@ -367,47 +474,46 @@ func validateParamsRequired(config *Config) error {
|
|||||||
if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) {
|
if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) {
|
||||||
return errors.New("CrowdsecLapiScheme: must be one of 'http' or 'https'")
|
return errors.New("CrowdsecLapiScheme: must be one of 'http' or 'https'")
|
||||||
}
|
}
|
||||||
if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider}, config.CaptchaProvider) {
|
if !contains([]string{HTTP, HTTPS, ""}, config.CrowdsecAppsecScheme) {
|
||||||
return errors.New("CaptchaProvider: must be one of 'hcaptcha', 'recaptcha' or 'turnstile'")
|
return errors.New("CrowdsecAppsecScheme: must be one of 'http' or 'https'")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTLSConfigCrowdsec get TLS config from Config.
|
func getTLSConfig(config *Config, log *slog.Logger, prefix, scheme string, insecureVerify bool) (*tls.Config, error) {
|
||||||
//
|
|
||||||
//nolint:nestif
|
|
||||||
func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error) {
|
|
||||||
tlsConfig := new(tls.Config)
|
tlsConfig := new(tls.Config)
|
||||||
tlsConfig.RootCAs = x509.NewCertPool()
|
if scheme != HTTPS {
|
||||||
//nolint:gocritic
|
log.Debug("getTLSConfig:" + prefix + "Scheme https:no")
|
||||||
if config.CrowdsecLapiScheme != HTTPS {
|
|
||||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiScheme https:no")
|
|
||||||
return tlsConfig, nil
|
return tlsConfig, nil
|
||||||
} else if config.CrowdsecLapiTLSInsecureVerify {
|
}
|
||||||
|
// RootCAs is intentionally left nil unless a custom CA is provided:
|
||||||
|
// crypto/tls then falls back to x509.SystemCertPool(), which is what we
|
||||||
|
// want when the LAPI is exposed behind a reverse proxy with a publicly
|
||||||
|
// trusted certificate (e.g. Let's Encrypt).
|
||||||
|
//nolint:nestif
|
||||||
|
if insecureVerify {
|
||||||
tlsConfig.InsecureSkipVerify = true
|
tlsConfig.InsecureSkipVerify = true
|
||||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSInsecureVerify tlsInsecure:true")
|
log.Debug("getTLSConfig:" + prefix + "TLSInsecureVerify tlsInsecure:true")
|
||||||
// If we return here and still want to use client auth this won't work
|
|
||||||
// return tlsConfig, nil
|
|
||||||
} else {
|
} else {
|
||||||
certAuthority, err := GetVariable(config, "CrowdsecLapiTLSCertificateAuthority")
|
certAuthority, err := GetVariable(config, prefix+"TLSCertificateAuthority")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(certAuthority) > 0 {
|
if len(certAuthority) > 0 {
|
||||||
|
tlsConfig.RootCAs = x509.NewCertPool()
|
||||||
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
||||||
// here we return because if CrowdsecLapiTLSInsecureVerify is false
|
return nil, errors.New("getTLSConfig:" + prefix + " cannot load CA and verify cert is enabled")
|
||||||
// and CA not load, we can't communicate with https
|
|
||||||
return nil, errors.New("getTLSConfigCrowdsec:cannot load CA and verify cert is enabled")
|
|
||||||
}
|
}
|
||||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSCertificateAuthority CA added successfully")
|
log.Debug("getTLSConfig:" + prefix + "TLSCertificateAuthority CA added successfully")
|
||||||
|
} else {
|
||||||
|
log.Debug("getTLSConfig:" + prefix + " no CA provided, using system trust store")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
|
||||||
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
certBouncerKey, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncerKey")
|
certBouncerKey, err := GetVariable(config, prefix+"TLSCertificateBouncerKey")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -422,3 +528,14 @@ func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error)
|
|||||||
|
|
||||||
return tlsConfig, nil
|
return tlsConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTLSConfigCrowdsec get TLS config from Config.
|
||||||
|
func GetTLSConfigCrowdsec(config *Config, log *slog.Logger, isAppsec bool) (*tls.Config, error) {
|
||||||
|
var prefix string
|
||||||
|
if isAppsec && config.CrowdsecAppsecScheme != "" {
|
||||||
|
prefix = "CrowdsecAppsec"
|
||||||
|
return getTLSConfig(config, log, prefix, config.CrowdsecAppsecScheme, config.CrowdsecAppsecTLSInsecureVerify)
|
||||||
|
}
|
||||||
|
prefix = "CrowdsecLapi"
|
||||||
|
return getTLSConfig(config, log, prefix, config.CrowdsecLapiScheme, config.CrowdsecLapiTLSInsecureVerify)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
package configuration
|
package configuration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// validPEM is a minimal self-signed certificate accepted by AppendCertsFromPEM,
|
||||||
|
// shared by the TLS tests below.
|
||||||
|
const validPEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
|
||||||
|
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
|
||||||
|
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
|
||||||
|
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
|
||||||
|
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
|
||||||
|
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
|
||||||
|
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
|
||||||
|
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
|
||||||
|
6MF9+Yw1Yy0t
|
||||||
|
-----END CERTIFICATE-----`
|
||||||
|
|
||||||
func getMinimalConfig() *Config {
|
func getMinimalConfig() *Config {
|
||||||
cfg := New()
|
cfg := New()
|
||||||
cfg.CrowdsecLapiKey = "test"
|
cfg.CrowdsecLapiKey = "test"
|
||||||
@@ -72,6 +84,7 @@ func Test_GetVariable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_ValidateParams(t *testing.T) {
|
func Test_ValidateParams(t *testing.T) {
|
||||||
|
log := logger.New("INFO", "")
|
||||||
cfg1 := New()
|
cfg1 := New()
|
||||||
cfg1.CrowdsecLapiKey = "test\n\n"
|
cfg1.CrowdsecLapiKey = "test\n\n"
|
||||||
cfg2 := New()
|
cfg2 := New()
|
||||||
@@ -110,14 +123,14 @@ func Test_ValidateParams(t *testing.T) {
|
|||||||
{name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true},
|
{name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true},
|
||||||
// HTTPS enabled
|
// HTTPS enabled
|
||||||
{name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false},
|
{name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false},
|
||||||
{name: "Not validate https without cert authority", args: args{config: 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 uppercase INFO", args: args{config: cfg8}, wantErr: false},
|
||||||
{name: "Valid log level lowercase info", args: args{config: cfg9}, wantErr: false},
|
{name: "Valid log level lowercase info", args: args{config: cfg9}, wantErr: false},
|
||||||
{name: "Invalid log level Warning", args: args{config: cfg10}, wantErr: true},
|
{name: "Invalid log level Warning", args: args{config: cfg10}, wantErr: true},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := ValidateParams(tt.args.config); (err != nil) != tt.wantErr {
|
if err := ValidateParams(tt.args.config, log); (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -125,19 +138,24 @@ func Test_ValidateParams(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_validateParamsTLS(t *testing.T) {
|
func Test_validateParamsTLS(t *testing.T) {
|
||||||
type args struct {
|
cfgEmpty := getMinimalConfig()
|
||||||
config *Config
|
cfgValid := getMinimalConfig()
|
||||||
}
|
cfgValid.CrowdsecLapiTLSCertificateAuthority = validPEM
|
||||||
|
cfgInvalidCA := getMinimalConfig()
|
||||||
|
cfgInvalidCA.CrowdsecLapiTLSCertificateAuthority = "not a pem"
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
args args
|
config *Config
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
// TODO: Add test cases.
|
{name: "Empty CA is accepted (system trust store used at runtime)", config: cfgEmpty, wantErr: false},
|
||||||
|
{name: "Valid PEM CA is accepted", config: cfgValid, wantErr: false},
|
||||||
|
{name: "Invalid CA is rejected", config: cfgInvalidCA, wantErr: true},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := validateParamsTLS(tt.args.config); (err != nil) != tt.wantErr {
|
if err := validateParamsTLS(tt.config); (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateParamsTLS() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateParamsTLS() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -145,6 +163,7 @@ func Test_validateParamsTLS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_validateParamsIPs(t *testing.T) {
|
func Test_validateParamsIPs(t *testing.T) {
|
||||||
|
log := logger.New("INFO", "")
|
||||||
type args struct {
|
type args struct {
|
||||||
listIP []string
|
listIP []string
|
||||||
key string
|
key string
|
||||||
@@ -164,7 +183,7 @@ func Test_validateParamsIPs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := validateParamsIPs(tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
|
if err := validateParamsIPs(log, tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -205,23 +224,24 @@ func Test_validateParamsRequired(t *testing.T) {
|
|||||||
|
|
||||||
func Test_validateParamsAPIKey(t *testing.T) {
|
func Test_validateParamsAPIKey(t *testing.T) {
|
||||||
type args struct {
|
type args struct {
|
||||||
lapiKey string
|
lapiKey string
|
||||||
|
paramName string
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
args args
|
args args
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~"}, wantErr: false},
|
{name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~", paramName: "CrowdsecParamName"}, wantErr: false},
|
||||||
{name: "Not validate a @", args: args{lapiKey: "test@"}, wantErr: true},
|
{name: "Not validate a @", args: args{lapiKey: "test@", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a (", args: args{lapiKey: "test("}, wantErr: true},
|
{name: "Not validate a (", args: args{lapiKey: "test(", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a [", args: args{lapiKey: "test["}, wantErr: true},
|
{name: "Not validate a [", args: args{lapiKey: "test[", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a ?", args: args{lapiKey: "test?"}, wantErr: true},
|
{name: "Not validate a ?", args: args{lapiKey: "test?", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n"}, wantErr: true},
|
{name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := validateParamsAPIKey(tt.args.lapiKey); (err != nil) != tt.wantErr {
|
if err := validateParamsAPIKey(tt.args.lapiKey, tt.args.paramName); (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateParamsAPIKey() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateParamsAPIKey() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -229,26 +249,79 @@ func Test_validateParamsAPIKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
||||||
type args struct {
|
log := logger.New("INFO", "")
|
||||||
config *Config
|
|
||||||
}
|
httpCfg := getMinimalConfig()
|
||||||
|
httpCfg.CrowdsecLapiScheme = HTTP
|
||||||
|
|
||||||
|
httpsSystemCA := getMinimalConfig()
|
||||||
|
httpsSystemCA.CrowdsecLapiScheme = HTTPS
|
||||||
|
|
||||||
|
httpsCustomCA := getMinimalConfig()
|
||||||
|
httpsCustomCA.CrowdsecLapiScheme = HTTPS
|
||||||
|
httpsCustomCA.CrowdsecLapiTLSCertificateAuthority = validPEM
|
||||||
|
|
||||||
|
httpsInsecure := getMinimalConfig()
|
||||||
|
httpsInsecure.CrowdsecLapiScheme = HTTPS
|
||||||
|
httpsInsecure.CrowdsecLapiTLSInsecureVerify = true
|
||||||
|
|
||||||
|
httpsBadCA := getMinimalConfig()
|
||||||
|
httpsBadCA.CrowdsecLapiScheme = HTTPS
|
||||||
|
httpsBadCA.CrowdsecLapiTLSCertificateAuthority = "not a pem"
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
args args
|
config *Config
|
||||||
want *tls.Config
|
wantErr bool
|
||||||
wantErr bool
|
wantRootCAsNil bool
|
||||||
|
wantInsecureSkip bool
|
||||||
}{
|
}{
|
||||||
// TODO: Add test cases.
|
{name: "HTTP scheme returns empty tls.Config", config: httpCfg, wantRootCAsNil: true},
|
||||||
|
{name: "HTTPS without CA leaves RootCAs nil (system trust store)", config: httpsSystemCA, wantRootCAsNil: true},
|
||||||
|
{name: "HTTPS with custom CA populates RootCAs", config: httpsCustomCA, wantRootCAsNil: false},
|
||||||
|
{name: "HTTPS with insecure verify sets InsecureSkipVerify", config: httpsInsecure, wantRootCAsNil: true, wantInsecureSkip: true},
|
||||||
|
{name: "HTTPS with garbage CA is rejected", config: httpsBadCA, wantErr: true},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO", ""))
|
got, err := GetTLSConfigCrowdsec(tt.config, log, false)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("GetTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
if tt.wantErr {
|
||||||
t.Errorf("getTLSConfigCrowdsec() = %v, want %v", got, tt.want)
|
return
|
||||||
|
}
|
||||||
|
if (got.RootCAs == nil) != tt.wantRootCAsNil {
|
||||||
|
t.Errorf("GetTLSConfigCrowdsec() RootCAs nil = %v, want nil = %v", got.RootCAs == nil, tt.wantRootCAsNil)
|
||||||
|
}
|
||||||
|
if got.InsecureSkipVerify != tt.wantInsecureSkip {
|
||||||
|
t.Errorf("GetTLSConfigCrowdsec() InsecureSkipVerify = %v, want %v", got.InsecureSkipVerify, tt.wantInsecureSkip)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_getContentTypeFromPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{name: "HTML file with .html extension", path: "/ban.html", expected: "text/html; charset=utf-8"},
|
||||||
|
{name: "JSON file", path: "/ban.json", expected: "application/json"},
|
||||||
|
{name: "Text file", path: "/ban.txt", expected: "text/plain"},
|
||||||
|
{name: "File with mixed case extension", path: "/ban.HtMl", expected: "text/html; charset=utf-8"},
|
||||||
|
{name: "Unknown extension defaults to HTML", path: "/ban.xyz", expected: "text/html; charset=utf-8"},
|
||||||
|
{name: "File without extension", path: "/ban", expected: "text/html; charset=utf-8"},
|
||||||
|
{name: "Empty path", path: "", expected: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := getContentTypeFromPath(tt.path)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("GetContentTypeFromPath(%q) = %q, want %q", tt.path, got, tt.expected)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -5,11 +5,10 @@ package ip
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CHECKER
|
// CHECKER
|
||||||
@@ -21,7 +20,7 @@ type Checker struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewChecker builds a new Checker given a list of CIDR-Strings to trusted IPs.
|
// NewChecker builds a new Checker given a list of CIDR-Strings to trusted IPs.
|
||||||
func NewChecker(log *logger.Log, trustedIPs []string) (*Checker, error) {
|
func NewChecker(log *slog.Logger, trustedIPs []string) (*Checker, error) {
|
||||||
checker := &Checker{}
|
checker := &Checker{}
|
||||||
|
|
||||||
for _, ipMaskRaw := range trustedIPs {
|
for _, ipMaskRaw := range trustedIPs {
|
||||||
|
|||||||
+71
-44
@@ -1,65 +1,92 @@
|
|||||||
// Package logger implements utility routines to write to stdout and stderr.
|
// Package logger implements utility routines to write to stdout and stderr.
|
||||||
// It supports debug, info and error level
|
// It supports trace, debug, info, warn and error level using Go's standard log/slog
|
||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"log/slog"
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Log Logger struct.
|
// Custom log levels following slog best practices.
|
||||||
type Log struct {
|
const (
|
||||||
logError *log.Logger
|
LevelDebug = slog.LevelDebug
|
||||||
logInfo *log.Logger
|
LevelInfo = slog.LevelInfo
|
||||||
logDebug *log.Logger
|
LevelWarn = slog.LevelWarn
|
||||||
|
LevelError = slog.LevelError
|
||||||
|
)
|
||||||
|
|
||||||
|
// New creates a Log wrapper with default format (common).
|
||||||
|
func New(logLevel string, logFilePath string) *slog.Logger {
|
||||||
|
return NewWithFormat(logLevel, logFilePath, "common")
|
||||||
}
|
}
|
||||||
|
|
||||||
// New Set Default log level to info in case log level to defined.
|
// NewWithFormat creates a Log wrapper with specified format (common or json).
|
||||||
func New(logLevel string, logFilePath string) *Log {
|
func NewWithFormat(logLevel, logFilePath, logFormat string) *slog.Logger {
|
||||||
logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
// Determine log level
|
||||||
logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
var level slog.Level
|
||||||
logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
switch logLevel {
|
||||||
|
case "ERROR":
|
||||||
logError.SetOutput(os.Stderr)
|
level = LevelError
|
||||||
logInfo.SetOutput(os.Stdout)
|
case "WARN":
|
||||||
// we initialize logger to STDOUT/STDERR first so if the file logger cannot be initialized we can inform the user
|
level = LevelWarn
|
||||||
if logLevel == "DEBUG" {
|
case "INFO":
|
||||||
logDebug.SetOutput(os.Stdout)
|
level = LevelInfo
|
||||||
|
case "DEBUG":
|
||||||
|
level = LevelDebug
|
||||||
|
default:
|
||||||
|
// Default to INFO level
|
||||||
|
level = LevelInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set output destination
|
||||||
|
var output *os.File
|
||||||
if logFilePath != "" {
|
if logFilePath != "" {
|
||||||
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
_ = fmt.Errorf("LogFilePath is not writable %w", err)
|
output = logFile
|
||||||
} else {
|
} else {
|
||||||
logInfo.SetOutput(logFile)
|
// Fall back to stdout and log the error
|
||||||
logError.SetOutput(logFile)
|
output = os.Stdout
|
||||||
if logLevel == "DEBUG" {
|
slog.Warn("LogFilePath is not writable, using stdout", "error", err)
|
||||||
logDebug.SetOutput(logFile)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
output = os.Stdout
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Log{
|
// Create handler based on format with custom level names
|
||||||
logError: logError,
|
var handler slog.Handler
|
||||||
logInfo: logInfo,
|
opts := &slog.HandlerOptions{
|
||||||
logDebug: logDebug,
|
Level: level,
|
||||||
|
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||||||
|
// Customize level names to match our expected format
|
||||||
|
if a.Key == slog.LevelKey {
|
||||||
|
lvl, ok := a.Value.Any().(slog.Level)
|
||||||
|
if !ok {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case lvl < LevelInfo:
|
||||||
|
a.Value = slog.StringValue("DEBUG")
|
||||||
|
case lvl < LevelWarn:
|
||||||
|
a.Value = slog.StringValue("INFO")
|
||||||
|
case lvl < LevelError:
|
||||||
|
a.Value = slog.StringValue("WARN")
|
||||||
|
default:
|
||||||
|
a.Value = slog.StringValue("ERROR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Info log to Stdout.
|
if logFormat == "json" {
|
||||||
func (l *Log) Info(str string) {
|
handler = slog.NewJSONHandler(output, opts)
|
||||||
l.logInfo.Printf("%s", str)
|
} else {
|
||||||
}
|
// Common format (default)
|
||||||
|
handler = slog.NewTextHandler(output, opts)
|
||||||
|
}
|
||||||
|
|
||||||
// Debug log to Stdout.
|
// Create logger with component attribute
|
||||||
func (l *Log) Debug(str string) {
|
return slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||||
l.logDebug.Printf("%s", str)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error log to Stderr.
|
|
||||||
func (l *Log) Error(str string) {
|
|
||||||
l.logError.Printf("%s", str)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
logLevel string
|
||||||
|
}{
|
||||||
|
{name: "ERROR level", logLevel: "ERROR"},
|
||||||
|
{name: "WARN level", logLevel: "WARN"},
|
||||||
|
{name: "INFO level", logLevel: "INFO"},
|
||||||
|
{name: "DEBUG level", logLevel: "DEBUG"},
|
||||||
|
{name: "Default level (INFO)", logLevel: "INVALID"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
logger := New(tt.logLevel, "")
|
||||||
|
|
||||||
|
// Verify logger is created
|
||||||
|
if logger == nil {
|
||||||
|
t.Fatal("Expected logger to be created, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a slog.Logger (we can call methods on it)
|
||||||
|
logger.Info("test initialization")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLogFormat(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Create a logger with JSON handler to capture output
|
||||||
|
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||||
|
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||||
|
|
||||||
|
testMessage := "json test message"
|
||||||
|
logger.Info(testMessage)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||||
|
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("Expected 1 log line, got %d", len(lines))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's valid JSON
|
||||||
|
var logEntry map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(lines[0]), &logEntry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected valid JSON output, got error: %v, output: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JSON structure
|
||||||
|
if logEntry["level"] != "INFO" {
|
||||||
|
t.Errorf("Expected level 'INFO', got '%v'", logEntry["level"])
|
||||||
|
}
|
||||||
|
if logEntry["msg"] != testMessage {
|
||||||
|
t.Errorf("Expected message '%s', got '%v'", testMessage, logEntry["msg"])
|
||||||
|
}
|
||||||
|
if logEntry["time"] == nil {
|
||||||
|
t.Error("Expected timestamp to be set")
|
||||||
|
}
|
||||||
|
if logEntry["component"] != "CrowdsecBouncerTraefikPlugin" {
|
||||||
|
t.Errorf("Expected component 'CrowdsecBouncerTraefikPlugin', got '%v'", logEntry["component"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonLogFormat(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Create a logger with text handler to capture output
|
||||||
|
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||||
|
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||||
|
|
||||||
|
testMessage := "common test message"
|
||||||
|
logger.Info(testMessage)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
// Verify common format (should contain level and message)
|
||||||
|
if !strings.Contains(output, "level=INFO") {
|
||||||
|
t.Error("Expected common format with INFO level")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, testMessage) {
|
||||||
|
t.Error("Expected test message in common format")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "component=CrowdsecBouncerTraefikPlugin") {
|
||||||
|
t.Error("Expected component field in common format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should NOT be JSON (should be slog text format)
|
||||||
|
var logEntry map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(strings.TrimSpace(output)), &logEntry)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected common format (not JSON), but got valid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorLevel(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Create a logger with ERROR level to capture output
|
||||||
|
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelError})
|
||||||
|
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||||
|
|
||||||
|
testMessage := "error only test"
|
||||||
|
|
||||||
|
// Test all log methods
|
||||||
|
logger.Error(testMessage)
|
||||||
|
logger.Warn(testMessage) // Should not appear
|
||||||
|
logger.Info(testMessage) // Should not appear
|
||||||
|
logger.Debug(testMessage) // Should not appear
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
// Only ERROR should appear
|
||||||
|
if !strings.Contains(output, "level=ERROR") {
|
||||||
|
t.Error("Expected ERROR message to appear")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other levels should NOT appear
|
||||||
|
unwantedLevels := []string{"level=WARN", "level=INFO", "level=DEBUG"}
|
||||||
|
for _, level := range unwantedLevels {
|
||||||
|
if strings.Contains(output, level) {
|
||||||
|
t.Errorf("Unexpected %s message appeared at ERROR level", level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify only one message appears
|
||||||
|
messageCount := strings.Count(output, testMessage)
|
||||||
|
if messageCount != 1 {
|
||||||
|
t.Errorf("Expected 1 occurrence of test message at ERROR level, got %d", messageCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidLogFile(t *testing.T) {
|
||||||
|
// Try to create logger with invalid file path
|
||||||
|
logger := New("INFO", "/invalid/path/that/does/not/exist/test.log")
|
||||||
|
|
||||||
|
// Logger should still be created (falls back to stdout)
|
||||||
|
if logger == nil {
|
||||||
|
t.Fatal("Expected logger to be created even with invalid file path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not panic when logging
|
||||||
|
logger.Info("test message")
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,261 @@
|
|||||||
|
#!/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.1}"
|
||||||
|
|
||||||
|
WEB_PORT="${WEB_PORT:-8000}"
|
||||||
|
LAPI_PORT="${LAPI_PORT:-8090}"
|
||||||
|
BACKEND_PORT="${BACKEND_PORT:-8091}"
|
||||||
|
APPSEC_PORT="${APPSEC_PORT:-8092}"
|
||||||
|
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|@@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}" \
|
||||||
|
"${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
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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 (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}()
|
||||||
|
|
||||||
|
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", *lapiAddr, *backendAddr, *appsecAddr)
|
||||||
|
log.Fatal(http.ListenAndServeTLS(*lapiAddr, *lapiTLSCert, *lapiTLSKey, mux))
|
||||||
|
}
|
||||||
|
log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s", *lapiAddr, *backendAddr, *appsecAddr)
|
||||||
|
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: none
|
||||||
|
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
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=../../lib/common.sh
|
||||||
|
source "$HERE/../../lib/common.sh"
|
||||||
|
|
||||||
|
SCENARIO=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&______"
|
||||||
|
}
|
||||||
|
|
||||||
|
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,26 @@
|
|||||||
|
http:
|
||||||
|
routers:
|
||||||
|
r:
|
||||||
|
rule: "PathPrefix(`/foo`)"
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
service: backend
|
||||||
|
middlewares:
|
||||||
|
- bouncer
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "@@BACKEND_URL@@"
|
||||||
|
middlewares:
|
||||||
|
bouncer:
|
||||||
|
plugin:
|
||||||
|
bouncer:
|
||||||
|
enabled: "true"
|
||||||
|
crowdsecMode: stream
|
||||||
|
updateIntervalSeconds: "2"
|
||||||
|
crowdsecLapiScheme: http
|
||||||
|
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||||
|
crowdsecLapiKey: "@@APIKEY@@"
|
||||||
|
forwardedHeadersTrustedIps:
|
||||||
|
- "127.0.0.1/32"
|
||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=../../lib/common.sh
|
||||||
|
source "$HERE/../../lib/common.sh"
|
||||||
|
|
||||||
|
SCENARIO=stream-mode
|
||||||
|
|
||||||
|
body() {
|
||||||
|
echo "[$SCENARIO] no decision yet -> request allowed"
|
||||||
|
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
|
||||||
|
lapi_add_decision 1.2.3.4 ban 5m
|
||||||
|
|
||||||
|
echo "[$SCENARIO] banned IP must be blocked once the next stream poll lands (HTTP 403)"
|
||||||
|
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] non-banned IP must still pass (HTTP 200)"
|
||||||
|
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 5.6.7.8"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] deleting ban decision"
|
||||||
|
lapi_delete_decision 1.2.3.4
|
||||||
|
|
||||||
|
echo "[$SCENARIO] previously banned IP must pass again once the deletion is polled"
|
||||||
|
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 200 15 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_scenario "$SCENARIO" "$HERE" body
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
http:
|
||||||
|
routers:
|
||||||
|
r:
|
||||||
|
rule: "PathPrefix(`/foo`)"
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
service: backend
|
||||||
|
middlewares:
|
||||||
|
- bouncer
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "@@BACKEND_URL@@"
|
||||||
|
middlewares:
|
||||||
|
bouncer:
|
||||||
|
plugin:
|
||||||
|
bouncer:
|
||||||
|
enabled: "true"
|
||||||
|
crowdsecMode: live
|
||||||
|
defaultDecisionSeconds: "2"
|
||||||
|
# HTTPS LAPI with NO custom CA configured: the bouncer must fall back to
|
||||||
|
# the OS/system trust store (which the scenario controls via SSL_CERT_FILE).
|
||||||
|
crowdsecLapiScheme: https
|
||||||
|
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||||
|
crowdsecLapiKey: "@@APIKEY@@"
|
||||||
|
forwardedHeadersTrustedIps:
|
||||||
|
- "127.0.0.1/32"
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Scenario: HTTPS LAPI with no custom CA configured -> the bouncer must fall back
|
||||||
|
# to the OS/system trust store (PR #331). In the binary suite the "system trust
|
||||||
|
# store" is whatever Go's x509.SystemCertPool() reads, which honours SSL_CERT_FILE
|
||||||
|
# on the Traefik process. We mint a throwaway CA, serve the mock LAPI over HTTPS
|
||||||
|
# with a cert signed by it, and run the stack twice:
|
||||||
|
#
|
||||||
|
# positive: SSL_CERT_FILE = our CA -> LAPI trusted -> 200
|
||||||
|
# negative: SSL_CERT_FILE = empty bundle -> LAPI not trusted -> 403
|
||||||
|
#
|
||||||
|
# live mode is fail-closed, so a TLS error becomes a 403. The negative run proves
|
||||||
|
# the patch still VERIFIES (it is not an insecure skip).
|
||||||
|
#
|
||||||
|
# Extra dependency vs other scenarios: openssl.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=../../lib/common.sh
|
||||||
|
source "$HERE/../../lib/common.sh"
|
||||||
|
|
||||||
|
SCENARIO=tls-system-ca
|
||||||
|
SCENARIO_NAME="$SCENARIO"
|
||||||
|
SCENARIO_LOG="/tmp/e2e-mock-${SCENARIO}.log"
|
||||||
|
CERT_DIR="$(mktemp -d)"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
if (( rc != 0 )); then
|
||||||
|
dump_diagnostics > "$SCENARIO_LOG" 2>&1 || true
|
||||||
|
echo "[$SCENARIO] failed. Logs written to $SCENARIO_LOG" >&2
|
||||||
|
fi
|
||||||
|
stop_stack
|
||||||
|
rm -rf "$CERT_DIR"
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "[$SCENARIO] minting throwaway CA + LAPI cert (SAN=IP:127.0.0.1)..."
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out "$CERT_DIR/ca.key" 2>/dev/null
|
||||||
|
openssl req -x509 -new -key "$CERT_DIR/ca.key" -sha256 -days 3650 \
|
||||||
|
-subj "/CN=crowdsec-bouncer e2e test CA" -out "$CERT_DIR/ca.crt" 2>/dev/null
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out "$CERT_DIR/lapi.key" 2>/dev/null
|
||||||
|
openssl req -new -key "$CERT_DIR/lapi.key" -subj "/CN=lapi" -out "$CERT_DIR/lapi.csr" 2>/dev/null
|
||||||
|
openssl x509 -req -in "$CERT_DIR/lapi.csr" -CA "$CERT_DIR/ca.crt" -CAkey "$CERT_DIR/ca.key" \
|
||||||
|
-CAcreateserial -days 3650 -sha256 -out "$CERT_DIR/lapi.crt" \
|
||||||
|
-extfile <(printf "subjectAltName=IP:127.0.0.1\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth") 2>/dev/null
|
||||||
|
: > "$CERT_DIR/empty.crt" # an empty bundle = a system store that trusts nothing
|
||||||
|
|
||||||
|
# The mock serves the same CA-signed cert in both runs; only Traefik's trust differs.
|
||||||
|
export LAPI_TLS_CERT="$CERT_DIR/lapi.crt" LAPI_TLS_KEY="$CERT_DIR/lapi.key"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] === positive: CA in the system trust store ==="
|
||||||
|
export TRAEFIK_SSL_CERT_FILE="$CERT_DIR/ca.crt"
|
||||||
|
start_stack "$HERE"
|
||||||
|
echo "[$SCENARIO] HTTPS LAPI verifies via system trust store -> request passes (200)"
|
||||||
|
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
stop_stack
|
||||||
|
|
||||||
|
echo "[$SCENARIO] === negative: CA absent from the system trust store ==="
|
||||||
|
export TRAEFIK_SSL_CERT_FILE="$CERT_DIR/empty.crt"
|
||||||
|
start_stack "$HERE"
|
||||||
|
echo "[$SCENARIO] LAPI cert not trusted -> TLS fails, fail-closed (403)"
|
||||||
|
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
stop_stack
|
||||||
|
|
||||||
|
echo "[$SCENARIO] OK"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
http:
|
||||||
|
routers:
|
||||||
|
r:
|
||||||
|
rule: "PathPrefix(`/foo`)"
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
service: backend
|
||||||
|
middlewares:
|
||||||
|
- bouncer
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "@@BACKEND_URL@@"
|
||||||
|
middlewares:
|
||||||
|
bouncer:
|
||||||
|
plugin:
|
||||||
|
bouncer:
|
||||||
|
enabled: "true"
|
||||||
|
crowdsecMode: stream
|
||||||
|
updateIntervalSeconds: "2"
|
||||||
|
crowdsecLapiScheme: http
|
||||||
|
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||||
|
crowdsecLapiKey: "@@APIKEY@@"
|
||||||
|
forwardedHeadersTrustedIps:
|
||||||
|
- "127.0.0.1/32"
|
||||||
|
clientTrustedIps:
|
||||||
|
- "1.2.3.4/32"
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=../../lib/common.sh
|
||||||
|
source "$HERE/../../lib/common.sh"
|
||||||
|
|
||||||
|
SCENARIO=trusted-ips
|
||||||
|
|
||||||
|
body() {
|
||||||
|
echo "[$SCENARIO] banning the trusted IP 1.2.3.4 and an untrusted IP 5.6.7.8"
|
||||||
|
lapi_add_decision 1.2.3.4 ban 5m
|
||||||
|
lapi_add_decision 5.6.7.8 ban 5m
|
||||||
|
|
||||||
|
# The untrusted IP turning 403 is our signal that the bans have been polled;
|
||||||
|
# it also doubles as the control proving the bouncer is active.
|
||||||
|
echo "[$SCENARIO] untrusted banned IP must be blocked once the bans are polled (HTTP 403)"
|
||||||
|
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 5.6.7.8"
|
||||||
|
|
||||||
|
echo "[$SCENARIO] trusted IP must bypass the bouncer even though it is banned"
|
||||||
|
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_scenario "$SCENARIO" "$HERE" body
|
||||||
+1
-1
@@ -117,7 +117,7 @@ func (sr *SimpleRedis) askRedis(cmd redisCmd, channel chan redisCmd) redisCmd {
|
|||||||
str := string(read)
|
str := string(read)
|
||||||
if strings.Contains(str, "-NOAUTH") {
|
if strings.Contains(str, "-NOAUTH") {
|
||||||
return redisCmd{Error: fmt.Errorf(RedisNoAuth)}
|
return redisCmd{Error: fmt.Errorf(RedisNoAuth)}
|
||||||
} else if str != "$1" {
|
} else if str == "$-1" {
|
||||||
return redisCmd{Error: fmt.Errorf(RedisMiss)}
|
return redisCmd{Error: fmt.Errorf(RedisMiss)}
|
||||||
}
|
}
|
||||||
read, _ = reader.ReadLineBytes()
|
read, _ = reader.ReadLineBytes()
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# github.com/leprosus/golang-ttl-map v1.1.7
|
# github.com/leprosus/golang-ttl-map v1.1.7
|
||||||
## explicit; go 1.15
|
## explicit; go 1.15
|
||||||
github.com/leprosus/golang-ttl-map
|
github.com/leprosus/golang-ttl-map
|
||||||
# github.com/maxlerebourg/simpleredis v1.0.11
|
# github.com/maxlerebourg/simpleredis v1.0.12
|
||||||
## explicit; go 1.22
|
## explicit; go 1.22
|
||||||
github.com/maxlerebourg/simpleredis
|
github.com/maxlerebourg/simpleredis
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||||
|
|
||||||
|
// pluginVersion is updated automatically by the release workflow.
|
||||||
|
var pluginVersion = "1.6.X" //nolint:gochecknoglobals
|
||||||
Reference in New Issue
Block a user