mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
Compare commits
11
Commits
v1.4.6
...
v1.6.0-alpha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f776fe0fe | ||
|
|
e54c1d5c4f | ||
|
|
c2bbc4dac5 | ||
|
|
889c5b55fe | ||
|
|
efb3a67019 | ||
|
|
0780027252 | ||
|
|
892909b9b8 | ||
|
|
c26923dee5 | ||
|
|
a9d83f2097 | ||
|
|
e20ccc5d0c | ||
|
|
50beb4294f |
@@ -1,5 +1,8 @@
|
|||||||
name: Main
|
name: Main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -30,14 +33,14 @@ jobs:
|
|||||||
|
|
||||||
# https://github.com/marketplace/actions/checkout
|
# https://github.com/marketplace/actions/checkout
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
path: go/src/github.com/${{ github.repository }}
|
path: go/src/github.com/${{ github.repository }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# https://github.com/marketplace/actions/cache
|
# https://github.com/marketplace/actions/cache
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ github.workspace }}/go/pkg/mod
|
path: ${{ github.workspace }}/go/pkg/mod
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: Release Version Update
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-version:
|
||||||
|
name: Update version in source
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
TAG="${{ github.event.release.tag_name }}"
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Update version in version.go
|
||||||
|
run: |
|
||||||
|
sed -i 's/pluginVersion = "[^"]*"/pluginVersion = "'"${{ steps.get_version.outputs.version }}"'"/' version.go
|
||||||
|
cat version.go
|
||||||
|
|
||||||
|
- name: Commit, push, and retag
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add version.go
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "Version already up to date, nothing to commit"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "⬆️ chore: bump version to ${{ steps.get_version.outputs.version }}"
|
||||||
|
git push origin main
|
||||||
|
# Move the release tag to include the version update
|
||||||
|
git tag -f "${{ steps.get_version.outputs.tag }}"
|
||||||
|
git push -f origin "${{ steps.get_version.outputs.tag }}"
|
||||||
@@ -73,6 +73,7 @@ linters:
|
|||||||
- gci
|
- gci
|
||||||
- mnd
|
- mnd
|
||||||
- exportloopref
|
- exportloopref
|
||||||
|
- contextcheck
|
||||||
issues:
|
issues:
|
||||||
exclude-use-default: false
|
exclude-use-default: false
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ clean_all_docker:
|
|||||||
docker compose -f examples/redis-cache/docker-compose.yml down --remove-orphans
|
docker compose -f examples/redis-cache/docker-compose.yml down --remove-orphans
|
||||||
docker compose -f examples/trusted-ips/docker-compose.yml down --remove-orphans
|
docker compose -f examples/trusted-ips/docker-compose.yml down --remove-orphans
|
||||||
docker compose -f examples/tls-auth/docker-compose.yml down --remove-orphans
|
docker compose -f examples/tls-auth/docker-compose.yml down --remove-orphans
|
||||||
docker compose -f examples/appsec-enabled/docker-compose.appsec-enabled.yml down --remove-orphans
|
docker compose -f examples/appsec-enabled/docker-compose.yml down --remove-orphans
|
||||||
docker compose -f examples/captcha/docker-compose.yml down --remove-orphans
|
docker compose -f examples/captcha/docker-compose.yml down --remove-orphans
|
||||||
docker compose -f examples/custom-captcha/docker-compose.yml down --remove-orphans
|
docker compose -f examples/custom-captcha/docker-compose.yml down --remove-orphans
|
||||||
docker compose -f examples/custom-ban-page/docker-compose.yml down --remove-orphans
|
docker compose -f examples/custom-ban-page/docker-compose.yml down --remove-orphans
|
||||||
|
|||||||
@@ -310,17 +310,16 @@ make run
|
|||||||
### Note
|
### Note
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> Some of the behaviours and configuration parameters are shared globally across *all* crowdsec middlewares even if you declare different middlewares with different settings.
|
> 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
|
> **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.
|
> 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.)
|
> 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]
|
> [!WARNING]
|
||||||
> **Appsec maximum body limit is defaulted to 10MB**
|
> **Appsec maximum body limit is defaulted to 10MB** > _Be careful when you upgrade to >1.4.x_
|
||||||
> *Be careful when you upgrade to >1.4.x*
|
|
||||||
|
|
||||||
### Variables
|
### Variables
|
||||||
|
|
||||||
@@ -330,8 +329,12 @@ make run
|
|||||||
- Enable the plugin
|
- Enable the plugin
|
||||||
- LogLevel
|
- LogLevel
|
||||||
- string
|
- string
|
||||||
- default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR`
|
- default: `INFO`, expected values are: `DEBUG`, `INFO`, `WARN`, `ERROR`
|
||||||
- Log are written to `stdout` / `stderr` or file if LogFilePath is provided
|
- Log are written to `stdout` / `stderr` or file if LogFilePath is provided
|
||||||
|
- LogFormat
|
||||||
|
- string
|
||||||
|
- default: `common`, expected values are: `common`, `json`
|
||||||
|
- Log format: `common` for traditional text logs, `json` for structured JSON logs
|
||||||
- LogFilePath
|
- LogFilePath
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
@@ -351,7 +354,18 @@ make run
|
|||||||
- CrowdsecAppsecHost
|
- CrowdsecAppsecHost
|
||||||
- string
|
- string
|
||||||
- default: "crowdsec:7422"
|
- default: "crowdsec:7422"
|
||||||
- Crowdsec Appsec Server available on which host and port. The scheme will be handled by the CrowdsecLapiScheme var.
|
- Crowdsec Appsec Server available on which host and port.
|
||||||
|
- CrowdsecAppsecTlsInsecureVerify
|
||||||
|
- bool
|
||||||
|
- default: false
|
||||||
|
- Disable verification of certificate presented by Appsec
|
||||||
|
- CrowdsecAppsecTlsCertificateAuthority
|
||||||
|
- string
|
||||||
|
- default: ""
|
||||||
|
- PEM-encoded Certificate Authority of Appsec
|
||||||
|
- CrowdsecAppsecScheme
|
||||||
|
- string
|
||||||
|
- default: value of `CrowdsecLapiScheme`, expected values are: `http`, `https`
|
||||||
- CrowdsecAppsecPath
|
- CrowdsecAppsecPath
|
||||||
- string
|
- string
|
||||||
- default: "/"
|
- default: "/"
|
||||||
@@ -368,6 +382,10 @@ make run
|
|||||||
- int64
|
- int64
|
||||||
- default: 10485760 (= 10MB)
|
- default: 10485760 (= 10MB)
|
||||||
- Transmit only the first number of bytes to Crowdsec Appsec Server.
|
- Transmit only the first number of bytes to Crowdsec Appsec Server.
|
||||||
|
- CrowdsecAppsecKey
|
||||||
|
- string
|
||||||
|
- default: value of `CrowdsecLapiKey`
|
||||||
|
- Crowdsec AppSec key for the bouncer.
|
||||||
- CrowdsecLapiScheme
|
- CrowdsecLapiScheme
|
||||||
- string
|
- string
|
||||||
- default: `http`, expected values are: `http`, `https`
|
- default: `http`, expected values are: `http`, `https`
|
||||||
@@ -406,7 +424,7 @@ make run
|
|||||||
- RemediationHeadersCustomName
|
- RemediationHeadersCustomName
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- Name of the header you want in response when request are cancelled (possible value of the header `ban` or `captcha`)
|
- Name of the header you want in response when request are handled by plugin (possible value of the header `ban`, `captcha` or `solved-captcha`)
|
||||||
- ForwardedHeadersCustomName
|
- ForwardedHeadersCustomName
|
||||||
- string
|
- string
|
||||||
- default: "X-Forwarded-For"
|
- default: "X-Forwarded-For"
|
||||||
@@ -497,6 +515,10 @@ make run
|
|||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- Path where the ban html file is stored (default empty ""=disabled)
|
- Path where the ban html file is stored (default empty ""=disabled)
|
||||||
|
- TraceHeadersCustomName
|
||||||
|
- string
|
||||||
|
- default: ""
|
||||||
|
- Request Header name whose value to inject in ban HTML response (default empty ""=disabled)
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
@@ -518,7 +540,37 @@ experimental:
|
|||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Dynamic configuration
|
# Simplified dynamic configuration
|
||||||
|
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
my-router:
|
||||||
|
rule: host(`whoami.localhost`)
|
||||||
|
service: service-foo
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
middlewares:
|
||||||
|
- crowdsec
|
||||||
|
|
||||||
|
services:
|
||||||
|
service-foo:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: http://127.0.0.1:5000
|
||||||
|
|
||||||
|
middlewares:
|
||||||
|
crowdsec:
|
||||||
|
plugin:
|
||||||
|
bouncer:
|
||||||
|
enabled: true
|
||||||
|
logLevel: DEBUG
|
||||||
|
crowdsecMode: live
|
||||||
|
crowdsecLapiKey: privateKey-foo
|
||||||
|
crowdsecLapiHost: crowdsec:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Full dynamic configuration
|
||||||
|
|
||||||
http:
|
http:
|
||||||
routers:
|
routers:
|
||||||
@@ -542,6 +594,7 @@ http:
|
|||||||
bouncer:
|
bouncer:
|
||||||
enabled: false
|
enabled: false
|
||||||
logLevel: DEBUG
|
logLevel: DEBUG
|
||||||
|
logFormat: common
|
||||||
LogFilePath: ""
|
LogFilePath: ""
|
||||||
updateIntervalSeconds: 60
|
updateIntervalSeconds: 60
|
||||||
updateMaxFailure: 0
|
updateMaxFailure: 0
|
||||||
@@ -550,6 +603,7 @@ http:
|
|||||||
httpTimeoutSeconds: 10
|
httpTimeoutSeconds: 10
|
||||||
crowdsecMode: live
|
crowdsecMode: live
|
||||||
crowdsecAppsecEnabled: false
|
crowdsecAppsecEnabled: false
|
||||||
|
crowdsecAppsecScheme: ""
|
||||||
crowdsecAppsecHost: crowdsec:7422
|
crowdsecAppsecHost: crowdsec:7422
|
||||||
crowdsecAppsecPath: "/"
|
crowdsecAppsecPath: "/"
|
||||||
crowdsecAppsecFailureBlock: true
|
crowdsecAppsecFailureBlock: true
|
||||||
@@ -603,12 +657,13 @@ http:
|
|||||||
captchaGracePeriodSeconds: 1800
|
captchaGracePeriodSeconds: 1800
|
||||||
captchaHTMLFilePath: /captcha.html
|
captchaHTMLFilePath: /captcha.html
|
||||||
banHTMLFilePath: /ban.html
|
banHTMLFilePath: /ban.html
|
||||||
|
traceHeadersCustomName: X-Request-ID
|
||||||
metricsUpdateIntervalSeconds: 600
|
metricsUpdateIntervalSeconds: 600
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Fill variable with value of file
|
#### Fill variable with value of file
|
||||||
|
|
||||||
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey`, `CaptchaSecretKey` and `RedisCachePassword` can be provided with the content as raw or through a file path that Traefik can read.
|
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecAppsecTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CrowdsecAppsecKey`, `CaptchaSiteKey`, `CaptchaSecretKey` and `RedisCachePassword` can be provided with the content as raw or through a file path that Traefik can read.
|
||||||
The file variable will be used as preference if both content and file are provided for the same variable.
|
The file variable will be used as preference if both content and file are provided for the same variable.
|
||||||
|
|
||||||
Format is:
|
Format is:
|
||||||
@@ -671,6 +726,13 @@ Set the `crowdsecLapiScheme` to https.
|
|||||||
Crowdsec must be listening in HTTPS for this to work.
|
Crowdsec must be listening in HTTPS for this to work.
|
||||||
Please see the [tls-auth example](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/tls-auth/README.md) or the official documentation: [docs.crowdsec.net/docs/local_api/tls_auth/](https://docs.crowdsec.net/docs/local_api/tls_auth/)
|
Please see the [tls-auth example](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/tls-auth/README.md) or the official documentation: [docs.crowdsec.net/docs/local_api/tls_auth/](https://docs.crowdsec.net/docs/local_api/tls_auth/)
|
||||||
|
|
||||||
|
#### Use HTTPS to communicate with the Appsec
|
||||||
|
|
||||||
|
To communicate with the Appsec in HTTPS you need to either accept any certificates by setting the `crowdsecAppsecTLSInsecureVerify` to true or add the CA used by the server certificate of Crowdsec using `crowdsecAppsecTLSCertificateAuthority` or `crowdsecAppsecTLSCertificateAuthorityFile`.
|
||||||
|
Set the `crowdsecAppsecScheme` to https.
|
||||||
|
|
||||||
|
Currently AppSec does not support mTLS authentication for the AppSec Component.
|
||||||
|
|
||||||
#### Manually add an IP to the blocklist (for testing purposes)
|
#### Manually add an IP to the blocklist (for testing purposes)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+75
-23
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
htmltemplate "html/template"
|
htmltemplate "html/template"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -85,8 +86,10 @@ type Bouncer struct {
|
|||||||
|
|
||||||
enabled bool
|
enabled bool
|
||||||
appsecEnabled bool
|
appsecEnabled bool
|
||||||
|
appsecScheme string
|
||||||
appsecHost string
|
appsecHost string
|
||||||
appsecPath string
|
appsecPath string
|
||||||
|
appsecKey string
|
||||||
appsecFailureBlock bool
|
appsecFailureBlock bool
|
||||||
appsecUnreachableBlock bool
|
appsecUnreachableBlock bool
|
||||||
appsecBodyLimit int64
|
appsecBodyLimit int64
|
||||||
@@ -108,12 +111,14 @@ type Bouncer struct {
|
|||||||
crowdsecHeader string
|
crowdsecHeader string
|
||||||
redisUnreachableBlock bool
|
redisUnreachableBlock bool
|
||||||
banTemplate *htmltemplate.Template
|
banTemplate *htmltemplate.Template
|
||||||
|
traceCustomHeader string
|
||||||
clientPoolStrategy *ip.PoolStrategy
|
clientPoolStrategy *ip.PoolStrategy
|
||||||
serverPoolStrategy *ip.PoolStrategy
|
serverPoolStrategy *ip.PoolStrategy
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
httpAppsecClient *http.Client
|
||||||
cacheClient *cache.Client
|
cacheClient *cache.Client
|
||||||
captchaClient *captcha.Client
|
captchaClient *captcha.Client
|
||||||
log *logger.Log
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates the crowdsec bouncer plugin.
|
// New creates the crowdsec bouncer plugin.
|
||||||
@@ -121,8 +126,8 @@ type Bouncer struct {
|
|||||||
//nolint:gocyclo
|
//nolint:gocyclo
|
||||||
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
||||||
config.LogLevel = strings.ToUpper(config.LogLevel)
|
config.LogLevel = strings.ToUpper(config.LogLevel)
|
||||||
log := logger.New(config.LogLevel, config.LogFilePath)
|
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
|
||||||
err := configuration.ValidateParams(config)
|
err := configuration.ValidateParams(config, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("New:validateParams " + err.Error())
|
log.Error("New:validateParams " + err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -131,6 +136,23 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
serverChecker, _ := ip.NewChecker(log, config.ForwardedHeadersTrustedIPs)
|
serverChecker, _ := ip.NewChecker(log, config.ForwardedHeadersTrustedIPs)
|
||||||
clientChecker, _ := ip.NewChecker(log, config.ClientTrustedIPs)
|
clientChecker, _ := ip.NewChecker(log, config.ClientTrustedIPs)
|
||||||
|
|
||||||
|
var tlsAppsecConfig *tls.Config
|
||||||
|
if config.CrowdsecAppsecEnabled {
|
||||||
|
tlsAppsecConfig, err = configuration.GetTLSConfigCrowdsec(config, log, true)
|
||||||
|
if config.CrowdsecAppsecScheme == "" {
|
||||||
|
config.CrowdsecAppsecScheme = config.CrowdsecLapiScheme
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error("New:getTLSConfigCrowdsec fail to get tlsAppsecConfig " + err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
apiAppsecKey, errAppsecKey := configuration.GetVariable(config, "CrowdsecAppsecKey")
|
||||||
|
if errAppsecKey != nil && len(tlsAppsecConfig.Certificates) == 0 {
|
||||||
|
log.Info("New:crowdsecLapiKey fail to get CrowdsecAppsecKey and no client certificate setup " + errAppsecKey.Error())
|
||||||
|
}
|
||||||
|
config.CrowdsecAppsecKey = apiAppsecKey
|
||||||
|
}
|
||||||
|
|
||||||
var tlsConfig *tls.Config
|
var tlsConfig *tls.Config
|
||||||
crowdsecStreamRoute := ""
|
crowdsecStreamRoute := ""
|
||||||
crowdsecHeader := ""
|
crowdsecHeader := ""
|
||||||
@@ -140,24 +162,26 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
config.CrowdsecLapiScheme = configuration.HTTPS
|
config.CrowdsecLapiScheme = configuration.HTTPS
|
||||||
config.CrowdsecLapiHost = crowdsecCapiHost
|
config.CrowdsecLapiHost = crowdsecCapiHost
|
||||||
config.CrowdsecLapiPath = "/"
|
config.CrowdsecLapiPath = "/"
|
||||||
config.CrowdsecAppsecEnabled = false
|
|
||||||
config.UpdateIntervalSeconds = 7200 // 2 hours
|
config.UpdateIntervalSeconds = 7200 // 2 hours
|
||||||
crowdsecStreamRoute = crowdsecCapiStreamRoute
|
crowdsecStreamRoute = crowdsecCapiStreamRoute
|
||||||
crowdsecHeader = crowdsecCapiHeader
|
crowdsecHeader = crowdsecCapiHeader
|
||||||
} else {
|
} else {
|
||||||
crowdsecStreamRoute = crowdsecLapiStreamRoute
|
crowdsecStreamRoute = crowdsecLapiStreamRoute
|
||||||
crowdsecHeader = crowdsecLapiHeader
|
crowdsecHeader = crowdsecLapiHeader
|
||||||
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log)
|
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("New:getTLSConfigCrowdsec fail to get tlsConfig " + err.Error())
|
log.Error("New:getTLSConfigCrowdsec fail to get tlsConfig " + err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
apiKey, errAPIKey := configuration.GetVariable(config, "CrowdsecLapiKey")
|
apiKey, errKey := configuration.GetVariable(config, "CrowdsecLapiKey")
|
||||||
if errAPIKey != nil && len(tlsConfig.Certificates) == 0 {
|
if errKey != nil && len(tlsConfig.Certificates) == 0 {
|
||||||
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errAPIKey.Error())
|
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errKey.Error())
|
||||||
return nil, errAPIKey
|
return nil, errKey
|
||||||
}
|
}
|
||||||
config.CrowdsecLapiKey = apiKey
|
config.CrowdsecLapiKey = apiKey
|
||||||
|
if config.CrowdsecAppsecKey == "" {
|
||||||
|
config.CrowdsecAppsecKey = apiKey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var banTemplate *htmltemplate.Template
|
var banTemplate *htmltemplate.Template
|
||||||
@@ -173,8 +197,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
enabled: config.Enabled,
|
enabled: config.Enabled,
|
||||||
crowdsecMode: config.CrowdsecMode,
|
crowdsecMode: config.CrowdsecMode,
|
||||||
appsecEnabled: config.CrowdsecAppsecEnabled,
|
appsecEnabled: config.CrowdsecAppsecEnabled,
|
||||||
|
appsecScheme: config.CrowdsecAppsecScheme,
|
||||||
appsecHost: config.CrowdsecAppsecHost,
|
appsecHost: config.CrowdsecAppsecHost,
|
||||||
appsecPath: config.CrowdsecAppsecPath,
|
appsecPath: config.CrowdsecAppsecPath,
|
||||||
|
appsecKey: config.CrowdsecAppsecKey,
|
||||||
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
||||||
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
||||||
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
|
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
|
||||||
@@ -193,6 +219,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
remediationStatusCode: config.RemediationStatusCode,
|
remediationStatusCode: config.RemediationStatusCode,
|
||||||
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
||||||
banTemplate: banTemplate,
|
banTemplate: banTemplate,
|
||||||
|
traceCustomHeader: config.TraceHeadersCustomName,
|
||||||
crowdsecStreamRoute: crowdsecStreamRoute,
|
crowdsecStreamRoute: crowdsecStreamRoute,
|
||||||
crowdsecHeader: crowdsecHeader,
|
crowdsecHeader: crowdsecHeader,
|
||||||
log: log,
|
log: log,
|
||||||
@@ -210,6 +237,14 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
},
|
},
|
||||||
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||||
},
|
},
|
||||||
|
httpAppsecClient: &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
MaxIdleConns: 10,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
TLSClientConfig: tlsAppsecConfig,
|
||||||
|
},
|
||||||
|
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||||
|
},
|
||||||
cacheClient: &cache.Client{},
|
cacheClient: &cache.Client{},
|
||||||
captchaClient: &captcha.Client{},
|
captchaClient: &captcha.Client{},
|
||||||
}
|
}
|
||||||
@@ -348,6 +383,9 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
value, err := handleNoStreamCache(bouncer, remoteIP)
|
value, err := handleNoStreamCache(bouncer, remoteIP)
|
||||||
|
if err != nil {
|
||||||
|
bouncer.log.Debug("handleNoStreamCache:crowdsecQuery " + err.Error())
|
||||||
|
}
|
||||||
if value == cache.NoBannedValue {
|
if value == cache.NoBannedValue {
|
||||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||||
} else {
|
} else {
|
||||||
@@ -402,9 +440,23 @@ func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Req
|
|||||||
if req.Method == http.MethodHead {
|
if req.Method == http.MethodHead {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := bouncer.banTemplate.Execute(rw, map[string]string{"RemediationReason": reason, "ClientIP": remoteIP})
|
templateData := map[string]string{
|
||||||
|
"RemediationReason": reason,
|
||||||
|
"ClientIP": remoteIP,
|
||||||
|
}
|
||||||
|
|
||||||
|
if bouncer.traceCustomHeader != "" {
|
||||||
|
headerVal := req.Header.Get(bouncer.traceCustomHeader)
|
||||||
|
|
||||||
|
if headerVal != "" {
|
||||||
|
templateData["TraceID"] = headerVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := bouncer.banTemplate.Execute(rw, templateData)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bouncer.log.Error("handleBanServeHTTP banTemplateServe " + err.Error())
|
bouncer.log.Warn("handleBanServeHTTP could not write template to ResponseWriter: " + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +487,7 @@ func (bouncer *Bouncer) handleNextServeHTTP(rw http.ResponseWriter, req *http.Re
|
|||||||
|
|
||||||
func handleStreamTicker(bouncer *Bouncer) {
|
func handleStreamTicker(bouncer *Bouncer) {
|
||||||
if err := handleStreamCache(bouncer); err != nil {
|
if err := handleStreamCache(bouncer); err != nil {
|
||||||
bouncer.log.Debug(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
|
bouncer.log.Warn(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error()))
|
||||||
if bouncer.updateMaxFailure != -1 && updateFailure >= bouncer.updateMaxFailure && isCrowdsecStreamHealthy {
|
if bouncer.updateMaxFailure != -1 && updateFailure >= bouncer.updateMaxFailure && isCrowdsecStreamHealthy {
|
||||||
isCrowdsecStreamHealthy = false
|
isCrowdsecStreamHealthy = false
|
||||||
bouncer.log.Error(fmt.Sprintf("handleStreamTicker:error updateFailure:%d %s", updateFailure, err.Error()))
|
bouncer.log.Error(fmt.Sprintf("handleStreamTicker:error updateFailure:%d %s", updateFailure, err.Error()))
|
||||||
@@ -453,7 +505,7 @@ func handleMetricsTicker(bouncer *Bouncer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startTicker(name string, updateInterval int64, log *logger.Log, work func()) chan bool {
|
func startTicker(name string, updateInterval int64, log *slog.Logger, work func()) chan bool {
|
||||||
ticker := time.NewTicker(time.Duration(updateInterval) * time.Second)
|
ticker := time.NewTicker(time.Duration(updateInterval) * time.Second)
|
||||||
stop := make(chan bool, 1)
|
stop := make(chan bool, 1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -520,7 +572,7 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
|
|||||||
case "captcha":
|
case "captcha":
|
||||||
value = cache.CaptchaValue
|
value = cache.CaptchaValue
|
||||||
default:
|
default:
|
||||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
|
||||||
}
|
}
|
||||||
if isLiveMode && bouncer.defaultDecisionTimeout > 0 {
|
if isLiveMode && bouncer.defaultDecisionTimeout > 0 {
|
||||||
durationSecond := int64(duration.Seconds())
|
durationSecond := int64(duration.Seconds())
|
||||||
@@ -556,11 +608,11 @@ func getToken(bouncer *Bouncer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getToken:parsingBody %w", err)
|
return fmt.Errorf("getToken:parsingBody %w", err)
|
||||||
}
|
}
|
||||||
if login.Code == 200 && len(login.Token) > 0 {
|
if login.Code == http.StatusOK && len(login.Token) > 0 {
|
||||||
bouncer.crowdsecKey = login.Token
|
bouncer.crowdsecKey = login.Token
|
||||||
bouncer.log.Debug(fmt.Sprintf("getToken statusCode:%d", login.Code))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
bouncer.log.Warn(fmt.Sprintf("getToken statusCode:%d", login.Code))
|
||||||
return fmt.Errorf("getToken statusCode:%d", login.Code)
|
return fmt.Errorf("getToken statusCode:%d", login.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,7 +655,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
case "captcha":
|
case "captcha":
|
||||||
value = cache.CaptchaValue
|
value = cache.CaptchaValue
|
||||||
default:
|
default:
|
||||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
|
||||||
}
|
}
|
||||||
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
|
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
|
||||||
}
|
}
|
||||||
@@ -611,7 +663,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
for _, decision := range stream.Deleted {
|
for _, decision := range stream.Deleted {
|
||||||
bouncer.cacheClient.Delete(decision.Value)
|
bouncer.cacheClient.Delete(decision.Value)
|
||||||
}
|
}
|
||||||
bouncer.log.Debug("handleStreamCache:updated")
|
bouncer.log.Info("handleStreamCache:updated")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,7 +675,7 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]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/1.X.X")
|
req.Header.Add("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||||
|
|
||||||
res, err := bouncer.httpClient.Do(req)
|
res, err := bouncer.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -656,7 +708,7 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
|
|||||||
|
|
||||||
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||||
routeURL := url.URL{
|
routeURL := url.URL{
|
||||||
Scheme: bouncer.crowdsecScheme,
|
Scheme: bouncer.appsecScheme,
|
||||||
Host: bouncer.appsecHost,
|
Host: bouncer.appsecHost,
|
||||||
Path: bouncer.appsecPath,
|
Path: bouncer.appsecPath,
|
||||||
}
|
}
|
||||||
@@ -681,14 +733,14 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
|||||||
req.Header.Add(key, value)
|
req.Header.Add(key, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
req.Header.Set(crowdsecAppsecHeader, bouncer.crowdsecKey)
|
req.Header.Set(crowdsecAppsecHeader, bouncer.appsecKey)
|
||||||
req.Header.Set(crowdsecAppsecIPHeader, ip)
|
req.Header.Set(crowdsecAppsecIPHeader, ip)
|
||||||
req.Header.Set(crowdsecAppsecVerbHeader, httpReq.Method)
|
req.Header.Set(crowdsecAppsecVerbHeader, httpReq.Method)
|
||||||
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
|
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
|
||||||
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
|
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
|
||||||
req.Header.Set(crowdsecAppsecUserAgent, httpReq.Header.Get("User-Agent"))
|
req.Header.Set(crowdsecAppsecUserAgent, httpReq.Header.Get("User-Agent"))
|
||||||
|
|
||||||
res, err := bouncer.httpClient.Do(req)
|
res, err := bouncer.httpAppsecClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bouncer.log.Error("appsecQuery:unreachable")
|
bouncer.log.Error("appsecQuery:unreachable")
|
||||||
if bouncer.appsecUnreachableBlock {
|
if bouncer.appsecUnreachableBlock {
|
||||||
@@ -728,7 +780,7 @@ func reportMetrics(bouncer *Bouncer) error {
|
|||||||
metrics := map[string]interface{}{
|
metrics := map[string]interface{}{
|
||||||
"remediation_components": []map[string]interface{}{
|
"remediation_components": []map[string]interface{}{
|
||||||
{
|
{
|
||||||
"version": "1.X.X",
|
"version": pluginVersion,
|
||||||
"type": "bouncer",
|
"type": "bouncer",
|
||||||
"name": "traefik_plugin",
|
"name": "traefik_plugin",
|
||||||
"metrics": []map[string]interface{}{
|
"metrics": []map[string]interface{}{
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getTestConfig returns a minimal valid configuration for testing.
|
||||||
|
// Override specific fields by modifying the returned config.
|
||||||
|
func getTestConfig() *configuration.Config {
|
||||||
|
return &configuration.Config{
|
||||||
|
Enabled: true,
|
||||||
|
LogLevel: "INFO",
|
||||||
|
LogFormat: "common",
|
||||||
|
LogFilePath: "",
|
||||||
|
CrowdsecMode: "none",
|
||||||
|
CrowdsecLapiKey: "test-key",
|
||||||
|
CrowdsecLapiHost: "localhost",
|
||||||
|
CrowdsecLapiScheme: "http",
|
||||||
|
UpdateIntervalSeconds: 60,
|
||||||
|
DefaultDecisionSeconds: 60,
|
||||||
|
HTTPTimeoutSeconds: 10,
|
||||||
|
ForwardedHeadersTrustedIPs: []string{"127.0.0.1"},
|
||||||
|
ForwardedHeadersCustomName: "",
|
||||||
|
RemediationStatusCode: 403,
|
||||||
|
BanHTMLFilePath: "",
|
||||||
|
RemediationHeadersCustomName: "",
|
||||||
|
CaptchaProvider: "",
|
||||||
|
CaptchaSiteKey: "",
|
||||||
|
CaptchaSecretKey: "",
|
||||||
|
CaptchaGracePeriodSeconds: 1,
|
||||||
|
CaptchaHTMLFilePath: "",
|
||||||
|
RedisCacheEnabled: false,
|
||||||
|
RedisCacheHost: "",
|
||||||
|
RedisCachePassword: "",
|
||||||
|
RedisCacheDatabase: "",
|
||||||
|
RedisCacheUnreachableBlock: false,
|
||||||
|
CrowdsecAppsecEnabled: false,
|
||||||
|
CrowdsecAppsecHost: "",
|
||||||
|
CrowdsecAppsecPath: "",
|
||||||
|
CrowdsecAppsecFailureBlock: false,
|
||||||
|
CrowdsecAppsecUnreachableBlock: false,
|
||||||
|
CrowdsecLapiTLSInsecureVerify: true,
|
||||||
|
CrowdsecLapiTLSCertificateBouncer: "",
|
||||||
|
CrowdsecLapiTLSCertificateBouncerKey: "",
|
||||||
|
CrowdsecCapiMachineID: "",
|
||||||
|
CrowdsecCapiPassword: "",
|
||||||
|
CrowdsecCapiScenarios: []string{},
|
||||||
|
UpdateMaxFailure: 0,
|
||||||
|
MetricsUpdateIntervalSeconds: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create and execute a bouncer request for testing
|
||||||
|
func createAndExecuteBouncerRequest(t *testing.T, config *configuration.Config) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Create a mock next handler
|
||||||
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("OK"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create the bouncer plugin (this will initialize the logger with file output)
|
||||||
|
bouncerHandler, err := New(context.Background(), nextHandler, config, "test-bouncer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create bouncer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test request to trigger logging
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
|
||||||
|
req.RemoteAddr = "192.168.1.100:12345" // Use a non-trusted IP to trigger logging
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Process the request (this should generate log entries)
|
||||||
|
bouncerHandler.ServeHTTP(rw, req)
|
||||||
|
|
||||||
|
// Give a moment for log writes to complete
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to parse log file and extract found levels
|
||||||
|
func parseLogFileAndExtractLevels(t *testing.T, logFile string) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Verify the log file was created and contains entries
|
||||||
|
if _, statErr := os.Stat(logFile); os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("Log file was not created: %s", logFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the log file content
|
||||||
|
// #nosec G304 - logFile is a test-generated temporary file path
|
||||||
|
logContent, err := os.ReadFile(logFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read log file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logString := string(logContent)
|
||||||
|
if len(logString) == 0 {
|
||||||
|
return make(map[string]bool) // Return empty map for empty log files
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and verify JSON log entries
|
||||||
|
lines := strings.Split(strings.TrimSpace(logString), "\n")
|
||||||
|
foundLevels := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var logEntry map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
|
||||||
|
t.Errorf("Invalid JSON log entry: %s, error: %v", line, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify required fields
|
||||||
|
validateLogEntry(t, logEntry)
|
||||||
|
|
||||||
|
// Track log levels we've seen
|
||||||
|
if level, ok := logEntry["level"].(string); ok {
|
||||||
|
foundLevels[level] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return foundLevels
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to validate log entry structure
|
||||||
|
func validateLogEntry(t *testing.T, logEntry map[string]interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if logEntry["time"] == nil {
|
||||||
|
t.Error("Log entry missing 'time' field")
|
||||||
|
}
|
||||||
|
if logEntry["level"] == nil {
|
||||||
|
t.Error("Log entry missing 'level' field")
|
||||||
|
}
|
||||||
|
if logEntry["msg"] == nil {
|
||||||
|
t.Error("Log entry missing 'msg' field")
|
||||||
|
}
|
||||||
|
if logEntry["component"] != "CrowdsecBouncerTraefikPlugin" {
|
||||||
|
t.Errorf("Expected component 'CrowdsecBouncerTraefikPlugin', got %v", logEntry["component"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to verify expected and forbidden log levels
|
||||||
|
func verifyLogLevels(t *testing.T, foundLevels map[string]bool, expectedLevels, forbiddenLevels []string, logLevel string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Handle case where no logs are expected
|
||||||
|
if len(expectedLevels) == 0 {
|
||||||
|
if len(foundLevels) > 0 {
|
||||||
|
t.Errorf("Expected no logs at %s level, but found: %v", logLevel, foundLevels)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Verify we got some log entries
|
||||||
|
if len(foundLevels) == 0 {
|
||||||
|
t.Fatal("No valid log entries found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify expected levels are present
|
||||||
|
for _, expectedLevel := range expectedLevels {
|
||||||
|
if !foundLevels[expectedLevel] {
|
||||||
|
t.Errorf("Expected to find %s level logs, but didn't. Found levels: %v", expectedLevel, foundLevels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify forbidden levels are NOT present
|
||||||
|
for _, forbiddenLevel := range forbiddenLevels {
|
||||||
|
if foundLevels[forbiddenLevel] {
|
||||||
|
t.Errorf("Found forbidden %s level logs at %s level. Found levels: %v", forbiddenLevel, logLevel, foundLevels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBouncerFileLoggingLevels(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
logLevel string
|
||||||
|
expectedLevels []string // Levels that should appear
|
||||||
|
forbiddenLevels []string // Levels that should NOT appear
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "DEBUG level should show DEBUG only",
|
||||||
|
logLevel: "DEBUG",
|
||||||
|
expectedLevels: []string{"DEBUG"},
|
||||||
|
forbiddenLevels: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "INFO level should show no logs (bouncer doesn't generate INFO during normal operation)",
|
||||||
|
logLevel: "INFO",
|
||||||
|
expectedLevels: []string{}, // No logs expected for normal operation
|
||||||
|
forbiddenLevels: []string{"DEBUG"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create temporary directory for log file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
logFile := filepath.Join(tmpDir, "bouncer.log")
|
||||||
|
|
||||||
|
// Get test config and override specific fields
|
||||||
|
config := getTestConfig()
|
||||||
|
config.LogLevel = tt.logLevel
|
||||||
|
config.LogFormat = "json" // Use JSON format for easier parsing
|
||||||
|
config.LogFilePath = logFile
|
||||||
|
|
||||||
|
// Create and execute bouncer request
|
||||||
|
createAndExecuteBouncerRequest(t, config)
|
||||||
|
|
||||||
|
// Parse log file and extract found levels
|
||||||
|
foundLevels := parseLogFileAndExtractLevels(t, logFile)
|
||||||
|
|
||||||
|
// Handle empty log files for higher log levels (expected behavior)
|
||||||
|
if len(foundLevels) == 0 && len(tt.expectedLevels) > 0 {
|
||||||
|
t.Fatalf("Expected log entries but log file is empty for level %s", tt.logLevel)
|
||||||
|
}
|
||||||
|
if len(foundLevels) == 0 {
|
||||||
|
// Empty file is expected for this log level
|
||||||
|
t.Logf("LogLevel %s: No logs generated (expected behavior)", tt.logLevel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify expected and forbidden log levels
|
||||||
|
verifyLogLevels(t, foundLevels, tt.expectedLevels, tt.forbiddenLevels, tt.logLevel)
|
||||||
|
|
||||||
|
t.Logf("LogLevel %s: Successfully logged to file with levels: %v", tt.logLevel, foundLevels)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBouncerFileLoggingCommonFormat(t *testing.T) {
|
||||||
|
// Create temporary directory for log file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
logFile := filepath.Join(tmpDir, "bouncer-common.log")
|
||||||
|
|
||||||
|
// Get test config and override specific fields
|
||||||
|
config := getTestConfig()
|
||||||
|
config.LogLevel = "DEBUG"
|
||||||
|
config.LogFormat = "common" // Use common format
|
||||||
|
config.LogFilePath = logFile
|
||||||
|
|
||||||
|
// Create a mock next handler
|
||||||
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("OK"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create the bouncer plugin
|
||||||
|
bouncerHandler, err := New(context.Background(), nextHandler, config, "test-bouncer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create bouncer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test request to trigger logging
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
|
||||||
|
req.RemoteAddr = "192.168.1.100:12345"
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Process the request
|
||||||
|
bouncerHandler.ServeHTTP(rw, req)
|
||||||
|
|
||||||
|
// Give a moment for log writes to complete
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify the log file was created and contains entries
|
||||||
|
if _, statErr := os.Stat(logFile); os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("Log file was not created: %s", logFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the log file content
|
||||||
|
// #nosec G304 - logFile is a test-generated temporary file path
|
||||||
|
logContent, err := os.ReadFile(logFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read log file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logString := string(logContent)
|
||||||
|
if len(logString) == 0 {
|
||||||
|
t.Fatal("Log file is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify common format structure
|
||||||
|
lines := strings.Split(strings.TrimSpace(logString), "\n")
|
||||||
|
foundDebug := false
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common format should contain time, level, msg, and component
|
||||||
|
if strings.Contains(line, "level=DEBUG") {
|
||||||
|
foundDebug = true
|
||||||
|
}
|
||||||
|
if !strings.Contains(line, "component=CrowdsecBouncerTraefikPlugin") {
|
||||||
|
t.Errorf("Log line missing component field: %s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should see DEBUG level logs since we set LogLevel to DEBUG
|
||||||
|
if !foundDebug {
|
||||||
|
t.Errorf("Expected to find DEBUG level logs in common format. Log content:\n%s", logString)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Successfully logged to file %s in common format with %d lines", logFile, len(lines))
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.5.0"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -16,8 +16,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- logs-local:/var/log/traefik
|
- logs-local:/var/log/traefik
|
||||||
- './ban.html:/ban.html:ro'
|
- "./ban.html:/ban.html:ro"
|
||||||
- './captcha.html:/captcha.html:ro'
|
- "./captcha.html:/captcha.html:ro"
|
||||||
- ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
- ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
ports:
|
ports:
|
||||||
- 8000:80
|
- 8000:80
|
||||||
@@ -52,6 +52,7 @@ services:
|
|||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=stream"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=stream"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.ForwardedHeadersTrustedIPs=172.21.0.1/8"
|
||||||
|
|
||||||
bar2:
|
bar2:
|
||||||
image: traefik/whoami
|
image: traefik/whoami
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.5.0"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.5.0"
|
||||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
@@ -50,8 +50,10 @@ make run_custom_ban_page
|
|||||||
In the html of the ban page, you can use:
|
In the html of the ban page, you can use:
|
||||||
- {{ .ClientIP }} to display the IP used to ban the request.
|
- {{ .ClientIP }} to display the IP used to ban the request.
|
||||||
- {{ .RemediationReason }} that convert on runtime into why the ban page is served. It's an enum with "APPSEC", "LAPI", "TECHNICAL_ISSUE" and it is useful to help user understand why the request is blocked.
|
- {{ .RemediationReason }} that convert on runtime into why the ban page is served. It's an enum with "APPSEC", "LAPI", "TECHNICAL_ISSUE" and it is useful to help user understand why the request is blocked.
|
||||||
|
- {{ .CustomHeader }} value of the specified Request Header (for example X-Request-ID)
|
||||||
```
|
```
|
||||||
<script>var remediation = "{{ .RemediationReason }}"</script>
|
<script>var remediation = "{{ .RemediationReason }}"</script>
|
||||||
<script>var clientIp = "{{ .ClientIP }}"</script>
|
<script>var clientIp = "{{ .ClientIP }}"</script>
|
||||||
|
<script>var traceID = "{{ .TraceID }}"</script>
|
||||||
```
|
```
|
||||||
With the above tweak and some other js, you can customize your ban page on runtime.
|
With the above tweak and some other js, you can customize your ban page on runtime.
|
||||||
|
|||||||
+30
-10
@@ -1,7 +1,9 @@
|
|||||||
# Example
|
# Example
|
||||||
|
|
||||||
## Using https communication and tls authentication with Crowdsec
|
## Using https communication and tls authentication with Crowdsec
|
||||||
|
|
||||||
##### Summary
|
##### Summary
|
||||||
|
|
||||||
This example demonstrates the use of https between the Traefik plugin and the Crowdsec LAPI.
|
This example demonstrates the use of https between the Traefik plugin and the Crowdsec LAPI.
|
||||||
|
|
||||||
It is possible to communicate with the LAPI in https and still authenticate with API key.
|
It is possible to communicate with the LAPI in https and still authenticate with API key.
|
||||||
@@ -17,7 +19,9 @@ In that case the setting **crowdsecLapiTLSInsecureVerify** must be set to true.
|
|||||||
It is recommended to validate the certificate presented by Crowdsec LAPI using the Certificate Authority which created it.
|
It is recommended to validate the certificate presented by Crowdsec LAPI using the Certificate Authority which created it.
|
||||||
|
|
||||||
You can provide the Certificate Authority using:
|
You can provide the Certificate Authority using:
|
||||||
* A file path readable by Traefik
|
|
||||||
|
- A file path readable by Traefik
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
http:
|
http:
|
||||||
middlewares:
|
middlewares:
|
||||||
@@ -26,9 +30,11 @@ http:
|
|||||||
bouncer:
|
bouncer:
|
||||||
crowdsecLapiTlsCertificateAuthorityFile: /etc/traefik/certs/crowdsecCA.pem
|
crowdsecLapiTlsCertificateAuthorityFile: /etc/traefik/certs/crowdsecCA.pem
|
||||||
```
|
```
|
||||||
* The PEM encoded certificate as a text variable
|
|
||||||
|
- The PEM encoded certificate as a text variable
|
||||||
|
|
||||||
In the static file configuration of Traefik
|
In the static file configuration of Traefik
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
http:
|
http:
|
||||||
middlewares:
|
middlewares:
|
||||||
@@ -36,15 +42,17 @@ http:
|
|||||||
plugin:
|
plugin:
|
||||||
bouncer:
|
bouncer:
|
||||||
crowdsecLapiTlsCertificateAuthority: |-
|
crowdsecLapiTlsCertificateAuthority: |-
|
||||||
-----BEGIN CERTIFICATE-----
|
-----BEGIN CERTIFICATE-----
|
||||||
MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
|
MIIEBzCCAu+gAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
|
||||||
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
|
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
|
||||||
...
|
...
|
||||||
C6qNieSwcvWL7C03ri0DefTQMY54r5wP33QU5hJ71JoaZI3YTeT0Nf+NRL4hM++w
|
C6qNieSwcvWL7C03ri0DefTQMY54r5wP33QU5hJ71JoaZI3YTeT0Nf+NRL4hM++w
|
||||||
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
|
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
|
||||||
-----END CERTIFICATE-----
|
-----END CERTIFICATE-----
|
||||||
```
|
```
|
||||||
|
|
||||||
In a dynamic configuration of a provider (ex docker) as a Label
|
In a dynamic configuration of a provider (ex docker) as a Label
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
whoami-foo:
|
whoami-foo:
|
||||||
@@ -71,22 +79,34 @@ The service `whoami-foo` will authenticate with an **API key** over HTTPS after
|
|||||||
The service `whoami-bar` will authenticate with a **client certificate** signed by the CA.
|
The service `whoami-bar` will authenticate with a **client certificate** signed by the CA.
|
||||||
|
|
||||||
Access to a route that communicate via https and authenticate with API-key:
|
Access to a route that communicate via https and authenticate with API-key:
|
||||||
|
|
||||||
```
|
```
|
||||||
curl http://localhost:8000/foo
|
curl http://localhost:8000/foo
|
||||||
```
|
```
|
||||||
|
|
||||||
Access to a route that communicate via https and authenticate with a client certificate:
|
Access to a route that communicate via https and authenticate with a client certificate:
|
||||||
|
|
||||||
```
|
```
|
||||||
curl http://localhost:8000/bar
|
curl http://localhost:8000/bar
|
||||||
```
|
```
|
||||||
|
|
||||||
Access to the traefik dashboard
|
Access to the traefik dashboard
|
||||||
|
|
||||||
```
|
```
|
||||||
curl http://localhost:8080/dashboard/#/
|
curl http://localhost:8080/dashboard/#/
|
||||||
```
|
```
|
||||||
|
|
||||||
To play the demo environnement run:
|
To play the demo environnement run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make run_tlsauth
|
make run_tlsauth
|
||||||
```
|
```
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
> Traefik need to be restart if certificates are regenerated after his launch
|
|
||||||
|
> Traefik need to be restarted if certificates are regenerated after his launch, crowdsec also
|
||||||
|
|
||||||
|
## Separate LAPI and Appsec HTTP/S config
|
||||||
|
|
||||||
|
To separate TLS config for LAPI and Appsec, you can use all the TLS LAPI variable beginning with `CrowdsecLapi...` into `CrowdsecAppsec...`.
|
||||||
|
Don't forget to set `CrowdsecAppsecScheme: HTTP` or `HTTPS` to trigger the separate setup.
|
||||||
|
|||||||
@@ -2,3 +2,12 @@ filenames:
|
|||||||
- /var/log/traefik/access.log
|
- /var/log/traefik/access.log
|
||||||
labels:
|
labels:
|
||||||
type: traefik
|
type: traefik
|
||||||
|
---
|
||||||
|
listen_addr: 0.0.0.0:7422
|
||||||
|
appsec_config: crowdsecurity/virtual-patching
|
||||||
|
name: myAppSecComponent
|
||||||
|
source: appsec
|
||||||
|
labels:
|
||||||
|
type: appsec
|
||||||
|
cert_file: /etc/crowdsec/certs/server.pem
|
||||||
|
key_file: /etc/crowdsec/certs/server-key.pem
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v3.0.0"
|
image: "traefik:v3.5.0"
|
||||||
container_name: "traefik"
|
container_name: "traefik"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
@@ -13,15 +13,13 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
|
|
||||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
- "--experimental.plugins.bouncer.version=v1.5.0"
|
||||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- ./LAPIKEY:/etc/traefik/LAPIKEY:ro
|
|
||||||
- logs-tls-auth:/var/log/traefik
|
- logs-tls-auth:/var/log/traefik
|
||||||
- crowdsec-certs-tls-auth:/etc/traefik/crowdsec-certs
|
- crowdsec-certs-tls-auth:/etc/traefik/crowdsec-certs
|
||||||
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- 8000:80
|
- 8000:80
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
@@ -29,7 +27,7 @@ services:
|
|||||||
- crowdsec
|
- crowdsec
|
||||||
- gencert
|
- gencert
|
||||||
|
|
||||||
# Use HTTPS scheme but with lapikey authentication
|
# Use HTTPS scheme but with lapikey authentication
|
||||||
# whoami-foo:
|
# whoami-foo:
|
||||||
# image: traefik/whoami
|
# image: traefik/whoami
|
||||||
# container_name: "simple-service-foo"
|
# container_name: "simple-service-foo"
|
||||||
@@ -38,7 +36,7 @@ services:
|
|||||||
# - "traefik.enable=true"
|
# - "traefik.enable=true"
|
||||||
# - "traefik.http.routers.router-foo.rule=Path(`/foo`)"
|
# - "traefik.http.routers.router-foo.rule=Path(`/foo`)"
|
||||||
# - "traefik.http.routers.router-foo.entrypoints=web"
|
# - "traefik.http.routers.router-foo.entrypoints=web"
|
||||||
# - "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
|
# - "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
|
||||||
# - "traefik.http.services.service-foo.loadbalancer.server.port=80"
|
# - "traefik.http.services.service-foo.loadbalancer.server.port=80"
|
||||||
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
@@ -46,34 +44,41 @@ services:
|
|||||||
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
||||||
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
||||||
|
|
||||||
# Use HTTPS scheme with TLS cert authentication
|
# Use HTTPS scheme with TLS cert authentication
|
||||||
whoami-bar:
|
whoami-bar:
|
||||||
image: traefik/whoami
|
image: traefik/whoami
|
||||||
container_name: "simple-service-bar"
|
container_name: "simple-service-bar"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.router-bar.rule=Path(`/bar`)"
|
- "traefik.http.routers.router-bar.rule=PathPrefix(`/bar`)"
|
||||||
- "traefik.http.routers.router-bar.entrypoints=web"
|
- "traefik.http.routers.router-bar.entrypoints=web"
|
||||||
- "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
|
- "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
|
||||||
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecMode=none"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecscheme=https"
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecAppsecTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
||||||
|
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapikey=40796d93c2958f9e58345514e67740e5="
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerFile=/etc/traefik/crowdsec-certs/bouncer.pem"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerFile=/etc/traefik/crowdsec-certs/bouncer.pem"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerKeyFile=/etc/traefik/crowdsec-certs/bouncer-key.pem"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerKeyFile=/etc/traefik/crowdsec-certs/bouncer-key.pem"
|
||||||
|
# Enable AppSec
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
||||||
|
# Define AppSec host and port informations
|
||||||
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:latest
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
COLLECTIONS: crowdsecurity/traefik
|
|
||||||
CUSTOM_HOSTNAME: crowdsec
|
CUSTOM_HOSTNAME: crowdsec
|
||||||
# whoami-foo is authenticating with api key over https
|
# whoami-foo is authenticating with api key over https
|
||||||
# whoami-bar is authenticating with tls cert over https
|
# whoami-bar is authenticating with tls cert over https
|
||||||
BOUNCER_KEY_TRAEFIK_FOO: 40796d93c2958f9e58345514e67740e5
|
BOUNCER_KEY_TRAEFIK_FOO: 40796d93c2958f9e58345514e67740e5=
|
||||||
LOCAL_API_URL: https://127.0.0.1:8080
|
LOCAL_API_URL: https://127.0.0.1:8080
|
||||||
USE_TLS: "true"
|
USE_TLS: "true"
|
||||||
CERT_FILE: "/etc/crowdsec/certs/server.pem"
|
CERT_FILE: "/etc/crowdsec/certs/server.pem"
|
||||||
@@ -88,6 +93,7 @@ services:
|
|||||||
# DISABLE_AGENT: "true"
|
# DISABLE_AGENT: "true"
|
||||||
# Disabled for the examples
|
# Disabled for the examples
|
||||||
DISABLE_ONLINE_API: "true"
|
DISABLE_ONLINE_API: "true"
|
||||||
|
COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/acquis.yaml:/etc/crowdsec/acquis.yaml
|
- ./config/acquis.yaml:/etc/crowdsec/acquis.yaml
|
||||||
# - ./config/config.yaml:/etc/crowdsec/config_local.yaml
|
# - ./config/config.yaml:/etc/crowdsec/config_local.yaml
|
||||||
@@ -100,7 +106,7 @@ services:
|
|||||||
- "traefik.enable=false"
|
- "traefik.enable=false"
|
||||||
depends_on:
|
depends_on:
|
||||||
- gencert
|
- gencert
|
||||||
|
|
||||||
gencert:
|
gencert:
|
||||||
build: .
|
build: .
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
stdout=/out/res.log
|
if [ -f "/out/inter-key.pem" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
cfssl gencert --initca /in/ca.json 2>${stdout} | cfssljson --bare "/out/ca" && \
|
cfssl gencert --initca /in/ca.json 2>${stdout} | cfssljson --bare "/out/ca" && \
|
||||||
# Generate an intermediate certificate that will be used to sign the client certificates
|
# Generate an intermediate certificate that will be used to sign the client certificates
|
||||||
cfssl gencert --initca /in/intermediate.json 2>${stdout} | cfssljson --bare "/out/inter" && \
|
cfssl gencert --initca /in/intermediate.json 2>${stdout} | cfssljson --bare "/out/inter" && \
|
||||||
|
|||||||
Vendored
+4
-5
@@ -5,11 +5,10 @@ package cache
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
ttl_map "github.com/leprosus/golang-ttl-map"
|
ttl_map "github.com/leprosus/golang-ttl-map"
|
||||||
simpleredis "github.com/maxlerebourg/simpleredis"
|
simpleredis "github.com/maxlerebourg/simpleredis"
|
||||||
|
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -53,7 +52,7 @@ func (localCache) delete(key string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type redisCache struct {
|
type redisCache struct {
|
||||||
log *logger.Log
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (redisCache) get(key string) (string, error) {
|
func (redisCache) get(key string) (string, error) {
|
||||||
@@ -93,11 +92,11 @@ type cacheInterface interface {
|
|||||||
// Client Cache client.
|
// Client Cache client.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
cache cacheInterface
|
cache cacheInterface
|
||||||
log *logger.Log
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New Initialize cache client.
|
// New Initialize cache client.
|
||||||
func (c *Client) New(log *logger.Log, isRedis bool, host, pass, database string) {
|
func (c *Client) New(log *slog.Logger, isRedis bool, host, pass, database string) {
|
||||||
c.log = log
|
c.log = log
|
||||||
if isRedis {
|
if isRedis {
|
||||||
redis.Init(host, pass, database)
|
redis.Init(host, pass, database)
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
||||||
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client Captcha client.
|
// Client Captcha client.
|
||||||
@@ -24,7 +24,7 @@ type Client struct {
|
|||||||
captchaTemplate *template.Template
|
captchaTemplate *template.Template
|
||||||
cacheClient *cache.Client
|
cacheClient *cache.Client
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
log *logger.Log
|
log *slog.Logger
|
||||||
infoProvider *infoProvider
|
infoProvider *infoProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ var infoProviders = map[string]*infoProvider{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New Initialize captcha client.
|
// New Initialize captcha client.
|
||||||
func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
func (c *Client) New(log *slog.Logger, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
||||||
c.Valid = provider != ""
|
c.Valid = provider != ""
|
||||||
if !c.Valid {
|
if !c.Valid {
|
||||||
return nil
|
return nil
|
||||||
@@ -94,6 +94,9 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
|
|||||||
if valid {
|
if valid {
|
||||||
c.log.Debug("captcha:ServeHTTP captcha:valid")
|
c.log.Debug("captcha:ServeHTTP captcha:valid")
|
||||||
c.cacheClient.Set(remoteIP+"_captcha", cache.CaptchaDoneValue, c.gracePeriodSeconds)
|
c.cacheClient.Set(remoteIP+"_captcha", cache.CaptchaDoneValue, c.gracePeriodSeconds)
|
||||||
|
if c.remediationCustomHeader != "" {
|
||||||
|
rw.Header().Set(c.remediationCustomHeader, "solved-captcha")
|
||||||
|
}
|
||||||
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
|
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+165
-123
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -16,7 +17,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Enums for crowdsec mode.
|
// Enums for crowdsec mode.
|
||||||
@@ -30,6 +30,7 @@ const (
|
|||||||
HTTP = "http"
|
HTTP = "http"
|
||||||
LogDEBUG = "DEBUG"
|
LogDEBUG = "DEBUG"
|
||||||
LogINFO = "INFO"
|
LogINFO = "INFO"
|
||||||
|
LogWARN = "WARN"
|
||||||
LogERROR = "ERROR"
|
LogERROR = "ERROR"
|
||||||
ReasonTECH = "TECHNICAL_ISSUE"
|
ReasonTECH = "TECHNICAL_ISSUE"
|
||||||
ReasonLAPI = "LAPI"
|
ReasonLAPI = "LAPI"
|
||||||
@@ -42,61 +43,73 @@ const (
|
|||||||
|
|
||||||
// Config the plugin configuration.
|
// Config the plugin configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled bool `json:"enabled,omitempty"`
|
Enabled bool `json:"enabled,omitempty"`
|
||||||
LogLevel string `json:"logLevel,omitempty"`
|
LogLevel string `json:"logLevel,omitempty"`
|
||||||
LogFilePath string `json:"logFilePath,omitempty"`
|
LogFormat string `json:"logFormat,omitempty"`
|
||||||
CrowdsecMode string `json:"crowdsecMode,omitempty"`
|
LogFilePath string `json:"logFilePath,omitempty"`
|
||||||
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
|
CrowdsecMode string `json:"crowdsecMode,omitempty"`
|
||||||
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
|
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
|
||||||
CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"`
|
CrowdsecAppsecScheme string `json:"crowdsecAppsecScheme,omitempty"`
|
||||||
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
|
||||||
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"`
|
||||||
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
|
CrowdsecAppsecKey string `json:"crowdsecAppsecKey,omitempty"`
|
||||||
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
|
CrowdsecAppsecKeyFile string `json:"crowdsecAppsecKeyFile,omitempty"`
|
||||||
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
|
CrowdsecAppsecTLSInsecureVerify bool `json:"crowdsecAppsecTlsInsecureVerify,omitempty"`
|
||||||
CrowdsecLapiPath string `json:"crowdsecLapiPath,omitempty"`
|
CrowdsecAppsecTLSCertificateAuthority string `json:"crowdsecAppsecTlsCertificateAuthority,omitempty"`
|
||||||
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
|
CrowdsecAppsecTLSCertificateAuthorityFile string `json:"crowdsecAppsecTlsCertificateAuthorityFile,omitempty"`
|
||||||
CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncer string `json:"crowdsecAppsecTlsCertificateBouncer,omitempty"`
|
||||||
CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncerFile string `json:"crowdsecAppsecTlsCertificateBouncerFile,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateAuthority string `json:"crowdsecLapiTlsCertificateAuthority,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncerKey string `json:"crowdsecAppsecTlsCertificateBouncerKey,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateAuthorityFile string `json:"crowdsecLapiTlsCertificateAuthorityFile,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncer string `json:"crowdsecLapiTlsCertificateBouncer,omitempty"`
|
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncerFile string `json:"crowdsecLapiTlsCertificateBouncerFile,omitempty"`
|
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncerKey string `json:"crowdsecLapiTlsCertificateBouncerKey,omitempty"`
|
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
|
||||||
CrowdsecLapiTLSCertificateBouncerKeyFile string `json:"crowdsecLapiTlsCertificateBouncerKeyFile,omitempty"`
|
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
|
||||||
CrowdsecCapiMachineID string `json:"crowdsecCapiMachineId,omitempty"`
|
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
|
||||||
CrowdsecCapiMachineIDFile string `json:"crowdsecCapiMachineIdFile,omitempty"`
|
CrowdsecLapiPath string `json:"crowdsecLapiPath,omitempty"`
|
||||||
CrowdsecCapiPassword string `json:"crowdsecCapiPassword,omitempty"`
|
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
|
||||||
CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"`
|
CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"`
|
||||||
CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"`
|
CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"`
|
||||||
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
CrowdsecLapiTLSCertificateAuthority string `json:"crowdsecLapiTlsCertificateAuthority,omitempty"`
|
||||||
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
|
CrowdsecLapiTLSCertificateAuthorityFile string `json:"crowdsecLapiTlsCertificateAuthorityFile,omitempty"`
|
||||||
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
|
CrowdsecLapiTLSCertificateBouncer string `json:"crowdsecLapiTlsCertificateBouncer,omitempty"`
|
||||||
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
CrowdsecLapiTLSCertificateBouncerFile string `json:"crowdsecLapiTlsCertificateBouncerFile,omitempty"`
|
||||||
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
|
CrowdsecLapiTLSCertificateBouncerKey string `json:"crowdsecLapiTlsCertificateBouncerKey,omitempty"`
|
||||||
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
CrowdsecLapiTLSCertificateBouncerKeyFile string `json:"crowdsecLapiTlsCertificateBouncerKeyFile,omitempty"`
|
||||||
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"`
|
CrowdsecCapiMachineID string `json:"crowdsecCapiMachineId,omitempty"`
|
||||||
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
|
CrowdsecCapiMachineIDFile string `json:"crowdsecCapiMachineIdFile,omitempty"`
|
||||||
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
|
CrowdsecCapiPassword string `json:"crowdsecCapiPassword,omitempty"`
|
||||||
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
|
CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"`
|
||||||
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
|
CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"`
|
||||||
RedisCacheHost string `json:"redisCacheHost,omitempty"`
|
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
||||||
RedisCachePassword string `json:"redisCachePassword,omitempty"`
|
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
|
||||||
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
|
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
|
||||||
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
|
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
||||||
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
|
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
|
||||||
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
|
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
||||||
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
|
TraceHeadersCustomName string `json:"traceHeadersCustomName,omitempty"`
|
||||||
CaptchaProvider string `json:"captchaProvider,omitempty"`
|
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"`
|
||||||
CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"`
|
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
|
||||||
CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"`
|
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
|
||||||
CaptchaCustomKey string `json:"captchaCustomKey,omitempty"`
|
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
|
||||||
CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"`
|
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
|
||||||
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"`
|
RedisCacheHost string `json:"redisCacheHost,omitempty"`
|
||||||
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"`
|
RedisCachePassword string `json:"redisCachePassword,omitempty"`
|
||||||
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"`
|
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
|
||||||
CaptchaSecretKeyFile string `json:"captchaSecretKeyFile,omitempty"`
|
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
|
||||||
CaptchaGracePeriodSeconds int64 `json:"captchaGracePeriodSeconds,omitempty"`
|
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
|
||||||
|
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
|
||||||
|
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
|
||||||
|
CaptchaProvider string `json:"captchaProvider,omitempty"`
|
||||||
|
CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"`
|
||||||
|
CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"`
|
||||||
|
CaptchaCustomKey string `json:"captchaCustomKey,omitempty"`
|
||||||
|
CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"`
|
||||||
|
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"`
|
||||||
|
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"`
|
||||||
|
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"`
|
||||||
|
CaptchaSecretKeyFile string `json:"captchaSecretKeyFile,omitempty"`
|
||||||
|
CaptchaGracePeriodSeconds int64 `json:"captchaGracePeriodSeconds,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func contains(source []string, target string) bool {
|
func contains(source []string, target string) bool {
|
||||||
@@ -111,46 +124,51 @@ func contains(source []string, target string) bool {
|
|||||||
// New creates the default plugin configuration.
|
// New creates the default plugin configuration.
|
||||||
func New() *Config {
|
func New() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
LogLevel: LogINFO,
|
LogLevel: LogINFO,
|
||||||
LogFilePath: "",
|
LogFormat: "common",
|
||||||
CrowdsecMode: LiveMode,
|
LogFilePath: "",
|
||||||
CrowdsecAppsecEnabled: false,
|
CrowdsecMode: LiveMode,
|
||||||
CrowdsecAppsecHost: "crowdsec:7422",
|
CrowdsecAppsecEnabled: false,
|
||||||
CrowdsecAppsecPath: "/",
|
CrowdsecAppsecFailureBlock: true,
|
||||||
CrowdsecAppsecFailureBlock: true,
|
CrowdsecAppsecUnreachableBlock: true,
|
||||||
CrowdsecAppsecUnreachableBlock: true,
|
CrowdsecAppsecBodyLimit: 10485760,
|
||||||
CrowdsecAppsecBodyLimit: 10485760,
|
CrowdsecAppsecScheme: "",
|
||||||
CrowdsecLapiScheme: HTTP,
|
CrowdsecAppsecHost: "crowdsec:7422",
|
||||||
CrowdsecLapiHost: "crowdsec:8080",
|
CrowdsecAppsecPath: "/",
|
||||||
CrowdsecLapiPath: "/",
|
CrowdsecAppsecKey: "",
|
||||||
CrowdsecLapiKey: "",
|
CrowdsecAppsecTLSInsecureVerify: false,
|
||||||
CrowdsecLapiTLSInsecureVerify: false,
|
CrowdsecLapiScheme: HTTP,
|
||||||
UpdateIntervalSeconds: 60,
|
CrowdsecLapiHost: "crowdsec:8080",
|
||||||
MetricsUpdateIntervalSeconds: 600,
|
CrowdsecLapiPath: "/",
|
||||||
UpdateMaxFailure: 0,
|
CrowdsecLapiKey: "",
|
||||||
DefaultDecisionSeconds: 60,
|
CrowdsecLapiTLSInsecureVerify: false,
|
||||||
RemediationStatusCode: http.StatusForbidden,
|
UpdateIntervalSeconds: 60,
|
||||||
HTTPTimeoutSeconds: 10,
|
MetricsUpdateIntervalSeconds: 600,
|
||||||
CaptchaProvider: "",
|
UpdateMaxFailure: 0,
|
||||||
CaptchaCustomJsURL: "",
|
DefaultDecisionSeconds: 60,
|
||||||
CaptchaCustomValidateURL: "",
|
RemediationStatusCode: http.StatusForbidden,
|
||||||
CaptchaCustomKey: "",
|
HTTPTimeoutSeconds: 10,
|
||||||
CaptchaCustomResponse: "",
|
CaptchaProvider: "",
|
||||||
CaptchaSiteKey: "",
|
CaptchaCustomJsURL: "",
|
||||||
CaptchaSecretKey: "",
|
CaptchaCustomValidateURL: "",
|
||||||
CaptchaGracePeriodSeconds: 1800,
|
CaptchaCustomKey: "",
|
||||||
CaptchaHTMLFilePath: "/captcha.html",
|
CaptchaCustomResponse: "",
|
||||||
BanHTMLFilePath: "",
|
CaptchaSiteKey: "",
|
||||||
RemediationHeadersCustomName: "",
|
CaptchaSecretKey: "",
|
||||||
ForwardedHeadersCustomName: "X-Forwarded-For",
|
CaptchaGracePeriodSeconds: 1800,
|
||||||
ForwardedHeadersTrustedIPs: []string{},
|
CaptchaHTMLFilePath: "/captcha.html",
|
||||||
ClientTrustedIPs: []string{},
|
BanHTMLFilePath: "",
|
||||||
RedisCacheEnabled: false,
|
TraceHeadersCustomName: "",
|
||||||
RedisCacheHost: "redis:6379",
|
RemediationHeadersCustomName: "",
|
||||||
RedisCachePassword: "",
|
ForwardedHeadersCustomName: "X-Forwarded-For",
|
||||||
RedisCacheDatabase: "",
|
ForwardedHeadersTrustedIPs: []string{},
|
||||||
RedisCacheUnreachableBlock: true,
|
ClientTrustedIPs: []string{},
|
||||||
|
RedisCacheEnabled: false,
|
||||||
|
RedisCacheHost: "redis:6379",
|
||||||
|
RedisCachePassword: "",
|
||||||
|
RedisCacheDatabase: "",
|
||||||
|
RedisCacheUnreachableBlock: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +221,7 @@ func GetHTMLTemplate(path string) (*template.Template, error) {
|
|||||||
// ValidateParams validate all the param gave by user.
|
// ValidateParams validate all the param gave by user.
|
||||||
//
|
//
|
||||||
//nolint:gocyclo,gocognit
|
//nolint:gocyclo,gocognit
|
||||||
func ValidateParams(config *Config) error {
|
func ValidateParams(config *Config, log *slog.Logger) error {
|
||||||
if err := validateParamsRequired(config); err != nil {
|
if err := validateParamsRequired(config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -212,10 +230,10 @@ func ValidateParams(config *Config) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
if err := validateParamsIPs(log, config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := validateParamsIPs(config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
|
if err := validateParamsIPs(log, config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +280,10 @@ func ValidateParams(config *Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
appsecKey, err := GetVariable(config, "CrowdsecAppsecKey")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -270,12 +292,21 @@ func ValidateParams(config *Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to either have crowdsecLapiKey defined or the BouncerCert and Bouncerkey
|
// We need to either have crowdsecLapiKey defined or the BouncerCert and Bouncerkey
|
||||||
if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") {
|
if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") && config.CrowdsecMode != AppsecMode {
|
||||||
return errors.New("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be all empty")
|
return errors.New("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be all empty")
|
||||||
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
|
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
|
||||||
lapiKey = strings.TrimSpace(lapiKey)
|
lapiKey = strings.TrimSpace(lapiKey)
|
||||||
if err = validateParamsAPIKey(lapiKey); err != nil {
|
if err = validateParamsAPIKey(lapiKey, "CrowdsecLapiKey"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CrowdsecAppsecKey if provided
|
||||||
|
if appsecKey != "" {
|
||||||
|
appsecKey = strings.TrimSpace(appsecKey)
|
||||||
|
if err = validateParamsAPIKey(appsecKey, "CrowdsecAppsecKey"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,8 +320,8 @@ func ValidateParams(config *Config) error {
|
|||||||
|
|
||||||
// Check logging configuration
|
// Check logging configuration
|
||||||
// to upper allow of anycase of log level
|
// to upper allow of anycase of log level
|
||||||
if !contains([]string{LogERROR, LogDEBUG, LogINFO}, strings.ToUpper(config.LogLevel)) {
|
if !contains([]string{LogDEBUG, LogINFO, LogWARN, LogERROR}, strings.ToUpper(config.LogLevel)) {
|
||||||
return fmt.Errorf("LogLevel should be one of (%s,%s,%s)", LogDEBUG, LogINFO, LogERROR)
|
return fmt.Errorf("LogLevel should be one of (%s,%s,%s,%s)", LogDEBUG, LogINFO, LogWARN, LogERROR)
|
||||||
}
|
}
|
||||||
if config.LogFilePath != "" {
|
if config.LogFilePath != "" {
|
||||||
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
@@ -314,10 +345,10 @@ func validateURL(variable, scheme, host, path string) error {
|
|||||||
// field name. RFC 7230 says:
|
// field name. RFC 7230 says:
|
||||||
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
|
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
|
||||||
// See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators
|
// See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators
|
||||||
func validateParamsAPIKey(lapiKey string) error {
|
func validateParamsAPIKey(key string, paramName string) error {
|
||||||
reg := regexp.MustCompile("^[a-zA-Z0-9 !#$%&'*+-.^_`|~=/]*$")
|
reg := regexp.MustCompile("^[a-zA-Z0-9 !#$%&'*+-.^_`|~=/]*$")
|
||||||
if !reg.MatchString(lapiKey) {
|
if !reg.MatchString(key) {
|
||||||
return fmt.Errorf("CrowdsecLapiKey doesn't valid this regexp: '/%s/'", reg.String())
|
return fmt.Errorf("%s doesn't validate this regexp: '/%s/'", paramName, reg.String())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -338,9 +369,9 @@ func validateParamsTLS(config *Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateParamsIPs(listIP []string, key string) error {
|
func validateParamsIPs(log *slog.Logger, listIP []string, key string) error {
|
||||||
if len(listIP) > 0 {
|
if len(listIP) > 0 {
|
||||||
if _, err := ip.NewChecker(logger.New(LogINFO, ""), listIP); err != nil {
|
if _, err := ip.NewChecker(log, listIP); err != nil {
|
||||||
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
|
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,26 +443,27 @@ func validateParamsRequired(config *Config) error {
|
|||||||
if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) {
|
if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) {
|
||||||
return errors.New("CrowdsecLapiScheme: must be one of 'http' or 'https'")
|
return errors.New("CrowdsecLapiScheme: must be one of 'http' or 'https'")
|
||||||
}
|
}
|
||||||
|
if !contains([]string{HTTP, HTTPS, ""}, config.CrowdsecAppsecScheme) {
|
||||||
|
return errors.New("CrowdsecAppsecScheme: must be one of 'http' or 'https'")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTLSConfigCrowdsec get TLS config from Config.
|
func getTLSConfig(config *Config, log *slog.Logger, prefix, scheme string, insecureVerify bool) (*tls.Config, error) {
|
||||||
//
|
|
||||||
//nolint:nestif
|
|
||||||
func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error) {
|
|
||||||
tlsConfig := new(tls.Config)
|
tlsConfig := new(tls.Config)
|
||||||
tlsConfig.RootCAs = x509.NewCertPool()
|
tlsConfig.RootCAs = x509.NewCertPool()
|
||||||
//nolint:gocritic
|
if scheme != HTTPS {
|
||||||
if config.CrowdsecLapiScheme != HTTPS {
|
log.Debug("getTLSConfig:" + prefix + "Scheme https:no")
|
||||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiScheme https:no")
|
|
||||||
return tlsConfig, nil
|
return tlsConfig, nil
|
||||||
} else if config.CrowdsecLapiTLSInsecureVerify {
|
}
|
||||||
|
//nolint:nestif
|
||||||
|
if insecureVerify {
|
||||||
tlsConfig.InsecureSkipVerify = true
|
tlsConfig.InsecureSkipVerify = true
|
||||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSInsecureVerify tlsInsecure:true")
|
log.Debug("getTLSConfig:" + prefix + "TLSInsecureVerify tlsInsecure:true")
|
||||||
// If we return here and still want to use client auth this won't work
|
// If we return here and still want to use client auth this won't work
|
||||||
// return tlsConfig, nil
|
// return tlsConfig, nil
|
||||||
} else {
|
} else {
|
||||||
certAuthority, err := GetVariable(config, "CrowdsecLapiTLSCertificateAuthority")
|
certAuthority, err := GetVariable(config, prefix+"TLSCertificateAuthority")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -439,17 +471,16 @@ func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error)
|
|||||||
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
||||||
// here we return because if CrowdsecLapiTLSInsecureVerify is false
|
// here we return because if CrowdsecLapiTLSInsecureVerify is false
|
||||||
// and CA not load, we can't communicate with https
|
// and CA not load, we can't communicate with https
|
||||||
return nil, errors.New("getTLSConfigCrowdsec:cannot load CA and verify cert is enabled")
|
return nil, errors.New("getTLSConfig:" + prefix + " cannot load CA and verify cert is enabled")
|
||||||
}
|
}
|
||||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSCertificateAuthority CA added successfully")
|
log.Debug("getTLSConfig:" + prefix + "TLSCertificateAuthority CA added successfully")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
|
||||||
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
certBouncerKey, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncerKey")
|
certBouncerKey, err := GetVariable(config, prefix+"TLSCertificateBouncerKey")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -464,3 +495,14 @@ func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error)
|
|||||||
|
|
||||||
return tlsConfig, nil
|
return tlsConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTLSConfigCrowdsec get TLS config from Config.
|
||||||
|
func GetTLSConfigCrowdsec(config *Config, log *slog.Logger, isAppsec bool) (*tls.Config, error) {
|
||||||
|
var prefix string
|
||||||
|
if isAppsec && config.CrowdsecAppsecScheme != "" {
|
||||||
|
prefix = "CrowdsecAppsec"
|
||||||
|
return getTLSConfig(config, log, prefix, config.CrowdsecAppsecScheme, config.CrowdsecAppsecTLSInsecureVerify)
|
||||||
|
}
|
||||||
|
prefix = "CrowdsecLapi"
|
||||||
|
return getTLSConfig(config, log, prefix, config.CrowdsecLapiScheme, config.CrowdsecLapiTLSInsecureVerify)
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ func Test_GetVariable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_ValidateParams(t *testing.T) {
|
func Test_ValidateParams(t *testing.T) {
|
||||||
|
log := logger.New("INFO", "")
|
||||||
cfg1 := New()
|
cfg1 := New()
|
||||||
cfg1.CrowdsecLapiKey = "test\n\n"
|
cfg1.CrowdsecLapiKey = "test\n\n"
|
||||||
cfg2 := New()
|
cfg2 := New()
|
||||||
@@ -117,7 +118,7 @@ func Test_ValidateParams(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := ValidateParams(tt.args.config); (err != nil) != tt.wantErr {
|
if err := ValidateParams(tt.args.config, log); (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -145,6 +146,7 @@ func Test_validateParamsTLS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_validateParamsIPs(t *testing.T) {
|
func Test_validateParamsIPs(t *testing.T) {
|
||||||
|
log := logger.New("INFO", "")
|
||||||
type args struct {
|
type args struct {
|
||||||
listIP []string
|
listIP []string
|
||||||
key string
|
key string
|
||||||
@@ -164,7 +166,7 @@ func Test_validateParamsIPs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := validateParamsIPs(tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
|
if err := validateParamsIPs(log, tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -205,23 +207,24 @@ func Test_validateParamsRequired(t *testing.T) {
|
|||||||
|
|
||||||
func Test_validateParamsAPIKey(t *testing.T) {
|
func Test_validateParamsAPIKey(t *testing.T) {
|
||||||
type args struct {
|
type args struct {
|
||||||
lapiKey string
|
lapiKey string
|
||||||
|
paramName string
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
args args
|
args args
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~"}, wantErr: false},
|
{name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~", paramName: "CrowdsecParamName"}, wantErr: false},
|
||||||
{name: "Not validate a @", args: args{lapiKey: "test@"}, wantErr: true},
|
{name: "Not validate a @", args: args{lapiKey: "test@", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a (", args: args{lapiKey: "test("}, wantErr: true},
|
{name: "Not validate a (", args: args{lapiKey: "test(", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a [", args: args{lapiKey: "test["}, wantErr: true},
|
{name: "Not validate a [", args: args{lapiKey: "test[", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a ?", args: args{lapiKey: "test?"}, wantErr: true},
|
{name: "Not validate a ?", args: args{lapiKey: "test?", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
{name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n"}, wantErr: true},
|
{name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := validateParamsAPIKey(tt.args.lapiKey); (err != nil) != tt.wantErr {
|
if err := validateParamsAPIKey(tt.args.lapiKey, tt.args.paramName); (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateParamsAPIKey() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateParamsAPIKey() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -229,6 +232,7 @@ func Test_validateParamsAPIKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
||||||
|
log := logger.New("INFO", "")
|
||||||
type args struct {
|
type args struct {
|
||||||
config *Config
|
config *Config
|
||||||
}
|
}
|
||||||
@@ -242,7 +246,7 @@ func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO", ""))
|
got, err := GetTLSConfigCrowdsec(tt.args.config, log, false)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
return
|
return
|
||||||
|
|||||||
+2
-3
@@ -5,11 +5,10 @@ package ip
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CHECKER
|
// CHECKER
|
||||||
@@ -21,7 +20,7 @@ type Checker struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewChecker builds a new Checker given a list of CIDR-Strings to trusted IPs.
|
// NewChecker builds a new Checker given a list of CIDR-Strings to trusted IPs.
|
||||||
func NewChecker(log *logger.Log, trustedIPs []string) (*Checker, error) {
|
func NewChecker(log *slog.Logger, trustedIPs []string) (*Checker, error) {
|
||||||
checker := &Checker{}
|
checker := &Checker{}
|
||||||
|
|
||||||
for _, ipMaskRaw := range trustedIPs {
|
for _, ipMaskRaw := range trustedIPs {
|
||||||
|
|||||||
+68
-55
@@ -1,79 +1,92 @@
|
|||||||
// Package logger implements utility routines to write to stdout and stderr.
|
// Package logger implements utility routines to write to stdout and stderr.
|
||||||
// It supports trace, debug, info and error level
|
// It supports trace, debug, info, warn and error level using Go's standard log/slog
|
||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"log/slog"
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Log Logger struct.
|
// Custom log levels following slog best practices.
|
||||||
type Log struct {
|
const (
|
||||||
logError *log.Logger
|
LevelDebug = slog.LevelDebug
|
||||||
logInfo *log.Logger
|
LevelInfo = slog.LevelInfo
|
||||||
logDebug *log.Logger
|
LevelWarn = slog.LevelWarn
|
||||||
|
LevelError = slog.LevelError
|
||||||
|
)
|
||||||
|
|
||||||
|
// New creates a Log wrapper with default format (common).
|
||||||
|
func New(logLevel string, logFilePath string) *slog.Logger {
|
||||||
|
return NewWithFormat(logLevel, logFilePath, "common")
|
||||||
}
|
}
|
||||||
|
|
||||||
// New Set Default log level to info in case log level to defined.
|
// NewWithFormat creates a Log wrapper with specified format (common or json).
|
||||||
func New(logLevel string, logFilePath string) *Log {
|
func NewWithFormat(logLevel, logFilePath, logFormat string) *slog.Logger {
|
||||||
// Initialize loggers with discard output
|
// Determine log level
|
||||||
logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
var level slog.Level
|
||||||
logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
switch logLevel {
|
||||||
logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
case "ERROR":
|
||||||
|
level = LevelError
|
||||||
|
case "WARN":
|
||||||
|
level = LevelWarn
|
||||||
|
case "INFO":
|
||||||
|
level = LevelInfo
|
||||||
|
case "DEBUG":
|
||||||
|
level = LevelDebug
|
||||||
|
default:
|
||||||
|
// Default to INFO level
|
||||||
|
level = LevelInfo
|
||||||
|
}
|
||||||
|
|
||||||
// we initialize logger to STDOUT/STDERR first so if the file logger cannot be initialized we can inform the user
|
// Set output destination
|
||||||
output := os.Stdout
|
var output *os.File
|
||||||
errorOutput := os.Stderr
|
|
||||||
|
|
||||||
// prepare file logging if specified
|
|
||||||
if logFilePath != "" {
|
if logFilePath != "" {
|
||||||
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
output = logFile
|
output = logFile
|
||||||
errorOutput = logFile
|
|
||||||
} else {
|
} else {
|
||||||
_ = fmt.Errorf("LogFilePath is not writable %w", err)
|
// Fall back to stdout and log the error
|
||||||
|
output = os.Stdout
|
||||||
|
slog.Warn("LogFilePath is not writable, using stdout", "error", err)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
output = os.Stdout
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set error logger output
|
// Create handler based on format with custom level names
|
||||||
logError.SetOutput(errorOutput)
|
var handler slog.Handler
|
||||||
|
opts := &slog.HandlerOptions{
|
||||||
// Configure log levels
|
Level: level,
|
||||||
switch logLevel {
|
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||||||
case "ERROR":
|
// Customize level names to match our expected format
|
||||||
// Only error logging is enabled
|
if a.Key == slog.LevelKey {
|
||||||
case "INFO":
|
lvl, ok := a.Value.Any().(slog.Level)
|
||||||
logInfo.SetOutput(output)
|
if !ok {
|
||||||
case "DEBUG":
|
return a
|
||||||
logInfo.SetOutput(output)
|
}
|
||||||
logDebug.SetOutput(output)
|
switch {
|
||||||
default:
|
case lvl < LevelInfo:
|
||||||
// Default to INFO level
|
a.Value = slog.StringValue("DEBUG")
|
||||||
logInfo.SetOutput(output)
|
case lvl < LevelWarn:
|
||||||
|
a.Value = slog.StringValue("INFO")
|
||||||
|
case lvl < LevelError:
|
||||||
|
a.Value = slog.StringValue("WARN")
|
||||||
|
default:
|
||||||
|
a.Value = slog.StringValue("ERROR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Log{
|
if logFormat == "json" {
|
||||||
logError: logError,
|
handler = slog.NewJSONHandler(output, opts)
|
||||||
logInfo: logInfo,
|
} else {
|
||||||
logDebug: logDebug,
|
// Common format (default)
|
||||||
|
handler = slog.NewTextHandler(output, opts)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Info log to Stdout.
|
// Create logger with component attribute
|
||||||
func (l *Log) Info(str string) {
|
return slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||||
l.logInfo.Printf("%s", str)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug log to Stdout.
|
|
||||||
func (l *Log) Debug(str string) {
|
|
||||||
l.logDebug.Printf("%s", str)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error log to Stderr.
|
|
||||||
func (l *Log) Error(str string) {
|
|
||||||
l.logError.Printf("%s", str)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
logLevel string
|
||||||
|
}{
|
||||||
|
{name: "ERROR level", logLevel: "ERROR"},
|
||||||
|
{name: "WARN level", logLevel: "WARN"},
|
||||||
|
{name: "INFO level", logLevel: "INFO"},
|
||||||
|
{name: "DEBUG level", logLevel: "DEBUG"},
|
||||||
|
{name: "Default level (INFO)", logLevel: "INVALID"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
logger := New(tt.logLevel, "")
|
||||||
|
|
||||||
|
// Verify logger is created
|
||||||
|
if logger == nil {
|
||||||
|
t.Fatal("Expected logger to be created, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a slog.Logger (we can call methods on it)
|
||||||
|
logger.Info("test initialization")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLogFormat(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Create a logger with JSON handler to capture output
|
||||||
|
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||||
|
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||||
|
|
||||||
|
testMessage := "json test message"
|
||||||
|
logger.Info(testMessage)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||||
|
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("Expected 1 log line, got %d", len(lines))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's valid JSON
|
||||||
|
var logEntry map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(lines[0]), &logEntry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected valid JSON output, got error: %v, output: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JSON structure
|
||||||
|
if logEntry["level"] != "INFO" {
|
||||||
|
t.Errorf("Expected level 'INFO', got '%v'", logEntry["level"])
|
||||||
|
}
|
||||||
|
if logEntry["msg"] != testMessage {
|
||||||
|
t.Errorf("Expected message '%s', got '%v'", testMessage, logEntry["msg"])
|
||||||
|
}
|
||||||
|
if logEntry["time"] == nil {
|
||||||
|
t.Error("Expected timestamp to be set")
|
||||||
|
}
|
||||||
|
if logEntry["component"] != "CrowdsecBouncerTraefikPlugin" {
|
||||||
|
t.Errorf("Expected component 'CrowdsecBouncerTraefikPlugin', got '%v'", logEntry["component"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonLogFormat(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Create a logger with text handler to capture output
|
||||||
|
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||||
|
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||||
|
|
||||||
|
testMessage := "common test message"
|
||||||
|
logger.Info(testMessage)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
// Verify common format (should contain level and message)
|
||||||
|
if !strings.Contains(output, "level=INFO") {
|
||||||
|
t.Error("Expected common format with INFO level")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, testMessage) {
|
||||||
|
t.Error("Expected test message in common format")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "component=CrowdsecBouncerTraefikPlugin") {
|
||||||
|
t.Error("Expected component field in common format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should NOT be JSON (should be slog text format)
|
||||||
|
var logEntry map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(strings.TrimSpace(output)), &logEntry)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected common format (not JSON), but got valid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorLevel(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Create a logger with ERROR level to capture output
|
||||||
|
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelError})
|
||||||
|
logger := slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||||
|
|
||||||
|
testMessage := "error only test"
|
||||||
|
|
||||||
|
// Test all log methods
|
||||||
|
logger.Error(testMessage)
|
||||||
|
logger.Warn(testMessage) // Should not appear
|
||||||
|
logger.Info(testMessage) // Should not appear
|
||||||
|
logger.Debug(testMessage) // Should not appear
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
// Only ERROR should appear
|
||||||
|
if !strings.Contains(output, "level=ERROR") {
|
||||||
|
t.Error("Expected ERROR message to appear")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other levels should NOT appear
|
||||||
|
unwantedLevels := []string{"level=WARN", "level=INFO", "level=DEBUG"}
|
||||||
|
for _, level := range unwantedLevels {
|
||||||
|
if strings.Contains(output, level) {
|
||||||
|
t.Errorf("Unexpected %s message appeared at ERROR level", level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify only one message appears
|
||||||
|
messageCount := strings.Count(output, testMessage)
|
||||||
|
if messageCount != 1 {
|
||||||
|
t.Errorf("Expected 1 occurrence of test message at ERROR level, got %d", messageCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidLogFile(t *testing.T) {
|
||||||
|
// Try to create logger with invalid file path
|
||||||
|
logger := New("INFO", "/invalid/path/that/does/not/exist/test.log")
|
||||||
|
|
||||||
|
// Logger should still be created (falls back to stdout)
|
||||||
|
if logger == nil {
|
||||||
|
t.Fatal("Expected logger to be created even with invalid file path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not panic when logging
|
||||||
|
logger.Info("test message")
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||||
|
|
||||||
|
// pluginVersion is updated automatically by the release workflow.
|
||||||
|
var pluginVersion = "1.5.0" //nolint:gochecknoglobals
|
||||||
Reference in New Issue
Block a user