From 188aea2446729dc02112d43210d66b1c2abf3505 Mon Sep 17 00:00:00 2001 From: MathieuHa Date: Wed, 28 Sep 2022 19:09:11 +0200 Subject: [PATCH 1/3] Review Code, add comments --- bouncer.go | 191 ++++++++++++++++++++++++++++------------------------- 1 file changed, 101 insertions(+), 90 deletions(-) diff --git a/bouncer.go b/bouncer.go index 92c0c40..618b329 100644 --- a/bouncer.go +++ b/bouncer.go @@ -17,13 +17,11 @@ import ( ) const ( - realIpHeader = "X-Real-Ip" - forwardHeader = "X-Forwarded-For" - crowdsecAuthHeader = "X-Api-Key" - crowdsecRoute = "v1/decisions" - crowdsecStreamRoute = "v1/decisions/stream" - cacheBannedValue = "t" - cacheNoBannedValue = "f" + crowdsecAuthHeader = "X-Api-Key" + crowdsecRoute = "v1/decisions" + crowdsecStreamRoute = "v1/decisions/stream" + cacheBannedValue = "t" + cacheNoBannedValue = "f" ) var cache = ttl_map.New() @@ -66,13 +64,13 @@ type Bouncer struct { client *http.Client } -// New created a new Demo plugin. +// New creates the crowdsec bouncer plugin. func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { requiredStrings := map[string]string{ "CrowdsecLapiScheme": config.CrowdsecLapiScheme, - "CrowdsecLapiHost": config.CrowdsecLapiHost, - "CrowdsecLapiKey": config.CrowdsecLapiKey, - "CrowdsecMode": config.CrowdsecMode, + "CrowdsecLapiHost": config.CrowdsecLapiHost, + "CrowdsecLapiKey": config.CrowdsecLapiKey, + "CrowdsecMode": config.CrowdsecMode, } for key, val := range requiredStrings { if len(val) == 0 { @@ -88,6 +86,16 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h return nil, fmt.Errorf("%v cannot be less than 1", key) } } + // 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. if !contains([]string{"none", "live", "stream"}, config.CrowdsecMode) { return nil, fmt.Errorf("CrowdsecMode must be one of: none, live or stream") } @@ -95,13 +103,13 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h return nil, fmt.Errorf("CrowdsecLapiScheme must be one of: http, https") } testUrl := url.URL{ - Scheme: config.CrowdsecLapiScheme, - Host: config.CrowdsecLapiHost, - Path: crowdsecRoute, + Scheme: config.CrowdsecLapiScheme, + Host: config.CrowdsecLapiHost, + Path: crowdsecRoute, } _, err := http.NewRequest(http.MethodGet, testUrl.String(), nil) if err != nil { - return nil, fmt.Errorf("CrowdsecLapiScheme://CrowdsecLapiHost: '%v://%v' must be an URL", config.CrowdsecLapiScheme, config.CrowdsecLapiHost) + return nil, fmt.Errorf("CrowdsecLapiScheme://CrowdsecLapiHost: '%v://%v' must be an URL", config.CrowdsecLapiScheme, config.CrowdsecLapiHost) } bouncer := &Bouncer{ @@ -109,13 +117,13 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h name: name, template: template.New("CrowdsecBouncer").Delims("[[", "]]"), - enabled: config.Enabled, - crowdsecStreamHealthy: false, - crowdsecMode: config.CrowdsecMode, - crowdsecScheme: config.CrowdsecLapiScheme, - crowdsecHost: config.CrowdsecLapiHost, - crowdsecKey: config.CrowdsecLapiKey, - updateInterval: config.UpdateIntervalSeconds, + enabled: config.Enabled, + crowdsecStreamHealthy: false, + crowdsecMode: config.CrowdsecMode, + crowdsecScheme: config.CrowdsecLapiScheme, + crowdsecHost: config.CrowdsecLapiHost, + crowdsecKey: config.CrowdsecLapiKey, + updateInterval: config.UpdateIntervalSeconds, defaultDecisionTimeout: config.DefaultDecisionSeconds, client: &http.Client{ Transport: &http.Transport{ @@ -125,18 +133,21 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h Timeout: 5 * time.Second, }, } - go handleStreamCache(bouncer, "true") + // if we are on a stream mode, we fetch in a go routine every minute the new decisions + if config.CrowdsecMode == "stream" { + go handleStreamCache(bouncer, true) + } return bouncer, nil } func (a *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - log.Printf("not enabled, %v", a.enabled ) if !a.enabled { - log.Printf("not enabled") + log.Printf("Crowdsec Bouncer not enabled") a.next.ServeHTTP(rw, req) return } + // TODO Make sur remote address does not include the port remoteHost, _, err := net.SplitHostPort(req.RemoteAddr) if err != nil { log.Printf("failed to extract ip from remote address: %v", err) @@ -156,6 +167,7 @@ func (a *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } } + // Right here if we cannot join the stream we forbid the request to go on if a.crowdsecMode == "stream" { if a.crowdsecStreamHealthy { a.next.ServeHTTP(rw, req) @@ -165,70 +177,66 @@ func (a *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { return } - if a.crowdsecMode == "none" || a.crowdsecMode == "live" { - noneUrl := url.URL{ - Scheme: a.crowdsecScheme, - Host: a.crowdsecHost, - Path: crowdsecRoute, - RawQuery: fmt.Sprintf("ip=%v&banned=true", remoteHost), - } - request, _ := http.NewRequest(http.MethodGet, noneUrl.String(), nil) - request.Header.Add(crowdsecAuthHeader, a.crowdsecKey) - res, err := a.client.Do(request) - if err != nil { - log.Printf("failed to get decision: %s", err) - rw.WriteHeader(http.StatusForbidden) - return - } - defer res.Body.Close() - if res.StatusCode != 200 { - log.Printf("failed to get decision, status code: %d", res.StatusCode) - rw.WriteHeader(http.StatusForbidden) - return - } - body, err := ioutil.ReadAll(res.Body) - if err != nil { - log.Printf("failed to read body: %s", err) - rw.WriteHeader(http.StatusForbidden) - return - } - if !bytes.Equal(body, []byte("null")) { - var decisions []Decision - err = json.Unmarshal(body, &decisions) - if err != nil { - log.Printf("failed to parse body: %s", err) - rw.WriteHeader(http.StatusForbidden) - return - } - if len(decisions) == 0 { - if a.crowdsecMode == "live" { - setDecision(remoteHost, false, a.defaultDecisionTimeout) - } - a.next.ServeHTTP(rw, req) - return - } - duration, err := time.ParseDuration(decisions[0].Duration) - if err != nil { - log.Printf("failed to parse duration: %s", err) - rw.WriteHeader(http.StatusForbidden) - return - } - rw.WriteHeader(http.StatusForbidden) - setDecision(remoteHost, true, int64(duration.Seconds())) - return - } - if a.crowdsecMode == "live" { - setDecision(remoteHost, false, a.defaultDecisionTimeout) - } - a.next.ServeHTTP(rw, req) + // We are now in none or live mode + noneUrl := url.URL{ + Scheme: a.crowdsecScheme, + Host: a.crowdsecHost, + Path: crowdsecRoute, + RawQuery: fmt.Sprintf("ip=%v&banned=true", remoteHost), + } + request, _ := http.NewRequest(http.MethodGet, noneUrl.String(), nil) + request.Header.Add(crowdsecAuthHeader, a.crowdsecKey) + res, err := a.client.Do(request) + if err != nil { + log.Printf("failed to get decision: %s", err) + rw.WriteHeader(http.StatusForbidden) return } - + defer res.Body.Close() + if res.StatusCode != 200 { + log.Printf("failed to get decision, status code: %d", res.StatusCode) + rw.WriteHeader(http.StatusForbidden) + return + } + body, err := ioutil.ReadAll(res.Body) + if err != nil { + log.Printf("failed to read body: %s", err) + rw.WriteHeader(http.StatusForbidden) + return + } + if !bytes.Equal(body, []byte("null")) { + var decisions []Decision + err = json.Unmarshal(body, &decisions) + if err != nil { + log.Printf("failed to parse body: %s", err) + rw.WriteHeader(http.StatusForbidden) + return + } + if len(decisions) == 0 { + if a.crowdsecMode == "live" { + setDecision(remoteHost, false, a.defaultDecisionTimeout) + } + a.next.ServeHTTP(rw, req) + return + } + duration, err := time.ParseDuration(decisions[0].Duration) + if err != nil { + log.Printf("failed to parse duration: %s", err) + rw.WriteHeader(http.StatusForbidden) + return + } + rw.WriteHeader(http.StatusForbidden) + setDecision(remoteHost, true, int64(duration.Seconds())) + return + } + if a.crowdsecMode == "live" { + setDecision(remoteHost, false, a.defaultDecisionTimeout) + } a.next.ServeHTTP(rw, req) } // CUSTOM CODE - +// TODO place in another file type Decision struct { Id int `json:"id"` Origin string `json:"origin"` @@ -241,8 +249,8 @@ type Decision struct { } type Stream struct { - Deleted []Decision `json:"deleted"` - New []Decision `json:"new"` + Deleted []Decision `json:"deleted"` + New []Decision `json:"new"` } func contains(source []string, target string) bool { @@ -254,6 +262,8 @@ func contains(source []string, target string) bool { return false } +// Get Decision 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) { isBanned, ok := cache.Get(clientIP) if ok && len(isBanned.(string)) > 0 { @@ -274,15 +284,16 @@ func setDecision(clientIP string, isBanned bool, duration int64) { } } -func handleStreamCache(a *Bouncer, initialized string) { - time.AfterFunc(time.Duration(a.updateInterval) * time.Second, func () { - handleStreamCache(a, "false") +func handleStreamCache(a *Bouncer, initialized bool) { + // TODO clean properly on exit + time.AfterFunc(time.Duration(a.updateInterval)*time.Second, func() { + handleStreamCache(a, false) }) streamUrl := url.URL{ Scheme: a.crowdsecScheme, Host: a.crowdsecHost, Path: crowdsecStreamRoute, - RawQuery: fmt.Sprintf("startup=%s", initialized), + RawQuery: fmt.Sprintf("startup=%t", initialized), } req, _ := http.NewRequest(http.MethodGet, streamUrl.String(), nil) req.Header.Add(crowdsecAuthHeader, a.crowdsecKey) @@ -321,4 +332,4 @@ func handleStreamCache(a *Bouncer, initialized string) { cache.Del(decision.Value) } a.crowdsecStreamHealthy = true -} \ No newline at end of file +} From 60b789ba9d73bdbf18511ab3e88287ef1fa31ff1 Mon Sep 17 00:00:00 2001 From: MathieuHa Date: Wed, 28 Sep 2022 20:44:52 +0200 Subject: [PATCH 2/3] Clean docker-compose, add test data in .traefik --- .traefik.yml | 10 ++++++---- bouncer_test.go | 4 ++-- docker-compose.yml | 8 +++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.traefik.yml b/.traefik.yml index 7db5177..3a461fd 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -4,9 +4,11 @@ iconPath: .assets/icon.png import: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin -summary: 'Add Request Header' +summary: 'Crowdsec Bouncer Traefik Plugin' testData: - Headers: - X-Demo: test - X-URL: '{{URL}}' + crowdsec: + bouncer: + enabled: true + crowdseclapikey: 40796d93c2958f9e58345514e67740e5 + diff --git a/bouncer_test.go b/bouncer_test.go index f552a03..1710947 100644 --- a/bouncer_test.go +++ b/bouncer_test.go @@ -6,10 +6,10 @@ import ( "net/http/httptest" "testing" - "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + crowdsec_bouncer_traefik_plugin "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" ) -func TestDemo(t *testing.T) { +func TestCrowdSec(t *testing.T) { cfg := crowdsec_bouncer_traefik_plugin.CreateConfig() cfg.CrowdsecLapiKey = "caca" diff --git a/docker-compose.yml b/docker-compose.yml index b0449b8..434758d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,11 +29,9 @@ services: - "traefik.enable=true" - "traefik.http.routers.whoami.rule=Host(`localhost`)" - "traefik.http.routers.whoami.entrypoints=web" - - "traefik.http.routers.whoami.middlewares=testo@docker" - - "traefik.http.middlewares.testo.plugin.bouncer.enabled=true" - - "traefik.http.middlewares.testo.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" - # - "traefik.http.routers.whoami.middlewares=blacklist@docker" - # - "traefik.http.middlewares.blacklist.ipblacklist.sourcerange=127.0.0.1/32, 192.168.1.7" + - "traefik.http.routers.whoami.middlewares=crowdsec@docker" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" crowdsec: image: crowdsecurity/crowdsec:v1.4.1 From 1098e5230e2483fbc34141ab1c3056f40db5e98c Mon Sep 17 00:00:00 2001 From: MathieuHa Date: Wed, 28 Sep 2022 21:04:58 +0200 Subject: [PATCH 3/3] Add initial readme, update com on bouncer --- README.md | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ bouncer.go | 1 + 2 files changed, 75 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad1a1ae --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +[![Build Status](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/workflows/Main/badge.svg?branch=master)](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/actions) + +# Crowdsec Bouncer Traefik plugin + +This plugins aims to implement a Crowdsec Bouncer into a traefik plugin + +## Usage + + +### Configuration + +For each plugin, the Traefik static configuration must define the module name (as is usual for Go packages). + +The following declaration (given here in YAML) defines a plugin: + +```yaml +# Static configuration + +experimental: + localPlugins: + bouncer: + moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin +``` + +```yaml +# Dynamic configuration + +http: + routers: + my-router: + rule: host(`woami.localhost`) + service: service-foo + entryPoints: + - web + middlewares: + - my-plugin + + services: + service-foo: + loadBalancer: + servers: + - url: http://127.0.0.1:5000 + + middlewares: + crowdsec: + plugin: + bouncer: + enabled: true + crowdseclapikey: 40796d93c2958f9e58345514e67740e5 +``` + +### Local Mode + +Traefik also offers a developer mode that can be used for temporary testing of plugins not hosted on GitHub. +To use a plugin in local mode, the Traefik static configuration must define the module name (as is usual for Go packages) and a path to a [Go workspace](https://golang.org/doc/gopath_code.html#Workspaces), which can be the local GOPATH or any directory. + +The plugins must be placed in `./plugins-local` directory, +which should be in the working directory of the process running the Traefik binary. +The source code of the plugin should be organized as follows: + +``` +./plugins-local/ + └── src + └── github.com + └── maxlerebourg + └── crowdsec-bouncer-traefik-plugin + ├── bouncer.go + ├── bouncer_test.go + ├── go.mod + ├── LICENSE + ├── Makefile + ├── readme.md + └── vendor/* +``` diff --git a/bouncer.go b/bouncer.go index 618b329..144eff1 100644 --- a/bouncer.go +++ b/bouncer.go @@ -140,6 +140,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h return bouncer, nil } +// TODO the serve HTTP should be split as it's too long func (a *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { if !a.enabled { log.Printf("Crowdsec Bouncer not enabled")