diff --git a/bouncer.go b/bouncer.go index e370807..c0ef4a0 100644 --- a/bouncer.go +++ b/bouncer.go @@ -5,30 +5,22 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck import ( "bytes" "context" - "crypto/tls" - "crypto/x509" "encoding/json" - "errors" "fmt" "io" "net/http" "net/url" - "os" - "path/filepath" - "reflect" "text/template" "time" cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache" + configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration" 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" ) const ( - streamMode = "stream" - liveMode = "live" - noneMode = "none" crowdsecLapiHeader = "X-Api-Key" crowdsecLapiRoute = "v1/decisions" crowdsecLapiStreamRoute = "v1/decisions/stream" @@ -41,49 +33,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 +58,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 @@ -117,12 +69,12 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h serverChecker, _ := ip.NewChecker(config.ForwardedHeadersTrustedIPs) clientChecker, _ := ip.NewChecker(config.ClientTrustedIPs) - tlsConfig, err := getTLSConfigCrowdsec(config) + tlsConfig, err := configuration.GetTLSConfigCrowdsec(config) if err != nil { 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 +111,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 +151,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 +171,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 +210,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 +229,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, @@ -391,180 +334,3 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string) ([]byte, error) { } return body, nil } - -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, errors.New("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 -} - -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 - } - testURL := url.URL{ - Scheme: config.CrowdsecLapiScheme, - Host: config.CrowdsecLapiHost, - } - // This only check that the format of the URL scheme:// is correct and do not make requests - - 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, - } - 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) - } - } - for key, val := range requiredStrings { - if len(val) == 0 { - return fmt.Errorf("%v: cannot be empty", 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 fbabbc4..7fb33a9 100644 --- a/bouncer_test.go +++ b/bouncer_test.go @@ -4,72 +4,14 @@ import ( "context" "net/http" "net/http/httptest" + "reflect" "testing" + "text/template" + + configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration" + ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip" ) -func TestCreation(t *testing.T) { - cfg := CreateConfig() - cfg.CrowdsecLapiKey = "test" - - ctx := context.Background() - next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {}) - - handler, err := New(ctx, next, cfg, "demo-plugin") - if err != nil { - t.Fatal(err) - } - - recorder := httptest.NewRecorder() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) - if err != nil { - t.Fatal(err) - } - - handler.ServeHTTP(recorder, req) -} - -// func TestValidateParamsCrowdsecLapiKey(t *testing.T) { -// cfg := CreateConfig() -// err := validateParams(cfg) -// fmt.Println(err.Error()) -// if err == nil { -// t.Errorf("Need error here %s", err.Error()) -// } -// } - -// func TestValidateParamsCrowdsecLapiScheme(t *testing.T) { -// cfg := CreateConfig() -// cfg.CrowdsecLapiKey = "test" -// cfg.CrowdsecLapiScheme = "bad" -// err := validateParams(cfg) -// fmt.Println(err.Error()) -// if err == nil { -// t.Errorf("Need error here %s", err.Error()) -// } -// } - -// func TestValidateParamsCrowdsecMode(t *testing.T) { -// cfg := CreateConfig() -// cfg.CrowdsecLapiKey = "test" -// cfg.CrowdsecMode = "bad" -// err := validateParams(cfg) -// fmt.Println(err.Error()) -// if err == nil { -// t.Errorf("Need error here %s", err.Error()) -// } -// } - -// func TestValidateParamsUpdateIntervalSeconds(t *testing.T) { -// cfg := CreateConfig() -// cfg.CrowdsecLapiKey = "test" -// cfg.UpdateIntervalSeconds = 0 -// err := validateParams(cfg) -// fmt.Println(err.Error()) -// if err == nil { -// t.Errorf("Need error here %s", err.Error()) -// } -// } - func TestServeHTTP(t *testing.T) { cfg := CreateConfig() cfg.CrowdsecLapiKey = "test" @@ -90,3 +32,148 @@ func TestServeHTTP(t *testing.T) { handler.ServeHTTP(recorder, req) } + +func TestNew(t *testing.T) { + type args struct { + ctx context.Context //nolint:containedctx + next http.Handler + config *configuration.Config + name string + } + tests := []struct { + name string + args args + want http.Handler + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := New(tt.args.ctx, tt.args.next, tt.args.config, tt.args.name) + if (err != nil) != tt.wantErr { + t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("New() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBouncer_ServeHTTP(t *testing.T) { + type fields struct { + next http.Handler + name string + template *template.Template + enabled bool + crowdsecScheme string + crowdsecHost string + crowdsecKey string + crowdsecMode string + updateInterval int64 + defaultDecisionTimeout int64 + customHeader string + clientPoolStrategy *ip.PoolStrategy + serverPoolStrategy *ip.PoolStrategy + client *http.Client + } + type args struct { + rw http.ResponseWriter + req *http.Request + } + tests := []struct { + name string + fields fields + args args + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bouncer := &Bouncer{ + next: tt.fields.next, + name: tt.fields.name, + template: tt.fields.template, + enabled: tt.fields.enabled, + crowdsecScheme: tt.fields.crowdsecScheme, + crowdsecHost: tt.fields.crowdsecHost, + crowdsecKey: tt.fields.crowdsecKey, + crowdsecMode: tt.fields.crowdsecMode, + updateInterval: tt.fields.updateInterval, + defaultDecisionTimeout: tt.fields.defaultDecisionTimeout, + customHeader: tt.fields.customHeader, + clientPoolStrategy: tt.fields.clientPoolStrategy, + serverPoolStrategy: tt.fields.serverPoolStrategy, + client: tt.fields.client, + } + bouncer.ServeHTTP(tt.args.rw, tt.args.req) + }) + } +} + +func Test_handleNoStreamCache(t *testing.T) { + type args struct { + bouncer *Bouncer + remoteIP string + } + 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 := handleNoStreamCache(tt.args.bouncer, tt.args.remoteIP); (err != nil) != tt.wantErr { + t.Errorf("handleNoStreamCache() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_handleStreamCache(t *testing.T) { + type args struct { + bouncer *Bouncer + } + tests := []struct { + name string + args args + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handleStreamCache(tt.args.bouncer) + }) + } +} + +func Test_crowdsecQuery(t *testing.T) { + type args struct { + bouncer *Bouncer + stringURL string + } + tests := []struct { + name string + args args + want []byte + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := crowdsecQuery(tt.args.bouncer, tt.args.stringURL) + if (err != nil) != tt.wantErr { + t.Errorf("crowdsecQuery() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("crowdsecQuery() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go new file mode 100644 index 0000000..4fdc5e3 --- /dev/null +++ b/pkg/cache/cache_test.go @@ -0,0 +1,226 @@ +// Package cache implements utility routines for manipulating cache. +// It supports currently local file and redis cache. +package cache + +import ( + "testing" +) + +func Test_getDecisionLocalCache(t *testing.T) { + IPInCache := "10.0.0.10" + IPNotInCache := "10.0.0.20" + setDecisionLocalCache(IPInCache, "t", 10) + type args struct { + clientIP string + } + tests := []struct { + name string + args args + want bool + wantErr bool + valueErr string + }{ + {name: "Fetch Known valid IP", args: args{clientIP: IPInCache}, want: true, wantErr: false, valueErr: ""}, + {name: "Fetch Unknown valid IP", args: args{clientIP: IPNotInCache}, want: false, wantErr: true, valueErr: "cache:miss"}, + {name: "Fetch invalid value", args: args{clientIP: "zaeaea"}, want: false, wantErr: true, valueErr: "cache:miss"}, + {name: "Fetch empty value", args: args{clientIP: ""}, want: false, wantErr: true, valueErr: "cache:miss"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getDecisionLocalCache(tt.args.clientIP) + if (err != nil) != tt.wantErr { + t.Errorf("getDecisionLocalCache() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("getDecisionLocalCache() = %v, want %v", got, tt.want) + return + } + if tt.valueErr != "" && tt.valueErr != err.Error() { + t.Errorf("getDecisionLocalCache() err = %v, want %v", err.Error(), tt.valueErr) + } + }) + } +} + +func Test_setDecisionLocalCache(t *testing.T) { + IPInCache := "10.0.0.10" + type args struct { + clientIP string + value string + duration int64 + } + tests := []struct { + name string + args args + }{ + {name: "Set valid IP in local cache as t", args: args{clientIP: IPInCache, value: "t", duration: 0}}, + {name: "Set valid IP in local cache as f", args: args{clientIP: IPInCache, value: "f", duration: 0}}, + {name: "Set valid IP in local cache as empty str", args: args{clientIP: IPInCache, value: "", duration: 0}}, + {name: "Set valid IP in local cache as f for -1 sec", args: args{clientIP: IPInCache, value: "f", duration: -1}}, + {name: "Set valid IP in local cache as f for 10 sec", args: args{clientIP: IPInCache, value: "f", duration: 10}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setDecisionLocalCache(tt.args.clientIP, tt.args.value, tt.args.duration) + }) + } +} + +func Test_deleteDecisionLocalCache(t *testing.T) { + type args struct { + clientIP string + } + tests := []struct { + name string + args args + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deleteDecisionLocalCache(tt.args.clientIP) + }) + } +} + +func Test_getDecisionRedisCache(t *testing.T) { + type args struct { + clientIP string + } + tests := []struct { + name string + args args + want bool + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getDecisionRedisCache(tt.args.clientIP) + if (err != nil) != tt.wantErr { + t.Errorf("getDecisionRedisCache() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("getDecisionRedisCache() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_setDecisionRedisCache(t *testing.T) { + type args struct { + clientIP string + value string + duration int64 + } + tests := []struct { + name string + args args + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setDecisionRedisCache(tt.args.clientIP, tt.args.value, tt.args.duration) + }) + } +} + +func Test_deleteDecisionRedisCache(t *testing.T) { + type args struct { + clientIP string + } + tests := []struct { + name string + args args + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deleteDecisionRedisCache(tt.args.clientIP) + }) + } +} + +func TestDeleteDecision(t *testing.T) { + type args struct { + clientIP string + } + tests := []struct { + name string + args args + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + DeleteDecision(tt.args.clientIP) + }) + } +} + +func TestGetDecision(t *testing.T) { + type args struct { + clientIP string + } + tests := []struct { + name string + args args + want bool + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetDecision(tt.args.clientIP) + if (err != nil) != tt.wantErr { + t.Errorf("GetDecision() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GetDecision() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSetDecision(t *testing.T) { + type args struct { + clientIP string + isBanned bool + duration int64 + } + tests := []struct { + name string + args args + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + SetDecision(tt.args.clientIP, tt.args.isBanned, tt.args.duration) + }) + } +} + +func TestInitRedisClient(t *testing.T) { + type args struct { + host string + } + tests := []struct { + name string + args args + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + InitRedisClient(tt.args.host) + }) + } +} diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go new file mode 100644 index 0000000..890bed3 --- /dev/null +++ b/pkg/configuration/configuration.go @@ -0,0 +1,259 @@ +// 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" + + 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. +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 +} + +// 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 +} diff --git a/pkg/configuration/configuration_test.go b/pkg/configuration/configuration_test.go new file mode 100644 index 0000000..f5c00a5 --- /dev/null +++ b/pkg/configuration/configuration_test.go @@ -0,0 +1,212 @@ +package configuration + +import ( + "crypto/tls" + "reflect" + "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) + } + }) + } +} + +func Test_GetTLSConfigCrowdsec(t *testing.T) { + type args struct { + config *Config + } + tests := []struct { + name string + args args + want *tls.Config + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetTLSConfigCrowdsec(tt.args.config) + if (err != nil) != tt.wantErr { + t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("getTLSConfigCrowdsec() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/tests/.keytest b/tests/.keytest new file mode 100644 index 0000000..30d74d2 --- /dev/null +++ b/tests/.keytest @@ -0,0 +1 @@ +test \ No newline at end of file