Compare commits

...
14 Commits
Author SHA1 Message Date
maxlerebourg de7e382fde Add env for RemediationStatusCode (#250)
*  Add env for defaultStatusCode

* 📝 doc

* change name of the parameter

* 🔧 Add config check

* fix lint
2025-07-01 21:59:12 +02:00
gptlang abae855d9e Set traefik logs to json so it can actually be parsed (#235) 2025-05-07 11:22:27 +02:00
Lluís D. 4bb6e830dc Fix query of the GET /decisions endpoint (#236)
- banned=true query parameter doesn't exist based on LAPI
  dcoumentation, remove it
2025-04-28 16:12:54 +02:00
mathieuHa 78869ecf77 🔧 Add support for logLevel in any case (#231) 2025-04-06 10:41:16 +02:00
maxlerebourg 7f99266f99 📝 doc: rename RedisUnreachableBlock to RedisCacheUnreachableBlock (#230) 2025-04-05 11:34:06 +02:00
mathieuHa 5418d35feb feat(logs) add supports write logs to files (#217)
*  feat(logs) add supports write logs to files

* fix(lint) 🚨 fix go lint

* 🐛 fix(bug) check path is done only if provided

* 📝 doc(vars) add LogFilePath to vars

* 🦺 chore(review) update doc, configuration check and logger
2025-03-31 20:19:44 +02:00
maxlerebourg a184ae6db9 💄 Center recaptcha div (#225) 2025-03-25 09:34:06 +01:00
blotus e4c84409e7 🔨 always set custom remediation header if configured for bans (#218) 2025-02-17 14:02:21 +01:00
maxlerebourg 4708d76854 Add variable to not block if redis is unreachable (#214)
*  Add variable to not block if redis is unreachable

* 🚨 fix lint

* 📝 Update README.md
2025-02-10 20:10:29 +01:00
maxlerebourg c34d7f4617 🍱 Fix lint (#211) 2025-01-29 08:14:15 +01:00
mathieuHaandMax Lerebourg 0e9620bfe9 👷 chore(ci) bump CI and automate dep updates (#210)
* 👷 chore(ci) bump CI and automate dep updates

* 🚨 chore(go) fix golang lint

* 🍱 fix lint

---------

Co-authored-by: Max Lerebourg <maxlerebourg@gmail.com>
2025-01-29 08:07:04 +01:00
maxlerebourg 92f05b0ba5 [BREAKING-CHANGE] Add CrowdsecAppsecBodyLimit (#208)
*  Add CrowdsecAppsecBodyLimit

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix error on main
2025-01-24 21:04:45 +01:00
980a7dd05e Add AppSec Path Variable (#202)
* Added Appsec Path config Variable

*  Add path env var for lapi and appsec

* 🍱 Update README.md

---------

Co-authored-by: Tobias Heinze <tobias.heinze@telekom.de>
Co-authored-by: Max Lerebourg <maxlerebourg@gmail.com>
2025-01-24 20:12:30 +01:00
mathieuHaandmaxlerebourg 5c8a60118f 🐛 fix(user-agent) add version in ua to reduce warning logs from crowd… (#195)
* 🐛 fix(user-agent) add version in ua to reduce warning logs from crowdsec LAPI

* 🐛 fix(user-agent) add version in ua to reduce warning logs from crowdsec LAPI

* 🐛 fix(user-agent) remove whitespaces

* 🐛 fix(user-agent) Add Cap for Bouncer

---------

Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
2024-10-24 21:39:53 +02:00
15 changed files with 264 additions and 122 deletions
+24
View File
@@ -0,0 +1,24 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
# Maintain dependencies for Go
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
# Maintain dependencies for build tools
- package-ecosystem: "gomod"
directory: "/tools"
schedule:
interval: "weekly"
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+5 -5
View File
@@ -12,8 +12,8 @@ jobs:
name: Main Process name: Main Process
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
GO_VERSION: 1.22 GO_VERSION: 1.23
GOLANGCI_LINT_VERSION: v1.57.2 GOLANGCI_LINT_VERSION: v1.63.4
YAEGI_VERSION: v0.16.1 YAEGI_VERSION: v0.16.1
CGO_ENABLED: 0 CGO_ENABLED: 0
defaults: defaults:
@@ -24,20 +24,20 @@ jobs:
# https://github.com/marketplace/actions/setup-go-environment # https://github.com/marketplace/actions/setup-go-environment
- name: Set up Go ${{ env.GO_VERSION }} - name: Set up Go ${{ env.GO_VERSION }}
uses: actions/setup-go@v2 uses: actions/setup-go@v5
with: with:
go-version: ${{ env.GO_VERSION }} go-version: ${{ env.GO_VERSION }}
# https://github.com/marketplace/actions/checkout # https://github.com/marketplace/actions/checkout
- name: Check out code - name: Check out code
uses: actions/checkout@v2 uses: actions/checkout@v4
with: with:
path: go/src/github.com/${{ github.repository }} path: go/src/github.com/${{ github.repository }}
fetch-depth: 0 fetch-depth: 0
# https://github.com/marketplace/actions/cache # https://github.com/marketplace/actions/cache
- name: Cache Go modules - name: Cache Go modules
uses: actions/cache@v2 uses: actions/cache@v4
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') }}
+21 -34
View File
@@ -15,7 +15,7 @@ linters-settings:
locale: US locale: US
funlen: funlen:
lines: -1 lines: -1
statements: 50 statements: 60
godox: godox:
keywords: keywords:
- FIXME - FIXME
@@ -25,45 +25,33 @@ linters-settings:
rules: rules:
Main: Main:
files: files:
- $all - $all
- "!$test" - "!$test"
allow: allow:
- $gostd - $gostd
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
- github.com/leprosus/golang-ttl-map - github.com/leprosus/golang-ttl-map
- github.com/maxlerebourg/simpleredis - github.com/maxlerebourg/simpleredis
Test: Test:
files: files:
- $test - $test
allow: allow:
- $gostd - $gostd
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
linters: linters:
enable-all: true enable-all: true
disable: disable:
- deadcode # deprecated
- exhaustivestruct # deprecated
- golint # deprecated
- ifshort # deprecated
- interfacer # deprecated
- maligned # deprecated
- nosnakecase # deprecated
- scopelint # deprecated
- scopelint # deprecated
- structcheck # deprecated
- varcheck # deprecated
- sqlclosecheck # not relevant (SQL) - sqlclosecheck # not relevant (SQL)
- rowserrcheck # not relevant (SQL) - rowserrcheck # not relevant (SQL)
- execinquery # not relevant (SQL)
- cyclop # duplicate of gocyclo - cyclop # duplicate of gocyclo
- bodyclose # Too many false positives: https://github.com/timakin/bodyclose/issues/30 - bodyclose # Too many false positives: https://github.com/timakin/bodyclose/issues/30
- dupl - dupl
@@ -74,18 +62,17 @@ linters:
- wsl - wsl
- exhaustive - exhaustive
- exhaustruct - exhaustruct
- goerr113 - err113
- wrapcheck - wrapcheck
- ifshort
- noctx - noctx
- lll - lll
- gomnd
- forbidigo - forbidigo
- varnamelen - varnamelen
- wastedassign # is disabled because of generics - wastedassign # is disabled because of generics
- gofumpt - gofumpt
- gci - gci
- mnd
- exportloopref
issues: issues:
exclude-use-default: false exclude-use-default: false
max-same-issues: 0 max-same-issues: 0
+64 -24
View File
@@ -26,6 +26,7 @@ The AppSec Component offers:
- Low-effort virtual patching capabilities. - Low-effort virtual patching capabilities.
- Support for your legacy ModSecurity rules. - Support for your legacy ModSecurity rules.
- Combining classic WAF benefits with advanced CrowdSec features for otherwise difficult advanced behavior detection. - Combining classic WAF benefits with advanced CrowdSec features for otherwise difficult advanced behavior detection.
More information on appsec in the [Crowdsec Documentation](https://doc.crowdsec.net/docs/next/appsec/intro/). More information on appsec in the [Crowdsec Documentation](https://doc.crowdsec.net/docs/next/appsec/intro/).
Remediation offered by [Crowdsec](https://docs.crowdsec.net/u/bouncers/intro) and supported by the plugin can be either `ban` or `captcha`. Remediation offered by [Crowdsec](https://docs.crowdsec.net/u/bouncers/intro) and supported by the plugin can be either `ban` or `captcha`.
@@ -34,20 +35,20 @@ For the `captcha` remediation, the user will be redirected to a page to complete
On successfull completion, he will be cleaned for a specified period of time before a new resolution challenge is expected if Crowdsec still has a decision to verify the user behavior. See the example captcha for more informations and configuration intructions. On successfull completion, he will be cleaned for a specified period of time before a new resolution challenge is expected if Crowdsec still has a decision to verify the user behavior. See the example captcha for more informations and configuration intructions.
The following captcha providers are supported now: The following captcha providers are supported now:
- [hcaptcha](https://www.hcaptcha.com/)
- [recaptcha](https://www.google.com/recaptcha/about/)
- [turnstile](https://www.cloudflare.com/products/turnstile/)
- [hcaptcha](https://www.hcaptcha.com/)
- [recaptcha](https://www.google.com/recaptcha/about/)
- [turnstile](https://www.cloudflare.com/products/turnstile/)
There are 5 operating modes (CrowdsecMode) for this plugin: There are 5 operating modes (CrowdsecMode) for this plugin:
| Mode | Description | | Mode | Description |
|------|------| | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| none | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. All request call the Crowdsec LAPI | | none | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. All request call the Crowdsec LAPI |
| live | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. The bouncer can leverage use of a local cache in order to reduce the number of requests made to the Crowdsec LAPI. It will keep in cache the status for each IP that makes queries. | | live | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. The bouncer can leverage use of a local cache in order to reduce the number of requests made to the Crowdsec LAPI. It will keep in cache the status for each IP that makes queries. |
| stream | Stream Streaming mode allows you to keep in the local cache only the Banned IPs, every requests that does not hit the cache is authorized. Every minute, the cache is updated with news from the Crowdsec LAPI. | | stream | Stream Streaming mode allows you to keep in the local cache only the Banned IPs, every requests that does not hit the cache is authorized. Every minute, the cache is updated with news from the Crowdsec LAPI. |
| alone | Standalone mode, similar to the streaming mode but the blacklisted IPs are fetched on the CAPI. Every 2 hours, the cache is updated with news from the Crowdsec CAPI. It does not include any locally banned IP, but can work without a crowdsec service. | | alone | Standalone mode, similar to the streaming mode but the blacklisted IPs are fetched on the CAPI. Every 2 hours, the cache is updated with news from the Crowdsec CAPI. It does not include any locally banned IP, but can work without a crowdsec service. |
| appsec | Disable Crowdsec IP checking but apply Crowdsec Appsec checking. This mode is intended to be used when Crowdsec IP checking is applied at the Firewall Level. | | appsec | Disable Crowdsec IP checking but apply Crowdsec Appsec checking. This mode is intended to be used when Crowdsec IP checking is applied at the Firewall Level. |
The `streaming mode` is recommended for performance, decisions are updated every 60 sec by default and that's the only communication between Traefik and Crowdsec. Every request that happens hits the cache for quick decisions. The `streaming mode` is recommended for performance, decisions are updated every 60 sec by default and that's the only communication between Traefik and Crowdsec. Every request that happens hits the cache for quick decisions.
@@ -300,6 +301,7 @@ sequenceDiagram
To get started, use the `docker-compose.yml` file. To get started, use the `docker-compose.yml` file.
You can run it with: You can run it with:
```bash ```bash
make run make run
``` ```
@@ -307,17 +309,26 @@ make run
### Note ### Note
**/!\ Cache is shared by all services** **/!\ 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* _This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP_
Only one instance of the plugin is *possible*. Only one instance of the plugin is _possible_.
**/!\ Appsec maximum body limit is defaulted to 10MB**
_By careful when you upgrade to >1.4.x_
### Variables ### Variables
- Enabled - Enabled
- bool - bool
- default: false - default: false
- Enable the plugin - Enable the plugin
- LogLevel - LogLevel
- string - string
- default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR`, log are written to `stdout` / `stderr` - default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR`
- Log are written to `stdout` / `stderr` of file if LogFilePath is provided
- LogFilePath
- string
- default: ""
- File Path to write logs, must be writable by Traefik, Log rotation may require a restart of traefik
- CrowdsecMode - CrowdsecMode
- string - string
- default: `live`, expected values are: `none`, `live`, `stream`, `alone`, `appsec` - default: `live`, expected values are: `none`, `live`, `stream`, `alone`, `appsec`
@@ -329,6 +340,10 @@ Only one instance of the plugin is *possible*.
- 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. The scheme will be handled by the CrowdsecLapiScheme var.
- CrowdsecAppsecPath
- string
- default: "/"
- Crowdsec Appsec Server available on this path. Will be appended to CrowdsecAppsecHost. Need to finish with "/".
- CrowdsecAppsecFailureBlock - CrowdsecAppsecFailureBlock
- bool - bool
- default: true - default: true
@@ -337,6 +352,10 @@ Only one instance of the plugin is *possible*.
- bool - bool
- default: true - default: true
- Block request when Crowdsec Appsec Server is unreachable. - Block request when Crowdsec Appsec Server is unreachable.
- CrowdsecAppsecBodyLimit
- int64
- default: 10485760 (= 10MB)
- Transmit only the first number of bytes to Crowdsec Appsec Server.
- CrowdsecLapiScheme - CrowdsecLapiScheme
- string - string
- default: `http`, expected values are: `http`, `https` - default: `http`, expected values are: `http`, `https`
@@ -344,6 +363,10 @@ Only one instance of the plugin is *possible*.
- string - string
- default: "crowdsec:8080" - default: "crowdsec:8080"
- Crowdsec LAPI available on which host and port. - Crowdsec LAPI available on which host and port.
- CrowdsecLapiPath
- string
- default: "/"
- Crowdsec LAPI Server available on this path. Will be appended to CrowdsecLapiHost. Need to finish with "/".
- CrowdsecLapiKey - CrowdsecLapiKey
- string - string
- default: "" - default: ""
@@ -396,6 +419,10 @@ Only one instance of the plugin is *possible*.
- string - string
- default: "" - default: ""
- Database selection for the Redis service - Database selection for the Redis service
- RedisCacheUnreachableBlock
- bool
- default: true
- Block request when Redis is unreachable (if Redis is unreachable, 1-second delay is added to each request)
- HTTPTimeoutSeconds - HTTPTimeoutSeconds
- int64 - int64
- default: 10 - default: 10
@@ -412,6 +439,10 @@ Only one instance of the plugin is *possible*.
- int64 - int64
- default: 60 - default: 60
- Used only in `live` mode, maximum decision duration - Used only in `live` mode, maximum decision duration
- RemediationStatusCode
- int
- default: 403
- HTTP status code for banned user (not captcha)
- CrowdsecCapiMachineId - CrowdsecCapiMachineId
- string - string
- Used only in `alone` mode, login for Crowdsec CAPI - Used only in `alone` mode, login for Crowdsec CAPI
@@ -448,6 +479,7 @@ Only one instance of the plugin is *possible*.
For each plugin, the Traefik static configuration must define the module name (as is usual for Go packages). For each plugin, the Traefik static configuration must define the module name (as is usual for Go packages).
The following declaration (given here in YAML) defines a plugin: The following declaration (given here in YAML) defines a plugin:
> Note that you don't need to copy all thoses settings but only the ones you want to use. > Note that you don't need to copy all thoses settings but only the ones you want to use.
> See the examples for advanced usage. > See the examples for advanced usage.
@@ -486,19 +518,23 @@ http:
bouncer: bouncer:
enabled: false enabled: false
logLevel: DEBUG logLevel: DEBUG
LogFilePath: ""
updateIntervalSeconds: 60 updateIntervalSeconds: 60
updateMaxFailure: 0 updateMaxFailure: 0
defaultDecisionSeconds: 60 defaultDecisionSeconds: 60
remediationStatusCode: 403
httpTimeoutSeconds: 10 httpTimeoutSeconds: 10
crowdsecMode: live crowdsecMode: live
crowdsecAppsecEnabled: false crowdsecAppsecEnabled: false
crowdsecAppsecHost: crowdsec:7422 crowdsecAppsecHost: crowdsec:7422
crowdsecAppsecPath: "/"
crowdsecAppsecFailureBlock: true crowdsecAppsecFailureBlock: true
crowdsecAppsecUnreachableBlock: true crowdsecAppsecUnreachableBlock: true
crowdsecAppsecBodyLimit: 10485760
crowdsecLapiKey: privateKey-foo crowdsecLapiKey: privateKey-foo
crowdsecLapiKeyFile: /etc/traefik/cs-privateKey-foo
crowdsecLapiHost: crowdsec:8080
crowdsecLapiScheme: http crowdsecLapiScheme: http
crowdsecLapiHost: crowdsec:8080
crowdsecLapiPath: "/"
crowdsecLapiTLSInsecureVerify: false crowdsecLapiTLSInsecureVerify: false
crowdsecCapiMachineId: login crowdsecCapiMachineId: login
crowdsecCapiPassword: password crowdsecCapiPassword: password
@@ -517,13 +553,13 @@ http:
redisCacheHost: "redis:6379" redisCacheHost: "redis:6379"
redisCachePassword: password redisCachePassword: password
redisCacheDatabase: "5" redisCacheDatabase: "5"
redisCacheUnreachableBlock: true
crowdsecLapiTLSCertificateAuthority: |- crowdsecLapiTLSCertificateAuthority: |-
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
... ...
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
-----END CERTIFICATE----- -----END CERTIFICATE-----
crowdsecLapiTLSCertificateAuthorityFile: /etc/traefik/crowdsec-certs/ca.pem
crowdsecLapiTLSCertificateBouncer: |- crowdsecLapiTLSCertificateBouncer: |-
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIEHjCCAwagAwIBAgIUOBTs1eqkaAUcPplztUr2xRapvNAwDQYJKoZIhvcNAQEL MIIEHjCCAwagAwIBAgIUOBTs1eqkaAUcPplztUr2xRapvNAwDQYJKoZIhvcNAQEL
@@ -531,14 +567,12 @@ http:
RaXAnYYUVRblS1jmePemh388hFxbmrpG2pITx8B5FMULqHoj11o2Rl0gSV6tHIHz RaXAnYYUVRblS1jmePemh388hFxbmrpG2pITx8B5FMULqHoj11o2Rl0gSV6tHIHz
N2U= N2U=
-----END CERTIFICATE----- -----END CERTIFICATE-----
crowdsecLapiTLSCertificateBouncerFile: /etc/traefik/crowdsec-certs/bouncer.pem
crowdsecLapiTLSCertificateBouncerKey: |- crowdsecLapiTLSCertificateBouncerKey: |-
-----BEGIN RSA PRIVATE KEY----- -----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAtYQnbJqifH+ZymePylDxGGLIuxzcAUU4/ajNj+qRAdI/Ux3d MIIEogIBAAKCAQEAtYQnbJqifH+ZymePylDxGGLIuxzcAUU4/ajNj+qRAdI/Ux3d
... ...
ic5cDRo6/VD3CS3MYzyBcibaGaV34nr0G/pI+KEqkYChzk/PZRA= ic5cDRo6/VD3CS3MYzyBcibaGaV34nr0G/pI+KEqkYChzk/PZRA=
-----END RSA PRIVATE KEY----- -----END RSA PRIVATE KEY-----
crowdsecLapiTLSCertificateBouncerKeyFile: /etc/traefik/crowdsec-certs/bouncer-key.pem
captchaProvider: hcaptcha captchaProvider: hcaptcha
captchaSiteKey: FIXME captchaSiteKey: FIXME
captchaSecretKey: FIXME captchaSecretKey: FIXME
@@ -549,12 +583,13 @@ http:
#### Fill variable with value of file #### Fill variable with value of file
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey` and `CaptchaSecretKey` can be provided with the content as raw or through a file path that Traefik can read. `CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey`, `CaptchaSecretKey` and `RedisCachePassword` can be provided with the content as raw or through a file path that Traefik can read.
The file variable will be used as preference if both content and file are provided for the same variable. The file variable will be used as preference if both content and file are provided for the same variable.
Format is: Format is:
- Content: VariableName: XXX - Content: VariableName: XXX
- File : VariableNameFile: /path - File : VariableNameFile: /path
#### Authenticate with LAPI #### Authenticate with LAPI
@@ -562,6 +597,7 @@ You can authenticate to the LAPI either with LAPIKEY or by using client certific
Please see below for more details on each option. Please see below for more details on each option.
#### Generate LAPI KEY #### Generate LAPI KEY
You can generate a crowdsec API key for the LAPI. You can generate a crowdsec API key for the LAPI.
You can follow the documentation here: [docs.crowdsec.net/docs/user_guides/lapi_mgmt](https://docs.crowdsec.net/docs/user_guides/lapi_mgmt) You can follow the documentation here: [docs.crowdsec.net/docs/user_guides/lapi_mgmt](https://docs.crowdsec.net/docs/user_guides/lapi_mgmt)
@@ -571,24 +607,26 @@ docker exec crowdsec cscli bouncers add crowdsecBouncer
``` ```
This LAPI key must be set where is noted FIXME-LAPI-KEY in the docker-compose.yml This LAPI key must be set where is noted FIXME-LAPI-KEY in the docker-compose.yml
```yaml ```yaml
... ..
whoami: whoami:
labels: labels:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=FIXME-LAPI-KEY" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=FIXME-LAPI-KEY"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=http" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=http"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapihost=crowdsec:8080" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapihost=crowdsec:8080"
... ..
crowdsec: crowdsec:
environment: environment:
BOUNCER_KEY_TRAEFIK: FIXME-LAPI-KEY BOUNCER_KEY_TRAEFIK: FIXME-LAPI-KEY
...
``` ```
Note: Note:
> Crowdsec does not require a specific format for la LAPI-key, you may use something like FIXME-LAPI-KEY but that is not recommanded for obvious reasons > Crowdsec does not require a specific format for la LAPI-key, you may use something like FIXME-LAPI-KEY but that is not recommanded for obvious reasons
You can then run all the containers: You can then run all the containers:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
@@ -665,12 +703,14 @@ The source code of the plugin should be organized as follows:
``` ```
For local development, a `docker-compose.local.yml` is provided which reproduces the directory layout needed by Traefik. For local development, a `docker-compose.local.yml` is provided which reproduces the directory layout needed by Traefik.
This works once you have generated and filled your *LAPI-KEY* (crowdsecLapiKey), if not read above for informations. This works once you have generated and filled your _LAPI-KEY_ (crowdsecLapiKey), if not read above for informations.
```bash ```bash
docker compose -f docker-compose.local.yml up -d docker compose -f docker-compose.local.yml up -d
``` ```
Equivalent to Equivalent to
```bash ```bash
make run_local make run_local
``` ```
+44 -19
View File
@@ -62,10 +62,13 @@ type Bouncer struct {
enabled bool enabled bool
appsecEnabled bool appsecEnabled bool
appsecHost string appsecHost string
appsecPath string
appsecFailureBlock bool appsecFailureBlock bool
appsecUnreachableBlock bool appsecUnreachableBlock bool
appsecBodyLimit int64
crowdsecScheme string crowdsecScheme string
crowdsecHost string crowdsecHost string
crowdsecPath string
crowdsecKey string crowdsecKey string
crowdsecMode string crowdsecMode string
crowdsecMachineID string crowdsecMachineID string
@@ -74,10 +77,12 @@ type Bouncer struct {
updateInterval int64 updateInterval int64
updateMaxFailure int updateMaxFailure int
defaultDecisionTimeout int64 defaultDecisionTimeout int64
remediationStatusCode int
remediationCustomHeader string remediationCustomHeader string
forwardedCustomHeader string forwardedCustomHeader string
crowdsecStreamRoute string crowdsecStreamRoute string
crowdsecHeader string crowdsecHeader string
redisUnreachableBlock bool
banTemplateString string banTemplateString string
clientPoolStrategy *ip.PoolStrategy clientPoolStrategy *ip.PoolStrategy
serverPoolStrategy *ip.PoolStrategy serverPoolStrategy *ip.PoolStrategy
@@ -89,7 +94,8 @@ type Bouncer struct {
// New creates the crowdsec bouncer plugin. // New creates the crowdsec bouncer plugin.
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) { func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
log := logger.New(config.LogLevel) config.LogLevel = strings.ToUpper(config.LogLevel)
log := logger.New(config.LogLevel, config.LogFilePath)
err := configuration.ValidateParams(config) err := configuration.ValidateParams(config)
if err != nil { if err != nil {
log.Error("New:validateParams " + err.Error()) log.Error("New:validateParams " + err.Error())
@@ -105,8 +111,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
if config.CrowdsecMode == configuration.AloneMode { if config.CrowdsecMode == configuration.AloneMode {
config.CrowdsecCapiMachineID, _ = configuration.GetVariable(config, "CrowdsecCapiMachineID") config.CrowdsecCapiMachineID, _ = configuration.GetVariable(config, "CrowdsecCapiMachineID")
config.CrowdsecCapiPassword, _ = configuration.GetVariable(config, "CrowdsecCapiPassword") config.CrowdsecCapiPassword, _ = configuration.GetVariable(config, "CrowdsecCapiPassword")
config.CrowdsecLapiScheme = configuration.HTTPS
config.CrowdsecLapiHost = crowdsecCapiHost config.CrowdsecLapiHost = crowdsecCapiHost
config.CrowdsecLapiScheme = "https" config.CrowdsecLapiPath = "/"
config.CrowdsecAppsecEnabled = false
config.UpdateIntervalSeconds = 7200 // 2 hours config.UpdateIntervalSeconds = 7200 // 2 hours
crowdsecStreamRoute = crowdsecCapiStreamRoute crowdsecStreamRoute = crowdsecCapiStreamRoute
crowdsecHeader = crowdsecCapiHeader crowdsecHeader = crowdsecCapiHeader
@@ -121,7 +129,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
apiKey, errAPIKey := configuration.GetVariable(config, "CrowdsecLapiKey") apiKey, errAPIKey := configuration.GetVariable(config, "CrowdsecLapiKey")
if errAPIKey != nil && len(tlsConfig.Certificates) == 0 { if errAPIKey != 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 " + errAPIKey.Error())
return nil, err return nil, errAPIKey
} }
config.CrowdsecLapiKey = apiKey config.CrowdsecLapiKey = apiKey
} }
@@ -147,10 +155,13 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
crowdsecMode: config.CrowdsecMode, crowdsecMode: config.CrowdsecMode,
appsecEnabled: config.CrowdsecAppsecEnabled, appsecEnabled: config.CrowdsecAppsecEnabled,
appsecHost: config.CrowdsecAppsecHost, appsecHost: config.CrowdsecAppsecHost,
appsecPath: config.CrowdsecAppsecPath,
appsecFailureBlock: config.CrowdsecAppsecFailureBlock, appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock, appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
crowdsecScheme: config.CrowdsecLapiScheme, crowdsecScheme: config.CrowdsecLapiScheme,
crowdsecHost: config.CrowdsecLapiHost, crowdsecHost: config.CrowdsecLapiHost,
crowdsecPath: config.CrowdsecLapiPath,
crowdsecKey: config.CrowdsecLapiKey, crowdsecKey: config.CrowdsecLapiKey,
crowdsecMachineID: config.CrowdsecCapiMachineID, crowdsecMachineID: config.CrowdsecCapiMachineID,
crowdsecPassword: config.CrowdsecCapiPassword, crowdsecPassword: config.CrowdsecCapiPassword,
@@ -160,6 +171,8 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
remediationCustomHeader: config.RemediationHeadersCustomName, remediationCustomHeader: config.RemediationHeadersCustomName,
forwardedCustomHeader: config.ForwardedHeadersCustomName, forwardedCustomHeader: config.ForwardedHeadersCustomName,
defaultDecisionTimeout: config.DefaultDecisionSeconds, defaultDecisionTimeout: config.DefaultDecisionSeconds,
remediationStatusCode: config.RemediationStatusCode,
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
banTemplateString: banTemplateString, banTemplateString: banTemplateString,
crowdsecStreamRoute: crowdsecStreamRoute, crowdsecStreamRoute: crowdsecStreamRoute,
crowdsecHeader: crowdsecHeader, crowdsecHeader: crowdsecHeader,
@@ -232,7 +245,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
// ServeHTTP principal function of plugin. // ServeHTTP principal function of plugin.
// //
//nolint:nestif //nolint:nestif,gocyclo
func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if !bouncer.enabled { if !bouncer.enabled {
bouncer.next.ServeHTTP(rw, req) bouncer.next.ServeHTTP(rw, req)
@@ -270,6 +283,11 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if cacheErr != nil { if cacheErr != nil {
cacheErrString := cacheErr.Error() cacheErrString := cacheErr.Error()
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString)) bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString))
if !bouncer.redisUnreachableBlock && cacheErrString == cache.CacheUnreachable {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s redisUnreachable=true", remoteIP))
handleNextServeHTTP(bouncer, remoteIP, rw, req)
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) handleBanServeHTTP(bouncer, rw)
@@ -335,16 +353,19 @@ 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 handleBanServeHTTP(bouncer *Bouncer, rw http.ResponseWriter) {
if bouncer.banTemplateString == "" {
rw.WriteHeader(http.StatusForbidden)
return
}
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
if bouncer.remediationCustomHeader != "" { if bouncer.remediationCustomHeader != "" {
rw.Header().Set(bouncer.remediationCustomHeader, "ban") rw.Header().Set(bouncer.remediationCustomHeader, "ban")
} }
rw.WriteHeader(http.StatusForbidden) if bouncer.banTemplateString == "" {
fmt.Fprint(rw, bouncer.banTemplateString) rw.WriteHeader(bouncer.remediationStatusCode)
return
}
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(bouncer.remediationStatusCode)
_, err := fmt.Fprint(rw, bouncer.banTemplateString)
if err != nil {
bouncer.log.Error("handleBanServeHTTP could not write template to ResponseWriter")
}
} }
func handleRemediationServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) { func handleRemediationServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) {
@@ -408,8 +429,8 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
routeURL := url.URL{ routeURL := url.URL{
Scheme: bouncer.crowdsecScheme, Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost, Host: bouncer.crowdsecHost,
Path: crowdsecLapiRoute, Path: bouncer.crowdsecPath + crowdsecLapiRoute,
RawQuery: fmt.Sprintf("ip=%v&banned=true", remoteIP), RawQuery: fmt.Sprintf("ip=%v", remoteIP),
} }
body, err := crowdsecQuery(bouncer, routeURL.String(), false) body, err := crowdsecQuery(bouncer, routeURL.String(), false)
if err != nil { if err != nil {
@@ -504,7 +525,7 @@ func handleStreamCache(bouncer *Bouncer) error {
streamRouteURL := url.URL{ streamRouteURL := url.URL{
Scheme: bouncer.crowdsecScheme, Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost, Host: bouncer.crowdsecHost,
Path: bouncer.crowdsecStreamRoute, Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute,
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup), RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup),
} }
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), false) body, err := crowdsecQuery(bouncer, streamRouteURL.String(), false)
@@ -552,7 +573,7 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, err
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") req.Header.Add("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/1.X.X")
res, err := bouncer.httpClient.Do(req) res, err := bouncer.httpClient.Do(req)
if err != nil { if err != nil {
@@ -584,15 +605,19 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
routeURL := url.URL{ routeURL := url.URL{
Scheme: bouncer.crowdsecScheme, Scheme: bouncer.crowdsecScheme,
Host: bouncer.appsecHost, Host: bouncer.appsecHost,
Path: "/", Path: bouncer.appsecPath,
} }
var req *http.Request var req *http.Request
if httpReq.Body != nil && httpReq.ContentLength > 0 { if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil && httpReq.ContentLength > 0 {
bodyBytes, err := io.ReadAll(httpReq.Body) var bodyBuffer bytes.Buffer
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
bodyBytes, err := io.ReadAll(teeReader)
if err != nil { if err != nil {
return fmt.Errorf("appsecQuery:GetBody %w", err) return fmt.Errorf("appsecQuery:GetBody %w", err)
} }
httpReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Conserve body intact after reading it for other middlewares and service
httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body))
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes)) req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
} else { } else {
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil) req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
+1 -1
View File
@@ -293,7 +293,7 @@
</svg> </svg>
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1> <h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
</div> </div>
<form action="" method="POST" class="flex flex-col space-y-1" id="captcha-form"> <form action="" method="POST" class="flex flex-col items-center space-y-1" id="captcha-form">
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback"> <div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
</div> </div>
</form> </form>
+1
View File
@@ -6,6 +6,7 @@ logs:
level: DEBUG level: DEBUG
access: access:
enabled: true enabled: true
format: json
fields: fields:
headers: headers:
defaultmode: keep defaultmode: keep
+3 -1
View File
@@ -3,7 +3,9 @@ You need to create a crowdsec API credentials for the CAPI.
You can follow the documentation here: https://docs.crowdsec.net/docs/central_api/intro You can follow the documentation here: https://docs.crowdsec.net/docs/central_api/intro
```bash ```bash
curl -X POST "https://api.crowdsec.net/v2/watchers" -H "accept: application/json" -H "Content-Type: application/json" -d "{ \"password\": \"PASSWORD\", \"machine_id\": \"LOGIN\"}" LOGIN=...
PASSWORD=...
curl -X POST "https://api.crowdsec.net/v2/watchers" -H "accept: application/json" -H "Content-Type: application/json" -d "{ \"password\": \"$PASSWORD\", \"machine_id\": \"$LOGIN\"}"
``` ```
These CAPI credentials must be set in your docker-compose.yml or in your config files These CAPI credentials must be set in your docker-compose.yml or in your config files
+1 -2
View File
@@ -35,8 +35,7 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG" # - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=alone" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecMode=alone"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiMachineId=FIXME" - "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiMachineId=FIXME"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiPassword=FIXME" - "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiPassword=FIXME"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseccapiscenarios=crowdsecurity/sshd,crowdsecurity/asterisk_bf,crowdsecurity/asterisk_user_enum,crowdsecurity/base-http-scenarios" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseccapiscenarios=crowdsecurity/sshd,crowdsecurity/asterisk_bf,crowdsecurity/asterisk_user_enum,crowdsecurity/base-http-scenarios"
+7 -1
View File
@@ -23,6 +23,8 @@ const (
CaptchaDoneValue = "d" CaptchaDoneValue = "d"
// CacheMiss error string when cache is miss. // CacheMiss error string when cache is miss.
CacheMiss = "cache:miss" CacheMiss = "cache:miss"
// CacheUnreachable error string when cache is unreachable.
CacheUnreachable = "cache:unreachable"
) )
//nolint:gochecknoglobals //nolint:gochecknoglobals
@@ -60,9 +62,13 @@ func (redisCache) get(key string) (string, error) {
if err == nil && len(valueString) > 0 { if err == nil && len(valueString) > 0 {
return valueString, nil return valueString, nil
} }
if err.Error() == simpleredis.RedisMiss { errRedisMessage := err.Error()
if errRedisMessage == simpleredis.RedisMiss {
return "", errors.New(CacheMiss) return "", errors.New(CacheMiss)
} }
if errRedisMessage == simpleredis.RedisUnreachable {
return "", errors.New(CacheUnreachable)
}
return "", err return "", err
} }
+3 -3
View File
@@ -11,7 +11,7 @@ import (
func Test_Get(t *testing.T) { func Test_Get(t *testing.T) {
IPInCache := "10.0.0.10" IPInCache := "10.0.0.10"
IPNotInCache := "10.0.0.20" IPNotInCache := "10.0.0.20"
client := &Client{cache: &localCache{}, log: logger.New("INFO")} client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
client.Set(IPInCache, BannedValue, 10) client.Set(IPInCache, BannedValue, 10)
type args struct { type args struct {
clientIP string clientIP string
@@ -47,7 +47,7 @@ func Test_Get(t *testing.T) {
} }
func Test_Set(t *testing.T) { func Test_Set(t *testing.T) {
client := &Client{cache: &localCache{}, log: logger.New("INFO")} client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
IPInCache := "10.0.0.11" IPInCache := "10.0.0.11"
type args struct { type args struct {
clientIP string clientIP string
@@ -88,7 +88,7 @@ func Test_Set(t *testing.T) {
func Test_Delete(t *testing.T) { func Test_Delete(t *testing.T) {
IPInCache := "10.0.0.12" IPInCache := "10.0.0.12"
IPNotInCache := "10.0.0.22" IPNotInCache := "10.0.0.22"
client := &Client{cache: &localCache{}, log: logger.New("INFO")} client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
client.Set(IPInCache, BannedValue, 10) client.Set(IPInCache, BannedValue, 10)
type args struct { type args struct {
clientIP string clientIP string
+1 -1
View File
@@ -138,7 +138,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
c.log.Error("captcha:Validate " + err.Error()) c.log.Error("captcha:Validate " + err.Error())
} }
}() }()
if !strings.Contains(res.Header.Get("content-type"), "application/json") { if !strings.Contains(res.Header.Get("Content-Type"), "application/json") {
c.log.Debug("captcha:Validate responseType:noJson") c.log.Debug("captcha:Validate responseType:noJson")
return false, nil return false, nil
} }
+40 -8
View File
@@ -28,6 +28,9 @@ const (
AppsecMode = "appsec" AppsecMode = "appsec"
HTTPS = "https" HTTPS = "https"
HTTP = "http" HTTP = "http"
LogDEBUG = "DEBUG"
LogINFO = "INFO"
LogERROR = "ERROR"
HcaptchaProvider = "hcaptcha" HcaptchaProvider = "hcaptcha"
RecaptchaProvider = "recaptcha" RecaptchaProvider = "recaptcha"
TurnstileProvider = "turnstile" TurnstileProvider = "turnstile"
@@ -37,13 +40,17 @@ const (
type Config struct { type Config struct {
Enabled bool `json:"enabled,omitempty"` Enabled bool `json:"enabled,omitempty"`
LogLevel string `json:"logLevel,omitempty"` LogLevel string `json:"logLevel,omitempty"`
LogFilePath string `json:"logFilePath,omitempty"`
CrowdsecMode string `json:"crowdsecMode,omitempty"` CrowdsecMode string `json:"crowdsecMode,omitempty"`
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"` CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"` CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
CrowdsecAppsecPath string `json:"crowdsecAppsecPath,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"`
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"` CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"` CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
CrowdsecLapiPath string `json:"crowdsecLapiPath,omitempty"`
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"` CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"` CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"`
CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"` CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"`
@@ -61,6 +68,7 @@ type Config struct {
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"` UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
UpdateMaxFailure int `json:"updateMaxFailure,omitempty"` UpdateMaxFailure int `json:"updateMaxFailure,omitempty"`
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"` DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"` HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"` RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"`
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"` ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
@@ -71,6 +79,7 @@ type Config struct {
RedisCachePassword string `json:"redisCachePassword,omitempty"` RedisCachePassword string `json:"redisCachePassword,omitempty"`
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"` RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"` RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
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"`
@@ -94,19 +103,24 @@ func contains(source []string, target string) bool {
func New() *Config { func New() *Config {
return &Config{ return &Config{
Enabled: false, Enabled: false,
LogLevel: "INFO", LogLevel: LogINFO,
LogFilePath: "",
CrowdsecMode: LiveMode, CrowdsecMode: LiveMode,
CrowdsecAppsecEnabled: false, CrowdsecAppsecEnabled: false,
CrowdsecAppsecHost: "crowdsec:7422", CrowdsecAppsecHost: "crowdsec:7422",
CrowdsecAppsecPath: "/",
CrowdsecAppsecFailureBlock: true, CrowdsecAppsecFailureBlock: true,
CrowdsecAppsecUnreachableBlock: true, CrowdsecAppsecUnreachableBlock: true,
CrowdsecAppsecBodyLimit: 10485760,
CrowdsecLapiScheme: HTTP, CrowdsecLapiScheme: HTTP,
CrowdsecLapiHost: "crowdsec:8080", CrowdsecLapiHost: "crowdsec:8080",
CrowdsecLapiPath: "/",
CrowdsecLapiKey: "", CrowdsecLapiKey: "",
CrowdsecLapiTLSInsecureVerify: false, CrowdsecLapiTLSInsecureVerify: false,
UpdateIntervalSeconds: 60, UpdateIntervalSeconds: 60,
UpdateMaxFailure: 0, UpdateMaxFailure: 0,
DefaultDecisionSeconds: 60, DefaultDecisionSeconds: 60,
RemediationStatusCode: http.StatusForbidden,
HTTPTimeoutSeconds: 10, HTTPTimeoutSeconds: 10,
CaptchaProvider: "", CaptchaProvider: "",
CaptchaSiteKey: "", CaptchaSiteKey: "",
@@ -122,6 +136,7 @@ func New() *Config {
RedisCacheHost: "redis:6379", RedisCacheHost: "redis:6379",
RedisCachePassword: "", RedisCachePassword: "",
RedisCacheDatabase: "", RedisCacheDatabase: "",
RedisCacheUnreachableBlock: true,
} }
} }
@@ -217,11 +232,11 @@ func ValidateParams(config *Config) error {
} }
} }
if err := validateURL("CrowdsecLapi", config.CrowdsecLapiScheme, config.CrowdsecLapiHost); err != nil { if err := validateURL("CrowdsecLapi", config.CrowdsecLapiScheme, config.CrowdsecLapiHost, config.CrowdsecLapiPath); err != nil {
return err return err
} }
if err := validateURL("CrowdsecAppsec", config.CrowdsecLapiScheme, config.CrowdsecAppsecHost); err != nil { if err := validateURL("CrowdsecAppsec", config.CrowdsecLapiScheme, config.CrowdsecAppsecHost, config.CrowdsecAppsecPath); err != nil {
return err return err
} }
@@ -254,14 +269,25 @@ func ValidateParams(config *Config) error {
} }
} }
// Check logging configuration
// to upper allow of anycase of log level
if !contains([]string{LogERROR, LogDEBUG, LogINFO}, strings.ToUpper(config.LogLevel)) {
return fmt.Errorf("LogLevel should be one of (%s,%s,%s)", LogDEBUG, LogINFO, LogERROR)
}
if config.LogFilePath != "" {
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("LogFilePath is not writable %w", err)
}
}
return nil return nil
} }
func validateURL(variable, scheme, host string) error { func validateURL(variable, scheme, host, path string) error {
// This only check that the format of the URL scheme://host is correct and do not make requests // This only check that the format of the URL scheme://host/path is correct and do not make requests
testURL := url.URL{Scheme: scheme, Host: host} testURL := url.URL{Scheme: scheme, Host: host, Path: path}
if _, err := http.NewRequest(http.MethodGet, testURL.String(), nil); err != nil { if _, err := http.NewRequest(http.MethodGet, testURL.String(), nil); err != nil {
return fmt.Errorf("CrowdsecLapiScheme://%sHost: '%v://%v' must be an URL", variable, scheme, host) return fmt.Errorf("CrowdsecLapiScheme://%sHost: '%v://%v%v' must be a valid URL", variable, scheme, host, path)
} }
return nil return nil
} }
@@ -296,7 +322,7 @@ func validateParamsTLS(config *Config) error {
func validateParamsIPs(listIP []string, key string) error { func validateParamsIPs(listIP []string, key string) error {
if len(listIP) > 0 { if len(listIP) > 0 {
if _, err := ip.NewChecker(logger.New("INFO"), listIP); err != nil { if _, err := ip.NewChecker(logger.New(LogINFO, ""), listIP); err != nil {
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err) return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
} }
} }
@@ -328,6 +354,12 @@ func validateParamsRequired(config *Config) error {
if config.UpdateMaxFailure < -1 { if config.UpdateMaxFailure < -1 {
return errors.New("UpdateMaxFailure: cannot be less than -1") return errors.New("UpdateMaxFailure: cannot be less than -1")
} }
if config.CrowdsecAppsecBodyLimit < 0 {
return errors.New("CrowdsecAppsecBodyLimit: cannot be less than 0")
}
if config.RemediationStatusCode < 100 || config.RemediationStatusCode >= 600 {
return errors.New("RemediationStatusCode: cannot be less than 100 and more than 600")
}
if !contains([]string{NoneMode, LiveMode, StreamMode, AloneMode, AppsecMode}, config.CrowdsecMode) { if !contains([]string{NoneMode, LiveMode, StreamMode, AloneMode, AppsecMode}, config.CrowdsecMode) {
return errors.New("CrowdsecMode: must be one of 'none', 'live', 'stream', 'alone' or 'appsec'") return errors.New("CrowdsecMode: must be one of 'none', 'live', 'stream', 'alone' or 'appsec'")
+12 -3
View File
@@ -85,8 +85,14 @@ func Test_ValidateParams(t *testing.T) {
cfg6 := getMinimalConfig() cfg6 := getMinimalConfig()
cfg6.CrowdsecLapiScheme = HTTPS cfg6.CrowdsecLapiScheme = HTTPS
cfg6.CrowdsecLapiTLSInsecureVerify = true cfg6.CrowdsecLapiTLSInsecureVerify = true
cfg7 := getMinimalConfig()
cfg7.CrowdsecLapiScheme = HTTPS
cfg8 := getMinimalConfig() cfg8 := getMinimalConfig()
cfg8.CrowdsecLapiScheme = HTTPS cfg8.LogLevel = LogINFO
cfg9 := getMinimalConfig()
cfg9.LogLevel = "info"
cfg10 := getMinimalConfig()
cfg10.LogLevel = "Warning"
type args struct { type args struct {
config *Config config *Config
} }
@@ -104,7 +110,10 @@ func Test_ValidateParams(t *testing.T) {
{name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true}, {name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true},
// HTTPS enabled // HTTPS enabled
{name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false}, {name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false},
{name: "Not validate https without cert authority", args: args{config: cfg8}, wantErr: true}, {name: "Not validate https without cert authority", args: args{config: cfg7}, wantErr: true},
{name: "Valid log level uppercase INFO", args: args{config: cfg8}, wantErr: false},
{name: "Valid log level lowercase info", args: args{config: cfg9}, wantErr: false},
{name: "Invalid log level Warning", args: args{config: cfg10}, wantErr: true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -233,7 +242,7 @@ func Test_GetTLSConfigCrowdsec(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO")) got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO", ""))
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
+21 -4
View File
@@ -3,9 +3,11 @@
package logger package logger
import ( import (
"fmt"
"io" "io"
"log" "log"
"os" "os"
"path/filepath"
) )
// Log Logger struct. // Log Logger struct.
@@ -16,15 +18,30 @@ type Log struct {
} }
// New Set Default log level to info in case log level to defined. // New Set Default log level to info in case log level to defined.
func New(logLevel string) *Log { func New(logLevel string, logFilePath string) *Log {
logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime) logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime) logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime) logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
logError.SetOutput(os.Stderr) logError.SetOutput(os.Stderr)
logInfo.SetOutput(os.Stdout) logInfo.SetOutput(os.Stdout)
// we initialize logger to STDOUT/STDERR first so if the file logger cannot be initialized we can inform the user
if logLevel == "DEBUG" { if logLevel == "DEBUG" {
logDebug.SetOutput(os.Stdout) logDebug.SetOutput(os.Stdout)
} }
if logFilePath != "" {
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
_ = fmt.Errorf("LogFilePath is not writable %w", err)
} else {
logInfo.SetOutput(logFile)
logError.SetOutput(logFile)
if logLevel == "DEBUG" {
logDebug.SetOutput(logFile)
}
}
}
return &Log{ return &Log{
logError: logError, logError: logError,
logInfo: logInfo, logInfo: logInfo,
@@ -34,15 +51,15 @@ func New(logLevel string) *Log {
// Info log to Stdout. // Info log to Stdout.
func (l *Log) Info(str string) { func (l *Log) Info(str string) {
l.logInfo.Printf(str) l.logInfo.Printf("%s", str)
} }
// Debug log to Stdout. // Debug log to Stdout.
func (l *Log) Debug(str string) { func (l *Log) Debug(str string) {
l.logDebug.Printf(str) l.logDebug.Printf("%s", str)
} }
// Error log to Stderr. // Error log to Stderr.
func (l *Log) Error(str string) { func (l *Log) Error(str string) {
l.logError.Printf(str) l.logError.Printf("%s", str)
} }