mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2bbc4dac5 | ||
|
|
889c5b55fe | ||
|
|
efb3a67019 | ||
|
|
0780027252 | ||
|
|
892909b9b8 | ||
|
|
c26923dee5 | ||
|
|
a9d83f2097 | ||
|
|
e20ccc5d0c | ||
|
|
50beb4294f | ||
|
|
4ab4f3f183 | ||
|
|
2aac531ea7 | ||
|
|
e8e60c958f | ||
|
|
a2d3708bc3 | ||
|
|
65a2f79fb3 | ||
|
|
a2ecc95dc9 | ||
|
|
7c4f5163e9 | ||
|
|
734975c206 | ||
|
|
7397834e58 | ||
|
|
84a5674b14 | ||
|
|
de7e382fde | ||
|
|
abae855d9e | ||
|
|
4bb6e830dc | ||
|
|
78869ecf77 | ||
|
|
7f99266f99 | ||
|
|
5418d35feb | ||
|
|
a184ae6db9 | ||
|
|
e4c84409e7 | ||
|
|
4708d76854 | ||
|
|
c34d7f4617 | ||
|
|
0e9620bfe9 | ||
|
|
92f05b0ba5 | ||
|
|
980a7dd05e | ||
|
|
5c8a60118f | ||
|
|
8fb0a016b6 | ||
|
|
45d5f38c4d |
@@ -0,0 +1,24 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# Maintain dependencies for Go
|
||||||
|
- package-ecosystem: "gomod"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
# Maintain dependencies for build tools
|
||||||
|
- package-ecosystem: "gomod"
|
||||||
|
directory: "/tools"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
# Maintain dependencies for GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
name: Main
|
name: Main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -12,8 +15,8 @@ jobs:
|
|||||||
name: Main Process
|
name: Main Process
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
GO_VERSION: 1.22
|
GO_VERSION: 1.23
|
||||||
GOLANGCI_LINT_VERSION: v1.57.2
|
GOLANGCI_LINT_VERSION: v1.63.4
|
||||||
YAEGI_VERSION: v0.16.1
|
YAEGI_VERSION: v0.16.1
|
||||||
CGO_ENABLED: 0
|
CGO_ENABLED: 0
|
||||||
defaults:
|
defaults:
|
||||||
@@ -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@v2
|
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@v2
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
path: go/src/github.com/${{ github.repository }}
|
path: go/src/github.com/${{ github.repository }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# https://github.com/marketplace/actions/cache
|
# https://github.com/marketplace/actions/cache
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ github.workspace }}/go/pkg/mod
|
path: ${{ github.workspace }}/go/pkg/mod
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: Release Version Update
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-version:
|
||||||
|
name: Update version in source
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
TAG="${{ github.event.release.tag_name }}"
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Update version in version.go
|
||||||
|
run: |
|
||||||
|
sed -i 's/pluginVersion = "[^"]*"/pluginVersion = "'"${{ steps.get_version.outputs.version }}"'"/' version.go
|
||||||
|
cat version.go
|
||||||
|
|
||||||
|
- name: Commit, push, and retag
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add version.go
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "Version already up to date, nothing to commit"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "⬆️ chore: bump version to ${{ steps.get_version.outputs.version }}"
|
||||||
|
git push origin main
|
||||||
|
# Move the release tag to include the version update
|
||||||
|
git tag -f "${{ steps.get_version.outputs.tag }}"
|
||||||
|
git push -f origin "${{ steps.get_version.outputs.tag }}"
|
||||||
+21
-34
@@ -15,7 +15,7 @@ linters-settings:
|
|||||||
locale: US
|
locale: US
|
||||||
funlen:
|
funlen:
|
||||||
lines: -1
|
lines: -1
|
||||||
statements: 50
|
statements: 60
|
||||||
godox:
|
godox:
|
||||||
keywords:
|
keywords:
|
||||||
- FIXME
|
- FIXME
|
||||||
@@ -25,45 +25,33 @@ linters-settings:
|
|||||||
rules:
|
rules:
|
||||||
Main:
|
Main:
|
||||||
files:
|
files:
|
||||||
- $all
|
- $all
|
||||||
- "!$test"
|
- "!$test"
|
||||||
allow:
|
allow:
|
||||||
- $gostd
|
- $gostd
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
|
||||||
- github.com/leprosus/golang-ttl-map
|
- github.com/leprosus/golang-ttl-map
|
||||||
- github.com/maxlerebourg/simpleredis
|
- github.com/maxlerebourg/simpleredis
|
||||||
Test:
|
Test:
|
||||||
files:
|
files:
|
||||||
- $test
|
- $test
|
||||||
allow:
|
allow:
|
||||||
- $gostd
|
- $gostd
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
|
||||||
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
|
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable-all: true
|
enable-all: true
|
||||||
disable:
|
disable:
|
||||||
- deadcode # deprecated
|
|
||||||
- exhaustivestruct # deprecated
|
|
||||||
- golint # deprecated
|
|
||||||
- ifshort # deprecated
|
|
||||||
- interfacer # deprecated
|
|
||||||
- maligned # deprecated
|
|
||||||
- nosnakecase # deprecated
|
|
||||||
- scopelint # deprecated
|
|
||||||
- scopelint # deprecated
|
|
||||||
- structcheck # deprecated
|
|
||||||
- varcheck # deprecated
|
|
||||||
- sqlclosecheck # not relevant (SQL)
|
- sqlclosecheck # not relevant (SQL)
|
||||||
- rowserrcheck # not relevant (SQL)
|
- rowserrcheck # not relevant (SQL)
|
||||||
- execinquery # not relevant (SQL)
|
|
||||||
- cyclop # duplicate of gocyclo
|
- cyclop # duplicate of gocyclo
|
||||||
- bodyclose # Too many false positives: https://github.com/timakin/bodyclose/issues/30
|
- bodyclose # Too many false positives: https://github.com/timakin/bodyclose/issues/30
|
||||||
- dupl
|
- dupl
|
||||||
@@ -74,18 +62,17 @@ linters:
|
|||||||
- wsl
|
- wsl
|
||||||
- exhaustive
|
- exhaustive
|
||||||
- exhaustruct
|
- exhaustruct
|
||||||
- goerr113
|
- err113
|
||||||
- wrapcheck
|
- wrapcheck
|
||||||
- ifshort
|
|
||||||
- noctx
|
- noctx
|
||||||
- lll
|
- lll
|
||||||
- gomnd
|
|
||||||
- forbidigo
|
- forbidigo
|
||||||
- varnamelen
|
- varnamelen
|
||||||
- wastedassign # is disabled because of generics
|
- wastedassign # is disabled because of generics
|
||||||
- gofumpt
|
- gofumpt
|
||||||
- gci
|
- gci
|
||||||
|
- mnd
|
||||||
|
- exportloopref
|
||||||
issues:
|
issues:
|
||||||
exclude-use-default: false
|
exclude-use-default: false
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|||||||
@@ -41,13 +41,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 +101,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
|
||||||
|
|||||||
@@ -26,34 +26,36 @@ The AppSec Component offers:
|
|||||||
- Low-effort virtual patching capabilities.
|
- Low-effort virtual patching capabilities.
|
||||||
- Support for your legacy ModSecurity rules.
|
- Support for your legacy ModSecurity rules.
|
||||||
- Combining classic WAF benefits with advanced CrowdSec features for otherwise difficult advanced behavior detection.
|
- Combining classic WAF benefits with advanced CrowdSec features for otherwise difficult advanced behavior detection.
|
||||||
|
|
||||||
More information on appsec in the [Crowdsec Documentation](https://doc.crowdsec.net/docs/next/appsec/intro/).
|
More information on appsec in the [Crowdsec Documentation](https://doc.crowdsec.net/docs/next/appsec/intro/).
|
||||||
|
|
||||||
Remediation offered by [Crowdsec](https://docs.crowdsec.net/u/bouncers/intro) and supported by the plugin can be either `ban` or `captcha`.
|
Remediation offered by [Crowdsec](https://docs.crowdsec.net/u/bouncers/intro) and supported by the plugin can be either `ban` or `captcha`.
|
||||||
For the `ban` remediation the user will be blocked in Traefik (HTTP 403).
|
For the `ban` remediation the user will be blocked in Traefik (HTTP 403).
|
||||||
For the `captcha` remediation, the user will be redirected to a page to complete a captcha challenge.
|
For the `captcha` remediation, the user will be redirected to a page to complete a captcha challenge.
|
||||||
|
|
||||||
On successfull completion, he will be cleaned for a specified period of time before a new resolution challenge is expected if Crowdsec still has a decision to verify the user behavior. See the example captcha for more informations and configuration intructions.
|
On successfull completion, he will be cleaned for a specified period of time before a new resolution challenge is expected if Crowdsec still has a decision to verify the user behavior. See the example captcha for more informations and configuration intructions.
|
||||||
The following captcha providers are supported now:
|
The following captcha providers are supported now:
|
||||||
- [hcaptcha](https://www.hcaptcha.com/)
|
|
||||||
- [recaptcha](https://www.google.com/recaptcha/about/)
|
|
||||||
- [turnstile](https://www.cloudflare.com/products/turnstile/)
|
|
||||||
|
|
||||||
|
- [hcaptcha](https://www.hcaptcha.com/)
|
||||||
|
- [recaptcha](https://www.google.com/recaptcha/about/)
|
||||||
|
- [turnstile](https://www.cloudflare.com/products/turnstile/)
|
||||||
|
- [custom/wicketkeeper](https://github.com/a-ve/wicketkeeper)
|
||||||
|
|
||||||
There are 5 operating modes (CrowdsecMode) for this plugin:
|
There are 5 operating modes (CrowdsecMode) for this plugin:
|
||||||
|
|
||||||
| Mode | Description |
|
| Mode | Description |
|
||||||
|------|------|
|
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| none | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. All request call the Crowdsec LAPI |
|
| none | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. All request call the Crowdsec LAPI |
|
||||||
| live | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. The bouncer can leverage use of a local cache in order to reduce the number of requests made to the Crowdsec LAPI. It will keep in cache the status for each IP that makes queries. |
|
| live | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. The bouncer can leverage use of a local cache in order to reduce the number of requests made to the Crowdsec LAPI. It will keep in cache the status for each IP that makes queries. |
|
||||||
| stream | Stream Streaming mode allows you to keep in the local cache only the Banned IPs, every requests that does not hit the cache is authorized. Every minute, the cache is updated with news from the Crowdsec LAPI. |
|
| stream | Stream Streaming mode allows you to keep in the local cache only the Banned IPs, every requests that does not hit the cache is authorized. Every minute, the cache is updated with news from the Crowdsec LAPI. |
|
||||||
| alone | Standalone mode, similar to the streaming mode but the blacklisted IPs are fetched on the CAPI. Every 2 hours, the cache is updated with news from the Crowdsec CAPI. It does not include any locally banned IP, but can work without a crowdsec service. |
|
| alone | Standalone mode, similar to the streaming mode but the blacklisted IPs are fetched on the CAPI. Every 2 hours, the cache is updated with news from the Crowdsec CAPI. It does not include any locally banned IP, but can work without a crowdsec service. |
|
||||||
| appsec | Disable Crowdsec IP checking but apply Crowdsec Appsec checking. This mode is intended to be used when Crowdsec IP checking is applied at the Firewall Level. |
|
| appsec | Disable Crowdsec IP checking but apply Crowdsec Appsec checking. This mode is intended to be used when Crowdsec IP checking is applied at the Firewall Level. |
|
||||||
|
|
||||||
The `streaming mode` is recommended for performance, decisions are updated every 60 sec by default and that's the only communication between Traefik and Crowdsec. Every request that happens hits the cache for quick decisions.
|
The `streaming mode` is recommended for performance, decisions are updated every 60 sec by default and that's the only communication between Traefik and Crowdsec. Every request that happens hits the cache for quick decisions.
|
||||||
|
|
||||||
The cache can be local to Traefik in memory or using a separate Redis instance.
|
The cache can be local to Traefik in memory or using a separate Redis instance.
|
||||||
|
|
||||||
Below are Mermaid diagrams detailling how each mode work:
|
Below are Mermaid diagrams detailling how each mode work:
|
||||||
|
|
||||||
<details><summary>Mode none workflow</summary>
|
<details><summary>Mode none workflow</summary>
|
||||||
|
|
||||||
@@ -283,7 +285,7 @@ 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
|
||||||
@@ -300,24 +302,44 @@ sequenceDiagram
|
|||||||
To get started, use the `docker-compose.yml` file.
|
To get started, use the `docker-compose.yml` file.
|
||||||
|
|
||||||
You can run it with:
|
You can run it with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make run
|
make run
|
||||||
```
|
```
|
||||||
|
|
||||||
### Note
|
### Note
|
||||||
|
|
||||||
**/!\ Cache is shared by all services**
|
> [!IMPORTANT]
|
||||||
*This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP*
|
> Some of the behaviours and configuration parameters are shared globally across _all_ crowdsec middlewares even if you declare different middlewares with different settings.
|
||||||
Only one instance of the plugin is *possible*.
|
>
|
||||||
|
> **Cache is shared by all services**: This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP
|
||||||
|
>
|
||||||
|
> If you define different caches for different middlewares, only the first one to be instantiated will be bound to the crowdsec stream.
|
||||||
|
>
|
||||||
|
> Overall, this middleware is designed in such a way that **only one instance of the plugin is _possible_.** You can have multiple crowdsec middlewares in the same cluster, the key parameters must be aligned (MetricsUpdateIntervalSeconds, CrowdsecMode, CrowdsecAppsecEnabled, etc.)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **Appsec maximum body limit is defaulted to 10MB** > _Be careful when you upgrade to >1.4.x_
|
||||||
|
|
||||||
### Variables
|
### Variables
|
||||||
|
|
||||||
- Enabled
|
- Enabled
|
||||||
- bool
|
- bool
|
||||||
- default: false
|
- default: false
|
||||||
- Enable the plugin
|
- Enable the plugin
|
||||||
- LogLevel
|
- LogLevel
|
||||||
- string
|
- string
|
||||||
- default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR`, log are written to `stdout` / `stderr`
|
- default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR`
|
||||||
|
- Log are written to `stdout` / `stderr` or file if LogFilePath is provided
|
||||||
|
- LogFilePath
|
||||||
|
- string
|
||||||
|
- default: ""
|
||||||
|
- File Path to write logs, must be writable by Traefik, Log rotation may require a restart of traefik
|
||||||
|
- MetricsUpdateIntervalSeconds
|
||||||
|
- int64
|
||||||
|
- default: 600
|
||||||
|
- Interval in seconds between metrics updates to Crowdsec
|
||||||
|
- If set to zero or less, metrics collection is disabled
|
||||||
- CrowdsecMode
|
- CrowdsecMode
|
||||||
- string
|
- string
|
||||||
- default: `live`, expected values are: `none`, `live`, `stream`, `alone`, `appsec`
|
- default: `live`, expected values are: `none`, `live`, `stream`, `alone`, `appsec`
|
||||||
@@ -328,7 +350,22 @@ Only one instance of the plugin is *possible*.
|
|||||||
- CrowdsecAppsecHost
|
- CrowdsecAppsecHost
|
||||||
- string
|
- string
|
||||||
- default: "crowdsec:7422"
|
- default: "crowdsec:7422"
|
||||||
- Crowdsec Appsec Server available on which host and port. The scheme will be handled by the CrowdsecLapiScheme var.
|
- Crowdsec Appsec Server available on which host and port.
|
||||||
|
- CrowdsecAppsecTlsInsecureVerify
|
||||||
|
- bool
|
||||||
|
- default: false
|
||||||
|
- Disable verification of certificate presented by Appsec
|
||||||
|
- CrowdsecAppsecTlsCertificateAuthority
|
||||||
|
- string
|
||||||
|
- default: ""
|
||||||
|
- PEM-encoded Certificate Authority of Appsec
|
||||||
|
- CrowdsecAppsecScheme
|
||||||
|
- string
|
||||||
|
- default: value of `CrowdsecLapiScheme`, expected values are: `http`, `https`
|
||||||
|
- CrowdsecAppsecPath
|
||||||
|
- string
|
||||||
|
- default: "/"
|
||||||
|
- Crowdsec Appsec Server available on this path. Will be appended to CrowdsecAppsecHost. Need to finish with "/".
|
||||||
- CrowdsecAppsecFailureBlock
|
- CrowdsecAppsecFailureBlock
|
||||||
- bool
|
- bool
|
||||||
- default: true
|
- default: true
|
||||||
@@ -337,6 +374,14 @@ Only one instance of the plugin is *possible*.
|
|||||||
- bool
|
- bool
|
||||||
- default: true
|
- default: true
|
||||||
- Block request when Crowdsec Appsec Server is unreachable.
|
- Block request when Crowdsec Appsec Server is unreachable.
|
||||||
|
- CrowdsecAppsecBodyLimit
|
||||||
|
- int64
|
||||||
|
- default: 10485760 (= 10MB)
|
||||||
|
- Transmit only the first number of bytes to Crowdsec Appsec Server.
|
||||||
|
- CrowdsecAppsecKey
|
||||||
|
- string
|
||||||
|
- default: value of `CrowdsecLapiKey`
|
||||||
|
- Crowdsec AppSec key for the bouncer.
|
||||||
- CrowdsecLapiScheme
|
- CrowdsecLapiScheme
|
||||||
- string
|
- string
|
||||||
- default: `http`, expected values are: `http`, `https`
|
- default: `http`, expected values are: `http`, `https`
|
||||||
@@ -344,10 +389,14 @@ Only one instance of the plugin is *possible*.
|
|||||||
- string
|
- string
|
||||||
- default: "crowdsec:8080"
|
- default: "crowdsec:8080"
|
||||||
- Crowdsec LAPI available on which host and port.
|
- Crowdsec LAPI available on which host and port.
|
||||||
|
- CrowdsecLapiPath
|
||||||
|
- string
|
||||||
|
- default: "/"
|
||||||
|
- Crowdsec LAPI Server available on this path. Will be appended to CrowdsecLapiHost. Need to finish with "/".
|
||||||
- CrowdsecLapiKey
|
- CrowdsecLapiKey
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- Crowdsec LAPI key for the bouncer.
|
- Crowdsec LAPI key for the bouncer.
|
||||||
- CrowdsecLapiTlsInsecureVerify
|
- CrowdsecLapiTlsInsecureVerify
|
||||||
- bool
|
- bool
|
||||||
- default: false
|
- default: false
|
||||||
@@ -365,33 +414,41 @@ Only one instance of the plugin is *possible*.
|
|||||||
- default: ""
|
- default: ""
|
||||||
- PEM-encoded client private key of the Bouncer
|
- PEM-encoded client private key of the Bouncer
|
||||||
- ClientTrustedIPs
|
- ClientTrustedIPs
|
||||||
- string
|
- string
|
||||||
- default: []
|
- default: []
|
||||||
- List of client IPs to trust, they will bypass any check from the bouncer or cache (useful for LAN or VPN IP)
|
- List of client IPs to trust, they will bypass any check from the bouncer or cache (useful for LAN or VPN IP)
|
||||||
- ForwardedHeadersTrustedIPs
|
- RemediationHeadersCustomName
|
||||||
- []string
|
- string
|
||||||
- default: []
|
- default: ""
|
||||||
- List of IPs of trusted Proxies that are in front of traefik (ex: Cloudflare)
|
- Name of the header you want in response when request are handled by plugin (possible value of the header `ban`, `captcha` or `solved-captcha`)
|
||||||
- ForwardedHeadersCustomName
|
- ForwardedHeadersCustomName
|
||||||
- string
|
- string
|
||||||
- default: "X-Forwarded-For"
|
- default: "X-Forwarded-For"
|
||||||
- Name of the header where the real IP of the client should be retrieved
|
- Name of the header where the real IP of the client should be retrieved
|
||||||
|
- ForwardedHeadersTrustedIPs
|
||||||
|
- []string
|
||||||
|
- default: []
|
||||||
|
- List of IPs of trusted Proxies that are in front of traefik (ex: Cloudflare)
|
||||||
- RedisCacheEnabled
|
- RedisCacheEnabled
|
||||||
- bool
|
- bool
|
||||||
- default: false
|
- default: false
|
||||||
- enable Redis cache instead of in-memory cache
|
- enable Redis cache instead of in-memory cache
|
||||||
- RedisCacheHost
|
- RedisCacheHost
|
||||||
- string
|
- string
|
||||||
- default: "redis:6379"
|
- default: "redis:6379"
|
||||||
- hostname and port for the Redis service
|
- hostname and port for the Redis service
|
||||||
- RedisCachePassword
|
- RedisCachePassword
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- Password for the Redis service
|
- Password for the Redis service
|
||||||
- RedisCacheDatabase
|
- RedisCacheDatabase
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- Database selection for the Redis service
|
- Database selection for the Redis service
|
||||||
|
- RedisCacheUnreachableBlock
|
||||||
|
- bool
|
||||||
|
- default: true
|
||||||
|
- Block request when Redis is unreachable (if Redis is unreachable, 1-second delay is added to each request)
|
||||||
- HTTPTimeoutSeconds
|
- HTTPTimeoutSeconds
|
||||||
- int64
|
- int64
|
||||||
- default: 10
|
- default: 10
|
||||||
@@ -408,6 +465,10 @@ Only one instance of the plugin is *possible*.
|
|||||||
- int64
|
- int64
|
||||||
- default: 60
|
- default: 60
|
||||||
- Used only in `live` mode, maximum decision duration
|
- Used only in `live` mode, maximum decision duration
|
||||||
|
- RemediationStatusCode
|
||||||
|
- int
|
||||||
|
- default: 403
|
||||||
|
- HTTP status code for banned user (not captcha)
|
||||||
- CrowdsecCapiMachineId
|
- CrowdsecCapiMachineId
|
||||||
- string
|
- string
|
||||||
- Used only in `alone` mode, login for Crowdsec CAPI
|
- Used only in `alone` mode, login for Crowdsec CAPI
|
||||||
@@ -419,7 +480,19 @@ Only one instance of the plugin is *possible*.
|
|||||||
- Used only in `alone` mode, scenarios for Crowdsec CAPI
|
- Used only in `alone` mode, scenarios for Crowdsec CAPI
|
||||||
- CaptchaProvider
|
- CaptchaProvider
|
||||||
- string
|
- string
|
||||||
- Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile`
|
- Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile` or `custom`
|
||||||
|
- CaptchaCustomJsURL
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, URL used to load the challenge in the HTML (in case of hcaptcha: `https://hcaptcha.com/1/api.js`)
|
||||||
|
- CaptchaCustomValidateURL
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, URL used to validate the challenge (in case of hcaptcha: `https://api.hcaptcha.com/siteverify`)
|
||||||
|
- CaptchaCustomKey
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, used to set class name of the div used by captcha provider (in case of hcaptcha: `h-captcha`)
|
||||||
|
- CaptchaCustomResponse
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, used to set the field in the POST body from the captcha.html to Traefik (in case of hcaptcha: `h-captcha-response`)
|
||||||
- CaptchaSiteKey
|
- CaptchaSiteKey
|
||||||
- string
|
- string
|
||||||
- Site key for the captcha provider
|
- Site key for the captcha provider
|
||||||
@@ -438,12 +511,17 @@ Only one instance of the plugin is *possible*.
|
|||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- Path where the ban html file is stored (default empty ""=disabled)
|
- Path where the ban html file is stored (default empty ""=disabled)
|
||||||
|
- TraceHeadersCustomName
|
||||||
|
- string
|
||||||
|
- default: ""
|
||||||
|
- Request Header name whose value to inject in ban HTML response (default empty ""=disabled)
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
For each plugin, the Traefik static configuration must define the module name (as is usual for Go packages).
|
For each plugin, the Traefik static configuration must define the module name (as is usual for Go packages).
|
||||||
|
|
||||||
The following declaration (given here in YAML) defines a plugin:
|
The following declaration (given here in YAML) defines a plugin:
|
||||||
|
|
||||||
> Note that you don't need to copy all thoses settings but only the ones you want to use.
|
> Note that you don't need to copy all thoses settings but only the ones you want to use.
|
||||||
> See the examples for advanced usage.
|
> See the examples for advanced usage.
|
||||||
|
|
||||||
@@ -475,26 +553,31 @@ http:
|
|||||||
loadBalancer:
|
loadBalancer:
|
||||||
servers:
|
servers:
|
||||||
- url: http://127.0.0.1:5000
|
- url: http://127.0.0.1:5000
|
||||||
|
|
||||||
middlewares:
|
middlewares:
|
||||||
crowdsec:
|
crowdsec:
|
||||||
plugin:
|
plugin:
|
||||||
bouncer:
|
bouncer:
|
||||||
enabled: false
|
enabled: false
|
||||||
logLevel: DEBUG
|
logLevel: DEBUG
|
||||||
|
LogFilePath: ""
|
||||||
updateIntervalSeconds: 60
|
updateIntervalSeconds: 60
|
||||||
updateMaxFailure: 0
|
updateMaxFailure: 0
|
||||||
defaultDecisionSeconds: 60
|
defaultDecisionSeconds: 60
|
||||||
|
remediationStatusCode: 403
|
||||||
httpTimeoutSeconds: 10
|
httpTimeoutSeconds: 10
|
||||||
crowdsecMode: live
|
crowdsecMode: live
|
||||||
crowdsecAppsecEnabled: false
|
crowdsecAppsecEnabled: false
|
||||||
|
crowdsecAppsecScheme: ""
|
||||||
crowdsecAppsecHost: crowdsec:7422
|
crowdsecAppsecHost: crowdsec:7422
|
||||||
|
crowdsecAppsecPath: "/"
|
||||||
crowdsecAppsecFailureBlock: true
|
crowdsecAppsecFailureBlock: true
|
||||||
crowdsecAppsecUnreachableBlock: true
|
crowdsecAppsecUnreachableBlock: true
|
||||||
|
crowdsecAppsecBodyLimit: 10485760
|
||||||
crowdsecLapiKey: privateKey-foo
|
crowdsecLapiKey: privateKey-foo
|
||||||
crowdsecLapiKeyFile: /etc/traefik/cs-privateKey-foo
|
|
||||||
crowdsecLapiHost: crowdsec:8080
|
|
||||||
crowdsecLapiScheme: http
|
crowdsecLapiScheme: http
|
||||||
|
crowdsecLapiHost: crowdsec:8080
|
||||||
|
crowdsecLapiPath: "/"
|
||||||
crowdsecLapiTLSInsecureVerify: false
|
crowdsecLapiTLSInsecureVerify: false
|
||||||
crowdsecCapiMachineId: login
|
crowdsecCapiMachineId: login
|
||||||
crowdsecCapiPassword: password
|
crowdsecCapiPassword: password
|
||||||
@@ -502,23 +585,24 @@ http:
|
|||||||
- crowdsecurity/http-path-traversal-probing
|
- crowdsecurity/http-path-traversal-probing
|
||||||
- crowdsecurity/http-xss-probing
|
- crowdsecurity/http-xss-probing
|
||||||
- crowdsecurity/http-generic-bf
|
- crowdsecurity/http-generic-bf
|
||||||
forwardedHeadersTrustedIPs:
|
forwardedHeadersTrustedIPs:
|
||||||
- 10.0.10.23/32
|
- 10.0.10.23/32
|
||||||
- 10.0.20.0/24
|
- 10.0.20.0/24
|
||||||
clientTrustedIPs:
|
clientTrustedIPs:
|
||||||
- 192.168.1.0/24
|
- 192.168.1.0/24
|
||||||
forwardedHeadersCustomName: X-Custom-Header
|
forwardedHeadersCustomName: X-Custom-Header
|
||||||
|
remediationHeadersCustomName: cs-remediation
|
||||||
redisCacheEnabled: false
|
redisCacheEnabled: false
|
||||||
redisCacheHost: "redis:6379"
|
redisCacheHost: "redis:6379"
|
||||||
redisCachePassword: password
|
redisCachePassword: password
|
||||||
redisCacheDatabase: "5"
|
redisCacheDatabase: "5"
|
||||||
|
redisCacheUnreachableBlock: true
|
||||||
crowdsecLapiTLSCertificateAuthority: |-
|
crowdsecLapiTLSCertificateAuthority: |-
|
||||||
-----BEGIN CERTIFICATE-----
|
-----BEGIN CERTIFICATE-----
|
||||||
MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
|
MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
|
||||||
...
|
...
|
||||||
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
|
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
|
||||||
-----END CERTIFICATE-----
|
-----END CERTIFICATE-----
|
||||||
crowdsecLapiTLSCertificateAuthorityFile: /etc/traefik/crowdsec-certs/ca.pem
|
|
||||||
crowdsecLapiTLSCertificateBouncer: |-
|
crowdsecLapiTLSCertificateBouncer: |-
|
||||||
-----BEGIN CERTIFICATE-----
|
-----BEGIN CERTIFICATE-----
|
||||||
MIIEHjCCAwagAwIBAgIUOBTs1eqkaAUcPplztUr2xRapvNAwDQYJKoZIhvcNAQEL
|
MIIEHjCCAwagAwIBAgIUOBTs1eqkaAUcPplztUr2xRapvNAwDQYJKoZIhvcNAQEL
|
||||||
@@ -526,30 +610,31 @@ http:
|
|||||||
RaXAnYYUVRblS1jmePemh388hFxbmrpG2pITx8B5FMULqHoj11o2Rl0gSV6tHIHz
|
RaXAnYYUVRblS1jmePemh388hFxbmrpG2pITx8B5FMULqHoj11o2Rl0gSV6tHIHz
|
||||||
N2U=
|
N2U=
|
||||||
-----END CERTIFICATE-----
|
-----END CERTIFICATE-----
|
||||||
crowdsecLapiTLSCertificateBouncerFile: /etc/traefik/crowdsec-certs/bouncer.pem
|
|
||||||
crowdsecLapiTLSCertificateBouncerKey: |-
|
crowdsecLapiTLSCertificateBouncerKey: |-
|
||||||
-----BEGIN RSA PRIVATE KEY-----
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
MIIEogIBAAKCAQEAtYQnbJqifH+ZymePylDxGGLIuxzcAUU4/ajNj+qRAdI/Ux3d
|
MIIEogIBAAKCAQEAtYQnbJqifH+ZymePylDxGGLIuxzcAUU4/ajNj+qRAdI/Ux3d
|
||||||
...
|
...
|
||||||
ic5cDRo6/VD3CS3MYzyBcibaGaV34nr0G/pI+KEqkYChzk/PZRA=
|
ic5cDRo6/VD3CS3MYzyBcibaGaV34nr0G/pI+KEqkYChzk/PZRA=
|
||||||
-----END RSA PRIVATE KEY-----
|
-----END RSA PRIVATE KEY-----
|
||||||
crowdsecLapiTLSCertificateBouncerKeyFile: /etc/traefik/crowdsec-certs/bouncer-key.pem
|
|
||||||
captchaProvider: hcaptcha
|
captchaProvider: hcaptcha
|
||||||
captchaSiteKey: FIXME
|
captchaSiteKey: FIXME
|
||||||
captchaSecretKey: FIXME
|
captchaSecretKey: FIXME
|
||||||
captchaGracePeriodSeconds: 1800
|
captchaGracePeriodSeconds: 1800
|
||||||
captchaHTMLFilePath: /captcha.html
|
captchaHTMLFilePath: /captcha.html
|
||||||
banHTMLFilePath: /ban.html
|
banHTMLFilePath: /ban.html
|
||||||
|
traceHeadersCustomName: X-Request-ID
|
||||||
|
metricsUpdateIntervalSeconds: 600
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Fill variable with value of file
|
#### Fill variable with value of file
|
||||||
|
|
||||||
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey` and `CaptchaSecretKey` can be provided with the content as raw or through a file path that Traefik can read.
|
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecAppsecTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CrowdsecAppsecKey`, `CaptchaSiteKey`, `CaptchaSecretKey` and `RedisCachePassword` can be provided with the content as raw or through a file path that Traefik can read.
|
||||||
The file variable will be used as preference if both content and file are provided for the same variable.
|
The file variable will be used as preference if both content and file are provided for the same variable.
|
||||||
|
|
||||||
Format is:
|
Format is:
|
||||||
|
|
||||||
- Content: VariableName: XXX
|
- Content: VariableName: XXX
|
||||||
- File : VariableNameFile: /path
|
- File : VariableNameFile: /path
|
||||||
|
|
||||||
#### Authenticate with LAPI
|
#### Authenticate with LAPI
|
||||||
|
|
||||||
@@ -557,6 +642,7 @@ You can authenticate to the LAPI either with LAPIKEY or by using client certific
|
|||||||
Please see below for more details on each option.
|
Please see below for more details on each option.
|
||||||
|
|
||||||
#### Generate LAPI KEY
|
#### Generate LAPI KEY
|
||||||
|
|
||||||
You can generate a crowdsec API key for the LAPI.
|
You can generate a crowdsec API key for the LAPI.
|
||||||
You can follow the documentation here: [docs.crowdsec.net/docs/user_guides/lapi_mgmt](https://docs.crowdsec.net/docs/user_guides/lapi_mgmt)
|
You can follow the documentation here: [docs.crowdsec.net/docs/user_guides/lapi_mgmt](https://docs.crowdsec.net/docs/user_guides/lapi_mgmt)
|
||||||
|
|
||||||
@@ -566,24 +652,26 @@ docker exec crowdsec cscli bouncers add crowdsecBouncer
|
|||||||
```
|
```
|
||||||
|
|
||||||
This LAPI key must be set where is noted FIXME-LAPI-KEY in the docker-compose.yml
|
This LAPI key must be set where is noted FIXME-LAPI-KEY in the docker-compose.yml
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
...
|
..
|
||||||
whoami:
|
whoami:
|
||||||
labels:
|
labels:
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=FIXME-LAPI-KEY"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=FIXME-LAPI-KEY"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=http"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=http"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapihost=crowdsec:8080"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapihost=crowdsec:8080"
|
||||||
...
|
..
|
||||||
crowdsec:
|
crowdsec:
|
||||||
environment:
|
environment:
|
||||||
BOUNCER_KEY_TRAEFIK: FIXME-LAPI-KEY
|
BOUNCER_KEY_TRAEFIK: FIXME-LAPI-KEY
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
|
|
||||||
> Crowdsec does not require a specific format for la LAPI-key, you may use something like FIXME-LAPI-KEY but that is not recommanded for obvious reasons
|
> Crowdsec does not require a specific format for la LAPI-key, you may use something like FIXME-LAPI-KEY but that is not recommanded for obvious reasons
|
||||||
|
|
||||||
You can then run all the containers:
|
You can then run all the containers:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
@@ -603,13 +691,20 @@ Set the `crowdsecLapiScheme` to https.
|
|||||||
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
|
||||||
|
|
||||||
|
To communicate with the Appsec in HTTPS you need to either accept any certificates by setting the `crowdsecAppsecTLSInsecureVerify` to true or add the CA used by the server certificate of Crowdsec using `crowdsecAppsecTLSCertificateAuthority` or `crowdsecAppsecTLSCertificateAuthorityFile`.
|
||||||
|
Set the `crowdsecAppsecScheme` to https.
|
||||||
|
|
||||||
|
Currently AppSec does not support mTLS authentication for the AppSec Component.
|
||||||
|
|
||||||
#### Manually add an IP to the blocklist (for testing purposes)
|
#### Manually add an IP to the blocklist (for testing purposes)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d crowdsec
|
docker compose up -d crowdsec
|
||||||
docker exec crowdsec cscli decisions add --ip 10.0.0.10 -d 10m # this will be effective 10min
|
docker exec crowdsec cscli decisions add --ip 10.0.0.10 -d 10m # this will be effective 10min
|
||||||
docker exec crowdsec cscli decisions remove --ip 10.0.0.10
|
docker exec crowdsec cscli decisions remove --ip 10.0.0.10
|
||||||
docker exec crowdsec cscli decisions add --ip 10.0.0.10 -d 10m -t captcha # this will return a captcha challenge
|
docker exec crowdsec cscli decisions add --ip 10.0.0.10 -d 10m -t captcha # this will return a captcha challenge
|
||||||
docker exec crowdsec cscli decisions remove --ip 10.0.0.10 -t captcha
|
docker exec crowdsec cscli decisions remove --ip 10.0.0.10 -t captcha
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -635,6 +730,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.
|
||||||
@@ -656,16 +753,18 @@ The source code of the plugin should be organized as follows:
|
|||||||
├── LICENSE
|
├── LICENSE
|
||||||
├── Makefile
|
├── Makefile
|
||||||
├── readme.md
|
├── readme.md
|
||||||
└── vendor/*
|
└── vendor/*
|
||||||
```
|
```
|
||||||
|
|
||||||
For local development, a `docker-compose.local.yml` is provided which reproduces the directory layout needed by Traefik.
|
For local development, a `docker-compose.local.yml` is provided which reproduces the directory layout needed by Traefik.
|
||||||
This works once you have generated and filled your *LAPI-KEY* (crowdsecLapiKey), if not read above for informations.
|
This works once you have generated and filled your _LAPI-KEY_ (crowdsecLapiKey), if not read above for informations.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.local.yml up -d
|
docker compose -f docker-compose.local.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Equivalent to
|
Equivalent to
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make run_local
|
make run_local
|
||||||
```
|
```
|
||||||
@@ -673,7 +772,7 @@ make run_local
|
|||||||
### About
|
### About
|
||||||
|
|
||||||
[mathieuHa](https://github.com/mathieuHa) and [I](https://github.com/maxlerebourg) have been using Traefik since 2020 at [Primadviz](https://primadviz.com).
|
[mathieuHa](https://github.com/mathieuHa) and [I](https://github.com/maxlerebourg) have been using Traefik since 2020 at [Primadviz](https://primadviz.com).
|
||||||
We come from a web development and security engineer background and wanted to add the power of a very promising technology (Crowdsec) to the edge router we love.
|
We come from a web development and security engineer background and wanted to add the power of a very promising technology (Crowdsec) to the edge router we love.
|
||||||
|
|
||||||
We initially ran into this project: [github.com/fbonalair/traefik-crowdsec-bouncer](https://github.com/fbonalair/traefik-crowdsec-bouncer)
|
We initially ran into this project: [github.com/fbonalair/traefik-crowdsec-bouncer](https://github.com/fbonalair/traefik-crowdsec-bouncer)
|
||||||
It was using traefik and forward auth middleware to verify every request.
|
It was using traefik and forward auth middleware to verify every request.
|
||||||
|
|||||||
+318
-119
@@ -9,10 +9,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
htmltemplate "html/template"
|
||||||
"io"
|
"io"
|
||||||
"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
|
isStartup = 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,36 +83,49 @@ type Bouncer struct {
|
|||||||
name string
|
name string
|
||||||
template *template.Template
|
template *template.Template
|
||||||
|
|
||||||
enabled bool
|
enabled bool
|
||||||
appsecEnabled bool
|
appsecEnabled bool
|
||||||
appsecHost string
|
appsecScheme string
|
||||||
appsecFailureBlock bool
|
appsecHost string
|
||||||
appsecUnreachableBlock bool
|
appsecPath string
|
||||||
crowdsecScheme string
|
appsecKey string
|
||||||
crowdsecHost string
|
appsecFailureBlock bool
|
||||||
crowdsecKey string
|
appsecUnreachableBlock bool
|
||||||
crowdsecMode string
|
appsecBodyLimit int64
|
||||||
crowdsecMachineID string
|
crowdsecScheme string
|
||||||
crowdsecPassword string
|
crowdsecHost string
|
||||||
crowdsecScenarios []string
|
crowdsecPath string
|
||||||
updateInterval int64
|
crowdsecKey string
|
||||||
updateMaxFailure int
|
crowdsecMode string
|
||||||
defaultDecisionTimeout int64
|
crowdsecMachineID string
|
||||||
customHeader string
|
crowdsecPassword string
|
||||||
crowdsecStreamRoute string
|
crowdsecScenarios []string
|
||||||
crowdsecHeader string
|
updateInterval int64
|
||||||
banTemplateString string
|
updateMaxFailure int64
|
||||||
clientPoolStrategy *ip.PoolStrategy
|
defaultDecisionTimeout int64
|
||||||
serverPoolStrategy *ip.PoolStrategy
|
remediationStatusCode int
|
||||||
httpClient *http.Client
|
remediationCustomHeader string
|
||||||
cacheClient *cache.Client
|
forwardedCustomHeader string
|
||||||
captchaClient *captcha.Client
|
crowdsecStreamRoute string
|
||||||
log *logger.Log
|
crowdsecHeader string
|
||||||
|
redisUnreachableBlock bool
|
||||||
|
banTemplate *htmltemplate.Template
|
||||||
|
traceCustomHeader string
|
||||||
|
clientPoolStrategy *ip.PoolStrategy
|
||||||
|
serverPoolStrategy *ip.PoolStrategy
|
||||||
|
httpClient *http.Client
|
||||||
|
httpAppsecClient *http.Client
|
||||||
|
cacheClient *cache.Client
|
||||||
|
captchaClient *captcha.Client
|
||||||
|
log *logger.Log
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates the crowdsec bouncer plugin.
|
// New creates the crowdsec bouncer plugin.
|
||||||
|
//
|
||||||
|
//nolint:gocyclo
|
||||||
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
||||||
log := logger.New(config.LogLevel)
|
config.LogLevel = strings.ToUpper(config.LogLevel)
|
||||||
|
log := logger.New(config.LogLevel, config.LogFilePath)
|
||||||
err := configuration.ValidateParams(config)
|
err := configuration.ValidateParams(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("New:validateParams " + err.Error())
|
log.Error("New:validateParams " + err.Error())
|
||||||
@@ -98,43 +135,57 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
serverChecker, _ := ip.NewChecker(log, config.ForwardedHeadersTrustedIPs)
|
serverChecker, _ := ip.NewChecker(log, config.ForwardedHeadersTrustedIPs)
|
||||||
clientChecker, _ := ip.NewChecker(log, config.ClientTrustedIPs)
|
clientChecker, _ := ip.NewChecker(log, config.ClientTrustedIPs)
|
||||||
|
|
||||||
|
var tlsAppsecConfig *tls.Config
|
||||||
|
if config.CrowdsecAppsecEnabled {
|
||||||
|
tlsAppsecConfig, err = configuration.GetTLSConfigCrowdsec(config, log, true)
|
||||||
|
if config.CrowdsecAppsecScheme == "" {
|
||||||
|
config.CrowdsecAppsecScheme = config.CrowdsecLapiScheme
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error("New:getTLSConfigCrowdsec fail to get tlsAppsecConfig " + err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
apiAppsecKey, errAppsecKey := configuration.GetVariable(config, "CrowdsecAppsecKey")
|
||||||
|
if errAppsecKey != nil && len(tlsAppsecConfig.Certificates) == 0 {
|
||||||
|
log.Info("New:crowdsecLapiKey fail to get CrowdsecAppsecKey and no client certificate setup " + errAppsecKey.Error())
|
||||||
|
}
|
||||||
|
config.CrowdsecAppsecKey = apiAppsecKey
|
||||||
|
}
|
||||||
|
|
||||||
var tlsConfig *tls.Config
|
var tlsConfig *tls.Config
|
||||||
crowdsecStreamRoute := ""
|
crowdsecStreamRoute := ""
|
||||||
crowdsecHeader := ""
|
crowdsecHeader := ""
|
||||||
if config.CrowdsecMode == configuration.AloneMode {
|
if config.CrowdsecMode == configuration.AloneMode {
|
||||||
config.CrowdsecCapiMachineID, _ = configuration.GetVariable(config, "CrowdsecCapiMachineID")
|
config.CrowdsecCapiMachineID, _ = configuration.GetVariable(config, "CrowdsecCapiMachineID")
|
||||||
config.CrowdsecCapiPassword, _ = configuration.GetVariable(config, "CrowdsecCapiPassword")
|
config.CrowdsecCapiPassword, _ = configuration.GetVariable(config, "CrowdsecCapiPassword")
|
||||||
|
config.CrowdsecLapiScheme = configuration.HTTPS
|
||||||
config.CrowdsecLapiHost = crowdsecCapiHost
|
config.CrowdsecLapiHost = crowdsecCapiHost
|
||||||
config.CrowdsecLapiScheme = "https"
|
config.CrowdsecLapiPath = "/"
|
||||||
config.UpdateIntervalSeconds = 7200 // 2 hours
|
config.UpdateIntervalSeconds = 7200 // 2 hours
|
||||||
crowdsecStreamRoute = crowdsecCapiStreamRoute
|
crowdsecStreamRoute = crowdsecCapiStreamRoute
|
||||||
crowdsecHeader = crowdsecCapiHeader
|
crowdsecHeader = crowdsecCapiHeader
|
||||||
} else {
|
} else {
|
||||||
crowdsecStreamRoute = crowdsecLapiStreamRoute
|
crowdsecStreamRoute = crowdsecLapiStreamRoute
|
||||||
crowdsecHeader = crowdsecLapiHeader
|
crowdsecHeader = crowdsecLapiHeader
|
||||||
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log)
|
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("New:getTLSConfigCrowdsec fail to get tlsConfig " + err.Error())
|
log.Error("New:getTLSConfigCrowdsec fail to get tlsConfig " + err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
apiKey, errAPIKey := configuration.GetVariable(config, "CrowdsecLapiKey")
|
apiKey, errKey := configuration.GetVariable(config, "CrowdsecLapiKey")
|
||||||
if errAPIKey != nil && len(tlsConfig.Certificates) == 0 {
|
if errKey != nil && len(tlsConfig.Certificates) == 0 {
|
||||||
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errAPIKey.Error())
|
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errKey.Error())
|
||||||
return nil, err
|
return nil, errKey
|
||||||
}
|
}
|
||||||
config.CrowdsecLapiKey = apiKey
|
config.CrowdsecLapiKey = apiKey
|
||||||
|
if config.CrowdsecAppsecKey == "" {
|
||||||
|
config.CrowdsecAppsecKey = apiKey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var banTemplateString string
|
var banTemplate *htmltemplate.Template
|
||||||
if config.BanHTMLFilePath != "" {
|
if config.BanHTMLFilePath != "" {
|
||||||
var buf bytes.Buffer
|
banTemplate, _ = configuration.GetHTMLTemplate(config.BanHTMLFilePath)
|
||||||
banTemplate, _ := configuration.GetHTMLTemplate(config.BanHTMLFilePath)
|
|
||||||
err = banTemplate.Execute(&buf, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("New:banTemplate is bad formatted " + err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
banTemplateString = buf.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bouncer := &Bouncer{
|
bouncer := &Bouncer{
|
||||||
@@ -142,26 +193,35 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
name: name,
|
name: name,
|
||||||
template: template.New("CrowdsecBouncer").Delims("[[", "]]"),
|
template: template.New("CrowdsecBouncer").Delims("[[", "]]"),
|
||||||
|
|
||||||
enabled: config.Enabled,
|
enabled: config.Enabled,
|
||||||
crowdsecMode: config.CrowdsecMode,
|
crowdsecMode: config.CrowdsecMode,
|
||||||
appsecEnabled: config.CrowdsecAppsecEnabled,
|
appsecEnabled: config.CrowdsecAppsecEnabled,
|
||||||
appsecHost: config.CrowdsecAppsecHost,
|
appsecScheme: config.CrowdsecAppsecScheme,
|
||||||
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
appsecHost: config.CrowdsecAppsecHost,
|
||||||
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
appsecPath: config.CrowdsecAppsecPath,
|
||||||
crowdsecScheme: config.CrowdsecLapiScheme,
|
appsecKey: config.CrowdsecAppsecKey,
|
||||||
crowdsecHost: config.CrowdsecLapiHost,
|
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
||||||
crowdsecKey: config.CrowdsecLapiKey,
|
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
||||||
crowdsecMachineID: config.CrowdsecCapiMachineID,
|
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
|
||||||
crowdsecPassword: config.CrowdsecCapiPassword,
|
crowdsecScheme: config.CrowdsecLapiScheme,
|
||||||
crowdsecScenarios: config.CrowdsecCapiScenarios,
|
crowdsecHost: config.CrowdsecLapiHost,
|
||||||
updateInterval: config.UpdateIntervalSeconds,
|
crowdsecPath: config.CrowdsecLapiPath,
|
||||||
updateMaxFailure: config.UpdateMaxFailure,
|
crowdsecKey: config.CrowdsecLapiKey,
|
||||||
customHeader: config.ForwardedHeadersCustomName,
|
crowdsecMachineID: config.CrowdsecCapiMachineID,
|
||||||
defaultDecisionTimeout: config.DefaultDecisionSeconds,
|
crowdsecPassword: config.CrowdsecCapiPassword,
|
||||||
banTemplateString: banTemplateString,
|
crowdsecScenarios: config.CrowdsecCapiScenarios,
|
||||||
crowdsecStreamRoute: crowdsecStreamRoute,
|
updateInterval: config.UpdateIntervalSeconds,
|
||||||
crowdsecHeader: crowdsecHeader,
|
updateMaxFailure: config.UpdateMaxFailure,
|
||||||
log: log,
|
remediationCustomHeader: config.RemediationHeadersCustomName,
|
||||||
|
forwardedCustomHeader: config.ForwardedHeadersCustomName,
|
||||||
|
defaultDecisionTimeout: config.DefaultDecisionSeconds,
|
||||||
|
remediationStatusCode: config.RemediationStatusCode,
|
||||||
|
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
||||||
|
banTemplate: banTemplate,
|
||||||
|
traceCustomHeader: config.TraceHeadersCustomName,
|
||||||
|
crowdsecStreamRoute: crowdsecStreamRoute,
|
||||||
|
crowdsecHeader: crowdsecHeader,
|
||||||
|
log: log,
|
||||||
serverPoolStrategy: &ip.PoolStrategy{
|
serverPoolStrategy: &ip.PoolStrategy{
|
||||||
Checker: serverChecker,
|
Checker: serverChecker,
|
||||||
},
|
},
|
||||||
@@ -176,6 +236,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{},
|
||||||
}
|
}
|
||||||
@@ -200,16 +268,22 @@ 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.CaptchaHTMLFilePath,
|
config.CaptchaHTMLFilePath,
|
||||||
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())
|
||||||
@@ -218,10 +292,20 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
}
|
}
|
||||||
handleStreamTicker(bouncer)
|
handleStreamTicker(bouncer)
|
||||||
isStartup = false
|
isStartup = false
|
||||||
ticker = startTicker(config, log, func() {
|
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
|
||||||
|
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
|
||||||
@@ -229,24 +313,24 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
|
|
||||||
// ServeHTTP principal function of plugin.
|
// ServeHTTP principal function of plugin.
|
||||||
//
|
//
|
||||||
//nolint:nestif
|
//nolint:nestif,gocyclo
|
||||||
func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||||
if !bouncer.enabled {
|
if !bouncer.enabled {
|
||||||
bouncer.next.ServeHTTP(rw, req)
|
bouncer.next.ServeHTTP(rw, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Here we check for the trusted IPs in the customHeader
|
// Here we check for the trusted IPs in the forwardedCustomHeader
|
||||||
remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.customHeader)
|
remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.forwardedCustomHeader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error()))
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error()))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP)
|
isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error()))
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error()))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// if our IP is in the trusted list we bypass the next checks
|
// if our IP is in the trusted list we bypass the next checks
|
||||||
@@ -257,7 +341,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if bouncer.crowdsecMode == configuration.AppsecMode {
|
if bouncer.crowdsecMode == configuration.AppsecMode {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,17 +351,22 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
if cacheErr != nil {
|
if cacheErr != nil {
|
||||||
cacheErrString := cacheErr.Error()
|
cacheErrString := cacheErr.Error()
|
||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString))
|
||||||
|
if !bouncer.redisUnreachableBlock && cacheErrString == cache.CacheUnreachable {
|
||||||
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s redisUnreachable=true", remoteIP))
|
||||||
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
|
return
|
||||||
|
}
|
||||||
if cacheErrString != cache.CacheMiss {
|
if cacheErrString != cache.CacheMiss {
|
||||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
|
||||||
if value == cache.NoBannedValue {
|
if value == cache.NoBannedValue {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
} else {
|
} else {
|
||||||
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
|
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -286,18 +375,21 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
// Right here if we cannot join the stream we forbid the request to go on.
|
// Right here if we cannot join the stream we forbid the request to go on.
|
||||||
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
|
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
|
||||||
if isCrowdsecStreamHealthy {
|
if isCrowdsecStreamHealthy {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
} else {
|
} else {
|
||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
value, err := handleNoStreamCache(bouncer, remoteIP)
|
value, err := handleNoStreamCache(bouncer, remoteIP)
|
||||||
|
if err != nil {
|
||||||
|
bouncer.log.Debug("handleNoStreamCache:crowdsecQuery " + err.Error())
|
||||||
|
}
|
||||||
if value == cache.NoBannedValue {
|
if value == cache.NoBannedValue {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
} else {
|
} else {
|
||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error()))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error()))
|
||||||
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
|
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,34 +423,61 @@ type Login struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// To append Headers we need to call rw.WriteHeader after set any header.
|
// To append Headers we need to call rw.WriteHeader after set any header.
|
||||||
func handleBanServeHTTP(bouncer *Bouncer, rw http.ResponseWriter) {
|
func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP, reason string) {
|
||||||
if bouncer.banTemplateString == "" {
|
atomic.AddInt64(&blockedRequests, 1)
|
||||||
rw.WriteHeader(http.StatusForbidden)
|
|
||||||
|
if bouncer.remediationCustomHeader != "" {
|
||||||
|
rw.Header().Set(bouncer.remediationCustomHeader, "ban")
|
||||||
|
}
|
||||||
|
if bouncer.banTemplate == nil {
|
||||||
|
rw.WriteHeader(bouncer.remediationStatusCode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
rw.WriteHeader(http.StatusForbidden)
|
rw.WriteHeader(bouncer.remediationStatusCode)
|
||||||
fmt.Fprint(rw, bouncer.banTemplateString)
|
|
||||||
|
if req.Method == http.MethodHead {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
templateData := map[string]string{
|
||||||
|
"RemediationReason": reason,
|
||||||
|
"ClientIP": remoteIP,
|
||||||
|
}
|
||||||
|
|
||||||
|
if bouncer.traceCustomHeader != "" {
|
||||||
|
headerVal := req.Header.Get(bouncer.traceCustomHeader)
|
||||||
|
|
||||||
|
if headerVal != "" {
|
||||||
|
templateData["TraceID"] = headerVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := bouncer.banTemplate.Execute(rw, templateData)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
bouncer.log.Error("handleBanServeHTTP banTemplateServe " + 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,11 +498,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 *logger.Log, work func()) chan bool {
|
||||||
|
ticker := time.NewTicker(time.Duration(updateInterval) * time.Second)
|
||||||
stop := make(chan bool, 1)
|
stop := make(chan bool, 1)
|
||||||
go func() {
|
go func() {
|
||||||
defer log.Debug("ticker:stopped")
|
defer log.Debug(name + "_ticker:stopped")
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
@@ -402,10 +527,10 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
|
|||||||
routeURL := url.URL{
|
routeURL := url.URL{
|
||||||
Scheme: bouncer.crowdsecScheme,
|
Scheme: bouncer.crowdsecScheme,
|
||||||
Host: bouncer.crowdsecHost,
|
Host: bouncer.crowdsecHost,
|
||||||
Path: crowdsecLapiRoute,
|
Path: bouncer.crowdsecPath + crowdsecLapiRoute,
|
||||||
RawQuery: fmt.Sprintf("ip=%v&banned=true", remoteIP),
|
RawQuery: fmt.Sprintf("ip=%v", remoteIP),
|
||||||
}
|
}
|
||||||
body, err := crowdsecQuery(bouncer, routeURL.String(), false)
|
body, err := crowdsecQuery(bouncer, routeURL.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cache.BannedValue, err
|
return cache.BannedValue, err
|
||||||
}
|
}
|
||||||
@@ -448,7 +573,7 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
|
|||||||
default:
|
default:
|
||||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
||||||
}
|
}
|
||||||
if isLiveMode {
|
if isLiveMode && bouncer.defaultDecisionTimeout > 0 {
|
||||||
durationSecond := int64(duration.Seconds())
|
durationSecond := int64(duration.Seconds())
|
||||||
if bouncer.defaultDecisionTimeout < durationSecond {
|
if bouncer.defaultDecisionTimeout < durationSecond {
|
||||||
durationSecond = bouncer.defaultDecisionTimeout
|
durationSecond = bouncer.defaultDecisionTimeout
|
||||||
@@ -464,7 +589,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
|
||||||
}
|
}
|
||||||
@@ -498,10 +632,10 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
streamRouteURL := url.URL{
|
streamRouteURL := url.URL{
|
||||||
Scheme: bouncer.crowdsecScheme,
|
Scheme: bouncer.crowdsecScheme,
|
||||||
Host: bouncer.crowdsecHost,
|
Host: bouncer.crowdsecHost,
|
||||||
Path: bouncer.crowdsecStreamRoute,
|
Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute,
|
||||||
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup),
|
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup),
|
||||||
}
|
}
|
||||||
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), false)
|
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -532,20 +666,16 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, error) {
|
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.Add(bouncer.crowdsecHeader, bouncer.crowdsecKey)
|
||||||
|
req.Header.Add("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 {
|
||||||
return nil, fmt.Errorf("crowdsecQuery:unreachable url:%s %w", stringURL, err)
|
return nil, fmt.Errorf("crowdsecQuery:unreachable url:%s %w", stringURL, err)
|
||||||
@@ -559,13 +689,16 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -574,17 +707,21 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, err
|
|||||||
|
|
||||||
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||||
routeURL := url.URL{
|
routeURL := url.URL{
|
||||||
Scheme: bouncer.crowdsecScheme,
|
Scheme: bouncer.appsecScheme,
|
||||||
Host: bouncer.appsecHost,
|
Host: bouncer.appsecHost,
|
||||||
Path: "/",
|
Path: bouncer.appsecPath,
|
||||||
}
|
}
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
if httpReq.Body != nil && httpReq.ContentLength > 0 {
|
if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil && httpReq.ContentLength > 0 {
|
||||||
bodyBytes, err := io.ReadAll(httpReq.Body)
|
var bodyBuffer bytes.Buffer
|
||||||
|
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
|
||||||
|
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
|
||||||
|
bodyBytes, err := io.ReadAll(teeReader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("appsecQuery:GetBody %w", err)
|
return fmt.Errorf("appsecQuery:GetBody %w", err)
|
||||||
}
|
}
|
||||||
httpReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
// Conserve body intact after reading it for other middlewares and service
|
||||||
|
httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body))
|
||||||
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
|
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
|
||||||
} else {
|
} else {
|
||||||
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
|
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
|
||||||
@@ -595,14 +732,14 @@ 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"))
|
||||||
|
|
||||||
res, err := bouncer.httpClient.Do(req)
|
res, err := bouncer.httpAppsecClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bouncer.log.Error("appsecQuery:unreachable")
|
bouncer.log.Error("appsecQuery:unreachable")
|
||||||
if bouncer.appsecUnreachableBlock {
|
if bouncer.appsecUnreachableBlock {
|
||||||
@@ -631,3 +768,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
|
||||||
|
}
|
||||||
|
|||||||
+150
-4
@@ -2,6 +2,7 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
htmltemplate "html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -75,7 +76,7 @@ func TestBouncer_ServeHTTP(t *testing.T) {
|
|||||||
crowdsecMode string
|
crowdsecMode string
|
||||||
updateInterval int64
|
updateInterval int64
|
||||||
defaultDecisionTimeout int64
|
defaultDecisionTimeout int64
|
||||||
customHeader string
|
forwardedCustomHeader string
|
||||||
clientPoolStrategy *ip.PoolStrategy
|
clientPoolStrategy *ip.PoolStrategy
|
||||||
serverPoolStrategy *ip.PoolStrategy
|
serverPoolStrategy *ip.PoolStrategy
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -105,7 +106,7 @@ func TestBouncer_ServeHTTP(t *testing.T) {
|
|||||||
crowdsecMode: tt.fields.crowdsecMode,
|
crowdsecMode: tt.fields.crowdsecMode,
|
||||||
updateInterval: tt.fields.updateInterval,
|
updateInterval: tt.fields.updateInterval,
|
||||||
defaultDecisionTimeout: tt.fields.defaultDecisionTimeout,
|
defaultDecisionTimeout: tt.fields.defaultDecisionTimeout,
|
||||||
customHeader: tt.fields.customHeader,
|
forwardedCustomHeader: tt.fields.forwardedCustomHeader,
|
||||||
clientPoolStrategy: tt.fields.clientPoolStrategy,
|
clientPoolStrategy: tt.fields.clientPoolStrategy,
|
||||||
serverPoolStrategy: tt.fields.serverPoolStrategy,
|
serverPoolStrategy: tt.fields.serverPoolStrategy,
|
||||||
httpClient: tt.fields.httpClient,
|
httpClient: tt.fields.httpClient,
|
||||||
@@ -163,7 +164,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 +176,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 +187,148 @@ func Test_crowdsecQuery(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
|
||||||
|
html := "<html>You are banned</html>"
|
||||||
|
banTemplate, _ := htmltemplate.New("html").Parse(html)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
banTemplate *htmltemplate.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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -293,7 +293,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
|
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
|
||||||
</div>
|
</div>
|
||||||
<form action="" method="POST" class="flex flex-col space-y-1" id="captcha-form">
|
<form action="" method="POST" class="flex flex-col items-center space-y-1" id="captcha-form">
|
||||||
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
|
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.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:
|
||||||
|
|||||||
+4
-8
@@ -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
|
||||||
@@ -36,7 +34,7 @@ services:
|
|||||||
# Definition of the router
|
# Definition of the router
|
||||||
- "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)"
|
- "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)"
|
||||||
- "traefik.http.routers.router-foo.entrypoints=web"
|
- "traefik.http.routers.router-foo.entrypoints=web"
|
||||||
- "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
|
- "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
|
||||||
# Definition of the service
|
# Definition of the service
|
||||||
- "traefik.http.services.service-foo.loadbalancer.server.port=80"
|
- "traefik.http.services.service-foo.loadbalancer.server.port=80"
|
||||||
# Definition of the middleware
|
# Definition of the middleware
|
||||||
@@ -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"
|
||||||
@@ -65,7 +61,7 @@ services:
|
|||||||
- crowdsec-config-appsec-enabled:/etc/crowdsec/
|
- crowdsec-config-appsec-enabled:/etc/crowdsec/
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=false"
|
- "traefik.enable=false"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
logs-appsec-enabled:
|
logs-appsec-enabled:
|
||||||
crowdsec-db-appsec-enabled:
|
crowdsec-db-appsec-enabled:
|
||||||
@@ -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
|
||||||
...
|
...
|
||||||
@@ -158,4 +158,4 @@ TODO
|
|||||||
|
|
||||||
- Hcatpcha
|
- Hcatpcha
|
||||||
|
|
||||||
TODO
|
TODO
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,6 +6,7 @@ logs:
|
|||||||
level: DEBUG
|
level: DEBUG
|
||||||
access:
|
access:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
format: json
|
||||||
fields:
|
fields:
|
||||||
headers:
|
headers:
|
||||||
defaultmode: keep
|
defaultmode: keep
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ You need to create a crowdsec API credentials for the CAPI.
|
|||||||
You can follow the documentation here: https://docs.crowdsec.net/docs/central_api/intro
|
You can follow the documentation here: https://docs.crowdsec.net/docs/central_api/intro
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "https://api.crowdsec.net/v2/watchers" -H "accept: application/json" -H "Content-Type: application/json" -d "{ \"password\": \"PASSWORD\", \"machine_id\": \"LOGIN\"}"
|
LOGIN=...
|
||||||
|
PASSWORD=...
|
||||||
|
curl -X POST "https://api.crowdsec.net/v2/watchers" -H "accept: application/json" -H "Content-Type: application/json" -d "{ \"password\": \"$PASSWORD\", \"machine_id\": \"$LOGIN\"}"
|
||||||
```
|
```
|
||||||
|
|
||||||
These CAPI credentials must be set in your docker-compose.yml or in your config files
|
These CAPI credentials must be set in your docker-compose.yml or in your config files
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ services:
|
|||||||
|
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=alone"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecMode=alone"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiMachineId=FIXME"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiMachineId=FIXME"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiPassword=FIXME"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiPassword=FIXME"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseccapiscenarios=crowdsecurity/sshd,crowdsecurity/asterisk_bf,crowdsecurity/asterisk_user_enum,crowdsecurity/base-http-scenarios"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseccapiscenarios=crowdsecurity/sshd,crowdsecurity/asterisk_bf,crowdsecurity/asterisk_user_enum,crowdsecurity/base-http-scenarios"
|
||||||
|
|||||||
+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"
|
||||||
@@ -38,7 +36,7 @@ services:
|
|||||||
# - "traefik.enable=true"
|
# - "traefik.enable=true"
|
||||||
# - "traefik.http.routers.router-foo.rule=Path(`/foo`)"
|
# - "traefik.http.routers.router-foo.rule=Path(`/foo`)"
|
||||||
# - "traefik.http.routers.router-foo.entrypoints=web"
|
# - "traefik.http.routers.router-foo.entrypoints=web"
|
||||||
# - "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"
|
||||||
# - "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"
|
||||||
@@ -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
|
||||||
@@ -100,7 +106,7 @@ services:
|
|||||||
- "traefik.enable=false"
|
- "traefik.enable=false"
|
||||||
depends_on:
|
depends_on:
|
||||||
- gencert
|
- gencert
|
||||||
|
|
||||||
gencert:
|
gencert:
|
||||||
build: .
|
build: .
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -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
+7
-1
@@ -23,6 +23,8 @@ const (
|
|||||||
CaptchaDoneValue = "d"
|
CaptchaDoneValue = "d"
|
||||||
// CacheMiss error string when cache is miss.
|
// CacheMiss error string when cache is miss.
|
||||||
CacheMiss = "cache:miss"
|
CacheMiss = "cache:miss"
|
||||||
|
// CacheUnreachable error string when cache is unreachable.
|
||||||
|
CacheUnreachable = "cache:unreachable"
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:gochecknoglobals
|
//nolint:gochecknoglobals
|
||||||
@@ -60,9 +62,13 @@ func (redisCache) get(key string) (string, error) {
|
|||||||
if err == nil && len(valueString) > 0 {
|
if err == nil && len(valueString) > 0 {
|
||||||
return valueString, nil
|
return valueString, nil
|
||||||
}
|
}
|
||||||
if err.Error() == simpleredis.RedisMiss {
|
errRedisMessage := err.Error()
|
||||||
|
if errRedisMessage == simpleredis.RedisMiss {
|
||||||
return "", errors.New(CacheMiss)
|
return "", errors.New(CacheMiss)
|
||||||
}
|
}
|
||||||
|
if errRedisMessage == simpleredis.RedisUnreachable {
|
||||||
|
return "", errors.New(CacheUnreachable)
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+3
-3
@@ -11,7 +11,7 @@ import (
|
|||||||
func Test_Get(t *testing.T) {
|
func Test_Get(t *testing.T) {
|
||||||
IPInCache := "10.0.0.10"
|
IPInCache := "10.0.0.10"
|
||||||
IPNotInCache := "10.0.0.20"
|
IPNotInCache := "10.0.0.20"
|
||||||
client := &Client{cache: &localCache{}, log: logger.New("INFO")}
|
client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
|
||||||
client.Set(IPInCache, BannedValue, 10)
|
client.Set(IPInCache, BannedValue, 10)
|
||||||
type args struct {
|
type args struct {
|
||||||
clientIP string
|
clientIP string
|
||||||
@@ -47,7 +47,7 @@ func Test_Get(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_Set(t *testing.T) {
|
func Test_Set(t *testing.T) {
|
||||||
client := &Client{cache: &localCache{}, log: logger.New("INFO")}
|
client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
|
||||||
IPInCache := "10.0.0.11"
|
IPInCache := "10.0.0.11"
|
||||||
type args struct {
|
type args struct {
|
||||||
clientIP string
|
clientIP string
|
||||||
@@ -88,7 +88,7 @@ func Test_Set(t *testing.T) {
|
|||||||
func Test_Delete(t *testing.T) {
|
func Test_Delete(t *testing.T) {
|
||||||
IPInCache := "10.0.0.12"
|
IPInCache := "10.0.0.12"
|
||||||
IPNotInCache := "10.0.0.22"
|
IPNotInCache := "10.0.0.22"
|
||||||
client := &Client{cache: &localCache{}, log: logger.New("INFO")}
|
client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
|
||||||
client.Set(IPInCache, BannedValue, 10)
|
client.Set(IPInCache, BannedValue, 10)
|
||||||
type args struct {
|
type args struct {
|
||||||
clientIP string
|
clientIP string
|
||||||
|
|||||||
+53
-36
@@ -16,53 +16,64 @@ import (
|
|||||||
|
|
||||||
// Client Captcha client.
|
// Client Captcha client.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Valid bool
|
Valid bool
|
||||||
provider string
|
siteKey string
|
||||||
siteKey string
|
secretKey string
|
||||||
secretKey string
|
remediationCustomHeader string
|
||||||
gracePeriodSeconds int64
|
gracePeriodSeconds int64
|
||||||
captchaTemplate *template.Template
|
captchaTemplate *template.Template
|
||||||
cacheClient *cache.Client
|
cacheClient *cache.Client
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
log *logger.Log
|
log *logger.Log
|
||||||
|
infoProvider *infoProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Information for self-hosted provider.
|
||||||
type infoProvider struct {
|
type infoProvider struct {
|
||||||
js string
|
js string
|
||||||
key string
|
key string
|
||||||
|
response string
|
||||||
validate string
|
validate string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
//nolint:gochecknoglobals
|
||||||
//nolint:gochecknoglobals
|
var infoProviders = map[string]*infoProvider{
|
||||||
captcha = map[string]infoProvider{
|
configuration.HcaptchaProvider: {
|
||||||
configuration.HcaptchaProvider: {
|
js: "https://hcaptcha.com/1/api.js",
|
||||||
js: "https://hcaptcha.com/1/api.js",
|
key: "h-captcha",
|
||||||
key: "h-captcha",
|
response: "h-captcha-response",
|
||||||
validate: "https://api.hcaptcha.com/siteverify",
|
validate: "https://api.hcaptcha.com/siteverify",
|
||||||
},
|
},
|
||||||
configuration.RecaptchaProvider: {
|
configuration.RecaptchaProvider: {
|
||||||
js: "https://www.google.com/recaptcha/api.js",
|
js: "https://www.google.com/recaptcha/api.js",
|
||||||
key: "g-recaptcha",
|
key: "g-recaptcha",
|
||||||
validate: "https://www.google.com/recaptcha/api/siteverify",
|
response: "g-recaptcha-response",
|
||||||
},
|
validate: "https://www.google.com/recaptcha/api/siteverify",
|
||||||
configuration.TurnstileProvider: {
|
},
|
||||||
js: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
configuration.TurnstileProvider: {
|
||||||
key: "cf-turnstile",
|
js: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||||
validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
key: "cf-turnstile",
|
||||||
},
|
response: "cf-turnstile-response",
|
||||||
}
|
validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||||
)
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// New Initialize captcha client.
|
// New Initialize captcha client.
|
||||||
func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, siteKey, secretKey, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
||||||
c.Valid = provider != ""
|
c.Valid = provider != ""
|
||||||
if !c.Valid {
|
if !c.Valid {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
var info *infoProvider
|
||||||
|
if provider == configuration.CustomProvider {
|
||||||
|
info = &infoProvider{js: js, key: key, response: response, validate: validate}
|
||||||
|
} else {
|
||||||
|
info = infoProviders[provider]
|
||||||
|
}
|
||||||
|
c.infoProvider = info
|
||||||
c.siteKey = siteKey
|
c.siteKey = siteKey
|
||||||
c.secretKey = secretKey
|
c.secretKey = secretKey
|
||||||
c.provider = provider
|
c.remediationCustomHeader = remediationCustomHeader
|
||||||
html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
|
html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
|
||||||
c.captchaTemplate = html
|
c.captchaTemplate = html
|
||||||
c.gracePeriodSeconds = gracePeriodSeconds
|
c.gracePeriodSeconds = gracePeriodSeconds
|
||||||
@@ -83,15 +94,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", "text/html; charset=utf-8")
|
||||||
|
if c.remediationCustomHeader != "" {
|
||||||
|
rw.Header().Set(c.remediationCustomHeader, "captcha")
|
||||||
|
}
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
err = c.captchaTemplate.Execute(rw, map[string]string{
|
err = c.captchaTemplate.Execute(rw, map[string]string{
|
||||||
"SiteKey": c.siteKey,
|
"SiteKey": c.siteKey,
|
||||||
"FrontendJS": captcha[c.provider].js,
|
"FrontendJS": c.infoProvider.js,
|
||||||
"FrontendKey": captcha[c.provider].key,
|
"FrontendKey": c.infoProvider.key,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Info("captcha:ServeHTTP captchaTemplateServe " + err.Error())
|
c.log.Info("captcha:ServeHTTP captchaTemplateServe " + err.Error())
|
||||||
@@ -116,7 +133,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
|
|||||||
c.log.Debug("captcha:Validate invalid method: " + r.Method)
|
c.log.Debug("captcha:Validate invalid method: " + r.Method)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
var response = r.FormValue(captcha[c.provider].key + "-response")
|
var response = r.FormValue(c.infoProvider.response)
|
||||||
if response == "" {
|
if response == "" {
|
||||||
c.log.Debug("captcha:Validate no captcha response found in request")
|
c.log.Debug("captcha:Validate no captcha response found in request")
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -124,7 +141,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
|
|||||||
var body = url.Values{}
|
var body = url.Values{}
|
||||||
body.Add("secret", c.secretKey)
|
body.Add("secret", c.secretKey)
|
||||||
body.Add("response", response)
|
body.Add("response", response)
|
||||||
res, err := c.httpClient.PostForm(captcha[c.provider].validate, body)
|
res, err := c.httpClient.PostForm(c.infoProvider.validate, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -133,7 +150,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
|
|||||||
c.log.Error("captcha:Validate " + err.Error())
|
c.log.Error("captcha:Validate " + err.Error())
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if !strings.Contains(res.Header.Get("content-type"), "application/json") {
|
if !strings.Contains(res.Header.Get("Content-Type"), "application/json") {
|
||||||
c.log.Debug("captcha:Validate responseType:noJson")
|
c.log.Debug("captcha:Validate responseType:noJson")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+219
-104
@@ -28,56 +28,86 @@ const (
|
|||||||
AppsecMode = "appsec"
|
AppsecMode = "appsec"
|
||||||
HTTPS = "https"
|
HTTPS = "https"
|
||||||
HTTP = "http"
|
HTTP = "http"
|
||||||
|
LogDEBUG = "DEBUG"
|
||||||
|
LogINFO = "INFO"
|
||||||
|
LogERROR = "ERROR"
|
||||||
|
ReasonTECH = "TECHNICAL_ISSUE"
|
||||||
|
ReasonLAPI = "LAPI"
|
||||||
|
ReasonAPPSEC = "APPSEC"
|
||||||
HcaptchaProvider = "hcaptcha"
|
HcaptchaProvider = "hcaptcha"
|
||||||
RecaptchaProvider = "recaptcha"
|
RecaptchaProvider = "recaptcha"
|
||||||
TurnstileProvider = "turnstile"
|
TurnstileProvider = "turnstile"
|
||||||
|
CustomProvider = "custom"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config the plugin configuration.
|
// Config the plugin configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled bool `json:"enabled,omitempty"`
|
Enabled bool `json:"enabled,omitempty"`
|
||||||
LogLevel string `json:"logLevel,omitempty"`
|
LogLevel string `json:"logLevel,omitempty"`
|
||||||
CrowdsecMode string `json:"crowdsecMode,omitempty"`
|
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"`
|
||||||
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
CrowdsecAppsecScheme string `json:"crowdsecAppsecScheme,omitempty"`
|
||||||
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
|
||||||
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
|
CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"`
|
||||||
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
|
CrowdsecAppsecKey string `json:"crowdsecAppsecKey,omitempty"`
|
||||||
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
|
CrowdsecAppsecKeyFile string `json:"crowdsecAppsecKeyFile,omitempty"`
|
||||||
CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"`
|
CrowdsecAppsecTLSInsecureVerify bool `json:"crowdsecAppsecTlsInsecureVerify,omitempty"`
|
||||||
CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"`
|
CrowdsecAppsecTLSCertificateAuthority string `json:"crowdsecAppsecTlsCertificateAuthority,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateAuthority string `json:"crowdsecLapiTlsCertificateAuthority,omitempty"`
|
CrowdsecAppsecTLSCertificateAuthorityFile string `json:"crowdsecAppsecTlsCertificateAuthorityFile,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateAuthorityFile string `json:"crowdsecLapiTlsCertificateAuthorityFile,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncer string `json:"crowdsecAppsecTlsCertificateBouncer,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncer string `json:"crowdsecLapiTlsCertificateBouncer,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncerFile string `json:"crowdsecAppsecTlsCertificateBouncerFile,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncerFile string `json:"crowdsecLapiTlsCertificateBouncerFile,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncerKey string `json:"crowdsecAppsecTlsCertificateBouncerKey,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncerKey string `json:"crowdsecLapiTlsCertificateBouncerKey,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncerKeyFile string `json:"crowdsecLapiTlsCertificateBouncerKeyFile,omitempty"`
|
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
||||||
CrowdsecCapiMachineID string `json:"crowdsecCapiMachineId,omitempty"`
|
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
||||||
CrowdsecCapiMachineIDFile string `json:"crowdsecCapiMachineIdFile,omitempty"`
|
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
|
||||||
CrowdsecCapiPassword string `json:"crowdsecCapiPassword,omitempty"`
|
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
|
||||||
CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"`
|
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
|
||||||
CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"`
|
CrowdsecLapiPath string `json:"crowdsecLapiPath,omitempty"`
|
||||||
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
|
||||||
UpdateMaxFailure int `json:"updateMaxFailure,omitempty"`
|
CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"`
|
||||||
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"`
|
||||||
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
CrowdsecLapiTLSCertificateAuthority string `json:"crowdsecLapiTlsCertificateAuthority,omitempty"`
|
||||||
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
|
CrowdsecLapiTLSCertificateAuthorityFile string `json:"crowdsecLapiTlsCertificateAuthorityFile,omitempty"`
|
||||||
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
|
CrowdsecLapiTLSCertificateBouncer string `json:"crowdsecLapiTlsCertificateBouncer,omitempty"`
|
||||||
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
|
CrowdsecLapiTLSCertificateBouncerFile string `json:"crowdsecLapiTlsCertificateBouncerFile,omitempty"`
|
||||||
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
|
CrowdsecLapiTLSCertificateBouncerKey string `json:"crowdsecLapiTlsCertificateBouncerKey,omitempty"`
|
||||||
RedisCacheHost string `json:"redisCacheHost,omitempty"`
|
CrowdsecLapiTLSCertificateBouncerKeyFile string `json:"crowdsecLapiTlsCertificateBouncerKeyFile,omitempty"`
|
||||||
RedisCachePassword string `json:"redisCachePassword,omitempty"`
|
CrowdsecCapiMachineID string `json:"crowdsecCapiMachineId,omitempty"`
|
||||||
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
|
CrowdsecCapiMachineIDFile string `json:"crowdsecCapiMachineIdFile,omitempty"`
|
||||||
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
|
CrowdsecCapiPassword string `json:"crowdsecCapiPassword,omitempty"`
|
||||||
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
|
CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"`
|
||||||
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
|
CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"`
|
||||||
CaptchaProvider string `json:"captchaProvider,omitempty"`
|
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
||||||
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"`
|
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
|
||||||
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"`
|
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
|
||||||
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"`
|
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
||||||
CaptchaSecretKeyFile string `json:"captchaSecretKeyFile,omitempty"`
|
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
|
||||||
CaptchaGracePeriodSeconds int64 `json:"captchaGracePeriodSeconds,omitempty"`
|
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
||||||
|
TraceHeadersCustomName string `json:"traceHeadersCustomName,omitempty"`
|
||||||
|
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"`
|
||||||
|
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
|
||||||
|
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
|
||||||
|
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
|
||||||
|
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
|
||||||
|
RedisCacheHost string `json:"redisCacheHost,omitempty"`
|
||||||
|
RedisCachePassword string `json:"redisCachePassword,omitempty"`
|
||||||
|
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
|
||||||
|
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
|
||||||
|
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
|
||||||
|
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
|
||||||
|
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
|
||||||
|
CaptchaProvider string `json:"captchaProvider,omitempty"`
|
||||||
|
CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"`
|
||||||
|
CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"`
|
||||||
|
CaptchaCustomKey string `json:"captchaCustomKey,omitempty"`
|
||||||
|
CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"`
|
||||||
|
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"`
|
||||||
|
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"`
|
||||||
|
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"`
|
||||||
|
CaptchaSecretKeyFile string `json:"captchaSecretKeyFile,omitempty"`
|
||||||
|
CaptchaGracePeriodSeconds int64 `json:"captchaGracePeriodSeconds,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func contains(source []string, target string) bool {
|
func contains(source []string, target string) bool {
|
||||||
@@ -92,34 +122,50 @@ func contains(source []string, target string) bool {
|
|||||||
// New creates the default plugin configuration.
|
// New creates the default plugin configuration.
|
||||||
func New() *Config {
|
func New() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
LogLevel: "INFO",
|
LogLevel: LogINFO,
|
||||||
CrowdsecMode: LiveMode,
|
LogFilePath: "",
|
||||||
CrowdsecAppsecEnabled: false,
|
CrowdsecMode: LiveMode,
|
||||||
CrowdsecAppsecHost: "crowdsec:7422",
|
CrowdsecAppsecEnabled: false,
|
||||||
CrowdsecAppsecFailureBlock: true,
|
CrowdsecAppsecFailureBlock: true,
|
||||||
CrowdsecAppsecUnreachableBlock: true,
|
CrowdsecAppsecUnreachableBlock: true,
|
||||||
CrowdsecLapiScheme: HTTP,
|
CrowdsecAppsecBodyLimit: 10485760,
|
||||||
CrowdsecLapiHost: "crowdsec:8080",
|
CrowdsecAppsecScheme: "",
|
||||||
CrowdsecLapiKey: "",
|
CrowdsecAppsecHost: "crowdsec:7422",
|
||||||
CrowdsecLapiTLSInsecureVerify: false,
|
CrowdsecAppsecPath: "/",
|
||||||
UpdateIntervalSeconds: 60,
|
CrowdsecAppsecKey: "",
|
||||||
UpdateMaxFailure: 0,
|
CrowdsecAppsecTLSInsecureVerify: false,
|
||||||
DefaultDecisionSeconds: 60,
|
CrowdsecLapiScheme: HTTP,
|
||||||
HTTPTimeoutSeconds: 10,
|
CrowdsecLapiHost: "crowdsec:8080",
|
||||||
CaptchaProvider: "",
|
CrowdsecLapiPath: "/",
|
||||||
CaptchaSiteKey: "",
|
CrowdsecLapiKey: "",
|
||||||
CaptchaSecretKey: "",
|
CrowdsecLapiTLSInsecureVerify: false,
|
||||||
CaptchaGracePeriodSeconds: 1800,
|
UpdateIntervalSeconds: 60,
|
||||||
CaptchaHTMLFilePath: "/captcha.html",
|
MetricsUpdateIntervalSeconds: 600,
|
||||||
BanHTMLFilePath: "",
|
UpdateMaxFailure: 0,
|
||||||
ForwardedHeadersCustomName: "X-Forwarded-For",
|
DefaultDecisionSeconds: 60,
|
||||||
ForwardedHeadersTrustedIPs: []string{},
|
RemediationStatusCode: http.StatusForbidden,
|
||||||
ClientTrustedIPs: []string{},
|
HTTPTimeoutSeconds: 10,
|
||||||
RedisCacheEnabled: false,
|
CaptchaProvider: "",
|
||||||
RedisCacheHost: "redis:6379",
|
CaptchaCustomJsURL: "",
|
||||||
RedisCachePassword: "",
|
CaptchaCustomValidateURL: "",
|
||||||
RedisCacheDatabase: "",
|
CaptchaCustomKey: "",
|
||||||
|
CaptchaCustomResponse: "",
|
||||||
|
CaptchaSiteKey: "",
|
||||||
|
CaptchaSecretKey: "",
|
||||||
|
CaptchaGracePeriodSeconds: 1800,
|
||||||
|
CaptchaHTMLFilePath: "/captcha.html",
|
||||||
|
BanHTMLFilePath: "",
|
||||||
|
TraceHeadersCustomName: "",
|
||||||
|
RemediationHeadersCustomName: "",
|
||||||
|
ForwardedHeadersCustomName: "X-Forwarded-For",
|
||||||
|
ForwardedHeadersTrustedIPs: []string{},
|
||||||
|
ClientTrustedIPs: []string{},
|
||||||
|
RedisCacheEnabled: false,
|
||||||
|
RedisCacheHost: "redis:6379",
|
||||||
|
RedisCachePassword: "",
|
||||||
|
RedisCacheDatabase: "",
|
||||||
|
RedisCacheUnreachableBlock: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +223,10 @@ func ValidateParams(config *Config) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := validateCaptcha(config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -215,11 +265,11 @@ func ValidateParams(config *Config) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateURL("CrowdsecLapi", config.CrowdsecLapiScheme, config.CrowdsecLapiHost); err != nil {
|
if err := validateURL("CrowdsecLapi", config.CrowdsecLapiScheme, config.CrowdsecLapiHost, config.CrowdsecLapiPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateURL("CrowdsecAppsec", config.CrowdsecLapiScheme, config.CrowdsecAppsecHost); err != nil {
|
if err := validateURL("CrowdsecAppsec", config.CrowdsecLapiScheme, config.CrowdsecAppsecHost, config.CrowdsecAppsecPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +277,10 @@ func ValidateParams(config *Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
appsecKey, err := GetVariable(config, "CrowdsecAppsecKey")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -235,12 +289,21 @@ func ValidateParams(config *Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to either have crowdsecLapiKey defined or the BouncerCert and Bouncerkey
|
// We need to either have crowdsecLapiKey defined or the BouncerCert and Bouncerkey
|
||||||
if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") {
|
if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") && config.CrowdsecMode != AppsecMode {
|
||||||
return errors.New("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be all empty")
|
return errors.New("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be all empty")
|
||||||
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
|
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
|
||||||
lapiKey = strings.TrimSpace(lapiKey)
|
lapiKey = strings.TrimSpace(lapiKey)
|
||||||
if err = validateParamsAPIKey(lapiKey); err != nil {
|
if err = validateParamsAPIKey(lapiKey, "CrowdsecLapiKey"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CrowdsecAppsecKey if provided
|
||||||
|
if appsecKey != "" {
|
||||||
|
appsecKey = strings.TrimSpace(appsecKey)
|
||||||
|
if err = validateParamsAPIKey(appsecKey, "CrowdsecAppsecKey"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,14 +315,25 @@ func ValidateParams(config *Config) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check logging configuration
|
||||||
|
// to upper allow of anycase of log level
|
||||||
|
if !contains([]string{LogERROR, LogDEBUG, LogINFO}, strings.ToUpper(config.LogLevel)) {
|
||||||
|
return fmt.Errorf("LogLevel should be one of (%s,%s,%s)", LogDEBUG, LogINFO, LogERROR)
|
||||||
|
}
|
||||||
|
if config.LogFilePath != "" {
|
||||||
|
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("LogFilePath is not writable %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateURL(variable, scheme, host string) error {
|
func validateURL(variable, scheme, host, path string) error {
|
||||||
// This only check that the format of the URL scheme://host is correct and do not make requests
|
// This only check that the format of the URL scheme://host/path is correct and do not make requests
|
||||||
testURL := url.URL{Scheme: scheme, Host: host}
|
testURL := url.URL{Scheme: scheme, Host: host, Path: path}
|
||||||
if _, err := http.NewRequest(http.MethodGet, testURL.String(), nil); err != nil {
|
if _, err := http.NewRequest(http.MethodGet, testURL.String(), nil); err != nil {
|
||||||
return fmt.Errorf("CrowdsecLapiScheme://%sHost: '%v://%v' must be an URL", variable, scheme, host)
|
return fmt.Errorf("CrowdsecLapiScheme://%sHost: '%v://%v%v' must be a valid URL", variable, scheme, host, path)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -268,10 +342,10 @@ func validateURL(variable, scheme, host string) error {
|
|||||||
// field name. RFC 7230 says:
|
// field name. RFC 7230 says:
|
||||||
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
|
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
|
||||||
// See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators
|
// See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators
|
||||||
func validateParamsAPIKey(lapiKey string) error {
|
func validateParamsAPIKey(key string, paramName string) error {
|
||||||
reg := regexp.MustCompile("^[a-zA-Z0-9 !#$%&'*+-.^_`|~=/]*$")
|
reg := regexp.MustCompile("^[a-zA-Z0-9 !#$%&'*+-.^_`|~=/]*$")
|
||||||
if !reg.MatchString(lapiKey) {
|
if !reg.MatchString(key) {
|
||||||
return fmt.Errorf("CrowdsecLapiKey doesn't valid this regexp: '/%s/'", reg.String())
|
return fmt.Errorf("%s doesn't validate this regexp: '/%s/'", paramName, reg.String())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -294,13 +368,31 @@ func validateParamsTLS(config *Config) error {
|
|||||||
|
|
||||||
func validateParamsIPs(listIP []string, key string) error {
|
func validateParamsIPs(listIP []string, key string) error {
|
||||||
if len(listIP) > 0 {
|
if len(listIP) > 0 {
|
||||||
if _, err := ip.NewChecker(logger.New("INFO"), listIP); err != nil {
|
if _, err := ip.NewChecker(logger.New(LogINFO, ""), listIP); err != nil {
|
||||||
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
|
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateCaptcha(config *Config) error {
|
||||||
|
if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider}, config.CaptchaProvider) {
|
||||||
|
return fmt.Errorf("CaptchaProvider: must be one of '%s', '%s', '%s' or '%s'", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider)
|
||||||
|
}
|
||||||
|
if config.CaptchaProvider == CustomProvider {
|
||||||
|
if config.CaptchaCustomKey == "" || config.CaptchaCustomResponse == "" || config.CaptchaCustomValidateURL == "" || config.CaptchaCustomJsURL == "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"CaptchaProvider: provider is custom, captchaCustom variables must be filled: CaptchaCustomKey:%s, CaptchaCustomResponse:%s, CaptchaCustomValidateURL:%s, CaptchaCustomJsURL:%s",
|
||||||
|
config.CaptchaCustomKey,
|
||||||
|
config.CaptchaCustomResponse,
|
||||||
|
config.CaptchaCustomValidateURL,
|
||||||
|
config.CaptchaCustomJsURL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateParamsRequired(config *Config) error {
|
func validateParamsRequired(config *Config) error {
|
||||||
requiredStrings := map[string]string{
|
requiredStrings := map[string]string{
|
||||||
"CrowdsecLapiScheme": config.CrowdsecLapiScheme,
|
"CrowdsecLapiScheme": config.CrowdsecLapiScheme,
|
||||||
@@ -309,23 +401,38 @@ func validateParamsRequired(config *Config) error {
|
|||||||
}
|
}
|
||||||
for key, val := range requiredStrings {
|
for key, val := range requiredStrings {
|
||||||
if len(val) == 0 {
|
if len(val) == 0 {
|
||||||
return fmt.Errorf("%v: cannot be empty", key)
|
return errors.New(key + ": cannot be empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requiredInt := map[string]int64{
|
requiredInt0 := map[string]int64{
|
||||||
|
"CrowdsecAppsecBodyLimit": config.CrowdsecAppsecBodyLimit,
|
||||||
|
"MetricsUpdateIntervalSeconds": config.MetricsUpdateIntervalSeconds,
|
||||||
|
}
|
||||||
|
for key, val := range requiredInt0 {
|
||||||
|
if val < 0 {
|
||||||
|
return errors.New(key + ": cannot be less than 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requiredInt1 := map[string]int64{
|
||||||
"UpdateIntervalSeconds": config.UpdateIntervalSeconds,
|
"UpdateIntervalSeconds": config.UpdateIntervalSeconds,
|
||||||
"DefaultDecisionSeconds": config.DefaultDecisionSeconds,
|
"DefaultDecisionSeconds": config.DefaultDecisionSeconds,
|
||||||
"HTTPTimeoutSeconds": config.HTTPTimeoutSeconds,
|
"HTTPTimeoutSeconds": config.HTTPTimeoutSeconds,
|
||||||
"CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds,
|
"CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds,
|
||||||
}
|
}
|
||||||
for key, val := range requiredInt {
|
for key, val := range requiredInt1 {
|
||||||
if val < 1 {
|
if val < 1 {
|
||||||
return fmt.Errorf("%v: cannot be less than 1", key)
|
return errors.New(key + ": cannot be less than 1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if config.UpdateMaxFailure < -1 {
|
if config.UpdateMaxFailure < -1 {
|
||||||
return errors.New("UpdateMaxFailure: cannot be less than -1")
|
return errors.New("UpdateMaxFailure: cannot be less than -1")
|
||||||
}
|
}
|
||||||
|
if config.CrowdsecAppsecBodyLimit < 0 {
|
||||||
|
return errors.New("CrowdsecAppsecBodyLimit: cannot be less than 0")
|
||||||
|
}
|
||||||
|
if config.RemediationStatusCode < 100 || config.RemediationStatusCode >= 600 {
|
||||||
|
return errors.New("RemediationStatusCode: cannot be less than 100 and more than 600")
|
||||||
|
}
|
||||||
|
|
||||||
if !contains([]string{NoneMode, LiveMode, StreamMode, AloneMode, AppsecMode}, config.CrowdsecMode) {
|
if !contains([]string{NoneMode, LiveMode, StreamMode, AloneMode, AppsecMode}, config.CrowdsecMode) {
|
||||||
return errors.New("CrowdsecMode: must be one of 'none', 'live', 'stream', 'alone' or 'appsec'")
|
return errors.New("CrowdsecMode: must be one of 'none', 'live', 'stream', 'alone' or 'appsec'")
|
||||||
@@ -333,29 +440,27 @@ 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 *logger.Log, 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()
|
tlsConfig.RootCAs = x509.NewCertPool()
|
||||||
//nolint:gocritic
|
if scheme != HTTPS {
|
||||||
if config.CrowdsecLapiScheme != HTTPS {
|
log.Debug("getTLSConfigCrowdsec:" + prefix + "Scheme https:no")
|
||||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiScheme https:no")
|
|
||||||
return tlsConfig, nil
|
return tlsConfig, nil
|
||||||
} else if config.CrowdsecLapiTLSInsecureVerify {
|
}
|
||||||
|
//nolint:nestif
|
||||||
|
if insecureVerify {
|
||||||
tlsConfig.InsecureSkipVerify = true
|
tlsConfig.InsecureSkipVerify = true
|
||||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSInsecureVerify tlsInsecure:true")
|
log.Debug("getTLSConfigCrowdsec:" + prefix + "TLSInsecureVerify tlsInsecure:true")
|
||||||
// If we return here and still want to use client auth this won't work
|
// If we return here and still want to use client auth this won't work
|
||||||
// return tlsConfig, nil
|
// 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
|
||||||
}
|
}
|
||||||
@@ -363,17 +468,16 @@ func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error)
|
|||||||
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
||||||
// here we return because if CrowdsecLapiTLSInsecureVerify is false
|
// here we return because if CrowdsecLapiTLSInsecureVerify is false
|
||||||
// and CA not load, we can't communicate with https
|
// and CA not load, we can't communicate with https
|
||||||
return nil, errors.New("getTLSConfigCrowdsec:cannot load CA and verify cert is enabled")
|
return nil, errors.New("getTLSConfigCrowdsec:" + prefix + "cannot load CA and verify cert is enabled")
|
||||||
}
|
}
|
||||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSCertificateAuthority CA added successfully")
|
log.Debug("getTLSConfigCrowdsec:" + prefix + "TLSCertificateAuthority CA added successfully")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
|
||||||
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
certBouncerKey, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncerKey")
|
certBouncerKey, err := GetVariable(config, prefix+"TLSCertificateBouncerKey")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -388,3 +492,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 *logger.Log, 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,8 +85,14 @@ func Test_ValidateParams(t *testing.T) {
|
|||||||
cfg6 := getMinimalConfig()
|
cfg6 := getMinimalConfig()
|
||||||
cfg6.CrowdsecLapiScheme = HTTPS
|
cfg6.CrowdsecLapiScheme = HTTPS
|
||||||
cfg6.CrowdsecLapiTLSInsecureVerify = true
|
cfg6.CrowdsecLapiTLSInsecureVerify = true
|
||||||
|
cfg7 := getMinimalConfig()
|
||||||
|
cfg7.CrowdsecLapiScheme = HTTPS
|
||||||
cfg8 := getMinimalConfig()
|
cfg8 := getMinimalConfig()
|
||||||
cfg8.CrowdsecLapiScheme = HTTPS
|
cfg8.LogLevel = LogINFO
|
||||||
|
cfg9 := getMinimalConfig()
|
||||||
|
cfg9.LogLevel = "info"
|
||||||
|
cfg10 := getMinimalConfig()
|
||||||
|
cfg10.LogLevel = "Warning"
|
||||||
type args struct {
|
type args struct {
|
||||||
config *Config
|
config *Config
|
||||||
}
|
}
|
||||||
@@ -104,7 +110,10 @@ func Test_ValidateParams(t *testing.T) {
|
|||||||
{name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true},
|
{name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true},
|
||||||
// HTTPS enabled
|
// HTTPS enabled
|
||||||
{name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false},
|
{name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false},
|
||||||
{name: "Not validate https without cert authority", args: args{config: cfg8}, wantErr: true},
|
{name: "Not validate https without cert authority", args: args{config: cfg7}, wantErr: true},
|
||||||
|
{name: "Valid log level uppercase INFO", args: args{config: cfg8}, wantErr: false},
|
||||||
|
{name: "Valid log level lowercase info", args: args{config: cfg9}, wantErr: false},
|
||||||
|
{name: "Invalid log level Warning", args: args{config: cfg10}, wantErr: true},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -196,23 +205,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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -233,7 +243,7 @@ func Test_GetTLSConfigCrowdsec(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 := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO"))
|
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO", ""), 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
|
||||||
|
|||||||
+40
-9
@@ -1,11 +1,13 @@
|
|||||||
// 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 and error level
|
||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Log Logger struct.
|
// Log Logger struct.
|
||||||
@@ -16,15 +18,44 @@ type Log struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New Set Default log level to info in case log level to defined.
|
// New Set Default log level to info in case log level to defined.
|
||||||
func New(logLevel string) *Log {
|
func New(logLevel string, logFilePath string) *Log {
|
||||||
|
// Initialize loggers with discard output
|
||||||
logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
||||||
logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
||||||
logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
||||||
logError.SetOutput(os.Stderr)
|
|
||||||
logInfo.SetOutput(os.Stdout)
|
// we initialize logger to STDOUT/STDERR first so if the file logger cannot be initialized we can inform the user
|
||||||
if logLevel == "DEBUG" {
|
output := os.Stdout
|
||||||
logDebug.SetOutput(os.Stdout)
|
errorOutput := os.Stderr
|
||||||
|
|
||||||
|
// prepare file logging if specified
|
||||||
|
if logFilePath != "" {
|
||||||
|
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if err == nil {
|
||||||
|
output = logFile
|
||||||
|
errorOutput = logFile
|
||||||
|
} else {
|
||||||
|
_ = fmt.Errorf("LogFilePath is not writable %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set error logger output
|
||||||
|
logError.SetOutput(errorOutput)
|
||||||
|
|
||||||
|
// Configure log levels
|
||||||
|
switch logLevel {
|
||||||
|
case "ERROR":
|
||||||
|
// Only error logging is enabled
|
||||||
|
case "INFO":
|
||||||
|
logInfo.SetOutput(output)
|
||||||
|
case "DEBUG":
|
||||||
|
logInfo.SetOutput(output)
|
||||||
|
logDebug.SetOutput(output)
|
||||||
|
default:
|
||||||
|
// Default to INFO level
|
||||||
|
logInfo.SetOutput(output)
|
||||||
|
}
|
||||||
|
|
||||||
return &Log{
|
return &Log{
|
||||||
logError: logError,
|
logError: logError,
|
||||||
logInfo: logInfo,
|
logInfo: logInfo,
|
||||||
@@ -34,15 +65,15 @@ func New(logLevel string) *Log {
|
|||||||
|
|
||||||
// Info log to Stdout.
|
// Info log to Stdout.
|
||||||
func (l *Log) Info(str string) {
|
func (l *Log) Info(str string) {
|
||||||
l.logInfo.Printf(str)
|
l.logInfo.Printf("%s", str)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug log to Stdout.
|
// Debug log to Stdout.
|
||||||
func (l *Log) Debug(str string) {
|
func (l *Log) Debug(str string) {
|
||||||
l.logDebug.Printf(str)
|
l.logDebug.Printf("%s", str)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error log to Stderr.
|
// Error log to Stderr.
|
||||||
func (l *Log) Error(str string) {
|
func (l *Log) Error(str string) {
|
||||||
l.logError.Printf(str)
|
l.logError.Printf("%s", str)
|
||||||
}
|
}
|
||||||
|
|||||||
+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.5.0" //nolint:gochecknoglobals
|
||||||
Reference in New Issue
Block a user