Compare commits

..

12 Commits

12 changed files with 265 additions and 53 deletions
+4 -2
View File
@@ -53,10 +53,12 @@
{$GITEA_DOMAIN} { {$GITEA_DOMAIN} {
import access-log import access-log
request_body { request_body {
max_size 512MB max_size 2048MB
} }
@not_registry not path /v2/*
route { route {
import security crowdsec
appsec @not_registry
reverse_proxy http://gitea:3000 { reverse_proxy http://gitea:3000 {
header_up Host {http.request.host} header_up Host {http.request.host}
} }
+10
View File
@@ -4,9 +4,19 @@
# and in caddy/.env (CADDY) and the host firewall bouncer config (FW). # and in caddy/.env (CADDY) and the host firewall bouncer config (FW).
CROWDSEC_BOUNCER_KEY_CADDY= CROWDSEC_BOUNCER_KEY_CADDY=
CROWDSEC_BOUNCER_KEY_FW= CROWDSEC_BOUNCER_KEY_FW=
CROWDSEC_BOUNCER_KEY_EXPORTER=
############# Console enrollment ############# ############# Console enrollment #############
# Enroll key from https://app.crowdsec.net (free). # Enroll key from https://app.crowdsec.net (free).
# Leave blank to run without Console (no community blocklist subscriptions). # Leave blank to run without Console (no community blocklist subscriptions).
CROWDSEC_ENROLL_KEY= CROWDSEC_ENROLL_KEY=
CROWDSEC_ENROLL_INSTANCE_NAME= CROWDSEC_ENROLL_INSTANCE_NAME=
############# MaxMind GeoIP #############
# Free account: https://www.maxmind.com/en/geolite2/signup
# After signup: Manage License Keys -> Generate new license key.
# Used by the geoipupdate sidecar to download/refresh GeoLite2-City.mmdb,
# consumed by the exporter for the Country column in the Local bans table.
# Leave blank to disable: exporter still runs, country labels will be empty.
MAXMIND_ACCOUNT_ID=
MAXMIND_LICENSE_KEY=
+1
View File
@@ -1,3 +1,4 @@
/db/* /db/*
/config/* /config/*
/geoip/*
!.gitkeep !.gitkeep
View File
+25 -1
View File
@@ -26,6 +26,7 @@ services:
Dominic-Wagner/vaultwarden Dominic-Wagner/vaultwarden
BOUNCER_KEY_caddy: "${CROWDSEC_BOUNCER_KEY_CADDY}" BOUNCER_KEY_caddy: "${CROWDSEC_BOUNCER_KEY_CADDY}"
BOUNCER_KEY_firewall: "${CROWDSEC_BOUNCER_KEY_FW}" BOUNCER_KEY_firewall: "${CROWDSEC_BOUNCER_KEY_FW}"
BOUNCER_KEY_exporter: "${CROWDSEC_BOUNCER_KEY_EXPORTER}"
ENROLL_KEY: "${CROWDSEC_ENROLL_KEY:-}" ENROLL_KEY: "${CROWDSEC_ENROLL_KEY:-}"
ENROLL_INSTANCE_NAME: "${CROWDSEC_ENROLL_INSTANCE_NAME:-aykhans-prod}" ENROLL_INSTANCE_NAME: "${CROWDSEC_ENROLL_INSTANCE_NAME:-aykhans-prod}"
ports: ports:
@@ -36,6 +37,7 @@ services:
- ./data/db:/var/lib/crowdsec/data - ./data/db:/var/lib/crowdsec/data
- ./data/config:/etc/crowdsec - ./data/config:/etc/crowdsec
- ./acquis.d:/etc/crowdsec/acquis.d:ro - ./acquis.d:/etc/crowdsec/acquis.d:ro
- ./profiles/profiles.yaml:/etc/crowdsec/profiles.yaml:ro
- ./parsers/s00-raw/stalwart-logs.yaml:/etc/crowdsec/parsers/s00-raw/stalwart-logs.yaml:ro - ./parsers/s00-raw/stalwart-logs.yaml:/etc/crowdsec/parsers/s00-raw/stalwart-logs.yaml:ro
- ./parsers/s01-parse/stalwart-logs-extended.yaml:/etc/crowdsec/parsers/s01-parse/stalwart-logs-extended.yaml:ro - ./parsers/s01-parse/stalwart-logs-extended.yaml:/etc/crowdsec/parsers/s01-parse/stalwart-logs-extended.yaml:ro
- ./parsers/s02-enrich/whitelist-trusted.yaml:/etc/crowdsec/parsers/s02-enrich/whitelist-trusted.yaml:ro - ./parsers/s02-enrich/whitelist-trusted.yaml:/etc/crowdsec/parsers/s02-enrich/whitelist-trusted.yaml:ro
@@ -60,9 +62,12 @@ services:
- caddy - caddy
environment: environment:
CROWDSEC_LAPI_URL: "http://crowdsec:8080" CROWDSEC_LAPI_URL: "http://crowdsec:8080"
CROWDSEC_API_KEY: "${CROWDSEC_BOUNCER_KEY_CADDY}" CROWDSEC_API_KEY: "${CROWDSEC_BOUNCER_KEY_EXPORTER}"
POLL_INTERVAL_SECS: "30" POLL_INTERVAL_SECS: "30"
LISTEN_PORT: "9100" LISTEN_PORT: "9100"
GEOIP_CITY_DB: "/geoip/GeoLite2-City.mmdb"
volumes:
- ./data/geoip:/geoip:ro
depends_on: depends_on:
- crowdsec - crowdsec
logging: logging:
@@ -70,3 +75,22 @@ services:
options: options:
max-size: "50m" max-size: "50m"
max-file: "3" max-file: "3"
geoipupdate:
image: ghcr.io/maxmind/geoipupdate:v7
container_name: crowdsec_geoipupdate
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=true"
environment:
GEOIPUPDATE_ACCOUNT_ID: "${MAXMIND_ACCOUNT_ID}"
GEOIPUPDATE_LICENSE_KEY: "${MAXMIND_LICENSE_KEY}"
GEOIPUPDATE_EDITION_IDS: "GeoLite2-City"
GEOIPUPDATE_FREQUENCY: "24"
volumes:
- ./data/geoip:/usr/share/GeoIP
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "2"
+5 -1
View File
@@ -2,13 +2,17 @@ module crowdsec-exporter
go 1.26.3 go 1.26.3
require github.com/prometheus/client_golang v1.23.2 require (
github.com/oschwald/geoip2-golang/v2 v2.1.0
github.com/prometheus/client_golang v1.23.2
)
require ( require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/compress v1.18.6 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect github.com/prometheus/procfs v0.20.1 // indirect
+4
View File
@@ -12,6 +12,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/oschwald/geoip2-golang/v2 v2.1.0 h1:DjnLhNJu9WHwTrmoiQFvgmyJoczhdnm7LB23UBI2Amo=
github.com/oschwald/geoip2-golang/v2 v2.1.0/go.mod h1:qdVmcPgrTJ4q2eP9tHq/yldMTdp2VMr33uVdFbHBiBc=
github.com/oschwald/maxminddb-golang/v2 v2.1.1 h1:lA8FH0oOrM4u7mLvowq8IT6a3Q/qEnqRzLQn9eH5ojc=
github.com/oschwald/maxminddb-golang/v2 v2.1.1/go.mod h1:PLdx6PR+siSIoXqqy7C7r3SB3KZnhxWr1Dp6g0Hacl8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+87 -1
View File
@@ -4,14 +4,61 @@ import (
"encoding/json" "encoding/json"
"log" "log"
"net/http" "net/http"
"net/netip"
"os" "os"
"strconv" "strconv"
"time" "time"
"github.com/oschwald/geoip2-golang/v2"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
) )
var geoDB *geoip2.Reader
// countryFlag converts a 2-letter ISO-3166-1 alpha-2 country code into its
// emoji flag using the Unicode regional indicator pairs trick (A-Z -> 0x1F1E6-FF).
func countryFlag(iso string) string {
if len(iso) != 2 {
return ""
}
a, b := iso[0], iso[1]
if a < 'A' || a > 'Z' || b < 'A' || b > 'Z' {
return ""
}
return string([]rune{rune(a-'A') + 0x1F1E6, rune(b-'A') + 0x1F1E6})
}
// lookupGeo returns "<flag> <country name> (<city>)" for the given IP, falling
// back gracefully (empty string, missing flag, missing city) when the DB is
// unavailable or the IP can't be resolved.
func lookupGeo(ip string) string {
if geoDB == nil {
return ""
}
addr, err := netip.ParseAddr(ip)
if err != nil {
return ""
}
rec, err := geoDB.City(addr)
if err != nil || rec == nil {
return ""
}
iso := rec.Country.ISOCode
name := rec.Country.Names.English
if name == "" {
name = iso
}
out := name
if flag := countryFlag(iso); flag != "" {
out = flag + " " + name
}
if city := rec.City.Names.English; city != "" {
out += " (" + city + ")"
}
return out
}
var ( var (
decisionsActive = prometheus.NewGaugeVec(prometheus.GaugeOpts{ decisionsActive = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "crowdsec_decisions_active", Name: "crowdsec_decisions_active",
@@ -28,6 +75,11 @@ var (
Help: "Unique IPs with at least one active decision across all origins.", Help: "Unique IPs with at least one active decision across all origins.",
}) })
decisionRemaining = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "crowdsec_decision_remaining_seconds",
Help: "Remaining seconds before each local decision expires. Only origin=crowdsec is exposed to keep cardinality bounded (CAPI/lists can contain tens of thousands of IPs).",
}, []string{"origin", "ip", "country", "scenario", "type"})
lastFetchUnix = prometheus.NewGauge(prometheus.GaugeOpts{ lastFetchUnix = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "crowdsec_exporter_last_fetch_unix", Name: "crowdsec_exporter_last_fetch_unix",
Help: "Unix timestamp of the last successful LAPI fetch.", Help: "Unix timestamp of the last successful LAPI fetch.",
@@ -44,6 +96,7 @@ type decision struct {
Scenario string `json:"scenario"` Scenario string `json:"scenario"`
Type string `json:"type"` Type string `json:"type"`
Value string `json:"value"` Value string `json:"value"`
Duration string `json:"duration"`
} }
func init() { func init() {
@@ -51,6 +104,7 @@ func init() {
decisionsActive, decisionsActive,
decisionsUniqueIPs, decisionsUniqueIPs,
decisionsUniqueIPsTotal, decisionsUniqueIPsTotal,
decisionRemaining,
lastFetchUnix, lastFetchUnix,
fetchErrors, fetchErrors,
) )
@@ -64,7 +118,12 @@ func getenv(k, def string) string {
} }
func fetchAndUpdate(client *http.Client, lapiURL, apiKey string) { func fetchAndUpdate(client *http.Client, lapiURL, apiKey string) {
req, _ := http.NewRequest("GET", lapiURL+"/v1/decisions", nil) req, err := http.NewRequest("GET", lapiURL+"/v1/decisions", nil)
if err != nil {
log.Printf("[error] build request failed: %v", err)
fetchErrors.Set(1)
return
}
req.Header.Set("X-Api-Key", apiKey) req.Header.Set("X-Api-Key", apiKey)
req.Header.Set("User-Agent", "crowdsec-exporter/1.0") req.Header.Set("User-Agent", "crowdsec-exporter/1.0")
@@ -93,6 +152,11 @@ func fetchAndUpdate(client *http.Client, lapiURL, apiKey string) {
counts := map[key]int{} counts := map[key]int{}
ips := map[string]map[string]struct{}{} ips := map[string]map[string]struct{}{}
allIPs := map[string]struct{}{} allIPs := map[string]struct{}{}
type localDecision struct {
origin, ip, country, scenario, dtype string
remaining float64
}
var locals []localDecision
for _, d := range data { for _, d := range data {
origin := d.Origin origin := d.Origin
@@ -115,10 +179,18 @@ func fetchAndUpdate(client *http.Client, lapiURL, apiKey string) {
ips[origin][d.Value] = struct{}{} ips[origin][d.Value] = struct{}{}
allIPs[d.Value] = struct{}{} allIPs[d.Value] = struct{}{}
} }
if origin == "crowdsec" {
dur, perr := time.ParseDuration(d.Duration)
if perr != nil {
dur = 0
}
locals = append(locals, localDecision{origin, d.Value, lookupGeo(d.Value), scenario, dtype, dur.Seconds()})
}
} }
decisionsActive.Reset() decisionsActive.Reset()
decisionsUniqueIPs.Reset() decisionsUniqueIPs.Reset()
decisionRemaining.Reset()
for k, n := range counts { for k, n := range counts {
decisionsActive.WithLabelValues(k.origin, k.scenario, k.dtype).Set(float64(n)) decisionsActive.WithLabelValues(k.origin, k.scenario, k.dtype).Set(float64(n))
} }
@@ -126,6 +198,9 @@ func fetchAndUpdate(client *http.Client, lapiURL, apiKey string) {
decisionsUniqueIPs.WithLabelValues(origin).Set(float64(len(s))) decisionsUniqueIPs.WithLabelValues(origin).Set(float64(len(s)))
} }
decisionsUniqueIPsTotal.Set(float64(len(allIPs))) decisionsUniqueIPsTotal.Set(float64(len(allIPs)))
for _, l := range locals {
decisionRemaining.WithLabelValues(l.origin, l.ip, l.country, l.scenario, l.dtype).Set(l.remaining)
}
fetchErrors.Set(0) fetchErrors.Set(0)
lastFetchUnix.Set(float64(time.Now().Unix())) lastFetchUnix.Set(float64(time.Now().Unix()))
@@ -147,6 +222,17 @@ func main() {
} }
port := getenv("LISTEN_PORT", "9100") port := getenv("LISTEN_PORT", "9100")
geoDBPath := getenv("GEOIP_CITY_DB", "")
if geoDBPath != "" {
if db, err := geoip2.Open(geoDBPath); err == nil {
geoDB = db
defer geoDB.Close()
log.Printf("[start] GeoIP DB loaded: %s", geoDBPath)
} else {
log.Printf("[warn] GeoIP DB unavailable (%v); country label will be empty", err)
}
}
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
go func() { go func() {
+18
View File
@@ -0,0 +1,18 @@
name: default_ip_remediation
filters:
- Alert.Remediation == true && Alert.GetScope() == "Ip"
duration_expr: Sprintf('%dh', min(730, (GetDecisionsCount(Alert.GetValue())+1)*4))
decisions:
- type: ban
duration: 4h
on_success: break
---
name: default_range_remediation
filters:
- Alert.Remediation == true && Alert.GetScope() == "Range"
duration_expr: Sprintf('%dh', min(730, (GetDecisionsCount(Alert.GetValue())+1)*4))
decisions:
- type: ban
duration: 4h
on_success: break
+109 -46
View File
@@ -24,19 +24,6 @@
"list": [] "list": []
}, },
"panels": [ "panels": [
{
"id": 1,
"type": "row",
"title": "Overview",
"collapsed": false,
"gridPos": {
"x": 0,
"y": 0,
"w": 24,
"h": 1
},
"panels": []
},
{ {
"id": 2, "id": 2,
"type": "stat", "type": "stat",
@@ -47,7 +34,7 @@
}, },
"gridPos": { "gridPos": {
"x": 0, "x": 0,
"y": 1, "y": 0,
"w": 6, "w": 6,
"h": 4 "h": 4
}, },
@@ -112,7 +99,7 @@
}, },
"gridPos": { "gridPos": {
"x": 6, "x": 6,
"y": 1, "y": 0,
"w": 6, "w": 6,
"h": 4 "h": 4
}, },
@@ -160,7 +147,7 @@
}, },
"gridPos": { "gridPos": {
"x": 12, "x": 12,
"y": 1, "y": 0,
"w": 6, "w": 6,
"h": 4 "h": 4
}, },
@@ -208,7 +195,7 @@
}, },
"gridPos": { "gridPos": {
"x": 18, "x": 18,
"y": 1, "y": 0,
"w": 6, "w": 6,
"h": 4 "h": 4
}, },
@@ -246,19 +233,6 @@
} }
} }
}, },
{
"id": 8,
"type": "row",
"title": "Active bans breakdown",
"collapsed": false,
"gridPos": {
"x": 0,
"y": 5,
"w": 24,
"h": 1
},
"panels": []
},
{ {
"id": 9, "id": 9,
"type": "piechart", "type": "piechart",
@@ -269,7 +243,7 @@
}, },
"gridPos": { "gridPos": {
"x": 0, "x": 0,
"y": 6, "y": 4,
"w": 12, "w": 12,
"h": 9 "h": 9
}, },
@@ -323,7 +297,7 @@
}, },
"gridPos": { "gridPos": {
"x": 12, "x": 12,
"y": 6, "y": 4,
"w": 12, "w": 12,
"h": 9 "h": 9
}, },
@@ -367,19 +341,6 @@
} }
} }
}, },
{
"id": 12,
"type": "row",
"title": "Local detection activity (your engine)",
"collapsed": false,
"gridPos": {
"x": 0,
"y": 15,
"w": 24,
"h": 1
},
"panels": []
},
{ {
"id": 14, "id": 14,
"type": "bargauge", "type": "bargauge",
@@ -390,7 +351,7 @@
}, },
"gridPos": { "gridPos": {
"x": 0, "x": 0,
"y": 16, "y": 23,
"w": 24, "w": 24,
"h": 8 "h": 8
}, },
@@ -428,6 +389,108 @@
"values": false "values": false
} }
} }
},
{
"id": 15,
"type": "table",
"title": "Local bans detail",
"datasource": {
"type": "prometheus",
"uid": "prometheusdatasource"
},
"gridPos": {
"x": 0,
"y": 13,
"w": 24,
"h": 10
},
"targets": [
{
"expr": "crowdsec_decision_remaining_seconds{origin=\"crowdsec\"}",
"refId": "A",
"instant": true,
"format": "table",
"datasource": {
"type": "prometheus",
"uid": "prometheusdatasource"
}
}
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"__name__": true,
"instance": true,
"job": true,
"origin": true
},
"indexByName": {
"country": 0,
"ip": 1,
"scenario": 2,
"type": 3,
"Value": 4
},
"renameByName": {
"country": "Country",
"ip": "IP",
"scenario": "Scenario",
"type": "Type",
"Value": "Time remaining"
}
}
}
],
"fieldConfig": {
"defaults": {
"custom": {
"align": "auto",
"filterable": true,
"inspect": false
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Time remaining"
},
"properties": [
{
"id": "unit",
"value": "s"
},
{
"id": "custom.cellOptions",
"value": {
"type": "color-text"
}
},
{
"id": "color",
"value": {
"mode": "continuous-GrYlRd"
}
}
]
}
]
},
"options": {
"showHeader": true,
"sortBy": [
{
"displayName": "Time remaining",
"desc": true
}
],
"footer": {
"show": false
}
}
} }
] ]
} }
+1 -1
View File
@@ -6,7 +6,7 @@ networks:
services: services:
memos: memos:
image: neosmemo/memos:0.27 image: neosmemo/memos:0.29
restart: unless-stopped restart: unless-stopped
container_name: memos container_name: memos
labels: labels:
+1 -1
View File
@@ -10,7 +10,7 @@ services:
- "--label-enable" - "--label-enable"
- "--cleanup" - "--cleanup"
- "--interval" - "--interval"
- "600" # 10 minutes - "28800" # 8 hours
logging: logging:
driver: "json-file" driver: "json-file"
options: options: