Compare commits

...
14 Commits
Author SHA1 Message Date
DavidandMax Lerebourg 84a5674b14 📈 Report traffic dropped metrics to LAPI (#223)
* Initial implementation

* fix

* fixes

* Fixes

* xx

* progress

* xx

* xx

* xx

* fix linter

* Progress

* Fixes

* xx

* xx

* Remove trace logger

* Last fix

* fix lint

* fix lint

* fix lint

---------

Co-authored-by: Max Lerebourg <maxlerebourg@gmail.com>
2025-07-02 11:36:09 +02:00
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
18 changed files with 476 additions and 160 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
runs-on: ubuntu-latest
env:
GO_VERSION: 1.22
GOLANGCI_LINT_VERSION: v1.57.2
GO_VERSION: 1.23
GOLANGCI_LINT_VERSION: v1.63.4
YAEGI_VERSION: v0.16.1
CGO_ENABLED: 0
defaults:
@@ -24,20 +24,20 @@ jobs:
# https://github.com/marketplace/actions/setup-go-environment
- name: Set up Go ${{ env.GO_VERSION }}
uses: actions/setup-go@v2
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
# https://github.com/marketplace/actions/checkout
- name: Check out code
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
path: go/src/github.com/${{ github.repository }}
fetch-depth: 0
# https://github.com/marketplace/actions/cache
- name: Cache Go modules
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+21 -34
View File
@@ -15,7 +15,7 @@ linters-settings:
locale: US
funlen:
lines: -1
statements: 50
statements: 60
godox:
keywords:
- FIXME
@@ -25,45 +25,33 @@ linters-settings:
rules:
Main:
files:
- $all
- "!$test"
- $all
- "!$test"
allow:
- $gostd
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
- github.com/leprosus/golang-ttl-map
- github.com/maxlerebourg/simpleredis
- $gostd
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
- github.com/leprosus/golang-ttl-map
- github.com/maxlerebourg/simpleredis
Test:
files:
- $test
- $test
allow:
- $gostd
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
- $gostd
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache
- github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha
linters:
enable-all: true
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)
- rowserrcheck # not relevant (SQL)
- execinquery # not relevant (SQL)
- cyclop # duplicate of gocyclo
- bodyclose # Too many false positives: https://github.com/timakin/bodyclose/issues/30
- dupl
@@ -74,18 +62,17 @@ linters:
- wsl
- exhaustive
- exhaustruct
- goerr113
- err113
- wrapcheck
- ifshort
- noctx
- lll
- gomnd
- forbidigo
- varnamelen
- wastedassign # is disabled because of generics
- gofumpt
- gci
- mnd
- exportloopref
issues:
exclude-use-default: false
max-same-issues: 0
+77 -25
View File
@@ -26,6 +26,7 @@ The AppSec Component offers:
- Low-effort virtual patching capabilities.
- Support for your legacy ModSecurity rules.
- 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/).
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.
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:
| 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 |
| 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. |
| 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. |
| 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 |
| 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. |
| 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. |
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,24 +301,45 @@ sequenceDiagram
To get started, use the `docker-compose.yml` file.
You can run it with:
```bash
make run
```
### Note
**/!\ 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*
Only one instance of the plugin is *possible*.
> [!IMPORTANT]
> Some of the behaviours and configuration parameters are shared globally across *all* crowdsec middlewares even if you declare different middlewares with different settings.
>
> **Cache is shared by all services**: This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP
>
> If you define different caches for different middlewares, only the first one to be instantiated will be bound to the crowdsec stream.
>
> Overall, this middleware is designed in such a way that **only one instance of the plugin is *possible*.** You can have multiple crowdsec middlewares in the same cluster, the key parameters must be aligned (MetricsUpdateIntervalSeconds, CrowdsecMode, CrowdsecAppsecEnabled, etc.)
> [!WARNING]
> **Appsec maximum body limit is defaulted to 10MB**
> *Be careful when you upgrade to >1.4.x*
### Variables
- Enabled
- bool
- default: false
- Enable the plugin
- LogLevel
- string
- default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR`, log are written to `stdout` / `stderr`
- default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR`
- Log are written to `stdout` / `stderr` or file if LogFilePath is provided
- LogFilePath
- string
- default: ""
- File Path to write logs, must be writable by Traefik, Log rotation may require a restart of traefik
- MetricsUpdateIntervalSeconds
- int64
- default: 600
- Interval in seconds between metrics updates to Crowdsec
- If set to zero or less, metrics collection is disabled
- CrowdsecMode
- string
- default: `live`, expected values are: `none`, `live`, `stream`, `alone`, `appsec`
@@ -329,6 +351,10 @@ Only one instance of the plugin is *possible*.
- string
- default: "crowdsec:7422"
- 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
- bool
- default: true
@@ -337,6 +363,10 @@ Only one instance of the plugin is *possible*.
- bool
- default: true
- 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
- string
- default: `http`, expected values are: `http`, `https`
@@ -344,6 +374,10 @@ Only one instance of the plugin is *possible*.
- string
- default: "crowdsec:8080"
- 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
- string
- default: ""
@@ -396,6 +430,10 @@ Only one instance of the plugin is *possible*.
- string
- default: ""
- 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
- int64
- default: 10
@@ -412,6 +450,10 @@ Only one instance of the plugin is *possible*.
- int64
- default: 60
- Used only in `live` mode, maximum decision duration
- RemediationStatusCode
- int
- default: 403
- HTTP status code for banned user (not captcha)
- CrowdsecCapiMachineId
- string
- Used only in `alone` mode, login for Crowdsec CAPI
@@ -448,6 +490,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).
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.
> See the examples for advanced usage.
@@ -486,19 +529,23 @@ http:
bouncer:
enabled: false
logLevel: DEBUG
LogFilePath: ""
updateIntervalSeconds: 60
updateMaxFailure: 0
defaultDecisionSeconds: 60
remediationStatusCode: 403
httpTimeoutSeconds: 10
crowdsecMode: live
crowdsecAppsecEnabled: false
crowdsecAppsecHost: crowdsec:7422
crowdsecAppsecPath: "/"
crowdsecAppsecFailureBlock: true
crowdsecAppsecUnreachableBlock: true
crowdsecAppsecBodyLimit: 10485760
crowdsecLapiKey: privateKey-foo
crowdsecLapiKeyFile: /etc/traefik/cs-privateKey-foo
crowdsecLapiHost: crowdsec:8080
crowdsecLapiScheme: http
crowdsecLapiHost: crowdsec:8080
crowdsecLapiPath: "/"
crowdsecLapiTLSInsecureVerify: false
crowdsecCapiMachineId: login
crowdsecCapiPassword: password
@@ -517,13 +564,13 @@ http:
redisCacheHost: "redis:6379"
redisCachePassword: password
redisCacheDatabase: "5"
redisCacheUnreachableBlock: true
crowdsecLapiTLSCertificateAuthority: |-
-----BEGIN CERTIFICATE-----
MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
...
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
-----END CERTIFICATE-----
crowdsecLapiTLSCertificateAuthorityFile: /etc/traefik/crowdsec-certs/ca.pem
crowdsecLapiTLSCertificateBouncer: |-
-----BEGIN CERTIFICATE-----
MIIEHjCCAwagAwIBAgIUOBTs1eqkaAUcPplztUr2xRapvNAwDQYJKoZIhvcNAQEL
@@ -531,30 +578,30 @@ http:
RaXAnYYUVRblS1jmePemh388hFxbmrpG2pITx8B5FMULqHoj11o2Rl0gSV6tHIHz
N2U=
-----END CERTIFICATE-----
crowdsecLapiTLSCertificateBouncerFile: /etc/traefik/crowdsec-certs/bouncer.pem
crowdsecLapiTLSCertificateBouncerKey: |-
-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAtYQnbJqifH+ZymePylDxGGLIuxzcAUU4/ajNj+qRAdI/Ux3d
...
ic5cDRo6/VD3CS3MYzyBcibaGaV34nr0G/pI+KEqkYChzk/PZRA=
-----END RSA PRIVATE KEY-----
crowdsecLapiTLSCertificateBouncerKeyFile: /etc/traefik/crowdsec-certs/bouncer-key.pem
captchaProvider: hcaptcha
captchaSiteKey: FIXME
captchaSecretKey: FIXME
captchaGracePeriodSeconds: 1800
captchaHTMLFilePath: /captcha.html
banHTMLFilePath: /ban.html
metricsUpdateIntervalSeconds: 600
```
#### 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.
Format is:
- Content: VariableName: XXX
- File : VariableNameFile: /path
- File : VariableNameFile: /path
#### Authenticate with LAPI
@@ -562,6 +609,7 @@ You can authenticate to the LAPI either with LAPIKEY or by using client certific
Please see below for more details on each option.
#### Generate LAPI KEY
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)
@@ -571,24 +619,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
```yaml
...
..
whoami:
labels:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=FIXME-LAPI-KEY"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=http"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapihost=crowdsec:8080"
...
..
crowdsec:
environment:
BOUNCER_KEY_TRAEFIK: FIXME-LAPI-KEY
...
```
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
You can then run all the containers:
```bash
docker compose up -d
```
@@ -665,12 +715,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.
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
docker compose -f docker-compose.local.yml up -d
```
Equivalent to
```bash
make run_local
```
+179 -42
View File
@@ -12,7 +12,9 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync/atomic"
"text/template"
"time"
@@ -33,6 +35,7 @@ const (
crowdsecLapiHeader = "X-Api-Key"
crowdsecLapiRoute = "v1/decisions"
crowdsecLapiStreamRoute = "v1/decisions/stream"
crowdsecLapiMetricsRoute = "v1/usage-metrics"
crowdsecCapiHost = "api.crowdsec.net"
crowdsecCapiHeader = "Authorization"
crowdsecCapiLoginRoute = "v2/watchers/login"
@@ -40,12 +43,32 @@ const (
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
var (
isStartup = true
isCrowdsecStreamHealthy = true
updateFailure = 0
ticker chan bool
updateFailure int64
streamTicker chan bool
metricsTicker chan bool
lastMetricsPush time.Time
blockedRequests int64
)
// CreateConfig creates the default plugin configuration.
@@ -62,22 +85,27 @@ type Bouncer struct {
enabled bool
appsecEnabled bool
appsecHost string
appsecPath string
appsecFailureBlock bool
appsecUnreachableBlock bool
appsecBodyLimit int64
crowdsecScheme string
crowdsecHost string
crowdsecPath string
crowdsecKey string
crowdsecMode string
crowdsecMachineID string
crowdsecPassword string
crowdsecScenarios []string
updateInterval int64
updateMaxFailure int
updateMaxFailure int64
defaultDecisionTimeout int64
remediationStatusCode int
remediationCustomHeader string
forwardedCustomHeader string
crowdsecStreamRoute string
crowdsecHeader string
redisUnreachableBlock bool
banTemplateString string
clientPoolStrategy *ip.PoolStrategy
serverPoolStrategy *ip.PoolStrategy
@@ -88,8 +116,11 @@ type Bouncer struct {
}
// New creates the crowdsec bouncer plugin.
//
//nolint:gocyclo
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)
if err != nil {
log.Error("New:validateParams " + err.Error())
@@ -105,8 +136,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
if config.CrowdsecMode == configuration.AloneMode {
config.CrowdsecCapiMachineID, _ = configuration.GetVariable(config, "CrowdsecCapiMachineID")
config.CrowdsecCapiPassword, _ = configuration.GetVariable(config, "CrowdsecCapiPassword")
config.CrowdsecLapiScheme = configuration.HTTPS
config.CrowdsecLapiHost = crowdsecCapiHost
config.CrowdsecLapiScheme = "https"
config.CrowdsecLapiPath = "/"
config.CrowdsecAppsecEnabled = false
config.UpdateIntervalSeconds = 7200 // 2 hours
crowdsecStreamRoute = crowdsecCapiStreamRoute
crowdsecHeader = crowdsecCapiHeader
@@ -121,7 +154,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
apiKey, errAPIKey := configuration.GetVariable(config, "CrowdsecLapiKey")
if errAPIKey != nil && len(tlsConfig.Certificates) == 0 {
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errAPIKey.Error())
return nil, err
return nil, errAPIKey
}
config.CrowdsecLapiKey = apiKey
}
@@ -147,10 +180,13 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
crowdsecMode: config.CrowdsecMode,
appsecEnabled: config.CrowdsecAppsecEnabled,
appsecHost: config.CrowdsecAppsecHost,
appsecPath: config.CrowdsecAppsecPath,
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
crowdsecScheme: config.CrowdsecLapiScheme,
crowdsecHost: config.CrowdsecLapiHost,
crowdsecPath: config.CrowdsecLapiPath,
crowdsecKey: config.CrowdsecLapiKey,
crowdsecMachineID: config.CrowdsecCapiMachineID,
crowdsecPassword: config.CrowdsecCapiPassword,
@@ -160,6 +196,8 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
remediationCustomHeader: config.RemediationHeadersCustomName,
forwardedCustomHeader: config.ForwardedHeadersCustomName,
defaultDecisionTimeout: config.DefaultDecisionSeconds,
remediationStatusCode: config.RemediationStatusCode,
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
banTemplateString: banTemplateString,
crowdsecStreamRoute: crowdsecStreamRoute,
crowdsecHeader: crowdsecHeader,
@@ -212,7 +250,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
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 err := getToken(bouncer); err != nil {
bouncer.log.Error("New:getToken " + err.Error())
@@ -221,10 +259,20 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
}
handleStreamTicker(bouncer)
isStartup = false
ticker = startTicker(config, log, func() {
streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() {
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)
return bouncer, nil
@@ -232,7 +280,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
// ServeHTTP principal function of plugin.
//
//nolint:nestif
//nolint:nestif,gocyclo
func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if !bouncer.enabled {
bouncer.next.ServeHTTP(rw, req)
@@ -270,6 +318,11 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if cacheErr != nil {
cacheErrString := cacheErr.Error()
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 {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
handleBanServeHTTP(bouncer, rw)
@@ -335,16 +388,21 @@ type Login struct {
// To append Headers we need to call rw.WriteHeader after set any header.
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")
atomic.AddInt64(&blockedRequests, 1)
if bouncer.remediationCustomHeader != "" {
rw.Header().Set(bouncer.remediationCustomHeader, "ban")
}
rw.WriteHeader(http.StatusForbidden)
fmt.Fprint(rw, bouncer.banTemplateString)
if 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) {
@@ -354,6 +412,7 @@ func handleRemediationServeHTTP(bouncer *Bouncer, remoteIP, remediation string,
handleNextServeHTTP(bouncer, remoteIP, rw, req)
return
}
atomic.AddInt64(&blockedRequests, 1) // If we serve a captcha that should count as a dropped request.
bouncer.captchaClient.ServeHTTP(rw, req, remoteIP)
return
}
@@ -385,11 +444,17 @@ func handleStreamTicker(bouncer *Bouncer) {
}
}
func startTicker(config *configuration.Config, log *logger.Log, work func()) chan bool {
ticker := time.NewTicker(time.Duration(config.UpdateIntervalSeconds) * time.Second)
func handleMetricsTicker(bouncer *Bouncer) {
if err := reportMetrics(bouncer); err != nil {
bouncer.log.Error("handleMetricsTicker:reportMetrics " + err.Error())
}
}
func startTicker(name string, updateInterval int64, log *logger.Log, work func()) chan bool {
ticker := time.NewTicker(time.Duration(updateInterval) * time.Second)
stop := make(chan bool, 1)
go func() {
defer log.Debug("ticker:stopped")
defer log.Debug(name + "_ticker:stopped")
for {
select {
case <-ticker.C:
@@ -408,10 +473,10 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
routeURL := url.URL{
Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost,
Path: crowdsecLapiRoute,
RawQuery: fmt.Sprintf("ip=%v&banned=true", remoteIP),
Path: bouncer.crowdsecPath + crowdsecLapiRoute,
RawQuery: fmt.Sprintf("ip=%v", remoteIP),
}
body, err := crowdsecQuery(bouncer, routeURL.String(), false)
body, err := crowdsecQuery(bouncer, routeURL.String(), nil)
if err != nil {
return cache.BannedValue, err
}
@@ -470,7 +535,16 @@ func getToken(bouncer *Bouncer) error {
Host: bouncer.crowdsecHost,
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 {
return err
}
@@ -504,10 +578,10 @@ func handleStreamCache(bouncer *Bouncer) error {
streamRouteURL := url.URL{
Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost,
Path: bouncer.crowdsecStreamRoute,
Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute,
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup),
}
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), false)
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil)
if err != nil {
return err
}
@@ -538,15 +612,9 @@ func handleStreamCache(bouncer *Bouncer) error {
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
if isPost {
data := []byte(fmt.Sprintf(
`{"machine_id": "%v","password": "%v","scenarios": ["%v"]}`,
bouncer.crowdsecMachineID,
bouncer.crowdsecPassword,
strings.Join(bouncer.crowdsecScenarios, `","`),
))
if len(data) > 0 {
req, _ = http.NewRequest(http.MethodPost, stringURL, bytes.NewBuffer(data))
} else {
req, _ = http.NewRequest(http.MethodGet, stringURL, nil)
@@ -567,13 +635,16 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, err
if errToken := getToken(bouncer); errToken != nil {
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 {
return nil, fmt.Errorf("crowdsecQuery:readBody %w", err)
}
@@ -584,15 +655,19 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
routeURL := url.URL{
Scheme: bouncer.crowdsecScheme,
Host: bouncer.appsecHost,
Path: "/",
Path: bouncer.appsecPath,
}
var req *http.Request
if httpReq.Body != nil && httpReq.ContentLength > 0 {
bodyBytes, err := io.ReadAll(httpReq.Body)
if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil && httpReq.ContentLength > 0 {
var bodyBuffer bytes.Buffer
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
bodyBytes, err := io.ReadAll(teeReader)
if err != nil {
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))
} else {
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
@@ -639,3 +714,65 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
}
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": "1.X.X",
"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
}
+2 -2
View File
@@ -163,7 +163,7 @@ func Test_crowdsecQuery(t *testing.T) {
type args struct {
bouncer *Bouncer
stringURL string
isPost bool
data []byte
}
tests := []struct {
name string
@@ -175,7 +175,7 @@ func Test_crowdsecQuery(t *testing.T) {
}
for _, tt := range tests {
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 {
t.Errorf("crowdsecQuery() error = %v, wantErr %v", err, tt.wantErr)
return
+1 -1
View File
@@ -293,7 +293,7 @@
</svg>
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
</div>
<form action="" method="POST" class="flex flex-col space-y-1" id="captcha-form">
<form action="" method="POST" class="flex flex-col items-center space-y-1" id="captcha-form">
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
</div>
</form>
+28 -3
View File
@@ -36,7 +36,7 @@ services:
- "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
- "traefik.http.services.service-foo.loadbalancer.server.port=80"
whoami2:
bar:
image: traefik/whoami
container_name: "simple-service-bar"
restart: unless-stopped
@@ -48,12 +48,38 @@ services:
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.metricsupdateintervalseconds=15"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=stream"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
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:
image: crowdsecurity/crowdsec:v1.6.1-2
image: crowdsecurity/crowdsec:v1.6.8
container_name: "crowdsec"
restart: unless-stopped
environment:
@@ -67,7 +93,6 @@ services:
- crowdsec-config-local:/etc/crowdsec/
labels:
- "traefik.enable=false"
volumes:
logs-local:
crowdsec-db-local:
+1 -1
View File
@@ -59,7 +59,7 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
crowdsec:
image: crowdsecurity/crowdsec:v1.6.1-2
image: crowdsecurity/crowdsec:v1.6.8
container_name: "crowdsec"
restart: unless-stopped
environment:
+1
View File
@@ -6,6 +6,7 @@ logs:
level: DEBUG
access:
enabled: true
format: json
fields:
headers:
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
```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
+1 -2
View File
@@ -35,8 +35,7 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=alone"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecMode=alone"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiMachineId=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"
+7 -1
View File
@@ -23,6 +23,8 @@ const (
CaptchaDoneValue = "d"
// CacheMiss error string when cache is miss.
CacheMiss = "cache:miss"
// CacheUnreachable error string when cache is unreachable.
CacheUnreachable = "cache:unreachable"
)
//nolint:gochecknoglobals
@@ -60,9 +62,13 @@ func (redisCache) get(key string) (string, error) {
if err == nil && len(valueString) > 0 {
return valueString, nil
}
if err.Error() == simpleredis.RedisMiss {
errRedisMessage := err.Error()
if errRedisMessage == simpleredis.RedisMiss {
return "", errors.New(CacheMiss)
}
if errRedisMessage == simpleredis.RedisUnreachable {
return "", errors.New(CacheUnreachable)
}
return "", err
}
+3 -3
View File
@@ -11,7 +11,7 @@ import (
func Test_Get(t *testing.T) {
IPInCache := "10.0.0.10"
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)
type args struct {
clientIP string
@@ -47,7 +47,7 @@ func Test_Get(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"
type args struct {
clientIP string
@@ -88,7 +88,7 @@ func Test_Set(t *testing.T) {
func Test_Delete(t *testing.T) {
IPInCache := "10.0.0.12"
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)
type args struct {
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())
}
}()
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")
return false, nil
}
+54 -11
View File
@@ -28,6 +28,9 @@ const (
AppsecMode = "appsec"
HTTPS = "https"
HTTP = "http"
LogDEBUG = "DEBUG"
LogINFO = "INFO"
LogERROR = "ERROR"
HcaptchaProvider = "hcaptcha"
RecaptchaProvider = "recaptcha"
TurnstileProvider = "turnstile"
@@ -37,13 +40,17 @@ const (
type Config struct {
Enabled bool `json:"enabled,omitempty"`
LogLevel string `json:"logLevel,omitempty"`
LogFilePath string `json:"logFilePath,omitempty"`
CrowdsecMode string `json:"crowdsecMode,omitempty"`
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"`
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
CrowdsecLapiPath string `json:"crowdsecLapiPath,omitempty"`
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"`
CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"`
@@ -59,8 +66,10 @@ type Config struct {
CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"`
CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,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"`
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"`
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
@@ -71,6 +80,7 @@ type Config struct {
RedisCachePassword string `json:"redisCachePassword,omitempty"`
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
CaptchaProvider string `json:"captchaProvider,omitempty"`
@@ -94,19 +104,25 @@ func contains(source []string, target string) bool {
func New() *Config {
return &Config{
Enabled: false,
LogLevel: "INFO",
LogLevel: LogINFO,
LogFilePath: "",
CrowdsecMode: LiveMode,
CrowdsecAppsecEnabled: false,
CrowdsecAppsecHost: "crowdsec:7422",
CrowdsecAppsecPath: "/",
CrowdsecAppsecFailureBlock: true,
CrowdsecAppsecUnreachableBlock: true,
CrowdsecAppsecBodyLimit: 10485760,
CrowdsecLapiScheme: HTTP,
CrowdsecLapiHost: "crowdsec:8080",
CrowdsecLapiPath: "/",
CrowdsecLapiKey: "",
CrowdsecLapiTLSInsecureVerify: false,
UpdateIntervalSeconds: 60,
MetricsUpdateIntervalSeconds: 600,
UpdateMaxFailure: 0,
DefaultDecisionSeconds: 60,
RemediationStatusCode: http.StatusForbidden,
HTTPTimeoutSeconds: 10,
CaptchaProvider: "",
CaptchaSiteKey: "",
@@ -122,6 +138,7 @@ func New() *Config {
RedisCacheHost: "redis:6379",
RedisCachePassword: "",
RedisCacheDatabase: "",
RedisCacheUnreachableBlock: true,
}
}
@@ -217,11 +234,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
}
if err := validateURL("CrowdsecAppsec", config.CrowdsecLapiScheme, config.CrowdsecAppsecHost); err != nil {
if err := validateURL("CrowdsecAppsec", config.CrowdsecLapiScheme, config.CrowdsecAppsecHost, config.CrowdsecAppsecPath); err != nil {
return err
}
@@ -254,14 +271,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
}
func validateURL(variable, scheme, host string) error {
// This only check that the format of the URL scheme://host is correct and do not make requests
testURL := url.URL{Scheme: scheme, Host: host}
func validateURL(variable, scheme, host, path string) error {
// 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, Path: path}
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
}
@@ -296,7 +324,7 @@ func validateParamsTLS(config *Config) error {
func validateParamsIPs(listIP []string, key string) error {
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)
}
}
@@ -314,13 +342,22 @@ func validateParamsRequired(config *Config) error {
return fmt.Errorf("%v: cannot be empty", key)
}
}
requiredInt := map[string]int64{
requiredInt0 := map[string]int64{
"CrowdsecAppsecBodyLimit": config.CrowdsecAppsecBodyLimit,
"MetricsUpdateIntervalSeconds": config.MetricsUpdateIntervalSeconds,
}
for key, val := range requiredInt0 {
if val < 0 {
return fmt.Errorf("%v: cannot be less than 0", key)
}
}
requiredInt1 := map[string]int64{
"UpdateIntervalSeconds": config.UpdateIntervalSeconds,
"DefaultDecisionSeconds": config.DefaultDecisionSeconds,
"HTTPTimeoutSeconds": config.HTTPTimeoutSeconds,
"CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds,
}
for key, val := range requiredInt {
for key, val := range requiredInt1 {
if val < 1 {
return fmt.Errorf("%v: cannot be less than 1", key)
}
@@ -328,6 +365,12 @@ func validateParamsRequired(config *Config) error {
if config.UpdateMaxFailure < -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) {
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.CrowdsecLapiScheme = HTTPS
cfg6.CrowdsecLapiTLSInsecureVerify = true
cfg7 := getMinimalConfig()
cfg7.CrowdsecLapiScheme = HTTPS
cfg8 := getMinimalConfig()
cfg8.CrowdsecLapiScheme = HTTPS
cfg8.LogLevel = LogINFO
cfg9 := getMinimalConfig()
cfg9.LogLevel = "info"
cfg10 := getMinimalConfig()
cfg10.LogLevel = "Warning"
type args struct {
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},
// HTTPS enabled
{name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false},
{name: "Not validate https without cert authority", args: args{config: 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 {
t.Run(tt.name, func(t *testing.T) {
@@ -233,7 +242,7 @@ func Test_GetTLSConfigCrowdsec(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO"))
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO", ""))
if (err != nil) != tt.wantErr {
t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
return
+40 -9
View File
@@ -1,11 +1,13 @@
// Package logger implements utility routines to write to stdout and stderr.
// It supports debug, info and error level
// It supports trace, debug, info and error level
package logger
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
)
// Log Logger struct.
@@ -16,15 +18,44 @@ type Log struct {
}
// New Set Default log level to info in case log level to defined.
func New(logLevel string) *Log {
func New(logLevel string, logFilePath string) *Log {
// Initialize loggers with discard output
logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
logError.SetOutput(os.Stderr)
logInfo.SetOutput(os.Stdout)
if logLevel == "DEBUG" {
logDebug.SetOutput(os.Stdout)
// we initialize logger to STDOUT/STDERR first so if the file logger cannot be initialized we can inform the user
output := os.Stdout
errorOutput := os.Stderr
// prepare file logging if specified
if logFilePath != "" {
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err == nil {
output = logFile
errorOutput = logFile
} else {
_ = fmt.Errorf("LogFilePath is not writable %w", err)
}
}
// Set error logger output
logError.SetOutput(errorOutput)
// Configure log levels
switch logLevel {
case "ERROR":
// Only error logging is enabled
case "INFO":
logInfo.SetOutput(output)
case "DEBUG":
logInfo.SetOutput(output)
logDebug.SetOutput(output)
default:
// Default to INFO level
logInfo.SetOutput(output)
}
return &Log{
logError: logError,
logInfo: logInfo,
@@ -34,15 +65,15 @@ func New(logLevel string) *Log {
// Info log to Stdout.
func (l *Log) Info(str string) {
l.logInfo.Printf(str)
l.logInfo.Printf("%s", str)
}
// Debug log to Stdout.
func (l *Log) Debug(str string) {
l.logDebug.Printf(str)
l.logDebug.Printf("%s", str)
}
// Error log to Stderr.
func (l *Log) Error(str string) {
l.logError.Printf(str)
l.logError.Printf("%s", str)
}