mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375a5f6bb1 | ||
|
|
7f776fe0fe | ||
|
|
e54c1d5c4f | ||
|
|
c2bbc4dac5 | ||
|
|
889c5b55fe | ||
|
|
efb3a67019 | ||
|
|
0780027252 | ||
|
|
892909b9b8 | ||
|
|
c26923dee5 | ||
|
|
a9d83f2097 | ||
|
|
e20ccc5d0c | ||
|
|
50beb4294f | ||
|
|
4ab4f3f183 | ||
|
|
2aac531ea7 | ||
|
|
e8e60c958f | ||
|
|
a2d3708bc3 | ||
|
|
65a2f79fb3 | ||
|
|
a2ecc95dc9 | ||
|
|
7c4f5163e9 | ||
|
|
734975c206 | ||
|
|
7397834e58 | ||
|
|
84a5674b14 |
@@ -1,5 +1,8 @@
|
|||||||
name: Main
|
name: Main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -24,20 +27,20 @@ jobs:
|
|||||||
|
|
||||||
# https://github.com/marketplace/actions/setup-go-environment
|
# https://github.com/marketplace/actions/setup-go-environment
|
||||||
- name: Set up Go ${{ env.GO_VERSION }}
|
- name: Set up Go ${{ env.GO_VERSION }}
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
# https://github.com/marketplace/actions/checkout
|
# https://github.com/marketplace/actions/checkout
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@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@v4
|
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 }}"
|
||||||
@@ -73,6 +73,7 @@ linters:
|
|||||||
- gci
|
- gci
|
||||||
- mnd
|
- mnd
|
||||||
- exportloopref
|
- exportloopref
|
||||||
|
- contextcheck
|
||||||
issues:
|
issues:
|
||||||
exclude-use-default: false
|
exclude-use-default: false
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ The following captcha providers are supported now:
|
|||||||
- [hcaptcha](https://www.hcaptcha.com/)
|
- [hcaptcha](https://www.hcaptcha.com/)
|
||||||
- [recaptcha](https://www.google.com/recaptcha/about/)
|
- [recaptcha](https://www.google.com/recaptcha/about/)
|
||||||
- [turnstile](https://www.cloudflare.com/products/turnstile/)
|
- [turnstile](https://www.cloudflare.com/products/turnstile/)
|
||||||
|
- [custom/wicketkeeper](https://github.com/a-ve/wicketkeeper)
|
||||||
|
|
||||||
There are 5 operating modes (CrowdsecMode) for this plugin:
|
There are 5 operating modes (CrowdsecMode) for this plugin:
|
||||||
|
|
||||||
@@ -308,12 +309,17 @@ make run
|
|||||||
|
|
||||||
### Note
|
### Note
|
||||||
|
|
||||||
**/!\ Cache is shared by all services**
|
> [!IMPORTANT]
|
||||||
_This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP_
|
> Some of the behaviours and configuration parameters are shared globally across _all_ crowdsec middlewares even if you declare different middlewares with different settings.
|
||||||
Only one instance of the plugin is _possible_.
|
>
|
||||||
|
> **Cache is shared by all services**: This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP
|
||||||
|
>
|
||||||
|
> If you define different caches for different middlewares, only the first one to be instantiated will be bound to the crowdsec stream.
|
||||||
|
>
|
||||||
|
> Overall, this middleware is designed in such a way that **only one instance of the plugin is _possible_.** You can have multiple crowdsec middlewares in the same cluster, the key parameters must be aligned (MetricsUpdateIntervalSeconds, CrowdsecMode, CrowdsecAppsecEnabled, etc.)
|
||||||
|
|
||||||
**/!\ Appsec maximum body limit is defaulted to 10MB**
|
> [!WARNING]
|
||||||
_By careful when you upgrade to >1.4.x_
|
> **Appsec maximum body limit is defaulted to 10MB** > _Be careful when you upgrade to >1.4.x_
|
||||||
|
|
||||||
### Variables
|
### Variables
|
||||||
|
|
||||||
@@ -323,12 +329,21 @@ _By careful when you upgrade to >1.4.x_
|
|||||||
- Enable the plugin
|
- Enable the plugin
|
||||||
- LogLevel
|
- LogLevel
|
||||||
- string
|
- string
|
||||||
- default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR`
|
- default: `INFO`, expected values are: `DEBUG`, `INFO`, `WARN`, `ERROR`
|
||||||
- Log are written to `stdout` / `stderr` of file if LogFilePath is provided
|
- Log are written to `stdout` / `stderr` or file if LogFilePath is provided
|
||||||
|
- LogFormat
|
||||||
|
- string
|
||||||
|
- default: `common`, expected values are: `common`, `json`
|
||||||
|
- Log format: `common` for traditional text logs, `json` for structured JSON logs
|
||||||
- LogFilePath
|
- LogFilePath
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- File Path to write logs, must be writable by Traefik, Log rotation may require a restart of traefik
|
- File Path to write logs, must be writable by Traefik, Log rotation may require a restart of traefik
|
||||||
|
- MetricsUpdateIntervalSeconds
|
||||||
|
- int64
|
||||||
|
- default: 600
|
||||||
|
- Interval in seconds between metrics updates to Crowdsec
|
||||||
|
- If set to zero or less, metrics collection is disabled
|
||||||
- CrowdsecMode
|
- CrowdsecMode
|
||||||
- string
|
- string
|
||||||
- default: `live`, expected values are: `none`, `live`, `stream`, `alone`, `appsec`
|
- default: `live`, expected values are: `none`, `live`, `stream`, `alone`, `appsec`
|
||||||
@@ -339,7 +354,18 @@ _By careful when you upgrade to >1.4.x_
|
|||||||
- CrowdsecAppsecHost
|
- CrowdsecAppsecHost
|
||||||
- string
|
- string
|
||||||
- default: "crowdsec:7422"
|
- default: "crowdsec:7422"
|
||||||
- Crowdsec Appsec Server available on which host and port. The scheme will be handled by the CrowdsecLapiScheme var.
|
- Crowdsec Appsec Server available on which host and port.
|
||||||
|
- CrowdsecAppsecTlsInsecureVerify
|
||||||
|
- bool
|
||||||
|
- default: false
|
||||||
|
- Disable verification of certificate presented by Appsec
|
||||||
|
- CrowdsecAppsecTlsCertificateAuthority
|
||||||
|
- string
|
||||||
|
- default: ""
|
||||||
|
- PEM-encoded Certificate Authority of Appsec
|
||||||
|
- CrowdsecAppsecScheme
|
||||||
|
- string
|
||||||
|
- default: value of `CrowdsecLapiScheme`, expected values are: `http`, `https`
|
||||||
- CrowdsecAppsecPath
|
- CrowdsecAppsecPath
|
||||||
- string
|
- string
|
||||||
- default: "/"
|
- default: "/"
|
||||||
@@ -356,6 +382,10 @@ _By careful when you upgrade to >1.4.x_
|
|||||||
- int64
|
- int64
|
||||||
- default: 10485760 (= 10MB)
|
- default: 10485760 (= 10MB)
|
||||||
- Transmit only the first number of bytes to Crowdsec Appsec Server.
|
- Transmit only the first number of bytes to Crowdsec Appsec Server.
|
||||||
|
- 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`
|
||||||
@@ -394,7 +424,7 @@ _By careful when you upgrade to >1.4.x_
|
|||||||
- RemediationHeadersCustomName
|
- RemediationHeadersCustomName
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- Name of the header you want in response when request are cancelled (possible value of the header `ban` or `captcha`)
|
- Name of the header you want in response when request are handled by plugin (possible value of the header `ban`, `captcha` or `solved-captcha`)
|
||||||
- ForwardedHeadersCustomName
|
- ForwardedHeadersCustomName
|
||||||
- string
|
- string
|
||||||
- default: "X-Forwarded-For"
|
- default: "X-Forwarded-For"
|
||||||
@@ -454,7 +484,19 @@ _By careful when you upgrade to >1.4.x_
|
|||||||
- Used only in `alone` mode, scenarios for Crowdsec CAPI
|
- Used only in `alone` mode, scenarios for Crowdsec CAPI
|
||||||
- CaptchaProvider
|
- CaptchaProvider
|
||||||
- string
|
- string
|
||||||
- Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile`
|
- Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile` or `custom`
|
||||||
|
- CaptchaCustomJsURL
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, URL used to load the challenge in the HTML (in case of hcaptcha: `https://hcaptcha.com/1/api.js`)
|
||||||
|
- CaptchaCustomValidateURL
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, URL used to validate the challenge (in case of hcaptcha: `https://api.hcaptcha.com/siteverify`)
|
||||||
|
- CaptchaCustomKey
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, used to set class name of the div used by captcha provider (in case of hcaptcha: `h-captcha`)
|
||||||
|
- CaptchaCustomResponse
|
||||||
|
- string
|
||||||
|
- If CaptchaProvider is `custom`, used to set the field in the POST body from the captcha.html to Traefik (in case of hcaptcha: `h-captcha-response`)
|
||||||
- CaptchaSiteKey
|
- CaptchaSiteKey
|
||||||
- string
|
- string
|
||||||
- Site key for the captcha provider
|
- Site key for the captcha provider
|
||||||
@@ -473,6 +515,10 @@ _By careful when you upgrade to >1.4.x_
|
|||||||
- 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
|
||||||
|
|
||||||
@@ -494,7 +540,37 @@ experimental:
|
|||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Dynamic configuration
|
# Simplified dynamic configuration
|
||||||
|
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
my-router:
|
||||||
|
rule: host(`whoami.localhost`)
|
||||||
|
service: service-foo
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
middlewares:
|
||||||
|
- crowdsec
|
||||||
|
|
||||||
|
services:
|
||||||
|
service-foo:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: http://127.0.0.1:5000
|
||||||
|
|
||||||
|
middlewares:
|
||||||
|
crowdsec:
|
||||||
|
plugin:
|
||||||
|
bouncer:
|
||||||
|
enabled: true
|
||||||
|
logLevel: DEBUG
|
||||||
|
crowdsecMode: live
|
||||||
|
crowdsecLapiKey: privateKey-foo
|
||||||
|
crowdsecLapiHost: crowdsec:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Full dynamic configuration
|
||||||
|
|
||||||
http:
|
http:
|
||||||
routers:
|
routers:
|
||||||
@@ -518,6 +594,7 @@ http:
|
|||||||
bouncer:
|
bouncer:
|
||||||
enabled: false
|
enabled: false
|
||||||
logLevel: DEBUG
|
logLevel: DEBUG
|
||||||
|
logFormat: common
|
||||||
LogFilePath: ""
|
LogFilePath: ""
|
||||||
updateIntervalSeconds: 60
|
updateIntervalSeconds: 60
|
||||||
updateMaxFailure: 0
|
updateMaxFailure: 0
|
||||||
@@ -526,6 +603,7 @@ http:
|
|||||||
httpTimeoutSeconds: 10
|
httpTimeoutSeconds: 10
|
||||||
crowdsecMode: live
|
crowdsecMode: live
|
||||||
crowdsecAppsecEnabled: false
|
crowdsecAppsecEnabled: false
|
||||||
|
crowdsecAppsecScheme: ""
|
||||||
crowdsecAppsecHost: crowdsec:7422
|
crowdsecAppsecHost: crowdsec:7422
|
||||||
crowdsecAppsecPath: "/"
|
crowdsecAppsecPath: "/"
|
||||||
crowdsecAppsecFailureBlock: true
|
crowdsecAppsecFailureBlock: true
|
||||||
@@ -579,11 +657,13 @@ http:
|
|||||||
captchaGracePeriodSeconds: 1800
|
captchaGracePeriodSeconds: 1800
|
||||||
captchaHTMLFilePath: /captcha.html
|
captchaHTMLFilePath: /captcha.html
|
||||||
banHTMLFilePath: /ban.html
|
banHTMLFilePath: /ban.html
|
||||||
|
traceHeadersCustomName: X-Request-ID
|
||||||
|
metricsUpdateIntervalSeconds: 600
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Fill variable with value of file
|
#### Fill variable with value of file
|
||||||
|
|
||||||
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey`, `CaptchaSecretKey` and `RedisCachePassword` can be provided with the content as raw or through a file path that Traefik can read.
|
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecAppsecTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CrowdsecAppsecKey`, `CaptchaSiteKey`, `CaptchaSecretKey` and `RedisCachePassword` can be provided with the content as raw or through a file path that Traefik can read.
|
||||||
The file variable will be used as preference if both content and file are provided for the same variable.
|
The file variable will be used as preference if both content and file are provided for the same variable.
|
||||||
|
|
||||||
Format is:
|
Format is:
|
||||||
@@ -646,6 +726,13 @@ 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
|
||||||
@@ -678,6 +765,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.
|
||||||
|
|||||||
+241
-74
@@ -9,10 +9,14 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
htmltemplate "html/template"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,6 +37,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 +45,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.
|
||||||
@@ -61,8 +86,10 @@ type Bouncer struct {
|
|||||||
|
|
||||||
enabled bool
|
enabled bool
|
||||||
appsecEnabled bool
|
appsecEnabled bool
|
||||||
|
appsecScheme string
|
||||||
appsecHost string
|
appsecHost string
|
||||||
appsecPath string
|
appsecPath string
|
||||||
|
appsecKey string
|
||||||
appsecFailureBlock bool
|
appsecFailureBlock bool
|
||||||
appsecUnreachableBlock bool
|
appsecUnreachableBlock bool
|
||||||
appsecBodyLimit int64
|
appsecBodyLimit int64
|
||||||
@@ -75,7 +102,7 @@ type Bouncer struct {
|
|||||||
crowdsecPassword string
|
crowdsecPassword string
|
||||||
crowdsecScenarios []string
|
crowdsecScenarios []string
|
||||||
updateInterval int64
|
updateInterval int64
|
||||||
updateMaxFailure int
|
updateMaxFailure int64
|
||||||
defaultDecisionTimeout int64
|
defaultDecisionTimeout int64
|
||||||
remediationStatusCode int
|
remediationStatusCode int
|
||||||
remediationCustomHeader string
|
remediationCustomHeader string
|
||||||
@@ -83,20 +110,24 @@ type Bouncer struct {
|
|||||||
crowdsecStreamRoute string
|
crowdsecStreamRoute string
|
||||||
crowdsecHeader string
|
crowdsecHeader string
|
||||||
redisUnreachableBlock bool
|
redisUnreachableBlock bool
|
||||||
banTemplateString string
|
banTemplate *htmltemplate.Template
|
||||||
|
traceCustomHeader string
|
||||||
clientPoolStrategy *ip.PoolStrategy
|
clientPoolStrategy *ip.PoolStrategy
|
||||||
serverPoolStrategy *ip.PoolStrategy
|
serverPoolStrategy *ip.PoolStrategy
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
httpAppsecClient *http.Client
|
||||||
cacheClient *cache.Client
|
cacheClient *cache.Client
|
||||||
captchaClient *captcha.Client
|
captchaClient *captcha.Client
|
||||||
log *logger.Log
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
config.LogLevel = strings.ToUpper(config.LogLevel)
|
config.LogLevel = strings.ToUpper(config.LogLevel)
|
||||||
log := logger.New(config.LogLevel, config.LogFilePath)
|
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
|
||||||
err := configuration.ValidateParams(config)
|
err := configuration.ValidateParams(config, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("New:validateParams " + err.Error())
|
log.Error("New:validateParams " + err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -105,6 +136,23 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
serverChecker, _ := ip.NewChecker(log, config.ForwardedHeadersTrustedIPs)
|
serverChecker, _ := ip.NewChecker(log, config.ForwardedHeadersTrustedIPs)
|
||||||
clientChecker, _ := ip.NewChecker(log, config.ClientTrustedIPs)
|
clientChecker, _ := ip.NewChecker(log, config.ClientTrustedIPs)
|
||||||
|
|
||||||
|
var tlsAppsecConfig *tls.Config
|
||||||
|
if config.CrowdsecAppsecEnabled {
|
||||||
|
tlsAppsecConfig, err = configuration.GetTLSConfigCrowdsec(config, log, true)
|
||||||
|
if config.CrowdsecAppsecScheme == "" {
|
||||||
|
config.CrowdsecAppsecScheme = config.CrowdsecLapiScheme
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error("New:getTLSConfigCrowdsec fail to get tlsAppsecConfig " + err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
apiAppsecKey, errAppsecKey := configuration.GetVariable(config, "CrowdsecAppsecKey")
|
||||||
|
if errAppsecKey != nil && len(tlsAppsecConfig.Certificates) == 0 {
|
||||||
|
log.Info("New:crowdsecLapiKey fail to get CrowdsecAppsecKey and no client certificate setup " + errAppsecKey.Error())
|
||||||
|
}
|
||||||
|
config.CrowdsecAppsecKey = apiAppsecKey
|
||||||
|
}
|
||||||
|
|
||||||
var tlsConfig *tls.Config
|
var tlsConfig *tls.Config
|
||||||
crowdsecStreamRoute := ""
|
crowdsecStreamRoute := ""
|
||||||
crowdsecHeader := ""
|
crowdsecHeader := ""
|
||||||
@@ -114,36 +162,31 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
config.CrowdsecLapiScheme = configuration.HTTPS
|
config.CrowdsecLapiScheme = configuration.HTTPS
|
||||||
config.CrowdsecLapiHost = crowdsecCapiHost
|
config.CrowdsecLapiHost = crowdsecCapiHost
|
||||||
config.CrowdsecLapiPath = "/"
|
config.CrowdsecLapiPath = "/"
|
||||||
config.CrowdsecAppsecEnabled = false
|
|
||||||
config.UpdateIntervalSeconds = 7200 // 2 hours
|
config.UpdateIntervalSeconds = 7200 // 2 hours
|
||||||
crowdsecStreamRoute = crowdsecCapiStreamRoute
|
crowdsecStreamRoute = crowdsecCapiStreamRoute
|
||||||
crowdsecHeader = crowdsecCapiHeader
|
crowdsecHeader = crowdsecCapiHeader
|
||||||
} else {
|
} else {
|
||||||
crowdsecStreamRoute = crowdsecLapiStreamRoute
|
crowdsecStreamRoute = crowdsecLapiStreamRoute
|
||||||
crowdsecHeader = crowdsecLapiHeader
|
crowdsecHeader = crowdsecLapiHeader
|
||||||
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log)
|
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("New:getTLSConfigCrowdsec fail to get tlsConfig " + err.Error())
|
log.Error("New:getTLSConfigCrowdsec fail to get tlsConfig " + err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
apiKey, errAPIKey := configuration.GetVariable(config, "CrowdsecLapiKey")
|
apiKey, errKey := configuration.GetVariable(config, "CrowdsecLapiKey")
|
||||||
if errAPIKey != nil && len(tlsConfig.Certificates) == 0 {
|
if errKey != nil && len(tlsConfig.Certificates) == 0 {
|
||||||
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errAPIKey.Error())
|
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errKey.Error())
|
||||||
return nil, errAPIKey
|
return nil, errKey
|
||||||
}
|
}
|
||||||
config.CrowdsecLapiKey = apiKey
|
config.CrowdsecLapiKey = apiKey
|
||||||
|
if config.CrowdsecAppsecKey == "" {
|
||||||
|
config.CrowdsecAppsecKey = apiKey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var banTemplateString string
|
var banTemplate *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{
|
||||||
@@ -154,8 +197,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
enabled: config.Enabled,
|
enabled: config.Enabled,
|
||||||
crowdsecMode: config.CrowdsecMode,
|
crowdsecMode: config.CrowdsecMode,
|
||||||
appsecEnabled: config.CrowdsecAppsecEnabled,
|
appsecEnabled: config.CrowdsecAppsecEnabled,
|
||||||
|
appsecScheme: config.CrowdsecAppsecScheme,
|
||||||
appsecHost: config.CrowdsecAppsecHost,
|
appsecHost: config.CrowdsecAppsecHost,
|
||||||
appsecPath: config.CrowdsecAppsecPath,
|
appsecPath: config.CrowdsecAppsecPath,
|
||||||
|
appsecKey: config.CrowdsecAppsecKey,
|
||||||
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
||||||
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
||||||
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
|
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
|
||||||
@@ -173,7 +218,8 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
defaultDecisionTimeout: config.DefaultDecisionSeconds,
|
defaultDecisionTimeout: config.DefaultDecisionSeconds,
|
||||||
remediationStatusCode: config.RemediationStatusCode,
|
remediationStatusCode: config.RemediationStatusCode,
|
||||||
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
||||||
banTemplateString: banTemplateString,
|
banTemplate: banTemplate,
|
||||||
|
traceCustomHeader: config.TraceHeadersCustomName,
|
||||||
crowdsecStreamRoute: crowdsecStreamRoute,
|
crowdsecStreamRoute: crowdsecStreamRoute,
|
||||||
crowdsecHeader: crowdsecHeader,
|
crowdsecHeader: crowdsecHeader,
|
||||||
log: log,
|
log: log,
|
||||||
@@ -191,6 +237,14 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
},
|
},
|
||||||
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||||
},
|
},
|
||||||
|
httpAppsecClient: &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
MaxIdleConns: 10,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
TLSClientConfig: tlsAppsecConfig,
|
||||||
|
},
|
||||||
|
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||||
|
},
|
||||||
cacheClient: &cache.Client{},
|
cacheClient: &cache.Client{},
|
||||||
captchaClient: &captcha.Client{},
|
captchaClient: &captcha.Client{},
|
||||||
}
|
}
|
||||||
@@ -215,6 +269,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||||
},
|
},
|
||||||
config.CaptchaProvider,
|
config.CaptchaProvider,
|
||||||
|
config.CaptchaCustomJsURL,
|
||||||
|
config.CaptchaCustomKey,
|
||||||
|
config.CaptchaCustomResponse,
|
||||||
|
config.CaptchaCustomValidateURL,
|
||||||
config.CaptchaSiteKey,
|
config.CaptchaSiteKey,
|
||||||
config.CaptchaSecretKey,
|
config.CaptchaSecretKey,
|
||||||
config.RemediationHeadersCustomName,
|
config.RemediationHeadersCustomName,
|
||||||
@@ -222,10 +280,11 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
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())
|
||||||
@@ -234,10 +293,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
|
||||||
@@ -256,13 +325,13 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.forwardedCustomHeader)
|
remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.forwardedCustomHeader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error()))
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error()))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP)
|
isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error()))
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error()))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// if our IP is in the trusted list we bypass the next checks
|
// if our IP is in the trusted list we bypass the next checks
|
||||||
@@ -273,7 +342,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if bouncer.crowdsecMode == configuration.AppsecMode {
|
if bouncer.crowdsecMode == configuration.AppsecMode {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,20 +354,20 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString))
|
||||||
if !bouncer.redisUnreachableBlock && cacheErrString == cache.CacheUnreachable {
|
if !bouncer.redisUnreachableBlock && cacheErrString == cache.CacheUnreachable {
|
||||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s redisUnreachable=true", remoteIP))
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s redisUnreachable=true", remoteIP))
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cacheErrString != cache.CacheMiss {
|
if cacheErrString != cache.CacheMiss {
|
||||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
|
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
|
||||||
if value == cache.NoBannedValue {
|
if value == cache.NoBannedValue {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
} else {
|
} else {
|
||||||
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
|
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -307,18 +376,21 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
// Right here if we cannot join the stream we forbid the request to go on.
|
// Right here if we cannot join the stream we forbid the request to go on.
|
||||||
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
|
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
|
||||||
if isCrowdsecStreamHealthy {
|
if isCrowdsecStreamHealthy {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
} else {
|
} else {
|
||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
value, err := handleNoStreamCache(bouncer, remoteIP)
|
value, err := handleNoStreamCache(bouncer, remoteIP)
|
||||||
|
if err != nil {
|
||||||
|
bouncer.log.Debug("handleNoStreamCache:crowdsecQuery " + err.Error())
|
||||||
|
}
|
||||||
if value == cache.NoBannedValue {
|
if value == cache.NoBannedValue {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
} else {
|
} else {
|
||||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error()))
|
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error()))
|
||||||
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
|
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,40 +424,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) {
|
||||||
|
atomic.AddInt64(&blockedRequests, 1)
|
||||||
|
|
||||||
if bouncer.remediationCustomHeader != "" {
|
if bouncer.remediationCustomHeader != "" {
|
||||||
rw.Header().Set(bouncer.remediationCustomHeader, "ban")
|
rw.Header().Set(bouncer.remediationCustomHeader, "ban")
|
||||||
}
|
}
|
||||||
if bouncer.banTemplateString == "" {
|
if bouncer.banTemplate == nil {
|
||||||
rw.WriteHeader(bouncer.remediationStatusCode)
|
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(bouncer.remediationStatusCode)
|
rw.WriteHeader(bouncer.remediationStatusCode)
|
||||||
_, err := 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 {
|
if err != nil {
|
||||||
bouncer.log.Error("handleBanServeHTTP could not write template to ResponseWriter")
|
bouncer.log.Warn("handleBanServeHTTP could not write template to ResponseWriter: " + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRemediationServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) {
|
func (bouncer *Bouncer) handleRemediationServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP, remediation string) {
|
||||||
bouncer.log.Debug(fmt.Sprintf("handleRemediationServeHTTP ip:%s remediation:%s", remoteIP, remediation))
|
bouncer.log.Debug(fmt.Sprintf("handleRemediationServeHTTP ip:%s remediation:%s", remoteIP, remediation))
|
||||||
if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue {
|
if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue && req.Method != http.MethodHead {
|
||||||
if bouncer.captchaClient.Check(remoteIP) {
|
if bouncer.captchaClient.Check(remoteIP) {
|
||||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
atomic.AddInt64(&blockedRequests, 1) // If we serve a captcha that should count as a dropped request.
|
||||||
bouncer.captchaClient.ServeHTTP(rw, req, remoteIP)
|
bouncer.captchaClient.ServeHTTP(rw, req, remoteIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonLAPI)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWriter, req *http.Request) {
|
func (bouncer *Bouncer) handleNextServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP string) {
|
||||||
if bouncer.appsecEnabled {
|
if bouncer.appsecEnabled {
|
||||||
if err := appsecQuery(bouncer, remoteIP, req); err != nil {
|
if err := appsecQuery(bouncer, remoteIP, req); err != nil {
|
||||||
bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error()))
|
bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error()))
|
||||||
handleBanServeHTTP(bouncer, rw)
|
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonAPPSEC)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,7 +487,7 @@ func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWrit
|
|||||||
|
|
||||||
func handleStreamTicker(bouncer *Bouncer) {
|
func handleStreamTicker(bouncer *Bouncer) {
|
||||||
if err := handleStreamCache(bouncer); err != nil {
|
if err := handleStreamCache(bouncer); err != nil {
|
||||||
bouncer.log.Debug(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
|
bouncer.log.Warn(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
|
||||||
if bouncer.updateMaxFailure != -1 && updateFailure >= bouncer.updateMaxFailure && isCrowdsecStreamHealthy {
|
if bouncer.updateMaxFailure != -1 && updateFailure >= bouncer.updateMaxFailure && isCrowdsecStreamHealthy {
|
||||||
isCrowdsecStreamHealthy = false
|
isCrowdsecStreamHealthy = false
|
||||||
bouncer.log.Error(fmt.Sprintf("handleStreamTicker:error updateFailure:%d %s", updateFailure, err.Error()))
|
bouncer.log.Error(fmt.Sprintf("handleStreamTicker:error updateFailure:%d %s", updateFailure, err.Error()))
|
||||||
@@ -406,11 +499,17 @@ func handleStreamTicker(bouncer *Bouncer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startTicker(config *configuration.Config, log *logger.Log, work func()) chan bool {
|
func handleMetricsTicker(bouncer *Bouncer) {
|
||||||
ticker := time.NewTicker(time.Duration(config.UpdateIntervalSeconds) * time.Second)
|
if err := reportMetrics(bouncer); err != nil {
|
||||||
|
bouncer.log.Error("handleMetricsTicker:reportMetrics " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTicker(name string, updateInterval int64, log *slog.Logger, work func()) chan bool {
|
||||||
|
ticker := time.NewTicker(time.Duration(updateInterval) * time.Second)
|
||||||
stop := make(chan bool, 1)
|
stop := make(chan bool, 1)
|
||||||
go func() {
|
go func() {
|
||||||
defer log.Debug("ticker:stopped")
|
defer log.Debug(name + "_ticker:stopped")
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
@@ -432,7 +531,7 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
|
|||||||
Path: bouncer.crowdsecPath + crowdsecLapiRoute,
|
Path: bouncer.crowdsecPath + crowdsecLapiRoute,
|
||||||
RawQuery: fmt.Sprintf("ip=%v", remoteIP),
|
RawQuery: fmt.Sprintf("ip=%v", remoteIP),
|
||||||
}
|
}
|
||||||
body, err := crowdsecQuery(bouncer, routeURL.String(), false)
|
body, err := crowdsecQuery(bouncer, routeURL.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cache.BannedValue, err
|
return cache.BannedValue, err
|
||||||
}
|
}
|
||||||
@@ -473,9 +572,9 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
|
|||||||
case "captcha":
|
case "captcha":
|
||||||
value = cache.CaptchaValue
|
value = cache.CaptchaValue
|
||||||
default:
|
default:
|
||||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
|
||||||
}
|
}
|
||||||
if isLiveMode {
|
if isLiveMode && bouncer.defaultDecisionTimeout > 0 {
|
||||||
durationSecond := int64(duration.Seconds())
|
durationSecond := int64(duration.Seconds())
|
||||||
if bouncer.defaultDecisionTimeout < durationSecond {
|
if bouncer.defaultDecisionTimeout < durationSecond {
|
||||||
durationSecond = bouncer.defaultDecisionTimeout
|
durationSecond = bouncer.defaultDecisionTimeout
|
||||||
@@ -491,7 +590,16 @@ func getToken(bouncer *Bouncer) error {
|
|||||||
Host: bouncer.crowdsecHost,
|
Host: bouncer.crowdsecHost,
|
||||||
Path: crowdsecCapiLoginRoute,
|
Path: crowdsecCapiLoginRoute,
|
||||||
}
|
}
|
||||||
body, err := crowdsecQuery(bouncer, loginURL.String(), true)
|
|
||||||
|
// Move the login-specific payload here
|
||||||
|
loginData := []byte(fmt.Sprintf(
|
||||||
|
`{"machine_id": "%v","password": "%v","scenarios": ["%v"]}`,
|
||||||
|
bouncer.crowdsecMachineID,
|
||||||
|
bouncer.crowdsecPassword,
|
||||||
|
strings.Join(bouncer.crowdsecScenarios, `","`),
|
||||||
|
))
|
||||||
|
|
||||||
|
body, err := crowdsecQuery(bouncer, loginURL.String(), loginData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -500,11 +608,11 @@ func getToken(bouncer *Bouncer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getToken:parsingBody %w", err)
|
return fmt.Errorf("getToken:parsingBody %w", err)
|
||||||
}
|
}
|
||||||
if login.Code == 200 && len(login.Token) > 0 {
|
if login.Code == http.StatusOK && len(login.Token) > 0 {
|
||||||
bouncer.crowdsecKey = login.Token
|
bouncer.crowdsecKey = login.Token
|
||||||
bouncer.log.Debug(fmt.Sprintf("getToken statusCode:%d", login.Code))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
bouncer.log.Warn(fmt.Sprintf("getToken statusCode:%d", login.Code))
|
||||||
return fmt.Errorf("getToken statusCode:%d", login.Code)
|
return fmt.Errorf("getToken statusCode:%d", login.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,7 +636,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
Path: bouncer.crowdsecPath + 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
|
||||||
}
|
}
|
||||||
@@ -559,21 +667,15 @@ 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/1.X.X")
|
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 {
|
||||||
@@ -588,13 +690,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)
|
||||||
}
|
}
|
||||||
@@ -603,7 +708,7 @@ 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: bouncer.appsecPath,
|
Path: bouncer.appsecPath,
|
||||||
}
|
}
|
||||||
@@ -628,14 +733,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 {
|
||||||
@@ -664,3 +769,65 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reportMetrics(bouncer *Bouncer) error {
|
||||||
|
now := time.Now()
|
||||||
|
currentCount := atomic.LoadInt64(&blockedRequests)
|
||||||
|
windowSizeSeconds := int(now.Sub(lastMetricsPush).Seconds())
|
||||||
|
|
||||||
|
bouncer.log.Debug(fmt.Sprintf("reportMetrics: blocked_requests=%d window_size=%ds", currentCount, windowSizeSeconds))
|
||||||
|
|
||||||
|
metrics := map[string]interface{}{
|
||||||
|
"remediation_components": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"version": pluginVersion,
|
||||||
|
"type": "bouncer",
|
||||||
|
"name": "traefik_plugin",
|
||||||
|
"metrics": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"items": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "dropped",
|
||||||
|
"value": currentCount,
|
||||||
|
"unit": "request",
|
||||||
|
"labels": map[string]string{
|
||||||
|
"type": "traefik_plugin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"meta": map[string]interface{}{
|
||||||
|
"window_size_seconds": windowSizeSeconds,
|
||||||
|
"utc_now_timestamp": now.Unix(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"utc_startup_timestamp": time.Now().Unix(),
|
||||||
|
"feature_flags": []string{},
|
||||||
|
"os": map[string]string{
|
||||||
|
"name": "unknown",
|
||||||
|
"version": "unknown",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(metrics)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reportMetrics:marshal %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metricsURL := url.URL{
|
||||||
|
Scheme: bouncer.crowdsecScheme,
|
||||||
|
Host: bouncer.crowdsecHost,
|
||||||
|
Path: bouncer.crowdsecPath + crowdsecLapiMetricsRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = crowdsecQuery(bouncer, metricsURL.String(), data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reportMetrics:query %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.StoreInt64(&blockedRequests, 0)
|
||||||
|
lastMetricsPush = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getTestConfig returns a minimal valid configuration for testing.
|
||||||
|
// Override specific fields by modifying the returned config.
|
||||||
|
func getTestConfig() *configuration.Config {
|
||||||
|
return &configuration.Config{
|
||||||
|
Enabled: true,
|
||||||
|
LogLevel: "INFO",
|
||||||
|
LogFormat: "common",
|
||||||
|
LogFilePath: "",
|
||||||
|
CrowdsecMode: "none",
|
||||||
|
CrowdsecLapiKey: "test-key",
|
||||||
|
CrowdsecLapiHost: "localhost",
|
||||||
|
CrowdsecLapiScheme: "http",
|
||||||
|
UpdateIntervalSeconds: 60,
|
||||||
|
DefaultDecisionSeconds: 60,
|
||||||
|
HTTPTimeoutSeconds: 10,
|
||||||
|
ForwardedHeadersTrustedIPs: []string{"127.0.0.1"},
|
||||||
|
ForwardedHeadersCustomName: "",
|
||||||
|
RemediationStatusCode: 403,
|
||||||
|
BanHTMLFilePath: "",
|
||||||
|
RemediationHeadersCustomName: "",
|
||||||
|
CaptchaProvider: "",
|
||||||
|
CaptchaSiteKey: "",
|
||||||
|
CaptchaSecretKey: "",
|
||||||
|
CaptchaGracePeriodSeconds: 1,
|
||||||
|
CaptchaHTMLFilePath: "",
|
||||||
|
RedisCacheEnabled: false,
|
||||||
|
RedisCacheHost: "",
|
||||||
|
RedisCachePassword: "",
|
||||||
|
RedisCacheDatabase: "",
|
||||||
|
RedisCacheUnreachableBlock: false,
|
||||||
|
CrowdsecAppsecEnabled: false,
|
||||||
|
CrowdsecAppsecHost: "",
|
||||||
|
CrowdsecAppsecPath: "",
|
||||||
|
CrowdsecAppsecFailureBlock: false,
|
||||||
|
CrowdsecAppsecUnreachableBlock: false,
|
||||||
|
CrowdsecLapiTLSInsecureVerify: true,
|
||||||
|
CrowdsecLapiTLSCertificateBouncer: "",
|
||||||
|
CrowdsecLapiTLSCertificateBouncerKey: "",
|
||||||
|
CrowdsecCapiMachineID: "",
|
||||||
|
CrowdsecCapiPassword: "",
|
||||||
|
CrowdsecCapiScenarios: []string{},
|
||||||
|
UpdateMaxFailure: 0,
|
||||||
|
MetricsUpdateIntervalSeconds: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create and execute a bouncer request for testing
|
||||||
|
func createAndExecuteBouncerRequest(t *testing.T, config *configuration.Config) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Create a mock next handler
|
||||||
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("OK"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create the bouncer plugin (this will initialize the logger with file output)
|
||||||
|
bouncerHandler, err := New(context.Background(), nextHandler, config, "test-bouncer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create bouncer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test request to trigger logging
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
|
||||||
|
req.RemoteAddr = "192.168.1.100:12345" // Use a non-trusted IP to trigger logging
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Process the request (this should generate log entries)
|
||||||
|
bouncerHandler.ServeHTTP(rw, req)
|
||||||
|
|
||||||
|
// Give a moment for log writes to complete
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to parse log file and extract found levels
|
||||||
|
func parseLogFileAndExtractLevels(t *testing.T, logFile string) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Verify the log file was created and contains entries
|
||||||
|
if _, statErr := os.Stat(logFile); os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("Log file was not created: %s", logFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the log file content
|
||||||
|
// #nosec G304 - logFile is a test-generated temporary file path
|
||||||
|
logContent, err := os.ReadFile(logFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read log file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logString := string(logContent)
|
||||||
|
if len(logString) == 0 {
|
||||||
|
return make(map[string]bool) // Return empty map for empty log files
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and verify JSON log entries
|
||||||
|
lines := strings.Split(strings.TrimSpace(logString), "\n")
|
||||||
|
foundLevels := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var logEntry map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
|
||||||
|
t.Errorf("Invalid JSON log entry: %s, error: %v", line, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify required fields
|
||||||
|
validateLogEntry(t, logEntry)
|
||||||
|
|
||||||
|
// Track log levels we've seen
|
||||||
|
if level, ok := logEntry["level"].(string); ok {
|
||||||
|
foundLevels[level] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return foundLevels
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to validate log entry structure
|
||||||
|
func validateLogEntry(t *testing.T, logEntry map[string]interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if logEntry["time"] == nil {
|
||||||
|
t.Error("Log entry missing 'time' field")
|
||||||
|
}
|
||||||
|
if logEntry["level"] == nil {
|
||||||
|
t.Error("Log entry missing 'level' field")
|
||||||
|
}
|
||||||
|
if logEntry["msg"] == nil {
|
||||||
|
t.Error("Log entry missing 'msg' field")
|
||||||
|
}
|
||||||
|
if logEntry["component"] != "CrowdsecBouncerTraefikPlugin" {
|
||||||
|
t.Errorf("Expected component 'CrowdsecBouncerTraefikPlugin', got %v", logEntry["component"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to verify expected and forbidden log levels
|
||||||
|
func verifyLogLevels(t *testing.T, foundLevels map[string]bool, expectedLevels, forbiddenLevels []string, logLevel string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Handle case where no logs are expected
|
||||||
|
if len(expectedLevels) == 0 {
|
||||||
|
if len(foundLevels) > 0 {
|
||||||
|
t.Errorf("Expected no logs at %s level, but found: %v", logLevel, foundLevels)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Verify we got some log entries
|
||||||
|
if len(foundLevels) == 0 {
|
||||||
|
t.Fatal("No valid log entries found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify expected levels are present
|
||||||
|
for _, expectedLevel := range expectedLevels {
|
||||||
|
if !foundLevels[expectedLevel] {
|
||||||
|
t.Errorf("Expected to find %s level logs, but didn't. Found levels: %v", expectedLevel, foundLevels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify forbidden levels are NOT present
|
||||||
|
for _, forbiddenLevel := range forbiddenLevels {
|
||||||
|
if foundLevels[forbiddenLevel] {
|
||||||
|
t.Errorf("Found forbidden %s level logs at %s level. Found levels: %v", forbiddenLevel, logLevel, foundLevels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBouncerFileLoggingLevels(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
logLevel string
|
||||||
|
expectedLevels []string // Levels that should appear
|
||||||
|
forbiddenLevels []string // Levels that should NOT appear
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "DEBUG level should show DEBUG only",
|
||||||
|
logLevel: "DEBUG",
|
||||||
|
expectedLevels: []string{"DEBUG"},
|
||||||
|
forbiddenLevels: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "INFO level should show no logs (bouncer doesn't generate INFO during normal operation)",
|
||||||
|
logLevel: "INFO",
|
||||||
|
expectedLevels: []string{}, // No logs expected for normal operation
|
||||||
|
forbiddenLevels: []string{"DEBUG"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create temporary directory for log file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
logFile := filepath.Join(tmpDir, "bouncer.log")
|
||||||
|
|
||||||
|
// Get test config and override specific fields
|
||||||
|
config := getTestConfig()
|
||||||
|
config.LogLevel = tt.logLevel
|
||||||
|
config.LogFormat = "json" // Use JSON format for easier parsing
|
||||||
|
config.LogFilePath = logFile
|
||||||
|
|
||||||
|
// Create and execute bouncer request
|
||||||
|
createAndExecuteBouncerRequest(t, config)
|
||||||
|
|
||||||
|
// Parse log file and extract found levels
|
||||||
|
foundLevels := parseLogFileAndExtractLevels(t, logFile)
|
||||||
|
|
||||||
|
// Handle empty log files for higher log levels (expected behavior)
|
||||||
|
if len(foundLevels) == 0 && len(tt.expectedLevels) > 0 {
|
||||||
|
t.Fatalf("Expected log entries but log file is empty for level %s", tt.logLevel)
|
||||||
|
}
|
||||||
|
if len(foundLevels) == 0 {
|
||||||
|
// Empty file is expected for this log level
|
||||||
|
t.Logf("LogLevel %s: No logs generated (expected behavior)", tt.logLevel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify expected and forbidden log levels
|
||||||
|
verifyLogLevels(t, foundLevels, tt.expectedLevels, tt.forbiddenLevels, tt.logLevel)
|
||||||
|
|
||||||
|
t.Logf("LogLevel %s: Successfully logged to file with levels: %v", tt.logLevel, foundLevels)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBouncerFileLoggingCommonFormat(t *testing.T) {
|
||||||
|
// Create temporary directory for log file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
logFile := filepath.Join(tmpDir, "bouncer-common.log")
|
||||||
|
|
||||||
|
// Get test config and override specific fields
|
||||||
|
config := getTestConfig()
|
||||||
|
config.LogLevel = "DEBUG"
|
||||||
|
config.LogFormat = "common" // Use common format
|
||||||
|
config.LogFilePath = logFile
|
||||||
|
|
||||||
|
// Create a mock next handler
|
||||||
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("OK"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create the bouncer plugin
|
||||||
|
bouncerHandler, err := New(context.Background(), nextHandler, config, "test-bouncer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create bouncer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test request to trigger logging
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
|
||||||
|
req.RemoteAddr = "192.168.1.100:12345"
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Process the request
|
||||||
|
bouncerHandler.ServeHTTP(rw, req)
|
||||||
|
|
||||||
|
// Give a moment for log writes to complete
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify the log file was created and contains entries
|
||||||
|
if _, statErr := os.Stat(logFile); os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("Log file was not created: %s", logFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the log file content
|
||||||
|
// #nosec G304 - logFile is a test-generated temporary file path
|
||||||
|
logContent, err := os.ReadFile(logFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read log file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logString := string(logContent)
|
||||||
|
if len(logString) == 0 {
|
||||||
|
t.Fatal("Log file is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify common format structure
|
||||||
|
lines := strings.Split(strings.TrimSpace(logString), "\n")
|
||||||
|
foundDebug := false
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common format should contain time, level, msg, and component
|
||||||
|
if strings.Contains(line, "level=DEBUG") {
|
||||||
|
foundDebug = true
|
||||||
|
}
|
||||||
|
if !strings.Contains(line, "component=CrowdsecBouncerTraefikPlugin") {
|
||||||
|
t.Errorf("Log line missing component field: %s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should see DEBUG level logs since we set LogLevel to DEBUG
|
||||||
|
if !foundDebug {
|
||||||
|
t.Errorf("Expected to find DEBUG level logs in common format. Log content:\n%s", logString)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Successfully logged to file %s in common format with %d lines", logFile, len(lines))
|
||||||
|
}
|
||||||
+148
-2
@@ -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"
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.5.0"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -16,8 +16,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- logs-local:/var/log/traefik
|
- logs-local:/var/log/traefik
|
||||||
- './ban.html:/ban.html:ro'
|
- "./ban.html:/ban.html:ro"
|
||||||
- './captcha.html:/captcha.html:ro'
|
- "./captcha.html:/captcha.html:ro"
|
||||||
- ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
- ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
ports:
|
ports:
|
||||||
- 8000:80
|
- 8000:80
|
||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
- "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
|
- "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
|
||||||
- "traefik.http.services.service-foo.loadbalancer.server.port=80"
|
- "traefik.http.services.service-foo.loadbalancer.server.port=80"
|
||||||
|
|
||||||
whoami2:
|
bar:
|
||||||
image: traefik/whoami
|
image: traefik/whoami
|
||||||
container_name: "simple-service-bar"
|
container_name: "simple-service-bar"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -48,12 +48,39 @@ services:
|
|||||||
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.metricsupdateintervalseconds=15"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=stream"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=stream"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.ForwardedHeadersTrustedIPs=172.21.0.1/8"
|
||||||
|
|
||||||
|
bar2:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: "simple-service-bar2"
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.router-bar2.rule=PathPrefix(`/bar2`)"
|
||||||
|
- "traefik.http.routers.router-bar2.entrypoints=web"
|
||||||
|
- "traefik.http.routers.router-bar2.middlewares=crowdsec2@docker"
|
||||||
|
- "traefik.http.services.service-bar2.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.enabled=true"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.loglevel=DEBUG"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.crowdsecmode=stream"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.updateintervalseconds=10"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.updatemaxfailure=-1"
|
||||||
|
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
|
||||||
|
bar3:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: "simple-service-bar3"
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.router-bar3.rule=PathPrefix(`/bar3`)"
|
||||||
|
- "traefik.http.routers.router-bar3.entrypoints=web"
|
||||||
|
- "traefik.http.routers.router-bar3.middlewares=crowdsec2@docker"
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.6.8
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -67,7 +94,6 @@ services:
|
|||||||
- crowdsec-config-local:/etc/crowdsec/
|
- crowdsec-config-local:/etc/crowdsec/
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=false"
|
- "traefik.enable=false"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
logs-local:
|
logs-local:
|
||||||
crowdsec-db-local:
|
crowdsec-db-local:
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@ services:
|
|||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.6.8
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+2
-6
@@ -1,8 +1,6 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.5.0"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -15,7 +13,7 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.5.0"
|
||||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
@@ -48,8 +46,6 @@ services:
|
|||||||
# Define AppSec host and port informations
|
# Define AppSec host and port informations
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
@@ -20,7 +20,7 @@ For now 3 captcha providers are supported:
|
|||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSiteKey=FIXME"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSiteKey=FIXME"
|
||||||
# Define captcha secret key
|
# Define captcha secret key
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSecretKey=FIXME"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSecretKey=FIXME"
|
||||||
# Define captcha grade period seconds
|
# Define captcha grace period seconds
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800"
|
||||||
# Define captcha HTML file path
|
# Define captcha HTML file path
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||||
@@ -47,7 +47,7 @@ To instruct Crowdsec to use captcha remediation, change the `/etc/crowdsec/profi
|
|||||||
- Return a captcha decision the first X times and then a ban decision.
|
- Return a captcha decision the first X times and then a ban decision.
|
||||||
|
|
||||||
The second mode could be used to prevent repeated malicious activity.
|
The second mode could be used to prevent repeated malicious activity.
|
||||||
More information is available on configuring Crowdsec in the [official documentation](https://docs.crowdsec.net/docs/next/profiles/captcha_profile/).
|
More information is available on configuring Crowdsec in the [official documentation](https://docs.crowdsec.net/docs/next/local_api/profiles/captcha_profile/).
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -44,7 +50,9 @@ http:
|
|||||||
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
|
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
|
||||||
-----END CERTIFICATE-----
|
-----END CERTIFICATE-----
|
||||||
```
|
```
|
||||||
|
|
||||||
In a dynamic configuration of a provider (ex docker) as a Label
|
In a dynamic configuration of a provider (ex docker) as a Label
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
whoami-foo:
|
whoami-foo:
|
||||||
@@ -71,22 +79,34 @@ The service `whoami-foo` will authenticate with an **API key** over HTTPS after
|
|||||||
The service `whoami-bar` will authenticate with a **client certificate** signed by the CA.
|
The service `whoami-bar` will authenticate with a **client certificate** signed by the CA.
|
||||||
|
|
||||||
Access to a route that communicate via https and authenticate with API-key:
|
Access to a route that communicate via https and authenticate with API-key:
|
||||||
|
|
||||||
```
|
```
|
||||||
curl http://localhost:8000/foo
|
curl http://localhost:8000/foo
|
||||||
```
|
```
|
||||||
|
|
||||||
Access to a route that communicate via https and authenticate with a client certificate:
|
Access to a route that communicate via https and authenticate with a client certificate:
|
||||||
|
|
||||||
```
|
```
|
||||||
curl http://localhost:8000/bar
|
curl http://localhost:8000/bar
|
||||||
```
|
```
|
||||||
|
|
||||||
Access to the traefik dashboard
|
Access to the traefik dashboard
|
||||||
|
|
||||||
```
|
```
|
||||||
curl http://localhost:8080/dashboard/#/
|
curl http://localhost:8080/dashboard/#/
|
||||||
```
|
```
|
||||||
|
|
||||||
To play the demo environnement run:
|
To play the demo environnement run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make run_tlsauth
|
make run_tlsauth
|
||||||
```
|
```
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
> Traefik need to be restart if certificates are regenerated after his launch
|
|
||||||
|
> Traefik need to be restarted if certificates are regenerated after his launch, crowdsec also
|
||||||
|
|
||||||
|
## Separate LAPI and Appsec HTTP/S config
|
||||||
|
|
||||||
|
To separate TLS config for LAPI and Appsec, you can use all the TLS LAPI variable beginning with `CrowdsecLapi...` into `CrowdsecAppsec...`.
|
||||||
|
Don't forget to set `CrowdsecAppsecScheme: HTTP` or `HTTPS` to trigger the separate setup.
|
||||||
|
|||||||
@@ -2,3 +2,12 @@ filenames:
|
|||||||
- /var/log/traefik/access.log
|
- /var/log/traefik/access.log
|
||||||
labels:
|
labels:
|
||||||
type: traefik
|
type: traefik
|
||||||
|
---
|
||||||
|
listen_addr: 0.0.0.0:7422
|
||||||
|
appsec_config: crowdsecurity/virtual-patching
|
||||||
|
name: myAppSecComponent
|
||||||
|
source: appsec
|
||||||
|
labels:
|
||||||
|
type: appsec
|
||||||
|
cert_file: /etc/crowdsec/certs/server.pem
|
||||||
|
key_file: /etc/crowdsec/certs/server-key.pem
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.5.0"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -13,15 +13,13 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.5.0"
|
||||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- ./LAPIKEY:/etc/traefik/LAPIKEY:ro
|
|
||||||
- logs-tls-auth:/var/log/traefik
|
- logs-tls-auth:/var/log/traefik
|
||||||
- crowdsec-certs-tls-auth:/etc/traefik/crowdsec-certs
|
- crowdsec-certs-tls-auth:/etc/traefik/crowdsec-certs
|
||||||
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- 8000:80
|
- 8000:80
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
@@ -29,7 +27,7 @@ services:
|
|||||||
- crowdsec
|
- crowdsec
|
||||||
- gencert
|
- gencert
|
||||||
|
|
||||||
# Use HTTPS scheme but with lapikey authentication
|
# Use HTTPS scheme but with lapikey authentication
|
||||||
# whoami-foo:
|
# whoami-foo:
|
||||||
# image: traefik/whoami
|
# image: traefik/whoami
|
||||||
# container_name: "simple-service-foo"
|
# container_name: "simple-service-foo"
|
||||||
@@ -46,34 +44,41 @@ services:
|
|||||||
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
||||||
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
||||||
|
|
||||||
# Use HTTPS scheme with TLS cert authentication
|
# Use HTTPS scheme with TLS cert authentication
|
||||||
whoami-bar:
|
whoami-bar:
|
||||||
image: traefik/whoami
|
image: traefik/whoami
|
||||||
container_name: "simple-service-bar"
|
container_name: "simple-service-bar"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.router-bar.rule=Path(`/bar`)"
|
- "traefik.http.routers.router-bar.rule=PathPrefix(`/bar`)"
|
||||||
- "traefik.http.routers.router-bar.entrypoints=web"
|
- "traefik.http.routers.router-bar.entrypoints=web"
|
||||||
- "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
|
- "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
|
||||||
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecMode=none"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecscheme=https"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecAppsecTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
||||||
|
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapikey=40796d93c2958f9e58345514e67740e5="
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerFile=/etc/traefik/crowdsec-certs/bouncer.pem"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerFile=/etc/traefik/crowdsec-certs/bouncer.pem"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerKeyFile=/etc/traefik/crowdsec-certs/bouncer-key.pem"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerKeyFile=/etc/traefik/crowdsec-certs/bouncer-key.pem"
|
||||||
|
# Enable AppSec
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
||||||
|
# Define AppSec host and port informations
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:latest
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
COLLECTIONS: crowdsecurity/traefik
|
|
||||||
CUSTOM_HOSTNAME: crowdsec
|
CUSTOM_HOSTNAME: crowdsec
|
||||||
# whoami-foo is authenticating with api key over https
|
# whoami-foo is authenticating with api key over https
|
||||||
# whoami-bar is authenticating with tls cert over https
|
# whoami-bar is authenticating with tls cert over https
|
||||||
BOUNCER_KEY_TRAEFIK_FOO: 40796d93c2958f9e58345514e67740e5
|
BOUNCER_KEY_TRAEFIK_FOO: 40796d93c2958f9e58345514e67740e5=
|
||||||
LOCAL_API_URL: https://127.0.0.1:8080
|
LOCAL_API_URL: https://127.0.0.1:8080
|
||||||
USE_TLS: "true"
|
USE_TLS: "true"
|
||||||
CERT_FILE: "/etc/crowdsec/certs/server.pem"
|
CERT_FILE: "/etc/crowdsec/certs/server.pem"
|
||||||
@@ -88,6 +93,7 @@ services:
|
|||||||
# DISABLE_AGENT: "true"
|
# DISABLE_AGENT: "true"
|
||||||
# Disabled for the examples
|
# Disabled for the examples
|
||||||
DISABLE_ONLINE_API: "true"
|
DISABLE_ONLINE_API: "true"
|
||||||
|
COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/acquis.yaml:/etc/crowdsec/acquis.yaml
|
- ./config/acquis.yaml:/etc/crowdsec/acquis.yaml
|
||||||
# - ./config/config.yaml:/etc/crowdsec/config_local.yaml
|
# - ./config/config.yaml:/etc/crowdsec/config_local.yaml
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
stdout=/out/res.log
|
if [ -f "/out/inter-key.pem" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
cfssl gencert --initca /in/ca.json 2>${stdout} | cfssljson --bare "/out/ca" && \
|
cfssl gencert --initca /in/ca.json 2>${stdout} | cfssljson --bare "/out/ca" && \
|
||||||
# Generate an intermediate certificate that will be used to sign the client certificates
|
# Generate an intermediate certificate that will be used to sign the client certificates
|
||||||
cfssl gencert --initca /in/intermediate.json 2>${stdout} | cfssljson --bare "/out/inter" && \
|
cfssl gencert --initca /in/intermediate.json 2>${stdout} | cfssljson --bare "/out/inter" && \
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ go 1.22
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/leprosus/golang-ttl-map v1.1.7
|
github.com/leprosus/golang-ttl-map v1.1.7
|
||||||
github.com/maxlerebourg/simpleredis v1.0.11
|
github.com/maxlerebourg/simpleredis v1.0.12
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
github.com/leprosus/golang-ttl-map v1.1.7 h1:cF4AAFDDnJTFSV+/42sKLhmMluvLdRlCGS2UaifH6UM=
|
github.com/leprosus/golang-ttl-map v1.1.7 h1:cF4AAFDDnJTFSV+/42sKLhmMluvLdRlCGS2UaifH6UM=
|
||||||
github.com/leprosus/golang-ttl-map v1.1.7/go.mod h1:4QWHJPeVBbrkhOhXdhCv9IEiyj/YzkO04/iexy4vSe0=
|
github.com/leprosus/golang-ttl-map v1.1.7/go.mod h1:4QWHJPeVBbrkhOhXdhCv9IEiyj/YzkO04/iexy4vSe0=
|
||||||
github.com/maxlerebourg/simpleredis v1.0.11 h1:B33TUeIrHtJH2/Qj2bRdU+UZ1BvZwFyP55JWMxHirWg=
|
github.com/maxlerebourg/simpleredis v1.0.12 h1:VsJpk2l8U9QqxOWbYnEXbrs7iSNZFm2fNmFvlxYlQNk=
|
||||||
github.com/maxlerebourg/simpleredis v1.0.11/go.mod h1:lT4LX02SOsE9PxUcSrz1QW5ZnO86gPbaiYBxmtcXEls=
|
github.com/maxlerebourg/simpleredis v1.0.12/go.mod h1:lT4LX02SOsE9PxUcSrz1QW5ZnO86gPbaiYBxmtcXEls=
|
||||||
|
|||||||
Vendored
+4
-5
@@ -5,11 +5,10 @@ package cache
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
ttl_map "github.com/leprosus/golang-ttl-map"
|
ttl_map "github.com/leprosus/golang-ttl-map"
|
||||||
simpleredis "github.com/maxlerebourg/simpleredis"
|
simpleredis "github.com/maxlerebourg/simpleredis"
|
||||||
|
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -53,7 +52,7 @@ func (localCache) delete(key string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type redisCache struct {
|
type redisCache struct {
|
||||||
log *logger.Log
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (redisCache) get(key string) (string, error) {
|
func (redisCache) get(key string) (string, error) {
|
||||||
@@ -93,11 +92,11 @@ type cacheInterface interface {
|
|||||||
// Client Cache client.
|
// Client Cache client.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
cache cacheInterface
|
cache cacheInterface
|
||||||
log *logger.Log
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New Initialize cache client.
|
// New Initialize cache client.
|
||||||
func (c *Client) New(log *logger.Log, isRedis bool, host, pass, database string) {
|
func (c *Client) New(log *slog.Logger, isRedis bool, host, pass, database string) {
|
||||||
c.log = log
|
c.log = log
|
||||||
if isRedis {
|
if isRedis {
|
||||||
redis.Init(host, pass, database)
|
redis.Init(host, pass, database)
|
||||||
|
|||||||
+26
-14
@@ -5,19 +5,18 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
||||||
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client Captcha client.
|
// Client Captcha client.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Valid bool
|
Valid bool
|
||||||
provider string
|
|
||||||
siteKey string
|
siteKey string
|
||||||
secretKey string
|
secretKey string
|
||||||
remediationCustomHeader string
|
remediationCustomHeader string
|
||||||
@@ -25,45 +24,55 @@ type Client struct {
|
|||||||
captchaTemplate *template.Template
|
captchaTemplate *template.Template
|
||||||
cacheClient *cache.Client
|
cacheClient *cache.Client
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
log *logger.Log
|
log *slog.Logger
|
||||||
|
infoProvider *infoProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Information for self-hosted provider.
|
||||||
type infoProvider struct {
|
type infoProvider struct {
|
||||||
js string
|
js string
|
||||||
key string
|
key string
|
||||||
|
response string
|
||||||
validate string
|
validate string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
//nolint:gochecknoglobals
|
||||||
//nolint:gochecknoglobals
|
var infoProviders = map[string]*infoProvider{
|
||||||
captcha = map[string]infoProvider{
|
|
||||||
configuration.HcaptchaProvider: {
|
configuration.HcaptchaProvider: {
|
||||||
js: "https://hcaptcha.com/1/api.js",
|
js: "https://hcaptcha.com/1/api.js",
|
||||||
key: "h-captcha",
|
key: "h-captcha",
|
||||||
|
response: "h-captcha-response",
|
||||||
validate: "https://api.hcaptcha.com/siteverify",
|
validate: "https://api.hcaptcha.com/siteverify",
|
||||||
},
|
},
|
||||||
configuration.RecaptchaProvider: {
|
configuration.RecaptchaProvider: {
|
||||||
js: "https://www.google.com/recaptcha/api.js",
|
js: "https://www.google.com/recaptcha/api.js",
|
||||||
key: "g-recaptcha",
|
key: "g-recaptcha",
|
||||||
|
response: "g-recaptcha-response",
|
||||||
validate: "https://www.google.com/recaptcha/api/siteverify",
|
validate: "https://www.google.com/recaptcha/api/siteverify",
|
||||||
},
|
},
|
||||||
configuration.TurnstileProvider: {
|
configuration.TurnstileProvider: {
|
||||||
js: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
js: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||||
key: "cf-turnstile",
|
key: "cf-turnstile",
|
||||||
|
response: "cf-turnstile-response",
|
||||||
validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
// New Initialize captcha client.
|
// New Initialize captcha client.
|
||||||
func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
func (c *Client) New(log *slog.Logger, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
||||||
c.Valid = provider != ""
|
c.Valid = provider != ""
|
||||||
if !c.Valid {
|
if !c.Valid {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
var info *infoProvider
|
||||||
|
if provider == configuration.CustomProvider {
|
||||||
|
info = &infoProvider{js: js, key: key, response: response, validate: validate}
|
||||||
|
} else {
|
||||||
|
info = infoProviders[provider]
|
||||||
|
}
|
||||||
|
c.infoProvider = info
|
||||||
c.siteKey = siteKey
|
c.siteKey = siteKey
|
||||||
c.secretKey = secretKey
|
c.secretKey = secretKey
|
||||||
c.provider = provider
|
|
||||||
c.remediationCustomHeader = remediationCustomHeader
|
c.remediationCustomHeader = remediationCustomHeader
|
||||||
html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
|
html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
|
||||||
c.captchaTemplate = html
|
c.captchaTemplate = html
|
||||||
@@ -85,6 +94,9 @@ 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
|
||||||
}
|
}
|
||||||
@@ -95,8 +107,8 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
|
|||||||
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())
|
||||||
@@ -121,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
|
||||||
@@ -129,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -16,7 +17,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Enums for crowdsec mode.
|
// Enums for crowdsec mode.
|
||||||
@@ -30,21 +30,37 @@ const (
|
|||||||
HTTP = "http"
|
HTTP = "http"
|
||||||
LogDEBUG = "DEBUG"
|
LogDEBUG = "DEBUG"
|
||||||
LogINFO = "INFO"
|
LogINFO = "INFO"
|
||||||
|
LogWARN = "WARN"
|
||||||
LogERROR = "ERROR"
|
LogERROR = "ERROR"
|
||||||
|
ReasonTECH = "TECHNICAL_ISSUE"
|
||||||
|
ReasonLAPI = "LAPI"
|
||||||
|
ReasonAPPSEC = "APPSEC"
|
||||||
HcaptchaProvider = "hcaptcha"
|
HcaptchaProvider = "hcaptcha"
|
||||||
RecaptchaProvider = "recaptcha"
|
RecaptchaProvider = "recaptcha"
|
||||||
TurnstileProvider = "turnstile"
|
TurnstileProvider = "turnstile"
|
||||||
|
CustomProvider = "custom"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config the plugin configuration.
|
// Config the plugin configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled bool `json:"enabled,omitempty"`
|
Enabled bool `json:"enabled,omitempty"`
|
||||||
LogLevel string `json:"logLevel,omitempty"`
|
LogLevel string `json:"logLevel,omitempty"`
|
||||||
|
LogFormat string `json:"logFormat,omitempty"`
|
||||||
LogFilePath string `json:"logFilePath,omitempty"`
|
LogFilePath string `json:"logFilePath,omitempty"`
|
||||||
CrowdsecMode string `json:"crowdsecMode,omitempty"`
|
CrowdsecMode string `json:"crowdsecMode,omitempty"`
|
||||||
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
|
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
|
||||||
|
CrowdsecAppsecScheme string `json:"crowdsecAppsecScheme,omitempty"`
|
||||||
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
|
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
|
||||||
CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"`
|
CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"`
|
||||||
|
CrowdsecAppsecKey string `json:"crowdsecAppsecKey,omitempty"`
|
||||||
|
CrowdsecAppsecKeyFile string `json:"crowdsecAppsecKeyFile,omitempty"`
|
||||||
|
CrowdsecAppsecTLSInsecureVerify bool `json:"crowdsecAppsecTlsInsecureVerify,omitempty"`
|
||||||
|
CrowdsecAppsecTLSCertificateAuthority string `json:"crowdsecAppsecTlsCertificateAuthority,omitempty"`
|
||||||
|
CrowdsecAppsecTLSCertificateAuthorityFile string `json:"crowdsecAppsecTlsCertificateAuthorityFile,omitempty"`
|
||||||
|
CrowdsecAppsecTLSCertificateBouncer string `json:"crowdsecAppsecTlsCertificateBouncer,omitempty"`
|
||||||
|
CrowdsecAppsecTLSCertificateBouncerFile string `json:"crowdsecAppsecTlsCertificateBouncerFile,omitempty"`
|
||||||
|
CrowdsecAppsecTLSCertificateBouncerKey string `json:"crowdsecAppsecTlsCertificateBouncerKey,omitempty"`
|
||||||
|
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
|
||||||
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
||||||
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
||||||
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
|
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
|
||||||
@@ -66,10 +82,12 @@ type Config struct {
|
|||||||
CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"`
|
CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"`
|
||||||
CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"`
|
CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"`
|
||||||
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
||||||
UpdateMaxFailure int `json:"updateMaxFailure,omitempty"`
|
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
|
||||||
|
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
|
||||||
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
||||||
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
|
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
|
||||||
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
||||||
|
TraceHeadersCustomName string `json:"traceHeadersCustomName,omitempty"`
|
||||||
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"`
|
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"`
|
||||||
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
|
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
|
||||||
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
|
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
|
||||||
@@ -83,6 +101,10 @@ type Config struct {
|
|||||||
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
|
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
|
||||||
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
|
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
|
||||||
CaptchaProvider string `json:"captchaProvider,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"`
|
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"`
|
||||||
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"`
|
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"`
|
||||||
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"`
|
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"`
|
||||||
@@ -104,30 +126,40 @@ func New() *Config {
|
|||||||
return &Config{
|
return &Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
LogLevel: LogINFO,
|
LogLevel: LogINFO,
|
||||||
|
LogFormat: "common",
|
||||||
LogFilePath: "",
|
LogFilePath: "",
|
||||||
CrowdsecMode: LiveMode,
|
CrowdsecMode: LiveMode,
|
||||||
CrowdsecAppsecEnabled: false,
|
CrowdsecAppsecEnabled: false,
|
||||||
CrowdsecAppsecHost: "crowdsec:7422",
|
|
||||||
CrowdsecAppsecPath: "/",
|
|
||||||
CrowdsecAppsecFailureBlock: true,
|
CrowdsecAppsecFailureBlock: true,
|
||||||
CrowdsecAppsecUnreachableBlock: true,
|
CrowdsecAppsecUnreachableBlock: true,
|
||||||
CrowdsecAppsecBodyLimit: 10485760,
|
CrowdsecAppsecBodyLimit: 10485760,
|
||||||
|
CrowdsecAppsecScheme: "",
|
||||||
|
CrowdsecAppsecHost: "crowdsec:7422",
|
||||||
|
CrowdsecAppsecPath: "/",
|
||||||
|
CrowdsecAppsecKey: "",
|
||||||
|
CrowdsecAppsecTLSInsecureVerify: false,
|
||||||
CrowdsecLapiScheme: HTTP,
|
CrowdsecLapiScheme: HTTP,
|
||||||
CrowdsecLapiHost: "crowdsec:8080",
|
CrowdsecLapiHost: "crowdsec:8080",
|
||||||
CrowdsecLapiPath: "/",
|
CrowdsecLapiPath: "/",
|
||||||
CrowdsecLapiKey: "",
|
CrowdsecLapiKey: "",
|
||||||
CrowdsecLapiTLSInsecureVerify: false,
|
CrowdsecLapiTLSInsecureVerify: false,
|
||||||
UpdateIntervalSeconds: 60,
|
UpdateIntervalSeconds: 60,
|
||||||
|
MetricsUpdateIntervalSeconds: 600,
|
||||||
UpdateMaxFailure: 0,
|
UpdateMaxFailure: 0,
|
||||||
DefaultDecisionSeconds: 60,
|
DefaultDecisionSeconds: 60,
|
||||||
RemediationStatusCode: http.StatusForbidden,
|
RemediationStatusCode: http.StatusForbidden,
|
||||||
HTTPTimeoutSeconds: 10,
|
HTTPTimeoutSeconds: 10,
|
||||||
CaptchaProvider: "",
|
CaptchaProvider: "",
|
||||||
|
CaptchaCustomJsURL: "",
|
||||||
|
CaptchaCustomValidateURL: "",
|
||||||
|
CaptchaCustomKey: "",
|
||||||
|
CaptchaCustomResponse: "",
|
||||||
CaptchaSiteKey: "",
|
CaptchaSiteKey: "",
|
||||||
CaptchaSecretKey: "",
|
CaptchaSecretKey: "",
|
||||||
CaptchaGracePeriodSeconds: 1800,
|
CaptchaGracePeriodSeconds: 1800,
|
||||||
CaptchaHTMLFilePath: "/captcha.html",
|
CaptchaHTMLFilePath: "/captcha.html",
|
||||||
BanHTMLFilePath: "",
|
BanHTMLFilePath: "",
|
||||||
|
TraceHeadersCustomName: "",
|
||||||
RemediationHeadersCustomName: "",
|
RemediationHeadersCustomName: "",
|
||||||
ForwardedHeadersCustomName: "X-Forwarded-For",
|
ForwardedHeadersCustomName: "X-Forwarded-For",
|
||||||
ForwardedHeadersTrustedIPs: []string{},
|
ForwardedHeadersTrustedIPs: []string{},
|
||||||
@@ -189,15 +221,19 @@ func GetHTMLTemplate(path string) (*template.Template, error) {
|
|||||||
// ValidateParams validate all the param gave by user.
|
// ValidateParams validate all the param gave by user.
|
||||||
//
|
//
|
||||||
//nolint:gocyclo,gocognit
|
//nolint:gocyclo,gocognit
|
||||||
func ValidateParams(config *Config) error {
|
func ValidateParams(config *Config, log *slog.Logger) error {
|
||||||
if err := validateParamsRequired(config); err != nil {
|
if err := validateParamsRequired(config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
if err := validateCaptcha(config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := validateParamsIPs(config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
|
|
||||||
|
if err := validateParamsIPs(log, config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateParamsIPs(log, config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +280,10 @@ func ValidateParams(config *Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
appsecKey, err := GetVariable(config, "CrowdsecAppsecKey")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -252,12 +292,21 @@ func ValidateParams(config *Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to either have crowdsecLapiKey defined or the BouncerCert and Bouncerkey
|
// We need to either have crowdsecLapiKey defined or the BouncerCert and Bouncerkey
|
||||||
if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") {
|
if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") && config.CrowdsecMode != AppsecMode {
|
||||||
return errors.New("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be all empty")
|
return errors.New("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be all empty")
|
||||||
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
|
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
|
||||||
lapiKey = strings.TrimSpace(lapiKey)
|
lapiKey = strings.TrimSpace(lapiKey)
|
||||||
if err = validateParamsAPIKey(lapiKey); err != nil {
|
if err = validateParamsAPIKey(lapiKey, "CrowdsecLapiKey"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CrowdsecAppsecKey if provided
|
||||||
|
if appsecKey != "" {
|
||||||
|
appsecKey = strings.TrimSpace(appsecKey)
|
||||||
|
if err = validateParamsAPIKey(appsecKey, "CrowdsecAppsecKey"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,8 +320,8 @@ func ValidateParams(config *Config) error {
|
|||||||
|
|
||||||
// Check logging configuration
|
// Check logging configuration
|
||||||
// to upper allow of anycase of log level
|
// to upper allow of anycase of log level
|
||||||
if !contains([]string{LogERROR, LogDEBUG, LogINFO}, strings.ToUpper(config.LogLevel)) {
|
if !contains([]string{LogDEBUG, LogINFO, LogWARN, LogERROR}, strings.ToUpper(config.LogLevel)) {
|
||||||
return fmt.Errorf("LogLevel should be one of (%s,%s,%s)", LogDEBUG, LogINFO, LogERROR)
|
return fmt.Errorf("LogLevel should be one of (%s,%s,%s,%s)", LogDEBUG, LogINFO, LogWARN, LogERROR)
|
||||||
}
|
}
|
||||||
if config.LogFilePath != "" {
|
if config.LogFilePath != "" {
|
||||||
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
@@ -296,10 +345,10 @@ func validateURL(variable, scheme, host, path string) error {
|
|||||||
// field name. RFC 7230 says:
|
// field name. RFC 7230 says:
|
||||||
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
|
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
|
||||||
// See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators
|
// See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators
|
||||||
func validateParamsAPIKey(lapiKey string) error {
|
func validateParamsAPIKey(key string, paramName string) error {
|
||||||
reg := regexp.MustCompile("^[a-zA-Z0-9 !#$%&'*+-.^_`|~=/]*$")
|
reg := regexp.MustCompile("^[a-zA-Z0-9 !#$%&'*+-.^_`|~=/]*$")
|
||||||
if !reg.MatchString(lapiKey) {
|
if !reg.MatchString(key) {
|
||||||
return fmt.Errorf("CrowdsecLapiKey doesn't valid this regexp: '/%s/'", reg.String())
|
return fmt.Errorf("%s doesn't validate this regexp: '/%s/'", paramName, reg.String())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -320,15 +369,33 @@ func validateParamsTLS(config *Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateParamsIPs(listIP []string, key string) error {
|
func validateParamsIPs(log *slog.Logger, listIP []string, key string) error {
|
||||||
if len(listIP) > 0 {
|
if len(listIP) > 0 {
|
||||||
if _, err := ip.NewChecker(logger.New(LogINFO, ""), listIP); err != nil {
|
if _, err := ip.NewChecker(log, listIP); err != nil {
|
||||||
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
|
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateCaptcha(config *Config) error {
|
||||||
|
if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider}, config.CaptchaProvider) {
|
||||||
|
return fmt.Errorf("CaptchaProvider: must be one of '%s', '%s', '%s' or '%s'", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider)
|
||||||
|
}
|
||||||
|
if config.CaptchaProvider == CustomProvider {
|
||||||
|
if config.CaptchaCustomKey == "" || config.CaptchaCustomResponse == "" || config.CaptchaCustomValidateURL == "" || config.CaptchaCustomJsURL == "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"CaptchaProvider: provider is custom, captchaCustom variables must be filled: CaptchaCustomKey:%s, CaptchaCustomResponse:%s, CaptchaCustomValidateURL:%s, CaptchaCustomJsURL:%s",
|
||||||
|
config.CaptchaCustomKey,
|
||||||
|
config.CaptchaCustomResponse,
|
||||||
|
config.CaptchaCustomValidateURL,
|
||||||
|
config.CaptchaCustomJsURL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateParamsRequired(config *Config) error {
|
func validateParamsRequired(config *Config) error {
|
||||||
requiredStrings := map[string]string{
|
requiredStrings := map[string]string{
|
||||||
"CrowdsecLapiScheme": config.CrowdsecLapiScheme,
|
"CrowdsecLapiScheme": config.CrowdsecLapiScheme,
|
||||||
@@ -337,18 +404,27 @@ func validateParamsRequired(config *Config) error {
|
|||||||
}
|
}
|
||||||
for key, val := range requiredStrings {
|
for key, val := range requiredStrings {
|
||||||
if len(val) == 0 {
|
if len(val) == 0 {
|
||||||
return fmt.Errorf("%v: cannot be empty", key)
|
return errors.New(key + ": cannot be empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requiredInt := map[string]int64{
|
requiredInt0 := map[string]int64{
|
||||||
|
"CrowdsecAppsecBodyLimit": config.CrowdsecAppsecBodyLimit,
|
||||||
|
"MetricsUpdateIntervalSeconds": config.MetricsUpdateIntervalSeconds,
|
||||||
|
}
|
||||||
|
for key, val := range requiredInt0 {
|
||||||
|
if val < 0 {
|
||||||
|
return errors.New(key + ": cannot be less than 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requiredInt1 := map[string]int64{
|
||||||
"UpdateIntervalSeconds": config.UpdateIntervalSeconds,
|
"UpdateIntervalSeconds": config.UpdateIntervalSeconds,
|
||||||
"DefaultDecisionSeconds": config.DefaultDecisionSeconds,
|
"DefaultDecisionSeconds": config.DefaultDecisionSeconds,
|
||||||
"HTTPTimeoutSeconds": config.HTTPTimeoutSeconds,
|
"HTTPTimeoutSeconds": config.HTTPTimeoutSeconds,
|
||||||
"CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds,
|
"CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds,
|
||||||
}
|
}
|
||||||
for key, val := range requiredInt {
|
for key, val := range requiredInt1 {
|
||||||
if val < 1 {
|
if val < 1 {
|
||||||
return fmt.Errorf("%v: cannot be less than 1", key)
|
return errors.New(key + ": cannot be less than 1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if config.UpdateMaxFailure < -1 {
|
if config.UpdateMaxFailure < -1 {
|
||||||
@@ -367,29 +443,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 *slog.Logger, prefix, scheme string, insecureVerify bool) (*tls.Config, error) {
|
||||||
//
|
|
||||||
//nolint:nestif
|
|
||||||
func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error) {
|
|
||||||
tlsConfig := new(tls.Config)
|
tlsConfig := new(tls.Config)
|
||||||
tlsConfig.RootCAs = x509.NewCertPool()
|
tlsConfig.RootCAs = x509.NewCertPool()
|
||||||
//nolint:gocritic
|
if scheme != HTTPS {
|
||||||
if config.CrowdsecLapiScheme != HTTPS {
|
log.Debug("getTLSConfig:" + 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("getTLSConfig:" + 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
|
||||||
}
|
}
|
||||||
@@ -397,17 +471,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("getTLSConfig:" + prefix + " cannot load CA and verify cert is enabled")
|
||||||
}
|
}
|
||||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSCertificateAuthority CA added successfully")
|
log.Debug("getTLSConfig:" + prefix + "TLSCertificateAuthority CA added successfully")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
|
||||||
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
certBouncerKey, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncerKey")
|
certBouncerKey, err := GetVariable(config, prefix+"TLSCertificateBouncerKey")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -422,3 +495,14 @@ func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error)
|
|||||||
|
|
||||||
return tlsConfig, nil
|
return tlsConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTLSConfigCrowdsec get TLS config from Config.
|
||||||
|
func GetTLSConfigCrowdsec(config *Config, log *slog.Logger, isAppsec bool) (*tls.Config, error) {
|
||||||
|
var prefix string
|
||||||
|
if isAppsec && config.CrowdsecAppsecScheme != "" {
|
||||||
|
prefix = "CrowdsecAppsec"
|
||||||
|
return getTLSConfig(config, log, prefix, config.CrowdsecAppsecScheme, config.CrowdsecAppsecTLSInsecureVerify)
|
||||||
|
}
|
||||||
|
prefix = "CrowdsecLapi"
|
||||||
|
return getTLSConfig(config, log, prefix, config.CrowdsecLapiScheme, config.CrowdsecLapiTLSInsecureVerify)
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ func Test_GetVariable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_ValidateParams(t *testing.T) {
|
func Test_ValidateParams(t *testing.T) {
|
||||||
|
log := logger.New("INFO", "")
|
||||||
cfg1 := New()
|
cfg1 := New()
|
||||||
cfg1.CrowdsecLapiKey = "test\n\n"
|
cfg1.CrowdsecLapiKey = "test\n\n"
|
||||||
cfg2 := New()
|
cfg2 := New()
|
||||||
@@ -117,7 +118,7 @@ func Test_ValidateParams(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := ValidateParams(tt.args.config); (err != nil) != tt.wantErr {
|
if err := ValidateParams(tt.args.config, log); (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -145,6 +146,7 @@ func Test_validateParamsTLS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_validateParamsIPs(t *testing.T) {
|
func Test_validateParamsIPs(t *testing.T) {
|
||||||
|
log := logger.New("INFO", "")
|
||||||
type args struct {
|
type args struct {
|
||||||
listIP []string
|
listIP []string
|
||||||
key string
|
key string
|
||||||
@@ -164,7 +166,7 @@ func Test_validateParamsIPs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := validateParamsIPs(tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
|
if err := validateParamsIPs(log, tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -206,22 +208,23 @@ func Test_validateParamsRequired(t *testing.T) {
|
|||||||
func Test_validateParamsAPIKey(t *testing.T) {
|
func Test_validateParamsAPIKey(t *testing.T) {
|
||||||
type args struct {
|
type args struct {
|
||||||
lapiKey string
|
lapiKey string
|
||||||
|
paramName string
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
args args
|
args args
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~"}, wantErr: false},
|
{name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~", paramName: "CrowdsecParamName"}, wantErr: false},
|
||||||
{name: "Not validate a @", args: args{lapiKey: "test@"}, wantErr: true},
|
{name: "Not validate a @", args: args{lapiKey: "test@", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a (", args: args{lapiKey: "test("}, wantErr: true},
|
{name: "Not validate a (", args: args{lapiKey: "test(", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a [", args: args{lapiKey: "test["}, wantErr: true},
|
{name: "Not validate a [", args: args{lapiKey: "test[", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a ?", args: args{lapiKey: "test?"}, wantErr: true},
|
{name: "Not validate a ?", args: args{lapiKey: "test?", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n"}, wantErr: true},
|
{name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := validateParamsAPIKey(tt.args.lapiKey); (err != nil) != tt.wantErr {
|
if err := validateParamsAPIKey(tt.args.lapiKey, tt.args.paramName); (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateParamsAPIKey() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateParamsAPIKey() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -229,6 +232,7 @@ func Test_validateParamsAPIKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
||||||
|
log := logger.New("INFO", "")
|
||||||
type args struct {
|
type args struct {
|
||||||
config *Config
|
config *Config
|
||||||
}
|
}
|
||||||
@@ -242,7 +246,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, log, false)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
return
|
return
|
||||||
|
|||||||
+2
-3
@@ -5,11 +5,10 @@ package ip
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CHECKER
|
// CHECKER
|
||||||
@@ -21,7 +20,7 @@ type Checker struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewChecker builds a new Checker given a list of CIDR-Strings to trusted IPs.
|
// NewChecker builds a new Checker given a list of CIDR-Strings to trusted IPs.
|
||||||
func NewChecker(log *logger.Log, trustedIPs []string) (*Checker, error) {
|
func NewChecker(log *slog.Logger, trustedIPs []string) (*Checker, error) {
|
||||||
checker := &Checker{}
|
checker := &Checker{}
|
||||||
|
|
||||||
for _, ipMaskRaw := range trustedIPs {
|
for _, ipMaskRaw := range trustedIPs {
|
||||||
|
|||||||
+71
-44
@@ -1,65 +1,92 @@
|
|||||||
// Package logger implements utility routines to write to stdout and stderr.
|
// Package logger implements utility routines to write to stdout and stderr.
|
||||||
// It supports debug, info and error level
|
// It supports trace, debug, info, warn and error level using Go's standard log/slog
|
||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"log/slog"
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Log Logger struct.
|
// Custom log levels following slog best practices.
|
||||||
type Log struct {
|
const (
|
||||||
logError *log.Logger
|
LevelDebug = slog.LevelDebug
|
||||||
logInfo *log.Logger
|
LevelInfo = slog.LevelInfo
|
||||||
logDebug *log.Logger
|
LevelWarn = slog.LevelWarn
|
||||||
|
LevelError = slog.LevelError
|
||||||
|
)
|
||||||
|
|
||||||
|
// New creates a Log wrapper with default format (common).
|
||||||
|
func New(logLevel string, logFilePath string) *slog.Logger {
|
||||||
|
return NewWithFormat(logLevel, logFilePath, "common")
|
||||||
}
|
}
|
||||||
|
|
||||||
// New Set Default log level to info in case log level to defined.
|
// NewWithFormat creates a Log wrapper with specified format (common or json).
|
||||||
func New(logLevel string, logFilePath string) *Log {
|
func NewWithFormat(logLevel, logFilePath, logFormat string) *slog.Logger {
|
||||||
logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
// Determine log level
|
||||||
logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
var level slog.Level
|
||||||
logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
switch logLevel {
|
||||||
|
case "ERROR":
|
||||||
logError.SetOutput(os.Stderr)
|
level = LevelError
|
||||||
logInfo.SetOutput(os.Stdout)
|
case "WARN":
|
||||||
// we initialize logger to STDOUT/STDERR first so if the file logger cannot be initialized we can inform the user
|
level = LevelWarn
|
||||||
if logLevel == "DEBUG" {
|
case "INFO":
|
||||||
logDebug.SetOutput(os.Stdout)
|
level = LevelInfo
|
||||||
|
case "DEBUG":
|
||||||
|
level = LevelDebug
|
||||||
|
default:
|
||||||
|
// Default to INFO level
|
||||||
|
level = LevelInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set output destination
|
||||||
|
var output *os.File
|
||||||
if logFilePath != "" {
|
if logFilePath != "" {
|
||||||
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
_ = fmt.Errorf("LogFilePath is not writable %w", err)
|
output = logFile
|
||||||
} else {
|
} else {
|
||||||
logInfo.SetOutput(logFile)
|
// Fall back to stdout and log the error
|
||||||
logError.SetOutput(logFile)
|
output = os.Stdout
|
||||||
if logLevel == "DEBUG" {
|
slog.Warn("LogFilePath is not writable, using stdout", "error", err)
|
||||||
logDebug.SetOutput(logFile)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
output = os.Stdout
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Log{
|
// Create handler based on format with custom level names
|
||||||
logError: logError,
|
var handler slog.Handler
|
||||||
logInfo: logInfo,
|
opts := &slog.HandlerOptions{
|
||||||
logDebug: logDebug,
|
Level: level,
|
||||||
|
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||||||
|
// Customize level names to match our expected format
|
||||||
|
if a.Key == slog.LevelKey {
|
||||||
|
lvl, ok := a.Value.Any().(slog.Level)
|
||||||
|
if !ok {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case lvl < LevelInfo:
|
||||||
|
a.Value = slog.StringValue("DEBUG")
|
||||||
|
case lvl < LevelWarn:
|
||||||
|
a.Value = slog.StringValue("INFO")
|
||||||
|
case lvl < LevelError:
|
||||||
|
a.Value = slog.StringValue("WARN")
|
||||||
|
default:
|
||||||
|
a.Value = slog.StringValue("ERROR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Info log to Stdout.
|
if logFormat == "json" {
|
||||||
func (l *Log) Info(str string) {
|
handler = slog.NewJSONHandler(output, opts)
|
||||||
l.logInfo.Printf("%s", str)
|
} else {
|
||||||
}
|
// Common format (default)
|
||||||
|
handler = slog.NewTextHandler(output, opts)
|
||||||
|
}
|
||||||
|
|
||||||
// Debug log to Stdout.
|
// Create logger with component attribute
|
||||||
func (l *Log) Debug(str string) {
|
return slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||||
l.logDebug.Printf("%s", str)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error log to Stderr.
|
|
||||||
func (l *Log) Error(str string) {
|
|
||||||
l.logError.Printf("%s", str)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
logLevel string
|
||||||
|
}{
|
||||||
|
{name: "ERROR level", logLevel: "ERROR"},
|
||||||
|
{name: "WARN level", logLevel: "WARN"},
|
||||||
|
{name: "INFO level", logLevel: "INFO"},
|
||||||
|
{name: "DEBUG level", logLevel: "DEBUG"},
|
||||||
|
{name: "Default level (INFO)", logLevel: "INVALID"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
logger := New(tt.logLevel, "")
|
||||||
|
|
||||||
|
// Verify logger is created
|
||||||
|
if logger == nil {
|
||||||
|
t.Fatal("Expected logger to be created, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a slog.Logger (we can call methods on it)
|
||||||
|
logger.Info("test initialization")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLogFormat(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Create a logger with JSON handler to capture output
|
||||||
|
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||||
|
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||||
|
|
||||||
|
testMessage := "json test message"
|
||||||
|
logger.Info(testMessage)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||||
|
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("Expected 1 log line, got %d", len(lines))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's valid JSON
|
||||||
|
var logEntry map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(lines[0]), &logEntry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected valid JSON output, got error: %v, output: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JSON structure
|
||||||
|
if logEntry["level"] != "INFO" {
|
||||||
|
t.Errorf("Expected level 'INFO', got '%v'", logEntry["level"])
|
||||||
|
}
|
||||||
|
if logEntry["msg"] != testMessage {
|
||||||
|
t.Errorf("Expected message '%s', got '%v'", testMessage, logEntry["msg"])
|
||||||
|
}
|
||||||
|
if logEntry["time"] == nil {
|
||||||
|
t.Error("Expected timestamp to be set")
|
||||||
|
}
|
||||||
|
if logEntry["component"] != "CrowdsecBouncerTraefikPlugin" {
|
||||||
|
t.Errorf("Expected component 'CrowdsecBouncerTraefikPlugin', got '%v'", logEntry["component"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonLogFormat(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Create a logger with text handler to capture output
|
||||||
|
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||||
|
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||||
|
|
||||||
|
testMessage := "common test message"
|
||||||
|
logger.Info(testMessage)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
// Verify common format (should contain level and message)
|
||||||
|
if !strings.Contains(output, "level=INFO") {
|
||||||
|
t.Error("Expected common format with INFO level")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, testMessage) {
|
||||||
|
t.Error("Expected test message in common format")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "component=CrowdsecBouncerTraefikPlugin") {
|
||||||
|
t.Error("Expected component field in common format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should NOT be JSON (should be slog text format)
|
||||||
|
var logEntry map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(strings.TrimSpace(output)), &logEntry)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected common format (not JSON), but got valid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorLevel(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Create a logger with ERROR level to capture output
|
||||||
|
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelError})
|
||||||
|
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||||
|
|
||||||
|
testMessage := "error only test"
|
||||||
|
|
||||||
|
// Test all log methods
|
||||||
|
logger.Error(testMessage)
|
||||||
|
logger.Warn(testMessage) // Should not appear
|
||||||
|
logger.Info(testMessage) // Should not appear
|
||||||
|
logger.Debug(testMessage) // Should not appear
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
// Only ERROR should appear
|
||||||
|
if !strings.Contains(output, "level=ERROR") {
|
||||||
|
t.Error("Expected ERROR message to appear")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other levels should NOT appear
|
||||||
|
unwantedLevels := []string{"level=WARN", "level=INFO", "level=DEBUG"}
|
||||||
|
for _, level := range unwantedLevels {
|
||||||
|
if strings.Contains(output, level) {
|
||||||
|
t.Errorf("Unexpected %s message appeared at ERROR level", level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify only one message appears
|
||||||
|
messageCount := strings.Count(output, testMessage)
|
||||||
|
if messageCount != 1 {
|
||||||
|
t.Errorf("Expected 1 occurrence of test message at ERROR level, got %d", messageCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidLogFile(t *testing.T) {
|
||||||
|
// Try to create logger with invalid file path
|
||||||
|
logger := New("INFO", "/invalid/path/that/does/not/exist/test.log")
|
||||||
|
|
||||||
|
// Logger should still be created (falls back to stdout)
|
||||||
|
if logger == nil {
|
||||||
|
t.Fatal("Expected logger to be created even with invalid file path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not panic when logging
|
||||||
|
logger.Info("test message")
|
||||||
|
}
|
||||||
+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