mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
* feat: Allow cache reading from replicas * 🍱 fix logic * ✨ add testing for redis with mock * 🍱 fix permission * 📝 test(e2e/redis): fix swapped IP→verdict comments The mock returns "f" (not banned) for 1.2.3.4 and "t" (banned) for 1.2.3.5, and the run.sh assertions match that. Both doc comments described the opposite mapping; correct them to match the code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ✅ test(e2e/redis): exercise read-from-replica path The redis scenario only set redisCacheHost, so it validated the writer but never the round-robin reader path this feature adds. Split the mock into two roles: the primary (--redis-addr) now answers every GET with a miss, while the replica (--redis-read-addr) serves the hardcoded verdicts. The scenario points redisCacheReadHosts at the replica (twice, to drive round-robin), so the banned-IP-blocked assertion only passes if the plugin actually reads decisions from the replica rather than the primary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 📝 docs: note replicas don't fall back to primary on outage When RedisCacheReadHosts is set, reads are not retried against the primary if the replicas are unreachable. Document that this, combined with the default RedisCacheUnreachableBlock=true, means a replica outage can block traffic while the primary is healthy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 🐛 fix(cache): avoid nil-pointer panic on empty redis read redisCache.get fell through to `switch err.Error()` when Get returned a nil error with an empty value, panicking on the nil error. simpleredis never returns that combination today (a miss yields RedisMiss), so it was unreachable in practice — but the read path is safer treating an empty, error-free read as a cache miss, which also guarantees err is non-nil before err.Error() is called. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 🍱 add test for rotation * 🐛 readd redis/run.sh * 🐛 fix redis/run.sh * 🐛 fix test label; remove log * 🍱 add tests for roundRobin * 🐛 fix test --------- Co-authored-by: maxlerebourg <maxlerebourg@gmail.com> Co-authored-by: mhx <mathieu@hanotaux.fr> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
544 lines
22 KiB
Go
544 lines
22 KiB
Go
// Package configuration implements plugin Config, default Config values and validation param functions.
|
|
package configuration
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
"text/template"
|
|
|
|
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
|
)
|
|
|
|
// Enums for crowdsec mode.
|
|
const (
|
|
AloneMode = "alone"
|
|
StreamMode = "stream"
|
|
LiveMode = "live"
|
|
NoneMode = "none"
|
|
AppsecMode = "appsec"
|
|
HTTPS = "https"
|
|
HTTP = "http"
|
|
LogDEBUG = "DEBUG"
|
|
LogINFO = "INFO"
|
|
LogWARN = "WARN"
|
|
LogERROR = "ERROR"
|
|
ReasonTECH = "TECHNICAL_ISSUE"
|
|
ReasonLAPI = "LAPI"
|
|
ReasonAPPSEC = "APPSEC"
|
|
HcaptchaProvider = "hcaptcha"
|
|
RecaptchaProvider = "recaptcha"
|
|
TurnstileProvider = "turnstile"
|
|
CustomProvider = "custom"
|
|
)
|
|
|
|
// Config the plugin configuration.
|
|
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"`
|
|
CrowdsecAppsecScheme string `json:"crowdsecAppsecScheme,omitempty"`
|
|
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
|
|
CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"`
|
|
CrowdsecAppsecKey string `json:"crowdsecAppsecKey,omitempty"`
|
|
CrowdsecAppsecKeyFile string `json:"crowdsecAppsecKeyFile,omitempty"`
|
|
CrowdsecAppsecTLSInsecureVerify bool `json:"crowdsecAppsecTlsInsecureVerify,omitempty"`
|
|
CrowdsecAppsecTLSCertificateAuthority string `json:"crowdsecAppsecTlsCertificateAuthority,omitempty"`
|
|
CrowdsecAppsecTLSCertificateAuthorityFile string `json:"crowdsecAppsecTlsCertificateAuthorityFile,omitempty"`
|
|
CrowdsecAppsecTLSCertificateBouncer string `json:"crowdsecAppsecTlsCertificateBouncer,omitempty"`
|
|
CrowdsecAppsecTLSCertificateBouncerFile string `json:"crowdsecAppsecTlsCertificateBouncerFile,omitempty"`
|
|
CrowdsecAppsecTLSCertificateBouncerKey string `json:"crowdsecAppsecTlsCertificateBouncerKey,omitempty"`
|
|
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
|
|
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
|
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
|
CrowdsecAppsecUnreadableBodyBlock bool `json:"crowdsecAppsecUnreadableBodyBlock,omitempty"`
|
|
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
|
|
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
|
|
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
|
|
CrowdsecLapiPath string `json:"crowdsecLapiPath,omitempty"`
|
|
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
|
|
CrowdsecLapiKeyFile string `json:"crowdsecLapiKeyFile,omitempty"`
|
|
CrowdsecLapiTLSInsecureVerify bool `json:"crowdsecLapiTlsInsecureVerify,omitempty"`
|
|
CrowdsecLapiTLSCertificateAuthority string `json:"crowdsecLapiTlsCertificateAuthority,omitempty"`
|
|
CrowdsecLapiTLSCertificateAuthorityFile string `json:"crowdsecLapiTlsCertificateAuthorityFile,omitempty"`
|
|
CrowdsecLapiTLSCertificateBouncer string `json:"crowdsecLapiTlsCertificateBouncer,omitempty"`
|
|
CrowdsecLapiTLSCertificateBouncerFile string `json:"crowdsecLapiTlsCertificateBouncerFile,omitempty"`
|
|
CrowdsecLapiTLSCertificateBouncerKey string `json:"crowdsecLapiTlsCertificateBouncerKey,omitempty"`
|
|
CrowdsecLapiTLSCertificateBouncerKeyFile string `json:"crowdsecLapiTlsCertificateBouncerKeyFile,omitempty"`
|
|
CrowdsecCapiMachineID string `json:"crowdsecCapiMachineId,omitempty"`
|
|
CrowdsecCapiMachineIDFile string `json:"crowdsecCapiMachineIdFile,omitempty"`
|
|
CrowdsecCapiPassword string `json:"crowdsecCapiPassword,omitempty"`
|
|
CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"`
|
|
CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"`
|
|
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"`
|
|
TraceHeadersCustomName string `json:"traceHeadersCustomName,omitempty"`
|
|
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"`
|
|
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
|
|
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
|
|
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
|
|
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
|
|
RedisCacheHost string `json:"redisCacheHost,omitempty"`
|
|
RedisCacheReadHosts []string `json:"redisCacheReadHosts,omitempty"`
|
|
RedisCachePassword string `json:"redisCachePassword,omitempty"`
|
|
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
|
|
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
|
|
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
|
|
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
|
|
BanFilePath string `json:"banFilePath,omitempty"`
|
|
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
|
|
CaptchaFilePath string `json:"captchaFilePath,omitempty"`
|
|
CaptchaProvider string `json:"captchaProvider,omitempty"`
|
|
CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"`
|
|
CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"`
|
|
CaptchaCustomKey string `json:"captchaCustomKey,omitempty"`
|
|
CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"`
|
|
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"`
|
|
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"`
|
|
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"`
|
|
CaptchaSecretKeyFile string `json:"captchaSecretKeyFile,omitempty"`
|
|
CaptchaGracePeriodSeconds int64 `json:"captchaGracePeriodSeconds,omitempty"`
|
|
}
|
|
|
|
func contains(source []string, target string) bool {
|
|
for _, item := range source {
|
|
if item == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// New creates the default plugin configuration.
|
|
func New() *Config {
|
|
return &Config{
|
|
Enabled: false,
|
|
LogLevel: LogINFO,
|
|
LogFormat: "common",
|
|
LogFilePath: "",
|
|
CrowdsecMode: LiveMode,
|
|
CrowdsecAppsecEnabled: false,
|
|
CrowdsecAppsecFailureBlock: true,
|
|
CrowdsecAppsecUnreachableBlock: true,
|
|
CrowdsecAppsecUnreadableBodyBlock: true,
|
|
CrowdsecAppsecBodyLimit: 10485760,
|
|
CrowdsecAppsecScheme: "",
|
|
CrowdsecAppsecHost: "crowdsec:7422",
|
|
CrowdsecAppsecPath: "/",
|
|
CrowdsecAppsecKey: "",
|
|
CrowdsecAppsecTLSInsecureVerify: false,
|
|
CrowdsecLapiScheme: HTTP,
|
|
CrowdsecLapiHost: "crowdsec:8080",
|
|
CrowdsecLapiPath: "/",
|
|
CrowdsecLapiKey: "",
|
|
CrowdsecLapiTLSInsecureVerify: false,
|
|
UpdateIntervalSeconds: 60,
|
|
MetricsUpdateIntervalSeconds: 600,
|
|
UpdateMaxFailure: 0,
|
|
StreamStartupBlock: true,
|
|
DefaultDecisionSeconds: 60,
|
|
RemediationStatusCode: http.StatusForbidden,
|
|
HTTPTimeoutSeconds: 10,
|
|
CaptchaProvider: "",
|
|
CaptchaCustomJsURL: "",
|
|
CaptchaCustomValidateURL: "",
|
|
CaptchaCustomKey: "",
|
|
CaptchaCustomResponse: "",
|
|
CaptchaSiteKey: "",
|
|
CaptchaSecretKey: "",
|
|
CaptchaGracePeriodSeconds: 1800,
|
|
CaptchaFilePath: "/captcha.html",
|
|
BanFilePath: "",
|
|
TraceHeadersCustomName: "",
|
|
RemediationHeadersCustomName: "",
|
|
ForwardedHeadersCustomName: "X-Forwarded-For",
|
|
ForwardedHeadersTrustedIPs: []string{},
|
|
ClientTrustedIPs: []string{},
|
|
RedisCacheEnabled: false,
|
|
RedisCacheHost: "redis:6379",
|
|
RedisCacheReadHosts: []string{},
|
|
RedisCachePassword: "",
|
|
RedisCacheDatabase: "",
|
|
RedisCacheUnreachableBlock: true,
|
|
}
|
|
}
|
|
|
|
// GetVariable get variable from file and after in the variables gave by user.
|
|
func GetVariable(config *Config, key string) (string, error) {
|
|
value := ""
|
|
object := reflect.Indirect(reflect.ValueOf(config))
|
|
field := object.FieldByName(key + "File")
|
|
// Here linter say you should simplify this code, but lets not, performance is important not clarity and complexity
|
|
fp := field.String()
|
|
if fp != "" {
|
|
file, err := os.Stat(fp)
|
|
if err != nil {
|
|
return value, fmt.Errorf("%s:%s invalid path %w", key, fp, err)
|
|
}
|
|
if file.IsDir() {
|
|
return value, fmt.Errorf("%s:%s path must be a file", key, fp)
|
|
}
|
|
fileValue, err := os.ReadFile(filepath.Clean(fp))
|
|
if err != nil {
|
|
return value, fmt.Errorf("%s:%s read file path failed %w", key, fp, err)
|
|
}
|
|
value = string(fileValue)
|
|
return strings.TrimSpace(value), nil
|
|
}
|
|
field = object.FieldByName(key)
|
|
value = field.String()
|
|
return strings.TrimSpace(value), nil
|
|
}
|
|
|
|
func getContentTypeFromPath(path string) string {
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
contentTypeMap := map[string]string{
|
|
".html": "text/html; charset=utf-8",
|
|
".htm": "text/html; charset=utf-8",
|
|
".json": "application/json",
|
|
".txt": "text/plain",
|
|
".xml": "application/xml",
|
|
".js": "application/javascript",
|
|
".css": "text/css",
|
|
}
|
|
if contentType, ok := contentTypeMap[ext]; ok {
|
|
return contentType
|
|
}
|
|
// Default to HTML for backward compatibility
|
|
return "text/html; charset=utf-8"
|
|
}
|
|
|
|
// GetTemplate get compiled template with {{ and }} delimiters.
|
|
// Uses text/template for all file types to avoid HTML escaping issues.
|
|
func GetTemplate(path string) (*template.Template, string, error) {
|
|
if path == "" {
|
|
return nil, "", errors.New("no template file provided")
|
|
}
|
|
contentType := getContentTypeFromPath(path)
|
|
//nolint:gosec
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
content := string(b)
|
|
compiledTemplate, err := template.New(filepath.Base(path)).Delims("{{", "}}").Parse(content)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("impossible to compile template %s: %w", path, err)
|
|
}
|
|
return compiledTemplate, contentType, nil
|
|
}
|
|
|
|
// ValidateParams validate all the param gave by user.
|
|
//
|
|
//nolint:gocyclo,gocognit,nestif
|
|
func ValidateParams(config *Config, log *slog.Logger) error {
|
|
if err := validateParamsRequired(config); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := validateCaptcha(config); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := validateParamsIPs(log, config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
|
return err
|
|
}
|
|
if err := validateParamsIPs(log, config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := GetVariable(config, "RedisCachePassword"); err != nil {
|
|
return err
|
|
}
|
|
|
|
if config.CrowdsecMode == AloneMode {
|
|
if _, err := GetVariable(config, "CrowdsecCapiMachineID"); err != nil {
|
|
return err
|
|
}
|
|
if _, err := GetVariable(config, "CrowdsecCapiPassword"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if config.CaptchaProvider != "" {
|
|
if _, err := GetVariable(config, "CaptchaSiteKey"); err != nil {
|
|
return err
|
|
}
|
|
if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil {
|
|
return err
|
|
}
|
|
if config.CaptchaFilePath != "" {
|
|
if _, _, err := GetTemplate(config.CaptchaFilePath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if config.BanFilePath != "" {
|
|
if _, _, err := GetTemplate(config.BanFilePath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := validateURL("CrowdsecLapi", config.CrowdsecLapiScheme, config.CrowdsecLapiHost, config.CrowdsecLapiPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := validateURL("CrowdsecAppsec", config.CrowdsecLapiScheme, config.CrowdsecAppsecHost, config.CrowdsecAppsecPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
lapiKey, err := GetVariable(config, "CrowdsecLapiKey")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
appsecKey, err := GetVariable(config, "CrowdsecAppsecKey")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
certBouncerKey, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncerKey")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// We need to either have crowdsecLapiKey defined or the BouncerCert and Bouncerkey
|
|
if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") && config.CrowdsecMode != AppsecMode {
|
|
return errors.New("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be all empty")
|
|
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
|
|
lapiKey = strings.TrimSpace(lapiKey)
|
|
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
|
|
}
|
|
}
|
|
|
|
// Case https to contact Crowdsec LAPI and certificate must be provided
|
|
if config.CrowdsecLapiScheme == HTTPS && !config.CrowdsecLapiTLSInsecureVerify {
|
|
if err = validateParamsTLS(config); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Check logging configuration
|
|
// to upper allow of anycase of log level
|
|
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)
|
|
if err != nil {
|
|
return fmt.Errorf("LogFilePath is not writable %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateURL(variable, scheme, host, path string) error {
|
|
// This only check that the format of the URL scheme://host/path is correct and do not make requests
|
|
testURL := url.URL{Scheme: scheme, Host: host, Path: path}
|
|
if _, err := http.NewRequest(http.MethodGet, testURL.String(), nil); err != nil {
|
|
return fmt.Errorf("CrowdsecLapiScheme://%sHost: '%v://%v%v' must be a valid URL", variable, scheme, host, path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validHeaderFieldByte reports whether b is a valid byte in a header
|
|
// field name. RFC 7230 says:
|
|
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
|
|
// See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators
|
|
func validateParamsAPIKey(key string, paramName string) error {
|
|
reg := regexp.MustCompile("^[a-zA-Z0-9 !#$%&'*+-.^_`|~=/]*$")
|
|
if !reg.MatchString(key) {
|
|
return fmt.Errorf("%s doesn't validate this regexp: '/%s/'", paramName, reg.String())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateParamsTLS(config *Config) error {
|
|
certAuth, err := GetVariable(config, "CrowdsecLapiTLSCertificateAuthority")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if certAuth == "" {
|
|
// No custom CA — runtime will fall back to the system trust store.
|
|
return nil
|
|
}
|
|
tlsConfig := new(tls.Config)
|
|
tlsConfig.RootCAs = x509.NewCertPool()
|
|
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuth)) {
|
|
return errors.New("failed parsing pem file")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateParamsIPs(log *slog.Logger, listIP []string, key string) error {
|
|
if len(listIP) > 0 {
|
|
if _, err := ip.NewChecker(log, listIP); err != nil {
|
|
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCaptcha(config *Config) error {
|
|
if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider}, config.CaptchaProvider) {
|
|
return fmt.Errorf("CaptchaProvider: must be one of '%s', '%s', '%s' or '%s'", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider)
|
|
}
|
|
if config.CaptchaProvider == CustomProvider {
|
|
if config.CaptchaCustomKey == "" || config.CaptchaCustomResponse == "" || config.CaptchaCustomValidateURL == "" || config.CaptchaCustomJsURL == "" {
|
|
return fmt.Errorf(
|
|
"CaptchaProvider: provider is custom, captchaCustom variables must be filled: CaptchaCustomKey:%s, CaptchaCustomResponse:%s, CaptchaCustomValidateURL:%s, CaptchaCustomJsURL:%s",
|
|
config.CaptchaCustomKey,
|
|
config.CaptchaCustomResponse,
|
|
config.CaptchaCustomValidateURL,
|
|
config.CaptchaCustomJsURL,
|
|
)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateParamsRequired(config *Config) error {
|
|
requiredStrings := map[string]string{
|
|
"CrowdsecLapiScheme": config.CrowdsecLapiScheme,
|
|
"CrowdsecLapiHost": config.CrowdsecLapiHost,
|
|
"CrowdsecMode": config.CrowdsecMode,
|
|
}
|
|
for key, val := range requiredStrings {
|
|
if len(val) == 0 {
|
|
return errors.New(key + ": cannot be empty")
|
|
}
|
|
}
|
|
requiredInt0 := map[string]int64{
|
|
"CrowdsecAppsecBodyLimit": config.CrowdsecAppsecBodyLimit,
|
|
"MetricsUpdateIntervalSeconds": config.MetricsUpdateIntervalSeconds,
|
|
}
|
|
for key, val := range requiredInt0 {
|
|
if val < 0 {
|
|
return errors.New(key + ": cannot be less than 0")
|
|
}
|
|
}
|
|
requiredInt1 := map[string]int64{
|
|
"UpdateIntervalSeconds": config.UpdateIntervalSeconds,
|
|
"DefaultDecisionSeconds": config.DefaultDecisionSeconds,
|
|
"HTTPTimeoutSeconds": config.HTTPTimeoutSeconds,
|
|
"CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds,
|
|
}
|
|
for key, val := range requiredInt1 {
|
|
if val < 1 {
|
|
return errors.New(key + ": cannot be less than 1")
|
|
}
|
|
}
|
|
if config.UpdateMaxFailure < -1 {
|
|
return errors.New("UpdateMaxFailure: cannot be less than -1")
|
|
}
|
|
if config.CrowdsecAppsecBodyLimit < 0 {
|
|
return errors.New("CrowdsecAppsecBodyLimit: cannot be less than 0")
|
|
}
|
|
if config.RemediationStatusCode < 100 || config.RemediationStatusCode >= 600 {
|
|
return errors.New("RemediationStatusCode: cannot be less than 100 and more than 600")
|
|
}
|
|
|
|
if !contains([]string{NoneMode, LiveMode, StreamMode, AloneMode, AppsecMode}, config.CrowdsecMode) {
|
|
return errors.New("CrowdsecMode: must be one of 'none', 'live', 'stream', 'alone' or 'appsec'")
|
|
}
|
|
if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) {
|
|
return errors.New("CrowdsecLapiScheme: must be one of 'http' or 'https'")
|
|
}
|
|
if !contains([]string{HTTP, HTTPS, ""}, config.CrowdsecAppsecScheme) {
|
|
return errors.New("CrowdsecAppsecScheme: must be one of 'http' or 'https'")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func getTLSConfig(config *Config, log *slog.Logger, prefix, scheme string, insecureVerify bool) (*tls.Config, error) {
|
|
tlsConfig := new(tls.Config)
|
|
if scheme != HTTPS {
|
|
log.Debug("getTLSConfig:" + prefix + "Scheme https:no")
|
|
return tlsConfig, nil
|
|
}
|
|
// RootCAs is intentionally left nil unless a custom CA is provided:
|
|
// crypto/tls then falls back to x509.SystemCertPool(), which is what we
|
|
// want when the LAPI is exposed behind a reverse proxy with a publicly
|
|
// trusted certificate (e.g. Let's Encrypt).
|
|
//nolint:nestif
|
|
if insecureVerify {
|
|
tlsConfig.InsecureSkipVerify = true
|
|
log.Debug("getTLSConfig:" + prefix + "TLSInsecureVerify tlsInsecure:true")
|
|
} else {
|
|
certAuthority, err := GetVariable(config, prefix+"TLSCertificateAuthority")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(certAuthority) > 0 {
|
|
tlsConfig.RootCAs = x509.NewCertPool()
|
|
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
|
return nil, errors.New("getTLSConfig:" + prefix + " cannot load CA and verify cert is enabled")
|
|
}
|
|
log.Debug("getTLSConfig:" + prefix + "TLSCertificateAuthority CA added successfully")
|
|
} else {
|
|
log.Debug("getTLSConfig:" + prefix + " no CA provided, using system trust store")
|
|
}
|
|
}
|
|
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
certBouncerKey, err := GetVariable(config, prefix+"TLSCertificateBouncerKey")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if certBouncer == "" || certBouncerKey == "" {
|
|
return tlsConfig, nil
|
|
}
|
|
clientCert, err := tls.X509KeyPair([]byte(certBouncer), []byte(certBouncerKey))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getTLSClientConfigCrowdsec impossible to generate ClientCert %w", err)
|
|
}
|
|
tlsConfig.Certificates = append(tlsConfig.Certificates, clientCert)
|
|
|
|
return tlsConfig, nil
|
|
}
|
|
|
|
// GetTLSConfigCrowdsec get TLS config from Config.
|
|
func GetTLSConfigCrowdsec(config *Config, log *slog.Logger, isAppsec bool) (*tls.Config, error) {
|
|
var prefix string
|
|
if isAppsec && config.CrowdsecAppsecScheme != "" {
|
|
prefix = "CrowdsecAppsec"
|
|
return getTLSConfig(config, log, prefix, config.CrowdsecAppsecScheme, config.CrowdsecAppsecTLSInsecureVerify)
|
|
}
|
|
prefix = "CrowdsecLapi"
|
|
return getTLSConfig(config, log, prefix, config.CrowdsecLapiScheme, config.CrowdsecLapiTLSInsecureVerify)
|
|
}
|