mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66b57496f8 | ||
|
|
71d845faae | ||
|
|
0d8fd2a7a9 | ||
|
|
1f6a8991c8 | ||
|
|
7f776fe0fe | ||
|
|
e54c1d5c4f | ||
|
|
c2bbc4dac5 | ||
|
|
889c5b55fe | ||
|
|
efb3a67019 |
@@ -1,5 +1,8 @@
|
||||
name: Main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
|
||||
@@ -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
|
||||
- mnd
|
||||
- exportloopref
|
||||
- contextcheck
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-same-issues: 0
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
.PHONY: lint test vendor clean
|
||||
.PHONY: lint test vendor clean e2e
|
||||
|
||||
export GO111MODULE=on
|
||||
|
||||
E2E_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec
|
||||
|
||||
default: lint test
|
||||
|
||||
lint:
|
||||
@@ -13,6 +15,11 @@ test:
|
||||
yaegi_test:
|
||||
yaegi test -v .
|
||||
|
||||
e2e: $(addprefix e2e_,$(E2E_SCENARIOS))
|
||||
|
||||
e2e_%:
|
||||
./tests/e2e/scenarios/$*/run.sh
|
||||
|
||||
vendor:
|
||||
go mod vendor
|
||||
|
||||
|
||||
@@ -329,8 +329,12 @@ make run
|
||||
- Enable the plugin
|
||||
- LogLevel
|
||||
- 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
|
||||
- LogFormat
|
||||
- string
|
||||
- default: `common`, expected values are: `common`, `json`
|
||||
- Log format: `common` for traditional text logs, `json` for structured JSON logs
|
||||
- LogFilePath
|
||||
- string
|
||||
- default: ""
|
||||
@@ -420,7 +424,7 @@ make run
|
||||
- RemediationHeadersCustomName
|
||||
- string
|
||||
- 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
|
||||
- string
|
||||
- default: "X-Forwarded-For"
|
||||
@@ -461,6 +465,12 @@ make run
|
||||
- int64
|
||||
- default: 0
|
||||
- Used only in `stream` and `alone` mode, the maximum number of time we can not reach Crowdsec before blocking traffic (set -1 to never block)
|
||||
- StreamStartupBlock
|
||||
- bool
|
||||
- default: true
|
||||
- Used only in `stream` and `alone` mode, controls whether the initial stream update runs synchronously or asynchronously during plugin initialization
|
||||
- When `true`, plugin initialization waits for Crowdsec to be ready before serving traffic.
|
||||
- **Warning**: When `false`, all requests bypass remediation until the first stream sync completes — banned IPs will be allowed through during this window. Only disable when startup availability is more important than blocking at startup.
|
||||
- DefaultDecisionSeconds
|
||||
- int64
|
||||
- default: 60
|
||||
@@ -536,7 +546,37 @@ experimental:
|
||||
```
|
||||
|
||||
```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:
|
||||
routers:
|
||||
@@ -560,9 +600,11 @@ http:
|
||||
bouncer:
|
||||
enabled: false
|
||||
logLevel: DEBUG
|
||||
logFormat: common
|
||||
LogFilePath: ""
|
||||
updateIntervalSeconds: 60
|
||||
updateMaxFailure: 0
|
||||
streamStartupBlock: true
|
||||
defaultDecisionSeconds: 60
|
||||
remediationStatusCode: 403
|
||||
httpTimeoutSeconds: 10
|
||||
|
||||
+26
-19
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
htmltemplate "html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -63,7 +64,7 @@ const (
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var (
|
||||
isStartup = true
|
||||
isCrowdsecStreamStartup = true
|
||||
isCrowdsecStreamHealthy = true
|
||||
updateFailure int64
|
||||
streamTicker chan bool
|
||||
@@ -117,16 +118,16 @@ type Bouncer struct {
|
||||
httpAppsecClient *http.Client
|
||||
cacheClient *cache.Client
|
||||
captchaClient *captcha.Client
|
||||
log *logger.Log
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates the crowdsec bouncer plugin.
|
||||
//
|
||||
//nolint:gocyclo
|
||||
//nolint:nestif,gocyclo,gocognit
|
||||
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
||||
config.LogLevel = strings.ToUpper(config.LogLevel)
|
||||
log := logger.New(config.LogLevel, config.LogFilePath)
|
||||
err := configuration.ValidateParams(config)
|
||||
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
|
||||
err := configuration.ValidateParams(config, log)
|
||||
if err != nil {
|
||||
log.Error("New:validateParams " + err.Error())
|
||||
return nil, err
|
||||
@@ -290,8 +291,11 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if config.StreamStartupBlock {
|
||||
handleStreamTicker(bouncer)
|
||||
isStartup = false
|
||||
} else {
|
||||
go handleStreamTicker(bouncer)
|
||||
}
|
||||
streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() {
|
||||
handleStreamTicker(bouncer)
|
||||
})
|
||||
@@ -300,7 +304,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
// Start metrics ticker if not already running
|
||||
if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 {
|
||||
lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker
|
||||
handleMetricsTicker(bouncer)
|
||||
go handleMetricsTicker(bouncer)
|
||||
metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() {
|
||||
handleMetricsTicker(bouncer)
|
||||
})
|
||||
@@ -455,7 +459,7 @@ func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Req
|
||||
err := bouncer.banTemplate.Execute(rw, templateData)
|
||||
|
||||
if err != nil {
|
||||
bouncer.log.Error("handleBanServeHTTP banTemplateServe " + err.Error())
|
||||
bouncer.log.Warn("handleBanServeHTTP could not write template to ResponseWriter: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,7 +490,7 @@ func (bouncer *Bouncer) handleNextServeHTTP(rw http.ResponseWriter, req *http.Re
|
||||
|
||||
func handleStreamTicker(bouncer *Bouncer) {
|
||||
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 {
|
||||
isCrowdsecStreamHealthy = false
|
||||
bouncer.log.Error(fmt.Sprintf("handleStreamTicker:error updateFailure:%d %s", updateFailure, err.Error()))
|
||||
@@ -504,7 +508,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)
|
||||
stop := make(chan bool, 1)
|
||||
go func() {
|
||||
@@ -571,7 +575,7 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
|
||||
case "captcha":
|
||||
value = cache.CaptchaValue
|
||||
default:
|
||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
||||
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
|
||||
}
|
||||
if isLiveMode && bouncer.defaultDecisionTimeout > 0 {
|
||||
durationSecond := int64(duration.Seconds())
|
||||
@@ -607,11 +611,11 @@ func getToken(bouncer *Bouncer) error {
|
||||
if err != nil {
|
||||
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.log.Debug(fmt.Sprintf("getToken statusCode:%d", login.Code))
|
||||
return nil
|
||||
}
|
||||
bouncer.log.Warn(fmt.Sprintf("getToken statusCode:%d", login.Code))
|
||||
return fmt.Errorf("getToken statusCode:%d", login.Code)
|
||||
}
|
||||
|
||||
@@ -623,6 +627,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
||||
_, err := bouncer.cacheClient.Get(cacheTimeoutKey)
|
||||
if err == nil {
|
||||
bouncer.log.Debug("handleStreamCache:alreadyUpdated")
|
||||
isCrowdsecStreamStartup = false
|
||||
return nil
|
||||
}
|
||||
if err.Error() != cache.CacheMiss {
|
||||
@@ -633,7 +638,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
||||
Scheme: bouncer.crowdsecScheme,
|
||||
Host: bouncer.crowdsecHost,
|
||||
Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute,
|
||||
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup),
|
||||
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isCrowdsecStreamStartup),
|
||||
}
|
||||
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil)
|
||||
if err != nil {
|
||||
@@ -654,7 +659,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
||||
case "captcha":
|
||||
value = cache.CaptchaValue
|
||||
default:
|
||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
||||
bouncer.log.Info("handleStreamCache:unknownType " + decision.Type)
|
||||
}
|
||||
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
|
||||
}
|
||||
@@ -663,6 +668,7 @@ func handleStreamCache(bouncer *Bouncer) error {
|
||||
bouncer.cacheClient.Delete(decision.Value)
|
||||
}
|
||||
bouncer.log.Debug("handleStreamCache:updated")
|
||||
isCrowdsecStreamStartup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -673,8 +679,8 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
|
||||
} else {
|
||||
req, _ = http.NewRequest(http.MethodGet, stringURL, nil)
|
||||
}
|
||||
req.Header.Add(bouncer.crowdsecHeader, bouncer.crowdsecKey)
|
||||
req.Header.Add("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/1.X.X")
|
||||
req.Header.Set(bouncer.crowdsecHeader, bouncer.crowdsecKey)
|
||||
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||
|
||||
res, err := bouncer.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -712,7 +718,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||
Path: bouncer.appsecPath,
|
||||
}
|
||||
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
|
||||
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
|
||||
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
|
||||
@@ -738,6 +744,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
|
||||
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
|
||||
req.Header.Set(crowdsecAppsecUserAgent, httpReq.Header.Get("User-Agent"))
|
||||
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||
|
||||
res, err := bouncer.httpAppsecClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -779,7 +786,7 @@ func reportMetrics(bouncer *Bouncer) error {
|
||||
metrics := map[string]interface{}{
|
||||
"remediation_components": []map[string]interface{}{
|
||||
{
|
||||
"version": "1.X.X",
|
||||
"version": pluginVersion,
|
||||
"type": "bouncer",
|
||||
"name": "traefik_plugin",
|
||||
"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))
|
||||
}
|
||||
Vendored
+4
-5
@@ -5,11 +5,10 @@ package cache
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
ttl_map "github.com/leprosus/golang-ttl-map"
|
||||
simpleredis "github.com/maxlerebourg/simpleredis"
|
||||
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -53,7 +52,7 @@ func (localCache) delete(key string) {
|
||||
}
|
||||
|
||||
type redisCache struct {
|
||||
log *logger.Log
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func (redisCache) get(key string) (string, error) {
|
||||
@@ -93,11 +92,11 @@ type cacheInterface interface {
|
||||
// Client Cache client.
|
||||
type Client struct {
|
||||
cache cacheInterface
|
||||
log *logger.Log
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// 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
|
||||
if isRedis {
|
||||
redis.Init(host, pass, database)
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
||||
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
// Client Captcha client.
|
||||
@@ -24,7 +24,7 @@ type Client struct {
|
||||
captchaTemplate *template.Template
|
||||
cacheClient *cache.Client
|
||||
httpClient *http.Client
|
||||
log *logger.Log
|
||||
log *slog.Logger
|
||||
infoProvider *infoProvider
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ var infoProviders = map[string]*infoProvider{
|
||||
}
|
||||
|
||||
// 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 != ""
|
||||
if !c.Valid {
|
||||
return nil
|
||||
@@ -94,6 +94,9 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
|
||||
if valid {
|
||||
c.log.Debug("captcha:ServeHTTP captcha:valid")
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -16,7 +17,6 @@ import (
|
||||
"strings"
|
||||
|
||||
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
// Enums for crowdsec mode.
|
||||
@@ -30,6 +30,7 @@ const (
|
||||
HTTP = "http"
|
||||
LogDEBUG = "DEBUG"
|
||||
LogINFO = "INFO"
|
||||
LogWARN = "WARN"
|
||||
LogERROR = "ERROR"
|
||||
ReasonTECH = "TECHNICAL_ISSUE"
|
||||
ReasonLAPI = "LAPI"
|
||||
@@ -44,6 +45,7 @@ const (
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
LogFormat string `json:"logFormat,omitempty"`
|
||||
LogFilePath string `json:"logFilePath,omitempty"`
|
||||
CrowdsecMode string `json:"crowdsecMode,omitempty"`
|
||||
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
|
||||
@@ -82,6 +84,7 @@ type Config struct {
|
||||
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
||||
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
|
||||
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
|
||||
StreamStartupBlock bool `json:"streamStartupBlock,omitempty"`
|
||||
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
||||
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
|
||||
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
||||
@@ -124,6 +127,7 @@ func New() *Config {
|
||||
return &Config{
|
||||
Enabled: false,
|
||||
LogLevel: LogINFO,
|
||||
LogFormat: "common",
|
||||
LogFilePath: "",
|
||||
CrowdsecMode: LiveMode,
|
||||
CrowdsecAppsecEnabled: false,
|
||||
@@ -143,6 +147,7 @@ func New() *Config {
|
||||
UpdateIntervalSeconds: 60,
|
||||
MetricsUpdateIntervalSeconds: 600,
|
||||
UpdateMaxFailure: 0,
|
||||
StreamStartupBlock: true,
|
||||
DefaultDecisionSeconds: 60,
|
||||
RemediationStatusCode: http.StatusForbidden,
|
||||
HTTPTimeoutSeconds: 10,
|
||||
@@ -218,7 +223,7 @@ func GetHTMLTemplate(path string) (*template.Template, error) {
|
||||
// ValidateParams validate all the param gave by user.
|
||||
//
|
||||
//nolint:gocyclo,gocognit
|
||||
func ValidateParams(config *Config) error {
|
||||
func ValidateParams(config *Config, log *slog.Logger) error {
|
||||
if err := validateParamsRequired(config); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -227,10 +232,10 @@ func ValidateParams(config *Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
||||
if err := validateParamsIPs(log, config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateParamsIPs(config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
|
||||
if err := validateParamsIPs(log, config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -317,8 +322,8 @@ func ValidateParams(config *Config) error {
|
||||
|
||||
// Check logging configuration
|
||||
// to upper allow of anycase of log level
|
||||
if !contains([]string{LogERROR, LogDEBUG, LogINFO}, strings.ToUpper(config.LogLevel)) {
|
||||
return fmt.Errorf("LogLevel should be one of (%s,%s,%s)", LogDEBUG, LogINFO, LogERROR)
|
||||
if !contains([]string{LogDEBUG, LogINFO, LogWARN, LogERROR}, strings.ToUpper(config.LogLevel)) {
|
||||
return fmt.Errorf("LogLevel should be one of (%s,%s,%s,%s)", LogDEBUG, LogINFO, LogWARN, LogERROR)
|
||||
}
|
||||
if config.LogFilePath != "" {
|
||||
_, err = os.OpenFile(filepath.Clean(config.LogFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
@@ -366,9 +371,9 @@ func validateParamsTLS(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateParamsIPs(listIP []string, key string) error {
|
||||
func validateParamsIPs(log *slog.Logger, listIP []string, key string) error {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -446,17 +451,17 @@ func validateParamsRequired(config *Config) error {
|
||||
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.RootCAs = x509.NewCertPool()
|
||||
if scheme != HTTPS {
|
||||
log.Debug("getTLSConfigCrowdsec:" + prefix + "Scheme https:no")
|
||||
log.Debug("getTLSConfig:" + prefix + "Scheme https:no")
|
||||
return tlsConfig, nil
|
||||
}
|
||||
//nolint:nestif
|
||||
if insecureVerify {
|
||||
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
|
||||
// return tlsConfig, nil
|
||||
} else {
|
||||
@@ -468,9 +473,9 @@ func getTLSConfig(config *Config, log *logger.Log, prefix, scheme string, insecu
|
||||
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
||||
// here we return because if CrowdsecLapiTLSInsecureVerify is false
|
||||
// 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")
|
||||
@@ -494,7 +499,7 @@ func getTLSConfig(config *Config, log *logger.Log, prefix, scheme string, insecu
|
||||
}
|
||||
|
||||
// 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
|
||||
if isAppsec && config.CrowdsecAppsecScheme != "" {
|
||||
prefix = "CrowdsecAppsec"
|
||||
|
||||
@@ -72,6 +72,7 @@ func Test_GetVariable(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_ValidateParams(t *testing.T) {
|
||||
log := logger.New("INFO", "")
|
||||
cfg1 := New()
|
||||
cfg1.CrowdsecLapiKey = "test\n\n"
|
||||
cfg2 := New()
|
||||
@@ -117,7 +118,7 @@ func Test_ValidateParams(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -145,6 +146,7 @@ func Test_validateParamsTLS(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_validateParamsIPs(t *testing.T) {
|
||||
log := logger.New("INFO", "")
|
||||
type args struct {
|
||||
listIP []string
|
||||
key string
|
||||
@@ -164,7 +166,7 @@ func Test_validateParamsIPs(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -230,6 +232,7 @@ func Test_validateParamsAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
||||
log := logger.New("INFO", "")
|
||||
type args struct {
|
||||
config *Config
|
||||
}
|
||||
@@ -243,7 +246,7 @@ func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO", ""), false)
|
||||
got, err := GetTLSConfigCrowdsec(tt.args.config, log, false)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
||||
+2
-3
@@ -5,11 +5,10 @@ package ip
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||
)
|
||||
|
||||
// CHECKER
|
||||
@@ -21,7 +20,7 @@ type Checker struct {
|
||||
}
|
||||
|
||||
// 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{}
|
||||
|
||||
for _, ipMaskRaw := range trustedIPs {
|
||||
|
||||
+67
-54
@@ -1,79 +1,92 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Log Logger struct.
|
||||
type Log struct {
|
||||
logError *log.Logger
|
||||
logInfo *log.Logger
|
||||
logDebug *log.Logger
|
||||
// Custom log levels following slog best practices.
|
||||
const (
|
||||
LevelDebug = slog.LevelDebug
|
||||
LevelInfo = slog.LevelInfo
|
||||
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.
|
||||
func New(logLevel string, logFilePath string) *Log {
|
||||
// Initialize loggers with discard output
|
||||
logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
||||
logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
||||
logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime)
|
||||
// NewWithFormat creates a Log wrapper with specified format (common or json).
|
||||
func NewWithFormat(logLevel, logFilePath, logFormat string) *slog.Logger {
|
||||
// Determine log level
|
||||
var level slog.Level
|
||||
switch logLevel {
|
||||
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
|
||||
output := os.Stdout
|
||||
errorOutput := os.Stderr
|
||||
|
||||
// prepare file logging if specified
|
||||
// Set output destination
|
||||
var output *os.File
|
||||
if logFilePath != "" {
|
||||
logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err == nil {
|
||||
output = logFile
|
||||
errorOutput = logFile
|
||||
} else {
|
||||
_ = fmt.Errorf("LogFilePath is not writable %w", err)
|
||||
// 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
|
||||
logError.SetOutput(errorOutput)
|
||||
|
||||
// Configure log levels
|
||||
switch logLevel {
|
||||
case "ERROR":
|
||||
// Only error logging is enabled
|
||||
case "INFO":
|
||||
logInfo.SetOutput(output)
|
||||
case "DEBUG":
|
||||
logInfo.SetOutput(output)
|
||||
logDebug.SetOutput(output)
|
||||
// Create handler based on format with custom level names
|
||||
var handler slog.Handler
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: level,
|
||||
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||||
// Customize level names to match our expected format
|
||||
if a.Key == slog.LevelKey {
|
||||
lvl, ok := a.Value.Any().(slog.Level)
|
||||
if !ok {
|
||||
return a
|
||||
}
|
||||
switch {
|
||||
case lvl < LevelInfo:
|
||||
a.Value = slog.StringValue("DEBUG")
|
||||
case lvl < LevelWarn:
|
||||
a.Value = slog.StringValue("INFO")
|
||||
case lvl < LevelError:
|
||||
a.Value = slog.StringValue("WARN")
|
||||
default:
|
||||
// Default to INFO level
|
||||
logInfo.SetOutput(output)
|
||||
a.Value = slog.StringValue("ERROR")
|
||||
}
|
||||
}
|
||||
return a
|
||||
},
|
||||
}
|
||||
|
||||
return &Log{
|
||||
logError: logError,
|
||||
logInfo: logInfo,
|
||||
logDebug: logDebug,
|
||||
}
|
||||
if logFormat == "json" {
|
||||
handler = slog.NewJSONHandler(output, opts)
|
||||
} else {
|
||||
// Common format (default)
|
||||
handler = slog.NewTextHandler(output, opts)
|
||||
}
|
||||
|
||||
// Info log to Stdout.
|
||||
func (l *Log) Info(str string) {
|
||||
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)
|
||||
// Create logger with component attribute
|
||||
return slog.New(handler).With("component", "CrowdsecBouncerTraefikPlugin")
|
||||
}
|
||||
|
||||
@@ -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,55 @@
|
||||
# End-to-end test suite
|
||||
|
||||
These tests spin up real Traefik + Crowdsec containers and exercise the
|
||||
plugin in the same conditions Traefik uses in production: loaded from a
|
||||
local path, no module download, no mocking.
|
||||
|
||||
Each scenario lives in its own directory under `scenarios/` and owns:
|
||||
|
||||
- `docker-compose.yml` — the stack to spin up
|
||||
- `run.sh` — orchestration + assertions
|
||||
- optional fixtures (`acquis.yaml`, `ban.html`, ...)
|
||||
|
||||
## Running locally
|
||||
|
||||
Prerequisites: `docker`, `docker compose`, `curl`, `bash`.
|
||||
|
||||
Run a single scenario:
|
||||
|
||||
```bash
|
||||
./tests/e2e/scenarios/stream-mode/run.sh
|
||||
# or
|
||||
make e2e_stream-mode
|
||||
```
|
||||
|
||||
Run everything:
|
||||
|
||||
```bash
|
||||
make e2e
|
||||
```
|
||||
|
||||
Scenarios run **sequentially** on a single host: they share the canonical
|
||||
`crowdsec` container name (so `cscli` commands work uniformly) and the same
|
||||
`8000:80` port. Each scenario uses its own Docker Compose project
|
||||
(`-p e2e-<scenario>`) and tears its stack down on exit, so the next one
|
||||
starts clean. `make e2e` runs them one after another; Docker reuses the
|
||||
images pulled by the first scenario, so the Traefik / Crowdsec / whoami
|
||||
images are downloaded only once for the whole suite.
|
||||
|
||||
## Writing a new scenario
|
||||
|
||||
1. Copy `scenarios/stream-mode/` as a template.
|
||||
2. Rename `container_name`s (keep `crowdsec` for the LAPI container).
|
||||
3. Edit `run.sh` to express the behavior under test.
|
||||
4. Add the scenario name to `E2E_SCENARIOS` in the `Makefile`.
|
||||
|
||||
## CI
|
||||
|
||||
This Docker suite is **local-only** and intentionally not run in CI: it boots a
|
||||
real Crowdsec (and downloads the AppSec collections for that scenario), which is
|
||||
heavier and less deterministic than CI needs. CI instead runs a lighter
|
||||
binary + mock-LAPI suite that exercises the plugin without Docker or a real
|
||||
Crowdsec.
|
||||
|
||||
Run this suite locally with `make e2e` (all scenarios) or
|
||||
`make e2e_<scenario>` (one scenario).
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helpers for end-to-end scenarios.
|
||||
# Dependencies: bash, curl, docker.
|
||||
|
||||
# Wait until the Crowdsec LAPI reports ready, or fail after timeout.
|
||||
# Usage: wait_crowdsec_ready [container_name] [timeout_seconds]
|
||||
wait_crowdsec_ready() {
|
||||
local container="${1:-crowdsec}"
|
||||
local timeout="${2:-90}"
|
||||
local elapsed=0
|
||||
while (( elapsed < timeout )); do
|
||||
if docker exec "$container" cscli lapi status >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
((elapsed++))
|
||||
done
|
||||
echo "wait_crowdsec_ready: timed out after ${timeout}s waiting for $container" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Poll URL until it returns the expected status code, or fail.
|
||||
# Usage: wait_for_status URL CODE [TIMEOUT_SECONDS] [curl args...]
|
||||
wait_for_status() {
|
||||
local url="$1" expected="$2" timeout="${3:-30}"
|
||||
shift 3 || true
|
||||
local elapsed=0
|
||||
local got=""
|
||||
while (( elapsed < timeout )); do
|
||||
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url" || true)
|
||||
if [[ "$got" == "$expected" ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
((elapsed++))
|
||||
done
|
||||
echo "wait_for_status: $url expected $expected, last seen ${got:-<none>}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Assert a single curl returns the expected status code.
|
||||
# Usage: assert_status URL CODE [curl args...]
|
||||
assert_status() {
|
||||
local url="$1" expected="$2"
|
||||
shift 2 || true
|
||||
local got
|
||||
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url")
|
||||
if [[ "$got" != "$expected" ]]; then
|
||||
echo "assert_status: $url expected $expected, got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Assert a response header matches a value (case-insensitive name).
|
||||
# Usage: assert_header URL HEADER VALUE [curl args...]
|
||||
assert_header() {
|
||||
local url="$1" header="$2" expected="$3"
|
||||
shift 3 || true
|
||||
local got
|
||||
got=$(curl -s -D - -o /dev/null "$@" "$url" | tr -d '\r' \
|
||||
| awk -v h="${header,,}" -F': ' 'tolower($1) == h { print $2; exit }')
|
||||
if [[ "$got" != "$expected" ]]; then
|
||||
echo "assert_header: $url header $header expected \"$expected\", got \"$got\"" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Dump diagnostic info for a compose project; called on failure.
|
||||
# Usage: dump_diagnostics PROJECT COMPOSE_FILE
|
||||
dump_diagnostics() {
|
||||
local project="$1" compose_file="$2"
|
||||
echo "=== docker compose ps ==="
|
||||
docker compose -p "$project" -f "$compose_file" ps || true
|
||||
echo "=== docker compose logs ==="
|
||||
docker compose -p "$project" -f "$compose_file" logs --no-color || true
|
||||
echo "=== cscli decisions list ==="
|
||||
docker exec crowdsec cscli decisions list 2>/dev/null || true
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
listen_addr: 0.0.0.0:7422
|
||||
appsec_config: crowdsecurity/crs-inband
|
||||
name: e2eAppSec
|
||||
source: appsec
|
||||
labels:
|
||||
type: appsec
|
||||
@@ -0,0 +1,55 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.7.1
|
||||
container_name: e2e-appsec-traefik
|
||||
command:
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog"
|
||||
- "--api.insecure=true"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entryPoints.web.address=:80"
|
||||
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
ports:
|
||||
- "8000:80"
|
||||
depends_on:
|
||||
crowdsec:
|
||||
condition: service_healthy
|
||||
|
||||
whoami:
|
||||
image: traefik/whoami
|
||||
container_name: e2e-appsec-whoami
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
|
||||
- "traefik.http.routers.r.entrypoints=web"
|
||||
- "traefik.http.routers.r.middlewares=bouncer@docker"
|
||||
- "traefik.http.services.s.loadbalancer.server.port=80"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
|
||||
# IP bouncing disabled — we only test AppSec body inspection here.
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=none"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecappsecenabled=true"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: crowdsec
|
||||
environment:
|
||||
COLLECTIONS: "crowdsecurity/appsec-crs-inband"
|
||||
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||
CUSTOM_HOSTNAME: crowdsec
|
||||
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
|
||||
volumes:
|
||||
- ./acquis.yaml:/etc/crowdsec/acquis.yaml:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "cscli", "lapi", "status"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 60
|
||||
start_period: 15s
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=appsec
|
||||
PROJECT="e2e-${SCENARIO}"
|
||||
COMPOSE_FILE="$HERE/docker-compose.yml"
|
||||
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
if (( rc != 0 )); then
|
||||
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
|
||||
echo "Scenario failed. Logs written to $LOG_FILE"
|
||||
fi
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[$SCENARIO] starting stack (AppSec collections download — may take ~30s on first boot)..."
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
|
||||
|
||||
echo "[$SCENARIO] waiting for crowdsec readiness..."
|
||||
wait_crowdsec_ready crowdsec 180
|
||||
|
||||
echo "[$SCENARIO] waiting for traefik readiness..."
|
||||
wait_for_status http://localhost:8000/foo 200 30
|
||||
|
||||
echo "[$SCENARIO] benign request must pass"
|
||||
assert_status http://localhost:8000/foo 200
|
||||
|
||||
echo "[$SCENARIO] SQL-injection-like query string must be blocked by OWASP CRS (inband)"
|
||||
# Classic SQLi probe: ?id=1' OR '1'='1 — caught by CRS rule 942100/942130 (paranoia 1).
|
||||
assert_status "http://localhost:8000/foo?id=1%27%20OR%20%271%27%3D%271" 403
|
||||
|
||||
echo "[$SCENARIO] OK"
|
||||
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>E2E captcha marker</title></head>
|
||||
<body>
|
||||
<h1 id="e2e-captcha-marker">E2E_CAPTCHA_PAGE_MARKER</h1>
|
||||
<script src="{{ .FrontendJS }}"></script>
|
||||
<div class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.7.1
|
||||
container_name: e2e-captcha-traefik
|
||||
command:
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog"
|
||||
- "--api.insecure=true"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entryPoints.web.address=:80"
|
||||
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
- ./captcha.html:/captcha.html:ro
|
||||
ports:
|
||||
- "8000:80"
|
||||
depends_on:
|
||||
crowdsec:
|
||||
condition: service_healthy
|
||||
|
||||
whoami:
|
||||
image: traefik/whoami
|
||||
container_name: e2e-captcha-whoami
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
|
||||
- "traefik.http.routers.r.entrypoints=web"
|
||||
- "traefik.http.routers.r.middlewares=bouncer@docker"
|
||||
- "traefik.http.services.s.loadbalancer.server.port=80"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchaprovider=turnstile"
|
||||
# Cloudflare Turnstile public test keys (always pass / always fail variants exist).
|
||||
# These render a valid widget without contacting any real API key.
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchasitekey=1x00000000000000000000AA"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchasecretkey=1x0000000000000000000000000000000AA"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchahtmlfilepath=/captcha.html"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.captchagraceperiodseconds=10"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: crowdsec
|
||||
environment:
|
||||
DISABLE_ONLINE_API: "true"
|
||||
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||
CUSTOM_HOSTNAME: crowdsec
|
||||
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
|
||||
healthcheck:
|
||||
test: ["CMD", "cscli", "lapi", "status"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=captcha
|
||||
PROJECT="e2e-${SCENARIO}"
|
||||
COMPOSE_FILE="$HERE/docker-compose.yml"
|
||||
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
if (( rc != 0 )); then
|
||||
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
|
||||
echo "Scenario failed. Logs written to $LOG_FILE"
|
||||
fi
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[$SCENARIO] starting stack..."
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
|
||||
|
||||
echo "[$SCENARIO] waiting for crowdsec readiness..."
|
||||
wait_crowdsec_ready
|
||||
|
||||
echo "[$SCENARIO] waiting for traefik readiness..."
|
||||
wait_for_status http://localhost:8000/foo 200 30
|
||||
|
||||
echo "[$SCENARIO] adding captcha decision for 1.2.3.4"
|
||||
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type captcha --duration 5m
|
||||
|
||||
echo "[$SCENARIO] waiting one stream tick + buffer..."
|
||||
sleep 6
|
||||
|
||||
echo "[$SCENARIO] captcha response must be HTTP 200 (the captcha page itself, not a 403)"
|
||||
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] captcha response body must contain the captcha template marker"
|
||||
body=$(curl -s http://localhost:8000/foo -H "X-Forwarded-For: 1.2.3.4")
|
||||
if ! grep -q "E2E_CAPTCHA_PAGE_MARKER" <<<"$body"; then
|
||||
echo "Expected response body to contain E2E_CAPTCHA_PAGE_MARKER, got:" >&2
|
||||
echo "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$SCENARIO] non-flagged IP must still pass through to the backend"
|
||||
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8"
|
||||
|
||||
echo "[$SCENARIO] OK"
|
||||
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>E2E ban marker</title></head>
|
||||
<body>
|
||||
<h1 id="e2e-ban-marker">E2E_CUSTOM_BAN_PAGE_MARKER</h1>
|
||||
<p>IP: {{ .ClientIP }} reason: {{ .RemediationReason }}</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.7.1
|
||||
container_name: e2e-banpage-traefik
|
||||
command:
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog"
|
||||
- "--api.insecure=true"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entryPoints.web.address=:80"
|
||||
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
- ./ban.html:/ban.html:ro
|
||||
ports:
|
||||
- "8000:80"
|
||||
depends_on:
|
||||
crowdsec:
|
||||
condition: service_healthy
|
||||
|
||||
whoami:
|
||||
image: traefik/whoami
|
||||
container_name: e2e-banpage-whoami
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
|
||||
- "traefik.http.routers.r.entrypoints=web"
|
||||
- "traefik.http.routers.r.middlewares=bouncer@docker"
|
||||
- "traefik.http.services.s.loadbalancer.server.port=80"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.banhtmlfilepath=/ban.html"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: crowdsec
|
||||
environment:
|
||||
DISABLE_ONLINE_API: "true"
|
||||
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||
CUSTOM_HOSTNAME: crowdsec
|
||||
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
|
||||
healthcheck:
|
||||
test: ["CMD", "cscli", "lapi", "status"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=custom-ban-page
|
||||
PROJECT="e2e-${SCENARIO}"
|
||||
COMPOSE_FILE="$HERE/docker-compose.yml"
|
||||
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
if (( rc != 0 )); then
|
||||
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
|
||||
echo "Scenario failed. Logs written to $LOG_FILE"
|
||||
fi
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[$SCENARIO] starting stack..."
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
|
||||
|
||||
echo "[$SCENARIO] waiting for crowdsec readiness..."
|
||||
wait_crowdsec_ready
|
||||
|
||||
echo "[$SCENARIO] waiting for traefik readiness..."
|
||||
wait_for_status http://localhost:8000/foo 200 30
|
||||
|
||||
echo "[$SCENARIO] adding ban decision"
|
||||
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
|
||||
|
||||
echo "[$SCENARIO] waiting one stream tick + buffer..."
|
||||
sleep 6
|
||||
|
||||
echo "[$SCENARIO] banned response status is 403"
|
||||
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] banned response Content-Type is HTML"
|
||||
assert_header http://localhost:8000/foo Content-Type "text/html; charset=utf-8" -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] banned response body contains the custom marker"
|
||||
body=$(curl -s http://localhost:8000/foo -H "X-Forwarded-For: 1.2.3.4")
|
||||
if ! grep -q "E2E_CUSTOM_BAN_PAGE_MARKER" <<<"$body"; then
|
||||
echo "Expected response body to contain E2E_CUSTOM_BAN_PAGE_MARKER, got:" >&2
|
||||
echo "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$SCENARIO] OK"
|
||||
@@ -0,0 +1,51 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.7.1
|
||||
container_name: e2e-live-traefik
|
||||
command:
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog"
|
||||
- "--api.insecure=true"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entryPoints.web.address=:80"
|
||||
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
ports:
|
||||
- "8000:80"
|
||||
depends_on:
|
||||
crowdsec:
|
||||
condition: service_healthy
|
||||
|
||||
whoami:
|
||||
image: traefik/whoami
|
||||
container_name: e2e-live-whoami
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
|
||||
- "traefik.http.routers.r.entrypoints=web"
|
||||
- "traefik.http.routers.r.middlewares=bouncer@docker"
|
||||
- "traefik.http.services.s.loadbalancer.server.port=80"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=live"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.defaultdecisionseconds=2"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: crowdsec
|
||||
environment:
|
||||
DISABLE_ONLINE_API: "true"
|
||||
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||
CUSTOM_HOSTNAME: crowdsec
|
||||
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
|
||||
healthcheck:
|
||||
test: ["CMD", "cscli", "lapi", "status"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=live-mode
|
||||
PROJECT="e2e-${SCENARIO}"
|
||||
COMPOSE_FILE="$HERE/docker-compose.yml"
|
||||
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
if (( rc != 0 )); then
|
||||
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
|
||||
echo "Scenario failed. Logs written to $LOG_FILE"
|
||||
fi
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[$SCENARIO] starting stack..."
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
|
||||
|
||||
echo "[$SCENARIO] waiting for crowdsec readiness..."
|
||||
wait_crowdsec_ready
|
||||
|
||||
echo "[$SCENARIO] waiting for traefik readiness..."
|
||||
wait_for_status http://localhost:8000/foo 200 30
|
||||
|
||||
echo "[$SCENARIO] no decision -> first hit queries LAPI, returns 200, caches 'allowed' for 2s"
|
||||
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
|
||||
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
|
||||
|
||||
echo "[$SCENARIO] waiting for defaultDecisionSeconds cache to expire..."
|
||||
sleep 3
|
||||
|
||||
echo "[$SCENARIO] next hit must re-query LAPI and now see the ban"
|
||||
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] another non-banned IP must still pass"
|
||||
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8"
|
||||
|
||||
echo "[$SCENARIO] OK"
|
||||
@@ -0,0 +1,50 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.7.1
|
||||
container_name: e2e-none-traefik
|
||||
command:
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog"
|
||||
- "--api.insecure=true"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entryPoints.web.address=:80"
|
||||
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
ports:
|
||||
- "8000:80"
|
||||
depends_on:
|
||||
crowdsec:
|
||||
condition: service_healthy
|
||||
|
||||
whoami:
|
||||
image: traefik/whoami
|
||||
container_name: e2e-none-whoami
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
|
||||
- "traefik.http.routers.r.entrypoints=web"
|
||||
- "traefik.http.routers.r.middlewares=bouncer@docker"
|
||||
- "traefik.http.services.s.loadbalancer.server.port=80"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=none"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: crowdsec
|
||||
environment:
|
||||
DISABLE_ONLINE_API: "true"
|
||||
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||
CUSTOM_HOSTNAME: crowdsec
|
||||
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
|
||||
healthcheck:
|
||||
test: ["CMD", "cscli", "lapi", "status"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=none-mode
|
||||
PROJECT="e2e-${SCENARIO}"
|
||||
COMPOSE_FILE="$HERE/docker-compose.yml"
|
||||
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
if (( rc != 0 )); then
|
||||
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
|
||||
echo "Scenario failed. Logs written to $LOG_FILE"
|
||||
fi
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[$SCENARIO] starting stack..."
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
|
||||
|
||||
echo "[$SCENARIO] waiting for crowdsec readiness..."
|
||||
wait_crowdsec_ready
|
||||
|
||||
echo "[$SCENARIO] waiting for traefik readiness..."
|
||||
wait_for_status http://localhost:8000/foo 200 30
|
||||
|
||||
echo "[$SCENARIO] no decision -> request passes (LAPI queried per request)"
|
||||
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] adding ban decision"
|
||||
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
|
||||
|
||||
echo "[$SCENARIO] none mode has no cache -> next request must be blocked immediately"
|
||||
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] deleting decision"
|
||||
docker exec crowdsec cscli decisions delete --ip 1.2.3.4
|
||||
|
||||
echo "[$SCENARIO] previously banned IP must pass again immediately"
|
||||
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] OK"
|
||||
@@ -0,0 +1,51 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.7.1
|
||||
container_name: e2e-stream-traefik
|
||||
command:
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog"
|
||||
- "--api.insecure=true"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entryPoints.web.address=:80"
|
||||
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
ports:
|
||||
- "8000:80"
|
||||
depends_on:
|
||||
crowdsec:
|
||||
condition: service_healthy
|
||||
|
||||
whoami:
|
||||
image: traefik/whoami
|
||||
container_name: e2e-stream-whoami
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
|
||||
- "traefik.http.routers.r.entrypoints=web"
|
||||
- "traefik.http.routers.r.middlewares=bouncer@docker"
|
||||
- "traefik.http.services.s.loadbalancer.server.port=80"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: crowdsec
|
||||
environment:
|
||||
DISABLE_ONLINE_API: "true"
|
||||
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||
CUSTOM_HOSTNAME: crowdsec
|
||||
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
|
||||
healthcheck:
|
||||
test: ["CMD", "cscli", "lapi", "status"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=stream-mode
|
||||
PROJECT="e2e-${SCENARIO}"
|
||||
COMPOSE_FILE="$HERE/docker-compose.yml"
|
||||
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
if (( rc != 0 )); then
|
||||
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
|
||||
echo "Scenario failed. Logs written to $LOG_FILE"
|
||||
fi
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[$SCENARIO] starting stack..."
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
|
||||
|
||||
echo "[$SCENARIO] waiting for crowdsec readiness..."
|
||||
wait_crowdsec_ready
|
||||
|
||||
echo "[$SCENARIO] waiting for traefik readiness..."
|
||||
wait_for_status http://localhost:8000/foo 200 30
|
||||
|
||||
echo "[$SCENARIO] no decision yet -> request allowed"
|
||||
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] adding ban decision for 1.2.3.4"
|
||||
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
|
||||
|
||||
echo "[$SCENARIO] waiting one stream tick + buffer..."
|
||||
sleep 6
|
||||
|
||||
echo "[$SCENARIO] banned IP must be blocked (HTTP 403)"
|
||||
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] non-banned IP must still pass (HTTP 200)"
|
||||
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8"
|
||||
|
||||
echo "[$SCENARIO] deleting ban decision"
|
||||
docker exec crowdsec cscli decisions delete --ip 1.2.3.4
|
||||
|
||||
echo "[$SCENARIO] waiting one stream tick + buffer..."
|
||||
sleep 6
|
||||
|
||||
echo "[$SCENARIO] previously banned IP must pass again"
|
||||
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] OK"
|
||||
@@ -0,0 +1,52 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.7.1
|
||||
container_name: e2e-trusted-traefik
|
||||
command:
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog"
|
||||
- "--api.insecure=true"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entryPoints.web.address=:80"
|
||||
- "--entryPoints.web.forwardedHeaders.insecure=true"
|
||||
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
ports:
|
||||
- "8000:80"
|
||||
depends_on:
|
||||
crowdsec:
|
||||
condition: service_healthy
|
||||
|
||||
whoami:
|
||||
image: traefik/whoami
|
||||
container_name: e2e-trusted-whoami
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.r.rule=PathPrefix(`/foo`)"
|
||||
- "traefik.http.routers.r.entrypoints=web"
|
||||
- "traefik.http.routers.r.middlewares=bouncer@docker"
|
||||
- "traefik.http.services.s.loadbalancer.server.port=80"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.enabled=true"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdsecmode=stream"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.updateintervalseconds=3"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.forwardedheaderstrustedips=172.16.0.0/12"
|
||||
- "traefik.http.middlewares.bouncer.plugin.bouncer.clienttrustedips=1.2.3.4/32"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.7.8
|
||||
container_name: crowdsec
|
||||
environment:
|
||||
DISABLE_ONLINE_API: "true"
|
||||
BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5
|
||||
CUSTOM_HOSTNAME: crowdsec
|
||||
CROWDSEC_BYPASS_DB_VOLUME_CHECK: "true"
|
||||
healthcheck:
|
||||
test: ["CMD", "cscli", "lapi", "status"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$HERE/../../lib/common.sh"
|
||||
|
||||
SCENARIO=trusted-ips
|
||||
PROJECT="e2e-${SCENARIO}"
|
||||
COMPOSE_FILE="$HERE/docker-compose.yml"
|
||||
LOG_FILE="/tmp/e2e-${SCENARIO}.log"
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
if (( rc != 0 )); then
|
||||
dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true
|
||||
echo "Scenario failed. Logs written to $LOG_FILE"
|
||||
fi
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[$SCENARIO] starting stack..."
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait
|
||||
|
||||
echo "[$SCENARIO] waiting for crowdsec readiness..."
|
||||
wait_crowdsec_ready
|
||||
|
||||
echo "[$SCENARIO] waiting for traefik readiness..."
|
||||
wait_for_status http://localhost:8000/foo 200 30
|
||||
|
||||
echo "[$SCENARIO] banning the trusted IP 1.2.3.4 and an untrusted IP 5.6.7.8"
|
||||
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m
|
||||
docker exec crowdsec cscli decisions add --ip 5.6.7.8 --type ban --duration 5m
|
||||
|
||||
echo "[$SCENARIO] waiting one stream tick + buffer..."
|
||||
sleep 6
|
||||
|
||||
echo "[$SCENARIO] trusted IP must bypass the bouncer even though it is banned"
|
||||
assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||
|
||||
echo "[$SCENARIO] untrusted banned IP must be blocked (control: proves the bouncer is active)"
|
||||
assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 5.6.7.8"
|
||||
|
||||
echo "[$SCENARIO] OK"
|
||||
@@ -0,0 +1,4 @@
|
||||
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||
|
||||
// pluginVersion is updated automatically by the release workflow.
|
||||
var pluginVersion = "1.6.X" //nolint:gochecknoglobals
|
||||
Reference in New Issue
Block a user