diff --git a/README.md b/README.md index d5b480b..7fc50a4 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ There are 3 operating modes (CrowdsecMode) for this plugin: | none | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. All request call the Crowdsec LAPI | | live | If the client IP is on ban list, it will get a http code 403 response. Otherwise, request will continue as usual. The bouncer can leverage use of a local cache in order to reduce the number of requests made to the Crowdsec LAPI. It will keep in cache the status for each IP that makes queries. | | stream | Stream Streaming mode allows you to keep in the local cache only the Banned IPs, every requests that does not hit the cache is authorized. Every minute, the cache is updated with news from the Crowdsec LAPI. | +| alone | Standalone mode, similar to the streaming mode but the blacklisted IPs are fetched on the CAPI. Every 2 hours, the cache is updated with news from the Crowdsec CAPI. It does not include any localy banned IP, but can work without a crowdsec service. | The streaming mode is recommended for performance, decisions are updated every 60 sec by default and that's the only communication between traefik and crowdsec. Every request that happens hits the cache for quick decisions. @@ -55,7 +56,7 @@ make run - default: `INFO`, expected values are: `INFO`, `DEBUG` - CrowdsecMode - string - - default: `live`, expected values are: `none`, `live`, `stream` + - default: `live`, expected values are: `none`, `live`, `stream`, `alone` - CrowdsecLapiScheme - string - default: `http`, expected values are: `http`, `https` @@ -83,14 +84,6 @@ make run - string - default: "" - PEM-encoded client private key of the Bouncer -- UpdateIntervalSeconds - - int64 - - default: 60 - - Used only in `stream` mode, the interval between requests to fetch blacklisted IPs from LAPI -- DefaultDecisionSeconds - - int64 - - default: 60 - - Used only in `live` mode, decision duration of accepted IPs - ClientTrustedIPs - string - default: [] @@ -111,6 +104,23 @@ make run - string - default: "redis:6379" - hostname and port for the redis service +- UpdateIntervalSeconds + - int64 + - default: 60 + - Used only in `stream` mode, the interval between requests to fetch blacklisted IPs from LAPI +- DefaultDecisionSeconds + - int64 + - default: 60 + - Used only in `live` mode, decision duration of accepted IPs +- CrowdsecCapiMachineId + - string + - Used only in `alone` mode, login for Crowdsec CAPI +- CrowdsecCapiPassword + - string + - Used only in `alone` mode, password for Crowdsec CAPI +- CrowdsecCapiScenarios + - []string + - Used only in `alone` mode, scenarios for Crowdsec CAPI ### Configuration @@ -160,6 +170,12 @@ http: crowdsecLapiHost: crowdsec:8080 crowdsecLapiScheme: http crowdsecLapiTLSInsecureVerify: false + crowdsecCapiMachineId: login + crowdsecCapiPassword: password + crowdsecCapiScenarios: + - crowdsecurity/http-path-traversal-probing + - crowdsecurity/http-xss-probing + - crowdsecurity/http-generic-bf forwardedHeadersTrustedIPs: - 10.0.10.23/32 - 10.0.20.0/24 @@ -190,12 +206,11 @@ http: ic5cDRo6/VD3CS3MYzyBcibaGaV34nr0G/pI+KEqkYChzk/PZRA= -----END RSA PRIVATE KEY----- crowdsecLapiTLSCertificateBouncerKeyFile: /etc/traefik/crowdsec-certs/bouncer-key.pem - ``` #### Fill variable with value of file -`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority` and `CrowdsecLapiKey` can be provided with the content as raw or through a file path that Traefik can read. +`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword` and `CrowdsecLapiKey` can be provided with the content as raw or through a file path that Traefik can read. The file variable will be used as preference if both content and file are provided for the same variable. Format is: diff --git a/bouncer.go b/bouncer.go index 77dbf16..9fabd54 100644 --- a/bouncer.go +++ b/bouncer.go @@ -5,6 +5,7 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck import ( "bytes" "context" + "crypto/tls" "encoding/json" "fmt" "io" @@ -24,14 +25,17 @@ import ( const ( crowdsecLapiHeader = "X-Api-Key" + crowdsecCapiHeader = "Authorization" crowdsecLapiRoute = "v1/decisions" crowdsecLapiStreamRoute = "v1/decisions/stream" + crowdsecCapiLogin = "v2/watchers/login" + crowdsecCapiStreamRoute = "v2/decisions/stream" cacheTimeoutKey = "updated" ) //nolint:gochecknoglobals var ( - isCrowdsecStreamHealthy = false + isCrowdsecStreamHealthy = true ticker chan bool ) @@ -51,12 +55,18 @@ type Bouncer struct { crowdsecHost string crowdsecKey string crowdsecMode string + crowdsecMachineID string + crowdsecPassword string + crowdsecScenarios []string updateInterval int64 defaultDecisionTimeout int64 customHeader string + crowdsecStreamRoute string + crowdsecHeader string clientPoolStrategy *ip.PoolStrategy serverPoolStrategy *ip.PoolStrategy - client *http.Client + httpClient *http.Client + cacheClient *cache.Client } // New creates the crowdsec bouncer plugin. @@ -64,24 +74,39 @@ func New(ctx context.Context, next http.Handler, config *configuration.Config, n logger.Init(config.LogLevel) err := configuration.ValidateParams(config) if err != nil { - logger.Info(fmt.Sprintf("New:validateParams %s", err.Error())) + logger.Error(fmt.Sprintf("New:validateParams %s", err.Error())) return nil, err } serverChecker, _ := ip.NewChecker(config.ForwardedHeadersTrustedIPs) clientChecker, _ := ip.NewChecker(config.ClientTrustedIPs) - tlsConfig, err := configuration.GetTLSConfigCrowdsec(config) - if err != nil { - logger.Error(fmt.Sprintf("New:getTLSConfigCrowdsec fail to get tlsConfig %s", err.Error())) - return nil, err + var tlsConfig *tls.Config + crowdsecStreamRoute := "" + crowdsecHeader := "" + if config.CrowdsecMode == configuration.AloneMode { + config.CrowdsecCapiMachineID, _ = configuration.GetVariable(config, "CrowdsecCapiMachineId") + config.CrowdsecCapiPassword, _ = configuration.GetVariable(config, "CrowdsecCapiPassword") + config.CrowdsecLapiHost = "api.crowdsec.net" + config.CrowdsecLapiScheme = "https" + config.UpdateIntervalSeconds = 7200 + crowdsecStreamRoute = crowdsecCapiStreamRoute + crowdsecHeader = crowdsecCapiHeader + } else { + crowdsecStreamRoute = crowdsecLapiStreamRoute + crowdsecHeader = crowdsecLapiHeader + 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, errAPIKey := configuration.GetVariable(config, "CrowdsecLapiKey") + if errAPIKey != nil && len(tlsConfig.Certificates) == 0 { + logger.Error(fmt.Sprintf("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup %s", errAPIKey.Error())) + return nil, err + } + config.CrowdsecLapiKey = apiKey } - 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 - } - apiKey = strings.TrimSuffix(apiKey, "\n") bouncer := &Bouncer{ next: next, @@ -92,17 +117,22 @@ func New(ctx context.Context, next http.Handler, config *configuration.Config, n crowdsecMode: config.CrowdsecMode, crowdsecScheme: config.CrowdsecLapiScheme, crowdsecHost: config.CrowdsecLapiHost, - crowdsecKey: apiKey, + crowdsecKey: config.CrowdsecLapiKey, + crowdsecMachineID: config.CrowdsecCapiMachineID, + crowdsecPassword: config.CrowdsecCapiPassword, + crowdsecScenarios: config.CrowdsecCapiScenarios, updateInterval: config.UpdateIntervalSeconds, customHeader: config.ForwardedHeadersCustomName, defaultDecisionTimeout: config.DefaultDecisionSeconds, + crowdsecStreamRoute: crowdsecStreamRoute, + crowdsecHeader: crowdsecHeader, serverPoolStrategy: &ip.PoolStrategy{ Checker: serverChecker, }, clientPoolStrategy: &ip.PoolStrategy{ Checker: clientChecker, }, - client: &http.Client{ + httpClient: &http.Client{ Transport: &http.Transport{ MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second, @@ -110,16 +140,24 @@ func New(ctx context.Context, next http.Handler, config *configuration.Config, n }, Timeout: 10 * time.Second, }, + cacheClient: &cache.Client{}, } - if config.RedisCacheEnabled { - cache.InitRedisClient(config.RedisCacheHost) - } - if config.CrowdsecMode == configuration.StreamMode && ticker == nil { + bouncer.cacheClient.New(config.RedisCacheEnabled, config.RedisCacheHost) + + if (config.CrowdsecMode == configuration.StreamMode || config.CrowdsecMode == configuration.AloneMode) && ticker == nil { + if config.CrowdsecMode == configuration.AloneMode { + err = getToken(bouncer) + if err != nil { + logger.Error(fmt.Sprintf("New:getToken %s", err.Error())) + return nil, err + } + } ticker = startTicker(config, func() { handleStreamCache(bouncer) }) go handleStreamCache(bouncer) } + logger.Debug(fmt.Sprintf("New initialized mode:%s", config.CrowdsecMode)) return bouncer, nil } @@ -155,9 +193,9 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { // TODO This should be simplified if bouncer.crowdsecMode != configuration.NoneMode { - isBanned, erro := cache.GetDecision(remoteIP) + isBanned, erro := bouncer.cacheClient.GetDecision(remoteIP) if erro != nil { - logger.Debug(fmt.Sprintf("ServeHTTP:getDecision ip:%s %s", remoteIP, erro.Error())) + logger.Debug(fmt.Sprintf("ServeHTTP:getDecision ip:%s isBanned:true %s", remoteIP, erro.Error())) if erro.Error() == simpleredis.RedisUnreachable { rw.WriteHeader(http.StatusForbidden) return @@ -174,19 +212,20 @@ 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 == configuration.StreamMode { + if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode { if isCrowdsecStreamHealthy { bouncer.next.ServeHTTP(rw, req) } else { - logger.Error(fmt.Sprintf("ServeHTTP:isCrowdsecStreamHealthy ip:%s", remoteIP)) + logger.Error(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s", remoteIP)) rw.WriteHeader(http.StatusForbidden) } } else { err = handleNoStreamCache(bouncer, remoteIP) if err != nil { - logger.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s %s", remoteIP, err.Error())) + logger.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:true %s", remoteIP, err.Error())) rw.WriteHeader(http.StatusForbidden) } else { + logger.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:false", remoteIP)) bouncer.next.ServeHTTP(rw, req) } } @@ -213,6 +252,13 @@ type Stream struct { New []Decision `json:"new"` } +// Login Body returned from Crowdsec Login CAPI. +type Login struct { + Code int `json:"code"` + Token string `json:"token"` + Expire string `json:"expire"` +} + func startTicker(config *configuration.Config, work func()) chan bool { ticker := time.NewTicker(time.Duration(config.UpdateIntervalSeconds) * time.Second) stop := make(chan bool, 1) @@ -239,14 +285,14 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) error { Path: crowdsecLapiRoute, RawQuery: fmt.Sprintf("ip=%v&banned=true", remoteIP), } - body, err := crowdsecQuery(bouncer, routeURL.String()) + body, err := crowdsecQuery(bouncer, routeURL.String(), false) if err != nil { return err } if bytes.Equal(body, []byte("null")) { if isLiveMode { - cache.SetDecision(remoteIP, false, bouncer.defaultDecisionTimeout) + bouncer.cacheClient.SetDecision(remoteIP, false, bouncer.defaultDecisionTimeout) } return nil } @@ -258,7 +304,7 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) error { } if len(decisions) == 0 { if isLiveMode { - cache.SetDecision(remoteIP, false, bouncer.defaultDecisionTimeout) + bouncer.cacheClient.SetDecision(remoteIP, false, bouncer.defaultDecisionTimeout) } return nil } @@ -267,29 +313,53 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) error { return fmt.Errorf("handleNoStreamCache:parseDuration %w", err) } if isLiveMode { - cache.SetDecision(remoteIP, true, int64(duration.Seconds())) + bouncer.cacheClient.SetDecision(remoteIP, true, int64(duration.Seconds())) } return fmt.Errorf("handleNoStreamCache:banned") } +func getToken(bouncer *Bouncer) error { + loginURL := url.URL{ + Scheme: bouncer.crowdsecScheme, + Host: bouncer.crowdsecHost, + Path: crowdsecCapiLogin, + } + body, err := crowdsecQuery(bouncer, loginURL.String(), true) + if err != nil { + return err + } + var login Login + err = json.Unmarshal(body, &login) + if err != nil { + isCrowdsecStreamHealthy = false + return fmt.Errorf("getToken:parsingBody %w", err) + } + if login.Code == 200 && len(login.Token) > 0 { + bouncer.crowdsecKey = login.Token + logger.Debug(fmt.Sprintf("getToken statusCode:%d", login.Code)) + return nil + } + return fmt.Errorf("getToken statusCode:%d", login.Code) +} + func handleStreamCache(bouncer *Bouncer) { // TODO clean properly on exit. // Instead of blocking the goroutine interval for all the secondary node, // if the master service is shut down, other goroutine can take the lead // because updated routine information is in the cache - _, err := cache.GetDecision(cacheTimeoutKey) + _, err := bouncer.cacheClient.GetDecision(cacheTimeoutKey) if err == nil { logger.Debug("handleStreamCache:alreadyUpdated") return } - cache.SetDecision(cacheTimeoutKey, false, bouncer.updateInterval-1) + bouncer.cacheClient.SetDecision(cacheTimeoutKey, false, bouncer.updateInterval-1) streamRouteURL := url.URL{ Scheme: bouncer.crowdsecScheme, Host: bouncer.crowdsecHost, - Path: crowdsecLapiStreamRoute, + Path: bouncer.crowdsecStreamRoute, RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy), } - body, err := crowdsecQuery(bouncer, streamRouteURL.String()) + body, err := crowdsecQuery(bouncer, streamRouteURL.String(), false) if err != nil { logger.Error(err.Error()) isCrowdsecStreamHealthy = false @@ -305,23 +375,40 @@ func handleStreamCache(bouncer *Bouncer) { for _, decision := range stream.New { duration, err := time.ParseDuration(decision.Duration) if err == nil { - cache.SetDecision(decision.Value, true, int64(duration.Seconds())) + bouncer.cacheClient.SetDecision(decision.Value, true, int64(duration.Seconds())) } } for _, decision := range stream.Deleted { - cache.DeleteDecision(decision.Value) + bouncer.cacheClient.DeleteDecision(decision.Value) } + logger.Debug("handleStreamCache:updated") isCrowdsecStreamHealthy = true } -func crowdsecQuery(bouncer *Bouncer, stringURL string) ([]byte, error) { +func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, error) { var req *http.Request - req, _ = http.NewRequest(http.MethodGet, stringURL, nil) - req.Header.Add(crowdsecLapiHeader, bouncer.crowdsecKey) - res, err := bouncer.client.Do(req) + if isPost { + data := []byte(fmt.Sprintf( + `{"machine_id": "%v","password": "%v","scenarios": ["%v"]}`, + bouncer.crowdsecMachineID, + bouncer.crowdsecPassword, + strings.Join(bouncer.crowdsecScenarios, `","`), + )) + req, _ = http.NewRequest(http.MethodPost, stringURL, bytes.NewBuffer(data)) + } else { + req, _ = http.NewRequest(http.MethodGet, stringURL, nil) + } + req.Header.Add(bouncer.crowdsecHeader, bouncer.crowdsecKey) + res, err := bouncer.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("crowdsecQuery url:%s %w", stringURL, err) } + if res.StatusCode == http.StatusUnauthorized && bouncer.crowdsecMode == configuration.AloneMode { + if errToken := getToken(bouncer); errToken != nil { + return nil, fmt.Errorf("crowdsecQuery:renewToken url:%s %w", stringURL, errToken) + } + return crowdsecQuery(bouncer, stringURL, false) + } if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("crowdsecQuery url:%s, statusCode:%d", stringURL, res.StatusCode) } diff --git a/bouncer_test.go b/bouncer_test.go index 7fb33a9..2b88e6d 100644 --- a/bouncer_test.go +++ b/bouncer_test.go @@ -8,6 +8,7 @@ import ( "testing" "text/template" + 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" ) @@ -77,7 +78,8 @@ func TestBouncer_ServeHTTP(t *testing.T) { customHeader string clientPoolStrategy *ip.PoolStrategy serverPoolStrategy *ip.PoolStrategy - client *http.Client + httpClient *http.Client + cacheClient *cache.Client } type args struct { rw http.ResponseWriter @@ -106,7 +108,8 @@ func TestBouncer_ServeHTTP(t *testing.T) { customHeader: tt.fields.customHeader, clientPoolStrategy: tt.fields.clientPoolStrategy, serverPoolStrategy: tt.fields.serverPoolStrategy, - client: tt.fields.client, + httpClient: tt.fields.httpClient, + cacheClient: tt.fields.cacheClient, } bouncer.ServeHTTP(tt.args.rw, tt.args.req) }) @@ -155,6 +158,7 @@ func Test_crowdsecQuery(t *testing.T) { type args struct { bouncer *Bouncer stringURL string + isPost bool } tests := []struct { name string @@ -166,7 +170,7 @@ func Test_crowdsecQuery(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := crowdsecQuery(tt.args.bouncer, tt.args.stringURL) + got, err := crowdsecQuery(tt.args.bouncer, tt.args.stringURL, tt.args.isPost) if (err != nil) != tt.wantErr { t.Errorf("crowdsecQuery() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/docker-compose.local.yml b/docker-compose.local.yml index a87e892..6634006 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -2,7 +2,7 @@ version: "3.8" services: traefik: - image: "traefik:v2.9.4" + image: "traefik:v2.9.6" container_name: "traefik" restart: unless-stopped command: diff --git a/docker-compose.yml b/docker-compose.yml index 6c12ce5..1543aa7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.8" services: traefik: - image: "traefik:v2.9.4" + image: "traefik:v2.9.6" container_name: "traefik" restart: unless-stopped command: @@ -14,7 +14,7 @@ services: - "--entrypoints.web.address=:80" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - - "--experimental.plugins.bouncer.version=v1.1.3" + - "--experimental.plugins.bouncer.version=v1.1.8" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" - "logs:/var/log/traefik" diff --git a/exemples/standalone-crowdsecmode/README.md b/exemples/standalone-crowdsecmode/README.md new file mode 100644 index 0000000..ee0d73a --- /dev/null +++ b/exemples/standalone-crowdsecmode/README.md @@ -0,0 +1,23 @@ +#### Generate CAPI credentials (only for `alone` mode) +You need to create a crowdsec API credentials for the CAPI. +You can follow the documentation here: https://docs.crowdsec.net/docs/central_api/intro + +```bash +curl -X POST "https://api.crowdsec.net/v2/watchers" -H "accept: application/json" -H "Content-Type: application/json" -d "{ \"password\": \"PASSWORD\", \"machine_id\": \"LOGIN\"}" +``` + +These CAPI credentials must be set in your docker-compose.yml or in your config files +```yaml +... +whoami: + labels: + - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecCapiMachineId=LOGIN" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecCapiPassword=PASSWORD" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseccapiscenarios=crowdsecurity/http-generic-bf,crowdsecurity/http-xss-probing,..." + - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true" +``` + +You can then run all the containers: +```bash +docker-compose up -d +``` \ No newline at end of file diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go index ed4ef9e..c92445e 100644 --- a/pkg/cache/cache.go +++ b/pkg/cache/cache.go @@ -18,14 +18,13 @@ const ( //nolint:gochecknoglobals var ( - cache = ttl_map.New() - redis simpleredis.SimpleRedis - redisEnabled = false + redis simpleredis.SimpleRedis + cache = ttl_map.New() ) -// FileSystem Cache +type localCache struct{} -func getDecisionLocalCache(clientIP string) (bool, error) { +func (localCache) getDecision(clientIP string) (bool, error) { banned, isCached := cache.Get(clientIP) bannedString, isValid := banned.(string) if isCached && isValid && len(bannedString) > 0 { @@ -34,17 +33,17 @@ func getDecisionLocalCache(clientIP string) (bool, error) { return false, fmt.Errorf("cache:miss") } -func setDecisionLocalCache(clientIP string, value string, duration int64) { +func (localCache) setDecision(clientIP string, value string, duration int64) { cache.Set(clientIP, value, duration) } -func deleteDecisionLocalCache(clientIP string) { +func (localCache) deleteDecision(clientIP string) { cache.Del(clientIP) } -// Redis Cache +type redisCache struct{} -func getDecisionRedisCache(clientIP string) (bool, error) { +func (redisCache) getDecision(clientIP string) (bool, error) { banned, err := redis.Get(clientIP) bannedString := string(banned) if err == nil && len(bannedString) > 0 { @@ -53,58 +52,61 @@ func getDecisionRedisCache(clientIP string) (bool, error) { return false, err } -func setDecisionRedisCache(clientIP string, value string, duration int64) { +func (redisCache) setDecision(clientIP string, value string, duration int64) { if err := redis.Set(clientIP, []byte(value), duration); err != nil { logger.Error(fmt.Sprintf("cache:setDecisionRedisCache %s", err.Error())) } } -func deleteDecisionRedisCache(clientIP string) { +func (redisCache) deleteDecision(clientIP string) { if err := redis.Del(clientIP); err != nil { logger.Error(fmt.Sprintf("cache:deleteDecisionRedisCache %s", err.Error())) } } -// DeleteDecision delete decision in cache. -func DeleteDecision(clientIP string) { - logger.Debug("cache:DeleteDecision") - if redisEnabled { - deleteDecisionRedisCache(clientIP) +type cacheInterface interface { + setDecision(clientIP string, value string, duration int64) + getDecision(clientIP string) (bool, error) + deleteDecision(clientIP string) +} + +// Client Cache client. +type Client struct { + cache cacheInterface +} + +// New Initialize cache client. +func (client *Client) New(isRedis bool, host string) { + if isRedis { + redis.Init(host) + client.cache = &redisCache{} } else { - deleteDecisionLocalCache(clientIP) + client.cache = &localCache{} } + logger.Debug(fmt.Sprintf("cache:New initialized isRedis:%v", isRedis)) +} + +// DeleteDecision delete decision in cache. +func (client *Client) DeleteDecision(clientIP string) { + logger.Debug(fmt.Sprintf("cache:DeleteDecision ip:%v", clientIP)) + client.cache.deleteDecision(clientIP) } // GetDecision check in the cache if the IP has the banned / not banned value. // Otherwise return with an error to add the IP in cache if we are on. -func GetDecision(clientIP string) (bool, error) { - logger.Debug("cache:GetDecision") - if redisEnabled { - return getDecisionRedisCache(clientIP) - } - return getDecisionLocalCache(clientIP) +func (client *Client) GetDecision(clientIP string) (bool, error) { + logger.Debug(fmt.Sprintf("cache:GetDecision ip:%v", clientIP)) + return client.cache.getDecision(clientIP) } // SetDecision update the cache with the IP as key and the value banned / not banned. -func SetDecision(clientIP string, isBanned bool, duration int64) { +func (client *Client) SetDecision(clientIP string, isBanned bool, duration int64) { + logger.Debug(fmt.Sprintf("cache:SetDecision ip:%v isBanned:%v", clientIP, isBanned)) var value string if isBanned { - logger.Debug(fmt.Sprintf("cache:SetDecision ip:%v banned", clientIP)) value = cacheBannedValue } else { value = cacheNoBannedValue } - logger.Debug("cache:SetDecision") - if redisEnabled { - setDecisionRedisCache(clientIP, value, duration) - } else { - setDecisionLocalCache(clientIP, value, duration) - } -} - -// InitRedisClient loads variables. -func InitRedisClient(host string) { - redisEnabled = true - redis.Init(host) - logger.Debug("cache:InitRedisClient redis:initialized") + client.cache.setDecision(clientIP, value, duration) } diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go index 4fdc5e3..9e40618 100644 --- a/pkg/cache/cache_test.go +++ b/pkg/cache/cache_test.go @@ -6,10 +6,11 @@ import ( "testing" ) -func Test_getDecisionLocalCache(t *testing.T) { +func Test_GetDecision(t *testing.T) { IPInCache := "10.0.0.10" IPNotInCache := "10.0.0.20" - setDecisionLocalCache(IPInCache, "t", 10) + client := &Client{cache: &localCache{}} + client.SetDecision(IPInCache, true, 10) type args struct { clientIP string } @@ -22,205 +23,80 @@ func Test_getDecisionLocalCache(t *testing.T) { }{ {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 invalid value", args: args{clientIP: "test"}, 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) + got, err := client.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) + return + } + if tt.valueErr != "" && tt.valueErr != err.Error() { + t.Errorf("GetDecision() err = %v, want %v", err.Error(), tt.valueErr) } }) } } -func TestSetDecision(t *testing.T) { +func Test_SetDecision(t *testing.T) { + client := &Client{cache: &localCache{}} + IPInCache := "10.0.0.11" type args struct { clientIP string - isBanned bool + value bool duration int64 } tests := []struct { name string args args + want bool }{ - // TODO: Add test cases. + {name: "Set valid IP in local cache for 0 sec", args: args{clientIP: IPInCache, value: true, duration: 0}, want: false}, + {name: "Set valid IP in local cache for 10 sec", args: args{clientIP: IPInCache, value: true, duration: 10}, want: true}, + {name: "Set valid IP in local cache for 10 sec", args: args{clientIP: IPInCache, value: false, duration: 10}, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - SetDecision(tt.args.clientIP, tt.args.isBanned, tt.args.duration) + client.SetDecision(tt.args.clientIP, tt.args.value, tt.args.duration) + got, _ := client.GetDecision(tt.args.clientIP) + if got != tt.want { + t.Errorf("SetDecision() = %v, want %v", got, tt.want) + return + } }) } } -func TestInitRedisClient(t *testing.T) { +func Test_DeleteDecision(t *testing.T) { + IPInCache := "10.0.0.12" + IPNotInCache := "10.0.0.22" + client := &Client{cache: &localCache{}} + client.SetDecision(IPInCache, true, 10) type args struct { - host string + clientIP string } tests := []struct { name string args args + want bool }{ - // TODO: Add test cases. + {name: "Delete Known valid IP", args: args{clientIP: IPInCache}, want: false}, + {name: "Delete Unknown valid IP", args: args{clientIP: IPNotInCache}, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - InitRedisClient(tt.args.host) + client.DeleteDecision(tt.args.clientIP) + got, _ := client.GetDecision(tt.args.clientIP) + if got != tt.want { + t.Errorf("DeleteDecision() = %v, want %v", got, tt.want) + return + } }) } } diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index 4b87e18..e12a579 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -19,6 +19,7 @@ import ( // Enums for crowdsec mode. const ( + AloneMode = "alone" StreamMode = "stream" LiveMode = "live" NoneMode = "none" @@ -42,6 +43,11 @@ type Config struct { 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"` DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"` ForwardedHeadersCustomName string `json:"forwardedheaderscustomheader,omitempty"` @@ -100,11 +106,11 @@ func GetVariable(config *Config, key string) (string, error) { return value, fmt.Errorf("%s:%s read file path failed %w", key, fp, err) } value = string(fileValue) - return value, nil + return strings.TrimSpace(value), nil } field = object.FieldByName(key) value = field.String() - return value, nil + return strings.TrimSpace(value), nil } // ValidateParams validate all the param gave by user. @@ -115,6 +121,23 @@ func ValidateParams(config *Config) error { return err } + if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil { + return err + } + if err := validateParamsIPs(config.ClientTrustedIPs, "ClientTrustedIPs"); 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 + } + // This only check that the format of the URL scheme:// is correct and do not make requests testURL := url.URL{ Scheme: config.CrowdsecLapiScheme, @@ -124,13 +147,6 @@ func ValidateParams(config *Config) error { 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 @@ -222,8 +238,8 @@ func validateParamsRequired(config *Config) error { 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{NoneMode, LiveMode, StreamMode, AloneMode}, config.CrowdsecMode) { + return fmt.Errorf("CrowdsecMode: must be one of 'none', 'live', 'stream' or 'alone'") } if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) { return fmt.Errorf("CrowdsecLapiScheme: must be one of 'http' or 'https'") @@ -237,10 +253,10 @@ func GetTLSConfigCrowdsec(config *Config) (*tls.Config, error) { tlsConfig.RootCAs = x509.NewCertPool() //nolint:gocritic if config.CrowdsecLapiScheme != HTTPS { - logger.Debug("getTLSConfigCrowdsec:CrowdsecLapiScheme not https") + logger.Debug("getTLSConfigCrowdsec:CrowdsecLapiScheme https:no") return tlsConfig, nil } else if config.CrowdsecLapiTLSInsecureVerify { - logger.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSInsecureVerify is true") + logger.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSInsecureVerify tlsInsecure:true") tlsConfig.InsecureSkipVerify = true // If we return here and still want to use client auth this won't work // return tlsConfig, nil