🍱 live added

This commit is contained in:
Max Lerebourg
2022-09-28 13:14:41 +02:00
parent 7048fd9092
commit 9adb7d9500
8 changed files with 216 additions and 152 deletions
+1
View File
@@ -1,2 +1,3 @@
.idea/ .idea/
.DS_Store .DS_Store
golang-ttl-map
+4
View File
@@ -0,0 +1,4 @@
filenames:
- /var/log/traefik/*.log
labels:
type: traefik
+132 -49
View File
@@ -4,6 +4,7 @@ package crowdsec_bouncer_traefik_plugin
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"log" "log"
@@ -13,6 +14,8 @@ import (
"regexp" "regexp"
"text/template" "text/template"
"time" "time"
ttl_map "github.com/leprosus/golang-ttl-map"
) )
const ( const (
@@ -21,75 +24,60 @@ const (
crowdsecAuthHeader = "X-Api-Key" crowdsecAuthHeader = "X-Api-Key"
crowdsecBouncerRoute = "v1/decisions" crowdsecBouncerRoute = "v1/decisions"
crowdsecBouncerStreamRoute = "v1/decisions/stream" crowdsecBouncerStreamRoute = "v1/decisions/stream"
cacheBannedValue = "t"
cacheNoBannedValue = "f"
) )
var ipRegex = regexp.MustCompile(`\b\d+\.\d+\.\d+\.\d+\b`) var ipRegex = regexp.MustCompile(`\b\d+\.\d+\.\d+\.\d+\b`)
var cache = ttl_map.New()
// 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),
// }
// }
type Config struct { type Config struct {
Enabled bool `json:"enabled,omitempty"` Enabled bool `json:"enabled,omitempty"`
CrowdsecMode string `json:"crowdsecMode,omitempty"` CrowdsecMode string `json:"crowdsecMode,omitempty"`
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"` CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"` CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"` CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
UpdateIntervalSeconds int `json:"updateIntervalSeconds,omitempty"` UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
} }
func CreateConfig() *Config { func CreateConfig() *Config {
return &Config{ return &Config{
Enabled: false, Enabled: false,
CrowdsecMode: "none", CrowdsecMode: "live",
CrowdsecLapiScheme: "http", CrowdsecLapiScheme: "http",
CrowdsecLapiHost: "crowdsec:8080", CrowdsecLapiHost: "crowdsec:8080",
CrowdsecLapiKey: "", CrowdsecLapiKey: "",
UpdateIntervalSeconds: 300, UpdateIntervalSeconds: 10,
DefaultDecisionSeconds: 10,
} }
} }
// Demo a Demo plugin.
type Bouncer struct { type Bouncer struct {
next http.Handler next http.Handler
name string name string
template *template.Template template *template.Template
enabled bool enabled bool
crowdsecScheme string crowdsecScheme string
crowdsecHost string crowdsecHost string
crowdsecMode string crowdsecKey string
crowdsecKey string crowdsecMode string
updateInterval time.Duration updateInterval int64
client *http.Client defaultDecisionTimeout int64
} client *http.Client
func contains(source []string, target string) bool {
for _, a := range source {
if a == target {
return true
}
}
return false
} }
// New created a new Demo plugin. // New created a new Demo 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 *Config, name string) (http.Handler, error) {
required := map[string]string{ required := map[string]string{
config.CrowdsecLapiScheme: "CrowdsecLapiScheme", "CrowdsecLapiScheme": config.CrowdsecLapiScheme,
config.CrowdsecLapiHost: "CrowdsecLapiHost", "CrowdsecLapiHost": config.CrowdsecLapiHost,
config.CrowdsecLapiKey: "CrowdsecLapiKey", "CrowdsecLapiKey": config.CrowdsecLapiKey,
config.CrowdsecMode: "CrowdsecMode", "CrowdsecMode": config.CrowdsecMode,
} }
for key, val := range required { for val, key := range required {
if len(val) != 0 { if len(val) == 0 {
return nil, fmt.Errorf("%v cannot be empty", key) 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, name: name,
template: template.New("CrowdsecBouncer").Delims("[[", "]]"), template: template.New("CrowdsecBouncer").Delims("[[", "]]"),
enabled: config.Enabled,
crowdsecMode: config.CrowdsecMode, crowdsecMode: config.CrowdsecMode,
crowdsecScheme: config.CrowdsecLapiScheme, crowdsecScheme: config.CrowdsecLapiScheme,
crowdsecHost: config.CrowdsecLapiHost, crowdsecHost: config.CrowdsecLapiHost,
crowdsecKey: config.CrowdsecLapiKey, crowdsecKey: config.CrowdsecLapiKey,
updateInterval: time.Duration(config.UpdateIntervalSeconds) * time.Second, updateInterval: config.UpdateIntervalSeconds,
defaultDecisionTimeout: config.DefaultDecisionSeconds,
client: &http.Client{ client: &http.Client{
Transport: &http.Transport{ Transport: &http.Transport{
MaxIdleConns: 10, 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) { func (a *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if !a.enabled { if !a.enabled {
log.Printf("not enabled")
a.next.ServeHTTP(rw, req) a.next.ServeHTTP(rw, req)
return return
} }
@@ -142,19 +133,39 @@ func (a *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return 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{ noneUrl := url.URL{
Scheme: a.crowdsecScheme, Scheme: a.crowdsecScheme,
Host: a.crowdsecHost, Host: a.crowdsecHost,
Path: crowdsecBouncerRoute, Path: crowdsecBouncerRoute,
RawQuery: fmt.Sprintf("ip=%v&banned=true", remoteHost), 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 { if err != nil {
log.Printf("failed to get decision: %s", err) log.Printf("failed to get decision: %s", err)
rw.WriteHeader(http.StatusForbidden) rw.WriteHeader(http.StatusForbidden)
return return
} }
defer res.Body.Close()
if res.StatusCode != 200 { if res.StatusCode != 200 {
log.Printf("failed to get decision, status code: %d", res.StatusCode) log.Printf("failed to get decision, status code: %d", res.StatusCode)
rw.WriteHeader(http.StatusForbidden) rw.WriteHeader(http.StatusForbidden)
@@ -162,17 +173,89 @@ func (a *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
} }
body, err := ioutil.ReadAll(res.Body) body, err := ioutil.ReadAll(res.Body)
if err != nil { 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) rw.WriteHeader(http.StatusForbidden)
return return
} }
if !bytes.Equal(body, []byte("null")) { 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) log.Printf("ip banned: %v", remoteHost)
rw.WriteHeader(http.StatusForbidden) rw.WriteHeader(http.StatusForbidden)
setDecision(remoteHost, true, int64(duration.Seconds()))
return return
} }
if a.crowdsecMode == "live" {
setDecision(remoteHost, false, a.defaultDecisionTimeout)
}
a.next.ServeHTTP(rw, req) a.next.ServeHTTP(rw, req)
return
} }
a.next.ServeHTTP(rw, req) 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)
}
} }
+15 -15
View File
@@ -11,11 +11,11 @@ import (
func TestDemo(t *testing.T) { func TestDemo(t *testing.T) {
cfg := crowdsec_bouncer_traefik_plugin.CreateConfig() cfg := crowdsec_bouncer_traefik_plugin.CreateConfig()
cfg.Headers["X-Host"] = "[[.Host]]" cfg.CrowdsecLapiKey = "caca"
cfg.Headers["X-Method"] = "[[.Method]]" // cfg.Headers["X-Method"] = "[[.Method]]"
cfg.Headers["X-URL"] = "[[.URL]]" // cfg.Headers["X-URL"] = "[[.URL]]"
cfg.Headers["X-URL"] = "[[.URL]]" // cfg.Headers["X-URL"] = "[[.URL]]"
cfg.Headers["X-Demo"] = "test" // cfg.Headers["X-Demo"] = "test"
ctx := context.Background() ctx := context.Background()
next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {}) next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {})
@@ -34,16 +34,16 @@ func TestDemo(t *testing.T) {
handler.ServeHTTP(recorder, req) handler.ServeHTTP(recorder, req)
assertHeader(t, req, "X-Host", "localhost") // assertHeader(t, req, "X-Host", "localhost")
assertHeader(t, req, "X-URL", "http://localhost") // assertHeader(t, req, "X-URL", "http://localhost")
assertHeader(t, req, "X-Method", "GET") // assertHeader(t, req, "X-Method", "GET")
assertHeader(t, req, "X-Demo", "test") // assertHeader(t, req, "X-Demo", "test")
} }
func assertHeader(t *testing.T, req *http.Request, key, expected string) { // func assertHeader(t *testing.T, req *http.Request, key, expected string) {
t.Helper() // t.Helper()
if req.Header.Get(key) != expected { // if req.Header.Get(key) != expected {
t.Errorf("invalid header value: %s", req.Header.Get(key)) // t.Errorf("invalid header value: %s", req.Header.Get(key))
} // }
} // }
-88
View File
@@ -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")
}
}
+60
View File
@@ -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:
+2
View File
@@ -1,3 +1,5 @@
module github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin module github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
go 1.17 go 1.17
require github.com/leprosus/golang-ttl-map v1.1.7
+2
View File
@@ -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=