Compare commits

...
9 Commits
Author SHA1 Message Date
maxlerebourg 1f6a8991c8 🐛 fix appsec bypass with invalid content-length (#321) 2026-04-27 08:33:11 +02:00
Davidandmaxlerebourg 7f776fe0fe 🔊 Improve Logging: move to slog and add trace level (#276)
* Add Warn and Trace loglevels

* Move to slog

* fixes

* missing test file

* Fix tests

* Add wrapper with trace

* fix

* fix lint

* LINT

* LINT

* 🍱 Use only 4 level of logs

* fix after merge

* 🍱 fix lint

* 🍱 fix lint + remove trace logger from tests

* 🍱 fix tests

* 🍱 fix lint

* 🍱 try to fix test

* 🍱 Fix tests

* 🍱 fix README + adjust logs

---------

Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
2026-03-13 18:03:12 +01:00
cliffjao e54c1d5c4f Update README.md to show simplified dynamic configuration (#313)
* Update README.md to show simplified dynamic configuration

The full dynamic configuration can be pretty intimidating as a starting point. Provide a simplified dynamic configuration to get up & running quickly.

* Update README.md

Set enabled to true
2026-02-21 19:45:02 +01:00
Shadoweee77andmaxlerebourg c2bbc4dac5 cicd: Automate proper version tagging in the bouncer.go that is reported to Crowdsec LAPI (#314)
* cicd(versioning): Automate proper version tagging in the bouncer.go that is reported to Crowdsec LAPI

* cicd(versioning): Move pluginVersion to it's own file

* Disablie lint check for no global variables

* Remove trailing new-line

* Update pluginVersion declaration style

* 🍱 Simplify for tests

Removed the verification step after updating the version and modified the commit message to include an emoji.

---------

Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
2026-02-19 23:41:55 +01:00
maxlerebourg 889c5b55fe Add solved-captcha as option of remediationCustomHeader (#310) 2026-01-23 11:36:32 +01:00
maxlerebourgandCopilot Autofix powered by AI efb3a67019 🔒️ Potential fix for code scanning alert no. 8: Workflow does not contain permissions (#311)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-01-22 09:44:33 +01:00
omer 0780027252 🐛 Do not validate Crowdsec LAPI authentication credentials if bouncer is in Appsec mode (#305)
* Do not validate Crowdsec LAPI key and TLS authentication if bouncer is in AppSec mode

* Add extra validation checks for lapiKey or appsecKey definition

* Fix linting on changed error message

* Update configuration.go

- Show paramName in validateParamsApiKey
- Remove check for empty appsecKey or lapiKey (LAPI can have TLS auth)
- Remove check for emptry appsecKey if CrowdsecMode is Appsec

* Update configuration_test.go after updated validateParamsAPIKey
2026-01-20 23:42:24 +01:00
maxlerebourgandmhx 892909b9b8 🐛 fix start up config error for appsec and review doc for appsec tls (#300)
* 🐛 fix start up config error for appsec

* :doc: add documentation on appsec variables and missing conf parameter

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix after lot of tests

* update exemple tls with new variables tested

* fix exemple appsec with release and not localplugin

---------

Co-authored-by: mhx <mathieu@hanotaux.fr>
2025-12-21 21:52:19 +01:00
dependabot[bot] c26923dee5 ⬆️ Bump actions/cache from 4 to 5 (#303)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-20 14:30:49 +01:00
21 changed files with 894 additions and 227 deletions
+4 -1
View File
@@ -1,5 +1,8 @@
name: Main name: Main
permissions:
contents: read
on: on:
push: push:
branches: branches:
@@ -37,7 +40,7 @@ jobs:
# 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') }}
+46
View File
@@ -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 }}"
+1
View File
@@ -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
+1 -1
View File
@@ -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
+65 -9
View File
@@ -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"
@@ -522,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:
@@ -546,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
@@ -614,7 +663,7 @@ http:
#### 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:
@@ -677,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
+30 -18
View File
@@ -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"
@@ -117,7 +118,7 @@ type Bouncer struct {
httpAppsecClient *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.
@@ -125,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
@@ -134,17 +135,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)
tlsAppsecConfig, err := configuration.GetTLSConfigCrowdsec(config, log, true)
var tlsAppsecConfig *tls.Config
if config.CrowdsecAppsecEnabled {
tlsAppsecConfig, err = configuration.GetTLSConfigCrowdsec(config, log, true)
if config.CrowdsecAppsecScheme == "" {
config.CrowdsecAppsecScheme = config.CrowdsecLapiScheme
}
if err != nil { if err != nil {
log.Error("New:getTLSConfigCrowdsec fail to get tlsAppsecConfig " + err.Error()) log.Error("New:getTLSConfigCrowdsec fail to get tlsAppsecConfig " + err.Error())
return nil, err return nil, err
} }
apiAppsecKey, errAppsecKey := configuration.GetVariable(config, "CrowdsecAppsecKey") apiAppsecKey, errAppsecKey := configuration.GetVariable(config, "CrowdsecAppsecKey")
if errAppsecKey != nil && len(tlsAppsecConfig.Certificates) == 0 { if errAppsecKey != nil && len(tlsAppsecConfig.Certificates) == 0 {
log.Error("New:crowdsecLapiKey fail to get CrowdsecAppsecKey and no client certificate setup " + errAppsecKey.Error()) log.Info("New:crowdsecLapiKey fail to get CrowdsecAppsecKey and no client certificate setup " + errAppsecKey.Error())
return nil, errAppsecKey
} }
config.CrowdsecAppsecKey = apiAppsecKey config.CrowdsecAppsecKey = apiAppsecKey
}
var tlsConfig *tls.Config var tlsConfig *tls.Config
crowdsecStreamRoute := "" crowdsecStreamRoute := ""
@@ -155,7 +162,6 @@ 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 = config.CrowdsecAppsecEnabled && config.CrowdsecAppsecScheme != ""
config.UpdateIntervalSeconds = 7200 // 2 hours config.UpdateIntervalSeconds = 7200 // 2 hours
crowdsecStreamRoute = crowdsecCapiStreamRoute crowdsecStreamRoute = crowdsecCapiStreamRoute
crowdsecHeader = crowdsecCapiHeader crowdsecHeader = crowdsecCapiHeader
@@ -173,6 +179,9 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
return nil, errKey return nil, errKey
} }
config.CrowdsecLapiKey = apiKey config.CrowdsecLapiKey = apiKey
if config.CrowdsecAppsecKey == "" {
config.CrowdsecAppsecKey = apiKey
}
} }
var banTemplate *htmltemplate.Template var banTemplate *htmltemplate.Template
@@ -374,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 {
@@ -444,7 +456,7 @@ func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Req
err := bouncer.banTemplate.Execute(rw, templateData) 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())
} }
} }
@@ -475,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()))
@@ -493,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() {
@@ -560,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())
@@ -596,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)
} }
@@ -643,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()))
} }
@@ -651,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
} }
@@ -663,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 {
@@ -701,7 +713,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
Path: bouncer.appsecPath, Path: bouncer.appsecPath,
} }
var req *http.Request var req *http.Request
if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil && httpReq.ContentLength > 0 { if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil {
var bodyBuffer bytes.Buffer var bodyBuffer bytes.Buffer
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit) limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
teeReader := io.TeeReader(limitedReader, &bodyBuffer) teeReader := io.TeeReader(limitedReader, &bodyBuffer)
@@ -768,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{}{
+320
View File
@@ -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))
}
+4 -3
View File
@@ -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
@@ -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
+19 -3
View File
@@ -1,7 +1,9 @@
# Example # Example
## Using https communication and tls authentication with Crowdsec ## Using https communication and tls authentication with Crowdsec
##### Summary ##### Summary
This example demonstrates the use of https between the Traefik plugin and the Crowdsec LAPI. This example demonstrates the use of https between the Traefik plugin and the Crowdsec LAPI.
It is possible to communicate with the LAPI in https and still authenticate with API key. It is possible to communicate with the LAPI in https and still authenticate with API key.
@@ -17,7 +19,9 @@ In that case the setting **crowdsecLapiTLSInsecureVerify** must be set to true.
It is recommended to validate the certificate presented by Crowdsec LAPI using the Certificate Authority which created it. It is recommended to validate the certificate presented by Crowdsec LAPI using the Certificate Authority which created it.
You can provide the Certificate Authority using: You can provide the Certificate Authority using:
* A file path readable by Traefik
- A file path readable by Traefik
```yaml ```yaml
http: http:
middlewares: middlewares:
@@ -26,9 +30,11 @@ http:
bouncer: bouncer:
crowdsecLapiTlsCertificateAuthorityFile: /etc/traefik/certs/crowdsecCA.pem crowdsecLapiTlsCertificateAuthorityFile: /etc/traefik/certs/crowdsecCA.pem
``` ```
* The PEM encoded certificate as a text variable
- The PEM encoded certificate as a text variable
In the static file configuration of Traefik In the static file configuration of Traefik
```yaml ```yaml
http: http:
middlewares: middlewares:
@@ -44,7 +50,9 @@ http:
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
-----END CERTIFICATE----- -----END CERTIFICATE-----
``` ```
In a dynamic configuration of a provider (ex docker) as a Label In a dynamic configuration of a provider (ex docker) as a Label
```yaml ```yaml
services: services:
whoami-foo: whoami-foo:
@@ -71,26 +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 restarted 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 ## 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...`. 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. Don't forget to set `CrowdsecAppsecScheme: HTTP` or `HTTPS` to trigger the separate setup.
+9
View File
@@ -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
+15 -9
View File
@@ -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
@@ -53,27 +51,34 @@ services:
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
+3 -1
View File
@@ -1,6 +1,8 @@
#!/bin/bash #!/bin/bash
stdout=/out/res.log if [ -f "/out/inter-key.pem" ]; then
exit 0
fi
cfssl gencert --initca /in/ca.json 2>${stdout} | cfssljson --bare "/out/ca" && \ cfssl gencert --initca /in/ca.json 2>${stdout} | cfssljson --bare "/out/ca" && \
# Generate an intermediate certificate that will be used to sign the client certificates # Generate an intermediate certificate that will be used to sign the client certificates
cfssl gencert --initca /in/intermediate.json 2>${stdout} | cfssljson --bare "/out/inter" && \ cfssl gencert --initca /in/intermediate.json 2>${stdout} | cfssljson --bare "/out/inter" && \
+4 -5
View File
@@ -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)
+6 -3
View File
@@ -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
} }
+36 -19
View File
@@ -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"
@@ -44,6 +45,7 @@ const (
type Config struct { type Config struct {
Enabled bool `json:"enabled,omitempty"` Enabled bool `json:"enabled,omitempty"`
LogLevel string `json:"logLevel,omitempty"` LogLevel string `json:"logLevel,omitempty"`
LogFormat string `json:"logFormat,omitempty"`
LogFilePath string `json:"logFilePath,omitempty"` LogFilePath string `json:"logFilePath,omitempty"`
CrowdsecMode string `json:"crowdsecMode,omitempty"` CrowdsecMode string `json:"crowdsecMode,omitempty"`
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"` CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
@@ -58,6 +60,7 @@ type Config struct {
CrowdsecAppsecTLSCertificateBouncer string `json:"crowdsecAppsecTlsCertificateBouncer,omitempty"` CrowdsecAppsecTLSCertificateBouncer string `json:"crowdsecAppsecTlsCertificateBouncer,omitempty"`
CrowdsecAppsecTLSCertificateBouncerFile string `json:"crowdsecAppsecTlsCertificateBouncerFile,omitempty"` CrowdsecAppsecTLSCertificateBouncerFile string `json:"crowdsecAppsecTlsCertificateBouncerFile,omitempty"`
CrowdsecAppsecTLSCertificateBouncerKey string `json:"crowdsecAppsecTlsCertificateBouncerKey,omitempty"` CrowdsecAppsecTLSCertificateBouncerKey string `json:"crowdsecAppsecTlsCertificateBouncerKey,omitempty"`
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"` CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"` CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"` CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
@@ -123,6 +126,7 @@ func New() *Config {
return &Config{ return &Config{
Enabled: false, Enabled: false,
LogLevel: LogINFO, LogLevel: LogINFO,
LogFormat: "common",
LogFilePath: "", LogFilePath: "",
CrowdsecMode: LiveMode, CrowdsecMode: LiveMode,
CrowdsecAppsecEnabled: false, CrowdsecAppsecEnabled: false,
@@ -217,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
} }
@@ -226,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
} }
@@ -276,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
@@ -284,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
} }
} }
@@ -303,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)
@@ -328,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
} }
@@ -352,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)
} }
} }
@@ -432,17 +449,17 @@ func validateParamsRequired(config *Config) error {
return nil return nil
} }
func getTLSConfig(config *Config, log *logger.Log, prefix, scheme string, insecureVerify bool) (*tls.Config, error) { func getTLSConfig(config *Config, log *slog.Logger, prefix, scheme string, insecureVerify bool) (*tls.Config, error) {
tlsConfig := new(tls.Config) tlsConfig := new(tls.Config)
tlsConfig.RootCAs = x509.NewCertPool() tlsConfig.RootCAs = x509.NewCertPool()
if scheme != HTTPS { if scheme != HTTPS {
log.Debug("getTLSConfigCrowdsec:" + prefix + "Scheme https:no") log.Debug("getTLSConfig:" + prefix + "Scheme https:no")
return tlsConfig, nil return tlsConfig, nil
} }
//nolint:nestif //nolint:nestif
if insecureVerify { if insecureVerify {
tlsConfig.InsecureSkipVerify = true tlsConfig.InsecureSkipVerify = true
log.Debug("getTLSConfigCrowdsec:" + prefix + "TLSInsecureVerify 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 {
@@ -454,9 +471,9 @@ func getTLSConfig(config *Config, log *logger.Log, prefix, scheme string, insecu
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:" + prefix + "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:" + prefix + "TLSCertificateAuthority CA added successfully") log.Debug("getTLSConfig:" + prefix + "TLSCertificateAuthority CA added successfully")
} }
} }
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer") certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
@@ -480,7 +497,7 @@ func getTLSConfig(config *Config, log *logger.Log, prefix, scheme string, insecu
} }
// GetTLSConfigCrowdsec get TLS config from Config. // GetTLSConfigCrowdsec get TLS config from Config.
func GetTLSConfigCrowdsec(config *Config, log *logger.Log, isAppsec bool) (*tls.Config, error) { func GetTLSConfigCrowdsec(config *Config, log *slog.Logger, isAppsec bool) (*tls.Config, error) {
var prefix string var prefix string
if isAppsec && config.CrowdsecAppsecScheme != "" { if isAppsec && config.CrowdsecAppsecScheme != "" {
prefix = "CrowdsecAppsec" prefix = "CrowdsecAppsec"
+14 -10
View File
@@ -72,6 +72,7 @@ func Test_GetVariable(t *testing.T) {
} }
func Test_ValidateParams(t *testing.T) { func Test_ValidateParams(t *testing.T) {
log := logger.New("INFO", "")
cfg1 := New() cfg1 := New()
cfg1.CrowdsecLapiKey = "test\n\n" cfg1.CrowdsecLapiKey = "test\n\n"
cfg2 := New() cfg2 := New()
@@ -117,7 +118,7 @@ func Test_ValidateParams(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if err := ValidateParams(tt.args.config); (err != nil) != tt.wantErr { if err := ValidateParams(tt.args.config, log); (err != nil) != tt.wantErr {
t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr)
} }
}) })
@@ -145,6 +146,7 @@ func Test_validateParamsTLS(t *testing.T) {
} }
func Test_validateParamsIPs(t *testing.T) { func Test_validateParamsIPs(t *testing.T) {
log := logger.New("INFO", "")
type args struct { type args struct {
listIP []string listIP []string
key string key string
@@ -164,7 +166,7 @@ func Test_validateParamsIPs(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if err := validateParamsIPs(tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr { if err := validateParamsIPs(log, tt.args.listIP, tt.args.key); (err != nil) != tt.wantErr {
t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr)
} }
}) })
@@ -206,22 +208,23 @@ func Test_validateParamsRequired(t *testing.T) {
func Test_validateParamsAPIKey(t *testing.T) { func Test_validateParamsAPIKey(t *testing.T) {
type args struct { type args struct {
lapiKey string lapiKey string
paramName string
} }
tests := []struct { tests := []struct {
name string name string
args args args args
wantErr bool wantErr bool
}{ }{
{name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~"}, wantErr: false}, {name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~", paramName: "CrowdsecParamName"}, wantErr: false},
{name: "Not validate a @", args: args{lapiKey: "test@"}, wantErr: true}, {name: "Not validate a @", args: args{lapiKey: "test@", paramName: "CrowdsecParamName"}, wantErr: true},
{name: "Not validate a (", args: args{lapiKey: "test("}, wantErr: true}, {name: "Not validate a (", args: args{lapiKey: "test(", paramName: "CrowdsecParamName"}, wantErr: true},
{name: "Not validate a [", args: args{lapiKey: "test["}, wantErr: true}, {name: "Not validate a [", args: args{lapiKey: "test[", paramName: "CrowdsecParamName"}, wantErr: true},
{name: "Not validate a ?", args: args{lapiKey: "test?"}, wantErr: true}, {name: "Not validate a ?", args: args{lapiKey: "test?", paramName: "CrowdsecParamName"}, wantErr: true},
{name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n"}, wantErr: true}, {name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n", paramName: "CrowdsecParamName"}, wantErr: true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if err := validateParamsAPIKey(tt.args.lapiKey); (err != nil) != tt.wantErr { if err := validateParamsAPIKey(tt.args.lapiKey, tt.args.paramName); (err != nil) != tt.wantErr {
t.Errorf("validateParamsAPIKey() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("validateParamsAPIKey() error = %v, wantErr %v", err, tt.wantErr)
} }
}) })
@@ -229,6 +232,7 @@ func Test_validateParamsAPIKey(t *testing.T) {
} }
func Test_GetTLSConfigCrowdsec(t *testing.T) { func Test_GetTLSConfigCrowdsec(t *testing.T) {
log := logger.New("INFO", "")
type args struct { type args struct {
config *Config config *Config
} }
@@ -242,7 +246,7 @@ func Test_GetTLSConfigCrowdsec(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO", ""), false) 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
View File
@@ -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 {
+67 -54
View File
@@ -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 {
case lvl < LevelInfo:
a.Value = slog.StringValue("DEBUG")
case lvl < LevelWarn:
a.Value = slog.StringValue("INFO")
case lvl < LevelError:
a.Value = slog.StringValue("WARN")
default: default:
// Default to INFO level a.Value = slog.StringValue("ERROR")
logInfo.SetOutput(output) }
}
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)
} }
+156
View File
@@ -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")
}
+4
View File
@@ -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