diff --git a/.gitignore b/.gitignore index f32e31a..6e6693f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea/ .DS_Store +golang-ttl-map \ No newline at end of file diff --git a/acquis.yaml b/acquis.yaml new file mode 100644 index 0000000..12f95af --- /dev/null +++ b/acquis.yaml @@ -0,0 +1,4 @@ +filenames: + - /var/log/traefik/*.log +labels: + type: traefik \ No newline at end of file diff --git a/bouncer.go b/bouncer.go index edea239..2be5acf 100644 --- a/bouncer.go +++ b/bouncer.go @@ -4,6 +4,7 @@ package crowdsec_bouncer_traefik_plugin import ( "bytes" "context" + "encoding/json" "fmt" "io/ioutil" "log" @@ -13,6 +14,8 @@ import ( "regexp" "text/template" "time" + + ttl_map "github.com/leprosus/golang-ttl-map" ) const ( @@ -21,75 +24,60 @@ const ( crowdsecAuthHeader = "X-Api-Key" crowdsecBouncerRoute = "v1/decisions" crowdsecBouncerStreamRoute = "v1/decisions/stream" + cacheBannedValue = "t" + cacheNoBannedValue = "f" ) var ipRegex = regexp.MustCompile(`\b\d+\.\d+\.\d+\.\d+\b`) - -// Config the plugin configuration. -// type Config struct { -// Headers map[string]string `json:"headers,omitempty"` -// } -// // CreateConfig creates the default plugin configuration. -// func CreateConfig() *Config { -// return &Config{ -// Headers: make(map[string]string), -// } -// } +var cache = ttl_map.New() type Config struct { - Enabled bool `json:"enabled,omitempty"` - CrowdsecMode string `json:"crowdsecMode,omitempty"` - CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"` + Enabled bool `json:"enabled,omitempty"` + CrowdsecMode string `json:"crowdsecMode,omitempty"` + CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"` CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"` - CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"` - UpdateIntervalSeconds int `json:"updateIntervalSeconds,omitempty"` + CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"` + UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"` + DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"` } func CreateConfig() *Config { return &Config{ - Enabled: false, - CrowdsecMode: "none", - CrowdsecLapiScheme: "http", - CrowdsecLapiHost: "crowdsec:8080", - CrowdsecLapiKey: "", - UpdateIntervalSeconds: 300, + Enabled: false, + CrowdsecMode: "live", + CrowdsecLapiScheme: "http", + CrowdsecLapiHost: "crowdsec:8080", + CrowdsecLapiKey: "", + UpdateIntervalSeconds: 10, + DefaultDecisionSeconds: 10, } } -// Demo a Demo plugin. type Bouncer struct { next http.Handler name string template *template.Template - enabled bool - crowdsecScheme string - crowdsecHost string - crowdsecMode string - crowdsecKey string - updateInterval time.Duration - client *http.Client -} - -func contains(source []string, target string) bool { - for _, a := range source { - if a == target { - return true - } - } - return false + enabled bool + crowdsecScheme string + crowdsecHost string + crowdsecKey string + crowdsecMode string + updateInterval int64 + defaultDecisionTimeout int64 + client *http.Client } // New created a new Demo plugin. func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { required := map[string]string{ - config.CrowdsecLapiScheme: "CrowdsecLapiScheme", - config.CrowdsecLapiHost: "CrowdsecLapiHost", - config.CrowdsecLapiKey: "CrowdsecLapiKey", - config.CrowdsecMode: "CrowdsecMode", + "CrowdsecLapiScheme": config.CrowdsecLapiScheme, + "CrowdsecLapiHost": config.CrowdsecLapiHost, + "CrowdsecLapiKey": config.CrowdsecLapiKey, + "CrowdsecMode": config.CrowdsecMode, } - for key, val := range required { - if len(val) != 0 { + for val, key := range required { + if len(val) == 0 { return nil, fmt.Errorf("%v cannot be empty", key) } } @@ -114,11 +102,13 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h name: name, template: template.New("CrowdsecBouncer").Delims("[[", "]]"), + enabled: config.Enabled, crowdsecMode: config.CrowdsecMode, crowdsecScheme: config.CrowdsecLapiScheme, crowdsecHost: config.CrowdsecLapiHost, crowdsecKey: config.CrowdsecLapiKey, - updateInterval: time.Duration(config.UpdateIntervalSeconds) * time.Second, + updateInterval: config.UpdateIntervalSeconds, + defaultDecisionTimeout: config.DefaultDecisionSeconds, client: &http.Client{ Transport: &http.Transport{ MaxIdleConns: 10, @@ -131,6 +121,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h func (a *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { if !a.enabled { + log.Printf("not enabled") a.next.ServeHTTP(rw, req) return } @@ -142,19 +133,39 @@ func (a *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { return } - if a.crowdsecMode == "none" { + if a.crowdsecMode == "stream" || a.crowdsecMode == "live" { + isBanned, err := getDecision(remoteHost) + if err == nil { + if isBanned { + rw.WriteHeader(http.StatusForbidden) + } else { + a.next.ServeHTTP(rw, req) + } + return + } + } + + if a.crowdsecMode == "stream" { + a.next.ServeHTTP(rw, req) + return + } + + if a.crowdsecMode == "none" || a.crowdsecMode == "live" { noneUrl := url.URL{ Scheme: a.crowdsecScheme, Host: a.crowdsecHost, Path: crowdsecBouncerRoute, RawQuery: fmt.Sprintf("ip=%v&banned=true", remoteHost), } - res, err := a.client.Get(noneUrl.String()) + req, _ := http.NewRequest(http.MethodGet, noneUrl.String(), nil) + req.Header.Add(crowdsecAuthHeader, a.crowdsecKey) + res, err := a.client.Do(req) 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) @@ -162,17 +173,89 @@ func (a *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } body, err := ioutil.ReadAll(res.Body) if err != nil { - log.Printf("failed to read body from crowdsec: %s", err) + 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 + } log.Printf("ip banned: %v", remoteHost) 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) + return } a.next.ServeHTTP(rw, req) +} + +// CUSTOM CODE + +type Decision struct { + Id int `json:"id"` + Origin string `json:"origin"` + Type string `json:"type"` + Scope string `json:"scope"` + Value string `json:"value"` + Duration string `json:"duration"` + Scenario string `json:"scenario"` + Simulated bool `json:"simulated"` +} + +type Stream struct { + Deleted []Decision `json:"deleted"` + New []Decision `json:"new"` +} + +func contains(source []string, target string) bool { + for _, a := range source { + if a == target { + return true + } + } + return false +} + +func getDecision(clientIP string) (bool, error) { + isBanned, ok := cache.Get(clientIP) + if ok && len(isBanned.(string)) > 0 { + if isBanned == cacheNoBannedValue { + return false, nil + } else { + return true, nil + } + } + return false, fmt.Errorf("no data") +} + +func setDecision(clientIP string, isBanned bool, duration int64) { + if isBanned { + cache.Set(clientIP, cacheBannedValue, duration) + } else { + cache.Set(clientIP, cacheNoBannedValue, duration) + } } \ No newline at end of file diff --git a/bouncer_test.go b/bouncer_test.go index f0296b8..c4c1b20 100644 --- a/bouncer_test.go +++ b/bouncer_test.go @@ -11,11 +11,11 @@ import ( func TestDemo(t *testing.T) { cfg := crowdsec_bouncer_traefik_plugin.CreateConfig() - cfg.Headers["X-Host"] = "[[.Host]]" - cfg.Headers["X-Method"] = "[[.Method]]" - cfg.Headers["X-URL"] = "[[.URL]]" - cfg.Headers["X-URL"] = "[[.URL]]" - cfg.Headers["X-Demo"] = "test" + cfg.CrowdsecLapiKey = "caca" + // cfg.Headers["X-Method"] = "[[.Method]]" + // cfg.Headers["X-URL"] = "[[.URL]]" + // cfg.Headers["X-URL"] = "[[.URL]]" + // cfg.Headers["X-Demo"] = "test" ctx := context.Background() next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {}) @@ -34,16 +34,16 @@ func TestDemo(t *testing.T) { handler.ServeHTTP(recorder, req) - assertHeader(t, req, "X-Host", "localhost") - assertHeader(t, req, "X-URL", "http://localhost") - assertHeader(t, req, "X-Method", "GET") - assertHeader(t, req, "X-Demo", "test") + // assertHeader(t, req, "X-Host", "localhost") + // assertHeader(t, req, "X-URL", "http://localhost") + // assertHeader(t, req, "X-Method", "GET") + // assertHeader(t, req, "X-Demo", "test") } -func assertHeader(t *testing.T, req *http.Request, key, expected string) { - t.Helper() +// func assertHeader(t *testing.T, req *http.Request, key, expected string) { +// t.Helper() - if req.Header.Get(key) != expected { - t.Errorf("invalid header value: %s", req.Header.Get(key)) - } -} +// if req.Header.Get(key) != expected { +// t.Errorf("invalid header value: %s", req.Header.Get(key)) +// } +// } diff --git a/config.go b/config.go deleted file mode 100644 index d8357ba..0000000 --- a/config.go +++ /dev/null @@ -1,88 +0,0 @@ -package config - -import ( - "log" - "os" - "strconv" - "time" -) - -const ( - RealIpHeader = "X-Real-Ip" - ForwardHeader = "X-Forwarded-For" - CrowdsecAuthHeader = "X-Api-Key" - CrowdsecBouncerRoute = "v1/decisions" - CrowdsecBouncerStreamRoute = "v1/decisions/stream" -) - -/* - Check for an environment variable value, if absent use a default value -*/ -func OptionalEnv(varName string, optional string) string { - envVar := os.Getenv(varName) - if envVar == "" { - return optional - } - return envVar -} - -/* - Check for an environment variable value, exit program if not found -*/ -func RequiredEnv(varName string) string { - envVar := os.Getenv(varName) - if envVar == "" { - log.Fatalf("The required env var %s is not provided. Exiting", varName) - } - return envVar -} - -/* - Check for an environment variable value with expected possibilities, exit program if value not expected -*/ -func ExpectedEnv(varName string, expected []string) string { - envVar := RequiredEnv(varName) - if !contains(expected, envVar) { - log.Fatalf("The value for env var %s is not expected. Expected values are %v", varName, expected) - } - return envVar -} - -func contains(source []string, target string) bool { - for _, a := range source { - if a == target { - return true - } - } - return false -} - -/* - Function for custom validation of configuration that will panic if values are not expected. - //FIXME it's a first start before centralizing configuration then injection of dependency. -*/ -func ValidateEnv() { - // Validate Ban response code is a valid http response code - banResponseCode := OptionalEnv("CROWDSEC_BOUNCER_BAN_RESPONSE_CODE", "403") - parsedCode, err := strconv.Atoi(banResponseCode) - if err != nil { - log.Fatalf("The value for env var %s is not an int. It should be a valid http response code.", "CROWDSEC_BOUNCER_BAN_RESPONSE_CODE") - } - if parsedCode < 100 || parsedCode > 599 { - log.Fatalf("The value for env var %s should be a valid http response code between 100 and 599 included.", "CROWDSEC_BOUNCER_BAN_RESPONSE_CODE") - } - cacheMode := OptionalEnv("CROWDSEC_BOUNCER_CACHE_MODE", "none") - if !contains([]string{"none", "live", "stream"}, cacheMode) { - log.Fatalf("Cache mode must be one of 'none', 'stream' or 'live'") - } - cacheStreamInterval := OptionalEnv("CROWDSEC_BOUNCER_CACHE_STREAM_INTERVAL", "1m") - duration, err := time.ParseDuration(cacheStreamInterval) - if err != nil && duration.Seconds() < 3600 { - log.Fatalf("Cache stream interval provided is not valid") - } - defaultCacheDuration := OptionalEnv("CROWDSEC_DEFAULT_CACHE_DURATION", "5m") - duration2, err := time.ParseDuration(defaultCacheDuration) - if err != nil && duration2.Seconds() < 3600 { - log.Fatalf("Cache default duration provided is not valid") - } -} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c13d27e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +version: "3.8" + +services: + traefik: + image: "traefik:v2.8.5" + container_name: "traefik" + command: + # - "--log.level=DEBUG" + - "--accesslog" + - "--accesslog.filepath=/var/log/traefik/traefik.log" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + + - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + - "logs:/var/log/traefik" + - ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + - ./golang-ttl-map:/plugins-local/src/github.com/leprosus/golang-ttl-map + ports: + - 8000:80 + - 8080:8080 + + whoami: + image: traefik/whoami + container_name: "simple-service" + labels: + - "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" + + crowdsec: + image: crowdsecurity/crowdsec:v1.4.1 + container_name: "crowdsec" + command: rm -rf /etc/crowdsec/acquis.yaml + environment: + COLLECTIONS: crowdsecurity/traefik + CUSTOM_HOSTNAME: crowdsec + BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5 + depends_on: + - 'traefik' + volumes: + - ./acquis.yaml:/etc/crowdsec/acquis.yaml:ro + - logs:/var/log/traefik + - crowdsec-db:/var/lib/crowdsec/data/ + - crowdsec-config:/etc/crowdsec/ + ports: + - "8083:8080" + +volumes: + logs: + crowdsec-db: + crowdsec-config: \ No newline at end of file diff --git a/go.mod b/go.mod index b57b129..f5d4f6d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin go 1.17 + +require github.com/leprosus/golang-ttl-map v1.1.7 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ea5d6cc --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/leprosus/golang-ttl-map v1.1.7 h1:cF4AAFDDnJTFSV+/42sKLhmMluvLdRlCGS2UaifH6UM= +github.com/leprosus/golang-ttl-map v1.1.7/go.mod h1:4QWHJPeVBbrkhOhXdhCv9IEiyj/YzkO04/iexy4vSe0=