diff --git a/crowdsec/docker-compose.yaml b/crowdsec/docker-compose.yaml index f38c4ca..b73e975 100644 --- a/crowdsec/docker-compose.yaml +++ b/crowdsec/docker-compose.yaml @@ -51,3 +51,22 @@ services: options: max-size: "100m" max-file: "3" + + exporter: + build: ./exporter + container_name: crowdsec_exporter + restart: unless-stopped + networks: + - caddy + environment: + CROWDSEC_LAPI_URL: "http://crowdsec:8080" + CROWDSEC_API_KEY: "${CROWDSEC_BOUNCER_KEY_CADDY}" + POLL_INTERVAL_SECS: "30" + LISTEN_PORT: "9100" + depends_on: + - crowdsec + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/crowdsec/exporter/Dockerfile b/crowdsec/exporter/Dockerfile new file mode 100644 index 0000000..b5c9829 --- /dev/null +++ b/crowdsec/exporter/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY main.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-s -w' -o /exporter . + +FROM scratch +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /exporter /exporter +EXPOSE 9100 +ENTRYPOINT ["/exporter"] diff --git a/crowdsec/exporter/go.mod b/crowdsec/exporter/go.mod new file mode 100644 index 0000000..d37ce6e --- /dev/null +++ b/crowdsec/exporter/go.mod @@ -0,0 +1,18 @@ +module crowdsec-exporter + +go 1.26.3 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/sys v0.44.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/crowdsec/exporter/go.sum b/crowdsec/exporter/go.sum new file mode 100644 index 0000000..a064a53 --- /dev/null +++ b/crowdsec/exporter/go.sum @@ -0,0 +1,36 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/crowdsec/exporter/main.go b/crowdsec/exporter/main.go new file mode 100644 index 0000000..b24281b --- /dev/null +++ b/crowdsec/exporter/main.go @@ -0,0 +1,164 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "os" + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + decisionsActive = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "crowdsec_decisions_active", + Help: "Active CrowdSec decisions counted directly from LAPI (no gauge drift).", + }, []string{"origin", "scenario", "type"}) + + decisionsUniqueIPs = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "crowdsec_decisions_unique_ips", + Help: "Unique IPs with at least one active decision, grouped by origin.", + }, []string{"origin"}) + + decisionsUniqueIPsTotal = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "crowdsec_decisions_unique_ips_total", + Help: "Unique IPs with at least one active decision across all origins.", + }) + + lastFetchUnix = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "crowdsec_exporter_last_fetch_unix", + Help: "Unix timestamp of the last successful LAPI fetch.", + }) + + fetchErrors = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "crowdsec_exporter_fetch_errors", + Help: "1 if the most recent fetch errored, else 0.", + }) +) + +type decision struct { + Origin string `json:"origin"` + Scenario string `json:"scenario"` + Type string `json:"type"` + Value string `json:"value"` +} + +func init() { + prometheus.MustRegister( + decisionsActive, + decisionsUniqueIPs, + decisionsUniqueIPsTotal, + lastFetchUnix, + fetchErrors, + ) +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func fetchAndUpdate(client *http.Client, lapiURL, apiKey string) { + req, _ := http.NewRequest("GET", lapiURL+"/v1/decisions", nil) + req.Header.Set("X-Api-Key", apiKey) + req.Header.Set("User-Agent", "crowdsec-exporter/1.0") + + resp, err := client.Do(req) + if err != nil { + log.Printf("[error] fetch failed: %v", err) + fetchErrors.Set(1) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + log.Printf("[error] LAPI returned status %d", resp.StatusCode) + fetchErrors.Set(1) + return + } + + var data []decision + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + log.Printf("[error] decode failed: %v", err) + fetchErrors.Set(1) + return + } + + type key struct{ origin, scenario, dtype string } + counts := map[key]int{} + ips := map[string]map[string]struct{}{} + allIPs := map[string]struct{}{} + + for _, d := range data { + origin := d.Origin + if origin == "" { + origin = "unknown" + } + scenario := d.Scenario + if scenario == "" { + scenario = "unknown" + } + dtype := d.Type + if dtype == "" { + dtype = "unknown" + } + counts[key{origin, scenario, dtype}]++ + if d.Value != "" { + if _, ok := ips[origin]; !ok { + ips[origin] = map[string]struct{}{} + } + ips[origin][d.Value] = struct{}{} + allIPs[d.Value] = struct{}{} + } + } + + decisionsActive.Reset() + decisionsUniqueIPs.Reset() + for k, n := range counts { + decisionsActive.WithLabelValues(k.origin, k.scenario, k.dtype).Set(float64(n)) + } + for origin, s := range ips { + decisionsUniqueIPs.WithLabelValues(origin).Set(float64(len(s))) + } + decisionsUniqueIPsTotal.Set(float64(len(allIPs))) + + fetchErrors.Set(0) + lastFetchUnix.Set(float64(time.Now().Unix())) + + log.Printf("[ok] %d decisions, %d unique IPs across %d origins", + len(data), len(allIPs), len(ips)) +} + +func main() { + lapiURL := getenv("CROWDSEC_LAPI_URL", "http://crowdsec:8080") + apiKey := os.Getenv("CROWDSEC_API_KEY") + if apiKey == "" { + log.Fatal("CROWDSEC_API_KEY env var required") + } + intervalStr := getenv("POLL_INTERVAL_SECS", "30") + interval, err := strconv.Atoi(intervalStr) + if err != nil || interval < 1 { + log.Fatalf("invalid POLL_INTERVAL_SECS: %q", intervalStr) + } + port := getenv("LISTEN_PORT", "9100") + + client := &http.Client{Timeout: 10 * time.Second} + + go func() { + for { + fetchAndUpdate(client, lapiURL, apiKey) + time.Sleep(time.Duration(interval) * time.Second) + } + }() + + http.Handle("/metrics", promhttp.Handler()) + log.Printf("[start] listening :%s, polling %s every %ds", port, lapiURL, interval) + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatal(err) + } +} diff --git a/grafana/data/grafana/dashboards/crowdsec.json b/grafana/data/grafana/dashboards/crowdsec.json index ad05e68..f9b9a3c 100644 --- a/grafana/data/grafana/dashboards/crowdsec.json +++ b/grafana/data/grafana/dashboards/crowdsec.json @@ -53,7 +53,7 @@ }, "targets": [ { - "expr": "sum(cs_active_decisions)", + "expr": "crowdsec_decisions_unique_ips_total", "refId": "A", "instant": true, "datasource": { @@ -118,7 +118,7 @@ }, "targets": [ { - "expr": "sum(cs_active_decisions{origin=\"CAPI\"})", + "expr": "crowdsec_decisions_unique_ips{origin=\"CAPI\"}", "refId": "A", "instant": true, "datasource": { @@ -166,7 +166,7 @@ }, "targets": [ { - "expr": "sum(cs_active_decisions{origin=\"lists\"})", + "expr": "crowdsec_decisions_unique_ips{origin=\"lists\"}", "refId": "A", "instant": true, "datasource": { @@ -214,7 +214,7 @@ }, "targets": [ { - "expr": "sum(cs_active_decisions{origin=\"crowdsec\"})", + "expr": "crowdsec_decisions_unique_ips{origin=\"crowdsec\"}", "refId": "A", "instant": true, "datasource": { @@ -371,7 +371,7 @@ }, "targets": [ { - "expr": "sum by (origin) (cs_active_decisions)", + "expr": "sum by (origin) (crowdsec_decisions_unique_ips)", "legendFormat": "{{origin}}", "refId": "A", "instant": true, @@ -425,8 +425,8 @@ }, "targets": [ { - "expr": "topk(10, sum by (reason) (cs_active_decisions))", - "legendFormat": "{{reason}}", + "expr": "topk(10, sum by (scenario) (crowdsec_decisions_active))", + "legendFormat": "{{scenario}}", "refId": "A", "instant": true, "datasource": { @@ -479,7 +479,7 @@ }, "targets": [ { - "expr": "sum by (origin) (cs_active_decisions)", + "expr": "sum by (origin) (crowdsec_decisions_unique_ips)", "legendFormat": "{{origin}}", "refId": "A", "datasource": { diff --git a/prometheus/data/config/prometheus.yaml b/prometheus/data/config/prometheus.yaml index caa5991..a920f25 100644 --- a/prometheus/data/config/prometheus.yaml +++ b/prometheus/data/config/prometheus.yaml @@ -21,3 +21,8 @@ scrape_configs: scrape_interval: 10s static_configs: - targets: ["crowdsec:6060"] + + - job_name: crowdsec_exporter + scrape_interval: 30s + static_configs: + - targets: ["crowdsec_exporter:9100"]