From 988f3ebeae302e1dbb1b88f407eeb7241ff12b19 Mon Sep 17 00:00:00 2001 From: Max Lerebourg Date: Sun, 4 Dec 2022 21:40:14 +0100 Subject: [PATCH] :sparkles: add configuration pkg and tests --- bouncer.go | 218 ++---------------- bouncer_test.go | 196 ---------------- exemples/redis-cache/docker-compose.redis.yml | 8 +- pkg/configuration/configuration.go | 206 +++++++++++++++++ pkg/configuration/configuration_test.go | 184 +++++++++++++++ 5 files changed, 410 insertions(+), 402 deletions(-) create mode 100644 pkg/configuration/configuration.go create mode 100644 pkg/configuration/configuration_test.go diff --git a/bouncer.go b/bouncer.go index b07b9c9..a786940 100644 --- a/bouncer.go +++ b/bouncer.go @@ -8,14 +8,10 @@ import ( "crypto/tls" "crypto/x509" "encoding/json" - "errors" "fmt" "io" "net/http" "net/url" - "os" - "path/filepath" - "reflect" "text/template" "time" @@ -23,12 +19,10 @@ import ( ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip" logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger" simpleredis "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/simpleredis" + configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration" ) const ( - streamMode = "stream" - liveMode = "live" - noneMode = "none" crowdsecLapiHeader = "X-Api-Key" crowdsecLapiRoute = "v1/decisions" crowdsecLapiStreamRoute = "v1/decisions/stream" @@ -41,49 +35,9 @@ var ( ticker chan bool ) -// 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"` -} - // CreateConfig creates the default plugin configuration. -func CreateConfig() *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", - } +func CreateConfig() *configuration.Config { + return configuration.New() } // Bouncer a Bouncer struct. @@ -106,9 +60,9 @@ type Bouncer struct { } // New creates the crowdsec bouncer plugin. -func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { +func New(ctx context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) { logger.Init(config.LogLevel) - err := validateParams(config) + err := configuration.ValidateParams(config) if err != nil { logger.Info(fmt.Sprintf("New:validateParams %s", err.Error())) return nil, err @@ -122,7 +76,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h logger.Error(fmt.Sprintf("New:getTLSConfigCrowdsec fail to get tlsConfig %s", err.Error())) return nil, err } - apiKey, err := getVariable(config, "CrowdsecLapiKey") + apiKey, err := configuration.GetVariable(config, "CrowdsecLapiKey") if err != nil && len(tlsConfig.Certificates) == 0 { logger.Error(fmt.Sprintf("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup %s", err.Error())) return nil, err @@ -159,7 +113,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h if config.RedisCacheEnabled { cache.InitRedisClient(config.RedisCacheHost) } - if config.CrowdsecMode == streamMode && ticker == nil { + if config.CrowdsecMode == configuration.StreamMode && ticker == nil { ticker = startTicker(config, func() { handleStreamCache(bouncer) }) @@ -199,7 +153,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } // TODO This should be simplified - if bouncer.crowdsecMode != noneMode { + if bouncer.crowdsecMode != configuration.NoneMode { isBanned, erro := cache.GetDecision(remoteIP) if erro != nil { logger.Debug(fmt.Sprintf("ServeHTTP:getDecision ip:%s %s", remoteIP, erro.Error())) @@ -219,7 +173,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } // Right here if we cannot join the stream we forbid the request to go on. - if bouncer.crowdsecMode == streamMode { + if bouncer.crowdsecMode == configuration.StreamMode { if isCrowdsecStreamHealthy { bouncer.next.ServeHTTP(rw, req) } else { @@ -258,16 +212,7 @@ type Stream struct { New []Decision `json:"new"` } -func contains(source []string, target string) bool { - for _, item := range source { - if item == target { - return true - } - } - return false -} - -func startTicker(config *Config, work func()) chan bool { +func startTicker(config *configuration.Config, work func()) chan bool { ticker := time.NewTicker(time.Duration(config.UpdateIntervalSeconds) * time.Second) stop := make(chan bool, 1) go func() { @@ -286,7 +231,7 @@ func startTicker(config *Config, work func()) chan bool { // We are now in none or live mode. func handleNoStreamCache(bouncer *Bouncer, remoteIP string) error { - isLiveMode := bouncer.crowdsecMode == liveMode + isLiveMode := bouncer.crowdsecMode == configuration.LiveMode routeURL := url.URL{ Scheme: bouncer.crowdsecScheme, Host: bouncer.crowdsecHost, @@ -392,7 +337,7 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string) ([]byte, error) { return body, nil } -func getTLSConfigCrowdsec(config *Config) (*tls.Config, error) { +func getTLSConfigCrowdsec(config *configuration.Config) (*tls.Config, error) { tlsConfig := new(tls.Config) tlsConfig.RootCAs = x509.NewCertPool() //nolint:gocritic @@ -405,7 +350,7 @@ func getTLSConfigCrowdsec(config *Config) (*tls.Config, error) { // If we return here and still want to use client auth this won't work // return tlsConfig, nil } else { - certAuthority, err := getVariable(config, "CrowdsecLapiTLSCertificateAuthority") + certAuthority, err := configuration.GetVariable(config, "CrowdsecLapiTLSCertificateAuthority") if err != nil { return nil, err } @@ -414,15 +359,15 @@ func getTLSConfigCrowdsec(config *Config) (*tls.Config, error) { 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, errors.New("getTLSConfigCrowdsec:cannot load CA and verify cert is enabled") + return nil, fmt.Errorf("getTLSConfigCrowdsec:cannot load CA and verify cert is enabled") } } - certBouncer, err := getVariable(config, "CrowdsecLapiTLSCertificateBouncer") + certBouncer, err := configuration.GetVariable(config, "CrowdsecLapiTLSCertificateBouncer") if err != nil { return nil, err } - certBouncerKey, err := getVariable(config, "CrowdsecLapiTLSCertificateBouncerKey") + certBouncerKey, err := configuration.GetVariable(config, "CrowdsecLapiTLSCertificateBouncerKey") if err != nil { return nil, err } @@ -437,134 +382,3 @@ func getTLSConfigCrowdsec(config *Config) (*tls.Config, error) { return tlsConfig, nil } - -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 -} - -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") - } - - // 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 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 -} diff --git a/bouncer_test.go b/bouncer_test.go index 678f0e9..2e4733b 100644 --- a/bouncer_test.go +++ b/bouncer_test.go @@ -12,12 +12,6 @@ import ( ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip" ) -func getMinimalConfig() *Config { - cfg := CreateConfig() - cfg.CrowdsecLapiKey = "test" - return cfg -} - func TestServeHTTP(t *testing.T) { cfg := CreateConfig() cfg.CrowdsecLapiKey = "test" @@ -119,27 +113,6 @@ func TestBouncer_ServeHTTP(t *testing.T) { } } -func Test_contains(t *testing.T) { - type args struct { - source []string - target string - } - tests := []struct { - name string - args args - want bool - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := contains(tt.args.source, tt.args.target); got != tt.want { - t.Errorf("contains() = %v, want %v", got, tt.want) - } - }) - } -} - func Test_handleNoStreamCache(t *testing.T) { type args struct { bouncer *Bouncer @@ -230,172 +203,3 @@ func Test_getTLSConfigCrowdsec(t *testing.T) { }) } } - -func Test_getVariable(t *testing.T) { - cfg1 := CreateConfig() - cfg1.CrowdsecLapiKey = "test" - cfg2 := CreateConfig() - cfg2.CrowdsecLapiKeyFile = "./tests/.keytest" - cfg3 := CreateConfig() - cfg3.CrowdsecLapiKeyFile = "./tests/.bad" - type args struct { - config *Config - key string - } - tests := []struct { - name string - args args - want string - wantErr bool - }{ - { - name: "valid string", - args: args{config: cfg1, key: "CrowdsecLapiKey"}, - want: "test", - wantErr: false, - }, - { - name: "valid file", - args: args{config: cfg2, key: "CrowdsecLapiKey"}, - want: "test", - wantErr: false, - }, - { - name: "invalid file", - args: args{config: cfg3, key: "CrowdsecLapiKey"}, - want: "", - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := getVariable(tt.args.config, tt.args.key) - if (err != nil) != tt.wantErr { - t.Errorf("getVariable() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.want { - t.Errorf("getVariable() = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_validateParams(t *testing.T) { - cfg2 := getMinimalConfig() - cfg2.CrowdsecLapiScheme = "bad" - cfg3 := getMinimalConfig() - cfg3.CrowdsecMode = "bad" - cfg4 := getMinimalConfig() - cfg4.UpdateIntervalSeconds = 0 - cfg5 := getMinimalConfig() - cfg5.ClientTrustedIPs = []string{0: "bad"} - cfg6 := getMinimalConfig() - cfg6.CrowdsecLapiScheme = "https" - cfg6.CrowdsecLapiTLSInsecureVerify = true - cfg8 := getMinimalConfig() - cfg8.CrowdsecLapiScheme = "https" - type args struct { - config *Config - } - tests := []struct { - name string - args args - wantErr bool - }{ - {name: "good minimal config", args: args{config: getMinimalConfig()}, wantErr: false}, - {name: "bad crowdsec lapi key", args: args{config: CreateConfig()}, wantErr: true}, - {name: "bad crowdsec scheme", args: args{config: cfg2}, wantErr: true}, - {name: "bad crowdsec mode", args: args{config: cfg3}, wantErr: true}, - {name: "bad update interval", args: args{config: cfg4}, wantErr: true}, - {name: "bad clients ips", args: args{config: cfg5}, wantErr: true}, - // HTTPS enabled - {name: "good https config with insecure verify", args: args{config: cfg6}, wantErr: false}, - {name: "no cert authority", args: args{config: cfg8}, wantErr: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := validateParams(tt.args.config); (err != nil) != tt.wantErr { - t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func Test_validateParamsTLS(t *testing.T) { - type args struct { - config *Config - } - tests := []struct { - name string - args args - wantErr bool - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := validateParamsTLS(tt.args.config); (err != nil) != tt.wantErr { - t.Errorf("validateParamsTLS() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func Test_validateParamsIPs(t *testing.T) { - type args struct { - listIP []string - key string - } - tests := []struct { - name string - args args - wantErr bool - }{ - {name: "not an ip", args: args{listIP: []string{0: "bad"}}, wantErr: true}, - {name: "weird ip", args: args{listIP: []string{0: "0.0.0.0/89"}}, wantErr: true}, - {name: "localhost ?", args: args{listIP: []string{0: "localhost"}}, wantErr: true}, - {name: "weird ip 2", args: args{listIP: []string{0: "0.0.0.256/12"}}, wantErr: true}, - {name: "valid ip", args: args{listIP: []string{0: "0.0.0.0/12"}}, wantErr: false}, - {name: "valid ip list", args: args{listIP: []string{0: "0.0.0.0/0", 1: "1.1.1.1/1"}}, wantErr: false}, - } - 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 { - t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func Test_validateParamsRequired(t *testing.T) { - cfg2 := getMinimalConfig() - cfg2.CrowdsecLapiScheme = "bad" - cfg3 := getMinimalConfig() - cfg3.CrowdsecMode = "bad" - cfg4 := getMinimalConfig() - cfg4.UpdateIntervalSeconds = 0 - cfg5 := getMinimalConfig() - cfg5.DefaultDecisionSeconds = 0 - type args struct { - config *Config - } - tests := []struct { - name string - args args - wantErr bool - }{ - {name: "good", args: args{config: getMinimalConfig()}, wantErr: false}, - {name: "bad crowdsec scheme", args: args{config: cfg2}, wantErr: true}, - {name: "bad crowdsec mode", args: args{config: cfg3}, wantErr: true}, - {name: "bad update interval seconds", args: args{config: cfg4}, wantErr: true}, - {name: "bad default decision seconds", args: args{config: cfg5}, wantErr: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := validateParamsRequired(tt.args.config); (err != nil) != tt.wantErr { - t.Errorf("validateParamsRequired() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} diff --git a/exemples/redis-cache/docker-compose.redis.yml b/exemples/redis-cache/docker-compose.redis.yml index b094ea9..bc9f311 100644 --- a/exemples/redis-cache/docker-compose.redis.yml +++ b/exemples/redis-cache/docker-compose.redis.yml @@ -14,13 +14,13 @@ services: - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - - "--experimental.plugins.bouncer.version=v1.1.4" - # - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + # - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + # - "--experimental.plugins.bouncer.version=v1.1.4" + - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - logs-redis:/var/log/traefik - # - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin ports: - 80:80 - 8080:8080 diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go new file mode 100644 index 0000000..bec43ac --- /dev/null +++ b/pkg/configuration/configuration.go @@ -0,0 +1,206 @@ +package configuration + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "reflect" + + ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip" + logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger" +) + +const ( + StreamMode = "stream" + LiveMode = "live" + NoneMode = "none" +) + +// 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 +} + +// CreateConfig 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", + } +} + +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 +} + +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") + } + + // 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 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 +} diff --git a/pkg/configuration/configuration_test.go b/pkg/configuration/configuration_test.go new file mode 100644 index 0000000..d371d59 --- /dev/null +++ b/pkg/configuration/configuration_test.go @@ -0,0 +1,184 @@ +package configuration + +import ( + "testing" +) + +func getMinimalConfig() *Config { + cfg := New() + cfg.CrowdsecLapiKey = "test" + return cfg +} + +func Test_contains(t *testing.T) { + type args struct { + source []string + target string + } + tests := []struct { + name string + args args + want bool + }{ + {name: "Contain in the list", args: args{source: []string{"a", "b"}, target: "a"}, want: true}, + {name: "Contain not in the list", args: args{source: []string{"a", "b"}, target: "c"}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := contains(tt.args.source, tt.args.target); got != tt.want { + t.Errorf("contains() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_GetVariable(t *testing.T) { + cfg1 := New() + cfg1.CrowdsecLapiKey = "test" + cfg2 := New() + cfg2.CrowdsecLapiKeyFile = "../../tests/.keytest" + cfg3 := New() + cfg3.CrowdsecLapiKeyFile = "../../tests/.bad" + type args struct { + config *Config + key string + } + tests := []struct{ + name string + args args + want string + wantErr bool + }{ + {name: "Validate a key string", args: args{config: cfg1, key: "CrowdsecLapiKey"}, want: "test", wantErr: false}, + {name: "Validate a key file", args: args{config: cfg2, key: "CrowdsecLapiKey"}, want: "test", wantErr: false}, + {name: "Not validate an invalid file", args: args{config: cfg3, key: "CrowdsecLapiKey"}, want: "", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetVariable(tt.args.config, tt.args.key) + if (err != nil) != tt.wantErr { + t.Errorf("getVariable() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("getVariable() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ValidateParams(t *testing.T) { + cfg3 := getMinimalConfig() + cfg3.CrowdsecMode = "bad" + cfg4 := getMinimalConfig() + cfg4.UpdateIntervalSeconds = 0 + cfg5 := getMinimalConfig() + cfg5.ClientTrustedIPs = []string{0: "bad"} + cfg6 := getMinimalConfig() + cfg6.CrowdsecLapiScheme = "https" + cfg6.CrowdsecLapiTLSInsecureVerify = true + cfg8 := getMinimalConfig() + cfg8.CrowdsecLapiScheme = "https" + type args struct { + config *Config + } + tests := []struct { + name string + args args + wantErr bool + }{ + {name: "Validate minimal config", args: args{config: getMinimalConfig()}, wantErr: false}, + {name: "Not validate an absent crowdsec lapi key", args: args{config: New()}, wantErr: true}, + {name: "Not validate a not listed item", args: args{config: cfg3}, wantErr: true}, + {name: "Not validate a bad number", args: args{config: cfg4}, wantErr: true}, + {name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true}, + // HTTPS enabled + {name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false}, + {name: "Not validate https without cert authority", args: args{config: cfg8}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := ValidateParams(tt.args.config); (err != nil) != tt.wantErr { + t.Errorf("validateParams() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_validateParamsTLS(t *testing.T) { + type args struct { + config *Config + } + tests := []struct { + name string + args args + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateParamsTLS(tt.args.config); (err != nil) != tt.wantErr { + t.Errorf("validateParamsTLS() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_validateParamsIPs(t *testing.T) { + type args struct { + listIP []string + key string + } + tests := []struct { + name string + args args + wantErr bool + }{ + {name: "Not validate a non ip", args: args{listIP: []string{0: "bad"}}, wantErr: true}, + {name: "Not validate localhost", args: args{listIP: []string{0: "localhost"}}, wantErr: true}, + {name: "Not validate a weird ip", args: args{listIP: []string{0: "0.0.0.0/89"}}, wantErr: true}, + {name: "Not validate a weird ip 2", args: args{listIP: []string{0: "0.0.0.256/12"}}, wantErr: true}, + {name: "Validate an ip", args: args{listIP: []string{0: "0.0.0.0/12"}}, wantErr: false}, + {name: "Validate a ip list", args: args{listIP: []string{0: "0.0.0.0/0", 1: "1.1.1.1/1"}}, wantErr: false}, + } + 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 { + t.Errorf("validateParamsIPs() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_validateParamsRequired(t *testing.T) { + cfg2 := getMinimalConfig() + cfg2.CrowdsecLapiScheme = "bad" + cfg3 := getMinimalConfig() + cfg3.CrowdsecMode = "bad" + cfg4 := getMinimalConfig() + cfg4.UpdateIntervalSeconds = 0 + cfg5 := getMinimalConfig() + cfg5.DefaultDecisionSeconds = 0 + type args struct { + config *Config + } + tests := []struct { + name string + args args + wantErr bool + }{ + {name: "Validate minimal config", args: args{config: getMinimalConfig()}, wantErr: false}, + {name: "Not validate a bad crowdsec scheme", args: args{config: cfg2}, wantErr: true}, + {name: "Not validate a bad crowdsec mode", args: args{config: cfg3}, wantErr: true}, + {name: "Not validate a bad update interval seconds", args: args{config: cfg4}, wantErr: true}, + {name: "Not validate a bad default decision seconds", args: args{config: cfg5}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateParamsRequired(tt.args.config); (err != nil) != tt.wantErr { + t.Errorf("validateParamsRequired() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}