mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-07-21 11:38:59 +02:00
373 lines
11 KiB
Go
373 lines
11 KiB
Go
// Package configuration implements plugin Config, default Config values and validation param functions.
|
|
package configuration
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"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.
|
|
const (
|
|
StreamMode = "stream"
|
|
LiveMode = "live"
|
|
NoneMode = "none"
|
|
HTTPS = "https"
|
|
HTTP = "http"
|
|
)
|
|
|
|
// Config the plugin configuration.
|
|
type Config struct {
|
|
Enabled bool `json:"enabled,omitempty"`
|
|
LogLevel string `json:"logLevel,omitempty"`
|
|
CrowdsecMode string `json:"crowdsecMode,omitempty"`
|
|
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
|
|
CrowdsecLapiHost string `json:"crowdsecLapiHost,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"`
|
|
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
|
|
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
|
ForwardedHeadersCustomName string `json:"forwardedheaderscustomheader,omitempty"`
|
|
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
|
|
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
|
|
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
|
|
RedisCacheHost string `json:"redisCacheHost,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: "INFO",
|
|
CrowdsecMode: LiveMode,
|
|
CrowdsecLapiScheme: HTTP,
|
|
CrowdsecLapiHost: "crowdsec:8080",
|
|
CrowdsecLapiKey: "",
|
|
CrowdsecLapiTLSInsecureVerify: false,
|
|
UpdateIntervalSeconds: 60,
|
|
DefaultDecisionSeconds: 60,
|
|
ForwardedHeadersCustomName: "X-Forwarded-For",
|
|
ForwardedHeadersTrustedIPs: []string{},
|
|
ClientTrustedIPs: []string{},
|
|
RedisCacheEnabled: false,
|
|
RedisCacheHost: "redis:6379",
|
|
}
|
|
}
|
|
|
|
// 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(fmt.Sprintf("%sFile", key))
|
|
// 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 value, nil
|
|
}
|
|
field = object.FieldByName(key)
|
|
value = field.String()
|
|
return value, nil
|
|
}
|
|
|
|
// ValidateParams validate all the param gave by user.
|
|
//
|
|
//nolint:gocyclo
|
|
func ValidateParams(config *Config) error {
|
|
if err := validateParamsRequired(config); err != nil {
|
|
return err
|
|
}
|
|
|
|
// This only check that the format of the URL scheme:// is correct and do not make requests
|
|
testURL := url.URL{
|
|
Scheme: config.CrowdsecLapiScheme,
|
|
Host: config.CrowdsecLapiHost,
|
|
}
|
|
if _, err := http.NewRequest(http.MethodGet, testURL.String(), nil); err != nil {
|
|
return fmt.Errorf("CrowdsecLapiScheme://CrowdsecLapiHost: '%v://%v' must be an URL", config.CrowdsecLapiScheme, config.CrowdsecLapiHost)
|
|
}
|
|
|
|
if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
|
return err
|
|
}
|
|
if err := validateParamsIPs(config.ClientTrustedIPs, "ClientTrustedIPs"); err != nil {
|
|
return err
|
|
}
|
|
|
|
lapiKey, err := GetVariable(config, "CrowdsecLapiKey")
|
|
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 == "") {
|
|
return fmt.Errorf("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be both empty")
|
|
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
|
|
// check LAPIKey
|
|
lapiKey = strings.TrimSuffix(lapiKey, "\n")
|
|
err = validateParamsAPIKey(lapiKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Case https to contact Crowdsec LAPI and certificate must be provided
|
|
if config.CrowdsecLapiScheme == HTTPS && !config.CrowdsecLapiTLSInsecureVerify {
|
|
err = validateParamsTLS(config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateParamsAPIKey(lapiKey string) error {
|
|
for i := 0; i < len(lapiKey); i++ {
|
|
c := lapiKey[i]
|
|
if !validHeaderFieldByte(c) {
|
|
return fmt.Errorf("CrowdsecLapiKey contains the following forbidden caracter %q", c)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validHeaderFieldByte reports whether b is a valid byte in a header
|
|
// field name. RFC 7230 says:
|
|
//
|
|
// header-field = field-name ":" OWS field-value OWS
|
|
// field-name = token
|
|
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
|
|
// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
|
|
// token = 1*tchar
|
|
func validHeaderFieldByte(b byte) bool {
|
|
// isTokenTable is a copy of net/http/lex.go's isTokenTable.
|
|
// See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators
|
|
var isTokenTable = [127]bool{
|
|
'!': true,
|
|
'#': true,
|
|
'$': true,
|
|
'%': true,
|
|
'&': true,
|
|
'\'': true,
|
|
'*': true,
|
|
'+': true,
|
|
'-': true,
|
|
'.': true,
|
|
'0': true,
|
|
'1': true,
|
|
'2': true,
|
|
'3': true,
|
|
'4': true,
|
|
'5': true,
|
|
'6': true,
|
|
'7': true,
|
|
'8': true,
|
|
'9': true,
|
|
'A': true,
|
|
'B': true,
|
|
'C': true,
|
|
'D': true,
|
|
'E': true,
|
|
'F': true,
|
|
'G': true,
|
|
'H': true,
|
|
'I': true,
|
|
'J': true,
|
|
'K': true,
|
|
'L': true,
|
|
'M': true,
|
|
'N': true,
|
|
'O': true,
|
|
'P': true,
|
|
'Q': true,
|
|
'R': true,
|
|
'S': true,
|
|
'T': true,
|
|
'U': true,
|
|
'W': true,
|
|
'V': true,
|
|
'X': true,
|
|
'Y': true,
|
|
'Z': true,
|
|
'^': true,
|
|
'_': true,
|
|
'`': true,
|
|
'a': true,
|
|
'b': true,
|
|
'c': true,
|
|
'd': true,
|
|
'e': true,
|
|
'f': true,
|
|
'g': true,
|
|
'h': true,
|
|
'i': true,
|
|
'j': true,
|
|
'k': true,
|
|
'l': true,
|
|
'm': true,
|
|
'n': true,
|
|
'o': true,
|
|
'p': true,
|
|
'q': true,
|
|
'r': true,
|
|
's': true,
|
|
't': true,
|
|
'u': true,
|
|
'v': true,
|
|
'w': true,
|
|
'x': true,
|
|
'y': true,
|
|
'z': true,
|
|
'|': true,
|
|
'~': true,
|
|
}
|
|
return int(b) < len(isTokenTable) && isTokenTable[b]
|
|
}
|
|
|
|
func validateParamsTLS(config *Config) error {
|
|
certAuth, err := GetVariable(config, "CrowdsecLapiTLSCertificateAuthority")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if certAuth == "" {
|
|
return fmt.Errorf("CrowdsecLapiTLSCertificateAuthority must be specified when CrowdsecLapiScheme='https' and CrowdsecLapiTLSInsecureVerify=false")
|
|
}
|
|
tlsConfig := new(tls.Config)
|
|
tlsConfig.RootCAs = x509.NewCertPool()
|
|
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuth)) {
|
|
return fmt.Errorf("failed parsing pem file")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateParamsIPs(listIP []string, key string) error {
|
|
if len(listIP) > 0 {
|
|
if _, err := ip.NewChecker(listIP); err != nil {
|
|
return fmt.Errorf("%s must be a list of IP/CIDR :%w", key, err)
|
|
}
|
|
} else {
|
|
logger.Debug(fmt.Sprintf("No IP provided for %s", key))
|
|
}
|
|
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 fmt.Errorf("%v: cannot be empty", key)
|
|
}
|
|
}
|
|
requiredInt := map[string]int64{
|
|
"UpdateIntervalSeconds": config.UpdateIntervalSeconds,
|
|
"DefaultDecisionSeconds": config.DefaultDecisionSeconds,
|
|
}
|
|
for key, val := range requiredInt {
|
|
if val < 1 {
|
|
return fmt.Errorf("%v: cannot be less than 1", key)
|
|
}
|
|
}
|
|
if !contains([]string{NoneMode, LiveMode, StreamMode}, config.CrowdsecMode) {
|
|
return fmt.Errorf("CrowdsecMode: must be one of 'none', 'live' or 'stream'")
|
|
}
|
|
if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) {
|
|
return fmt.Errorf("CrowdsecLapiScheme: must be one of 'http' or 'https'")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetTLSConfigCrowdsec get TLS config from Config.
|
|
func GetTLSConfigCrowdsec(config *Config) (*tls.Config, error) {
|
|
tlsConfig := new(tls.Config)
|
|
tlsConfig.RootCAs = x509.NewCertPool()
|
|
//nolint:gocritic
|
|
if config.CrowdsecLapiScheme != HTTPS {
|
|
logger.Debug("getTLSConfigCrowdsec:CrowdsecLapiScheme not https")
|
|
return tlsConfig, nil
|
|
} else if config.CrowdsecLapiTLSInsecureVerify {
|
|
logger.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSInsecureVerify is true")
|
|
tlsConfig.InsecureSkipVerify = true
|
|
// If we return here and still want to use client auth this won't work
|
|
// return tlsConfig, nil
|
|
} else {
|
|
certAuthority, err := GetVariable(config, "CrowdsecLapiTLSCertificateAuthority")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cert := []byte(certAuthority)
|
|
if !tlsConfig.RootCAs.AppendCertsFromPEM(cert) {
|
|
logger.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSCertificateAuthority read cert failed")
|
|
// here we return because if CrowdsecLapiTLSInsecureVerify is false
|
|
// and CA not load, we can't communicate with https
|
|
return nil, fmt.Errorf("getTLSConfigCrowdsec:cannot load CA and verify cert is enabled")
|
|
}
|
|
}
|
|
|
|
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
certBouncerKey, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncerKey")
|
|
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
|
|
}
|