diff --git a/.gitignore b/.gitignore index d8fe2c3..92721ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ endlessh-go +endlessh-go.exe diff --git a/README.md b/README.md index 2ae2942..e66b734 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,14 @@ Also check out [examples](./examples/README.md) for the setup of the full stack. ``` Usage of ./endlessh-go + -abuse_ipdb_api_key string + AbuseIPDB API key -alsologtostderr log to standard error as well as files -conn_type string Connection type. Possible values are tcp, tcp4, tcp6 (default "tcp") + -enable_abuseipdb + Enable AbuseIPDB reporting -enable_prometheus Enable prometheus -geoip_supplier string @@ -104,12 +108,16 @@ Endlessh-go exports the following Prometheus metrics. The metrics is off by default, you can turn it via the CLI argument `-enable_prometheus`. +AbuseIPDB reporting is also off by default, you can turn it on via the CLI argument '-enable_abuseipdb' + It listens to port `2112` and entry point is `/metrics` by default. The port and entry point can be changed via CLI arguments. The endlessh-go server stores the geohash of attackers as a label on `endlessh_client_open_count`, which is also off by default. You can turn it on via the CLI argument `-geoip_supplier`. The endlessh-go uses service from [ip-api](https://ip-api.com/), which may enforce a query rate and limit commercial use. Visit their website for their terms and policies. You could also use an offline GeoIP database from [MaxMind](https://www.maxmind.com) by setting `-geoip_supplier` to _max-mind-db_ and `-max_mind_db` to the path of the database file. +The AbuseIPDB reporting requires their free to use API available at their [website](https://www.abuseipdb.com/pricing), once you have it, add it as a docker environment variable `ABUSE_IPDB_API_KEY` + ## Dashboard The dashboard requires Grafana 8.2. diff --git a/examples/docker-abuseipdb/docker-compose.yml b/examples/docker-abuseipdb/docker-compose.yml new file mode 100644 index 0000000..fe286e6 --- /dev/null +++ b/examples/docker-abuseipdb/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3.5' +services: + endlessh: + container_name: endlessh + image: shizunge/endlessh-go:latest + restart: unless-stopped + command: + - "-logtostderr" + - "-v=1" + - "-enable_abuseipdb" + networks: + - example_network + ports: + - "2222:2222" # SSH port + - "127.0.0.1:2112:2112" # Prometheus metrics port + secrets: + - abuseipdb_api_key + environment: + ABUSE_IPDB_API_KEY_FILE: /run/secrets/abuseipdb_api_key + +networks: + example_network: + +secrets: + abuseipdb_api_key: + external: true # Ensure the secret is created beforehand with 'docker secret create' diff --git a/go.mod b/go.mod index 08dc9d3..92ce31b 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21.0 toolchain go1.21.4 require ( + github.com/dgraph-io/ristretto v0.1.1 github.com/golang/glog v1.2.0 github.com/oschwald/geoip2-golang v1.9.0 github.com/pierrre/geohash v1.1.1 @@ -14,8 +15,10 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dustin/go-humanize v1.0.0 // indirect github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/oschwald/maxminddb-golang v1.11.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect diff --git a/go.sum b/go.sum index ffad79c..15e2813 100644 --- a/go.sum +++ b/go.sum @@ -6,12 +6,20 @@ 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/broady/gogeohash v0.0.0-20120525094510-7b2c40d64042 h1:iEdmkrNMLXbM7ecffOAtZJQOQUTE4iMonxrb5opUgE4= github.com/broady/gogeohash v0.0.0-20120525094510-7b2c40d64042/go.mod h1:f1L9YvXvlt9JTa+A17trQjSMM6bV40f+tHjB+Pi+Fqk= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= +github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/fanixk/geohash v0.0.0-20150324002647-c1f9b5fa157a h1:Fyfh/dsHFrC6nkX7H7+nFdTd1wROlX/FxEIWVpKYf1U= github.com/fanixk/geohash v0.0.0-20150324002647-c1f9b5fa157a/go.mod h1:UgNw+PTmmGN8rV7RvjvnBMsoTU8ZXXnaT3hYsDTBlgQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68= github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= @@ -36,6 +44,8 @@ github.com/pierrre/go-libs v0.2.14 h1:wAPoOrslKLnha6ow5EKkxxZpo76kOea57efs71A/Zn github.com/pierrre/go-libs v0.2.14/go.mod h1:eA3pQD5LHZmavOpTpUfO8FszduBNHoFXDWrevDR6Dy8= github.com/pierrre/pretty v0.0.10 h1:Cb5som+1EpU+x7UA5AMy9I8AY2XkzMBywkLEAdo1JDg= github.com/pierrre/pretty v0.0.10/go.mod h1:F+Z4XV4T5GIvbr/swCAkuQ2ng81qMaQT9CfI8rKOLdY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= @@ -46,15 +56,20 @@ github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lne github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/the42/cartconvert v1.0.0 h1:g8kt6ic2GEhdcZ61ZP9GsWwhosVo5nCnH1n2/oAQXUU= github.com/the42/cartconvert v1.0.0/go.mod h1:fWO/msnJVhHqN1yX6OBoxSyfj7TEj1hHiL8bJSQsK30= +golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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/main.go b/main.go index c9d0353..c05c569 100644 --- a/main.go +++ b/main.go @@ -22,11 +22,14 @@ import ( "endlessh-go/metrics" "flag" "fmt" + "io" "net" + "net/http" "os" "strings" "time" + "github.com/dgraph-io/ristretto" "github.com/golang/glog" ) @@ -67,7 +70,7 @@ func startSending(maxClients int64, bannerMaxLength int64, records chan<- metric return clients } -func startAccepting(maxClients int64, connType, connHost, connPort string, interval time.Duration, clients chan<- *client.Client, records chan<- metrics.RecordEntry) { +func startAccepting(maxClients int64, connType, connHost, connPort string, interval time.Duration, clients chan<- *client.Client, records chan<- metrics.RecordEntry, abuseipdbeEnabled bool, abuseIpdbApiKey string) { go func() { l, err := net.Listen(connType, connHost+":"+connPort) if err != nil { @@ -92,10 +95,113 @@ func startAccepting(maxClients int64, connType, connHost, connPort string, inter LocalPort: connPort, } clients <- c + go reportIPToAbuseIPDB(remoteIpAddr, records, abuseipdbeEnabled, abuseIpdbApiKey) } }() } +func reportIPToAbuseIPDB(ip string, records chan<- metrics.RecordEntry, abuseipdbeEnabled bool, abuseIpdbApiKey string) { + if !abuseipdbeEnabled { + return + } + if isCached(ip) { + glog.V(1).Infof("IP is already cached, skipping report") + records <- metrics.RecordEntry{ + RecordType: metrics.RecordEntryTypeReport, + IpAddr: ip, + Message: "IP is already cached, skipping report", + } + return + } + appendToReportedIPs(ip) // Cache the IP before possibly early exiting due to API key issues + + var apiKey string + if abuseIpdbApiKey != "" { + apiKey = abuseIpdbApiKey + } else { + glog.V(1).Infof("AbuseIPDB API key not set, skipping report") + records <- metrics.RecordEntry{ + RecordType: metrics.RecordEntryTypeReport, + IpAddr: ip, + Message: "AbuseIPDB API key not set, skipping report", + } + return + } + + // Format the timestamp in ISO 8601 format, including the timezone (Z for UTC) + timestamp := time.Now().UTC().Format(time.RFC3339) + reportURL := fmt.Sprintf("https://api.abuseipdb.com/api/v2/report?ip=%s&categories=18,22&comment=SSH honeypot connection attempt.×tamp=%s", ip, timestamp) + req, err := http.NewRequest("POST", reportURL, nil) + if err != nil { + glog.V(1).Infof("Error creating request: %v", err) + records <- metrics.RecordEntry{ + RecordType: metrics.RecordEntryTypeReport, + IpAddr: ip, + Message: fmt.Sprintf("Error creating request: %v", err), + } + return + } + + req.Header.Set("Key", apiKey) + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + records <- metrics.RecordEntry{ + RecordType: metrics.RecordEntryTypeReport, + IpAddr: ip, + Message: fmt.Sprintf("Error making request: %v", err), + } + return + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnprocessableEntity { + body, err := io.ReadAll(resp.Body) + if err != nil { + glog.V(1).Infof("Error reading response body: %v", err) + } else { + glog.V(1).Infof("Unprocessable Entity response from AbuseIPDB: %s", body) + } + records <- metrics.RecordEntry{ + RecordType: metrics.RecordEntryTypeReport, + IpAddr: ip, + Message: fmt.Sprintf("Unprocessable Entity response from AbuseIPDB: %s", resp.Status), + } + return + } + + glog.V(1).Infof("Reported IP to AbuseIPDB: %s", resp.Status) + records <- metrics.RecordEntry{ + RecordType: metrics.RecordEntryTypeReport, + IpAddr: ip, + Message: fmt.Sprintf("Reported IP to AbuseIPDB: %s", resp.Status), + } +} + +func setupCache() { + config := &ristretto.Config{ + NumCounters: 1e7, // NumCounters is 10x the number of items you expect to keep in the cache when full. + MaxCost: 1 << 30, // Maximum cost of cache (e.g., bytes if the cost is measured in bytes). + BufferItems: 64, // Number of keys per Get buffer. + } + + var err error + cache, err = ristretto.NewCache(config) + if err != nil { + glog.Fatalf("Failed to create cache: %v", err) + } +} + +func isCached(ip string) bool { + _, found := cache.Get(ip) + return found +} + +func appendToReportedIPs(ip string) { + cache.Set(ip, struct{}{}, 1) +} + type arrayStrings []string func (a *arrayStrings) String() string { @@ -109,15 +215,20 @@ func (a *arrayStrings) Set(value string) error { const defaultPort = "2222" +var cache *ristretto.Cache var connPorts arrayStrings func main() { + setupCache() + + abuseIPDBApiKeyFlag := flag.String("abuse_ipdb_api_key", "", "AbuseIPDB API key") intervalMs := flag.Int("interval_ms", 1000, "Message millisecond delay") bannerMaxLength := flag.Int64("line_length", 32, "Maximum banner line length") maxClients := flag.Int64("max_clients", 4096, "Maximum number of clients") connType := flag.String("conn_type", "tcp", "Connection type. Possible values are tcp, tcp4, tcp6") connHost := flag.String("host", "0.0.0.0", "SSH listening address") flag.Var(&connPorts, "port", fmt.Sprintf("SSH listening port. You may provide multiple -port flags to listen to multiple ports. (default %q)", defaultPort)) + abuseipdbeEnabled := flag.Bool("enable_abuseipdb", false, "Enable AbuseIPDB reporting") prometheusEnabled := flag.Bool("enable_prometheus", false, "Enable prometheus") prometheusHost := flag.String("prometheus_host", "0.0.0.0", "The address for prometheus") prometheusPort := flag.String("prometheus_port", "2112", "The port for prometheus") @@ -132,6 +243,23 @@ func main() { } flag.Parse() + var apiKey string + if *abuseIPDBApiKeyFlag != "" { + apiKey = *abuseIPDBApiKeyFlag + } else { + abuseIPDBApiKeyFile := os.Getenv("ABUSE_IPDB_API_KEY_FILE") + if abuseIPDBApiKeyFile != "" { + key, err := os.ReadFile(abuseIPDBApiKeyFile) + if err != nil { + glog.Warningf("Error reading API key file: %v", err) + } else { + apiKey = strings.TrimSpace(string(key)) + } + } else { + glog.Warning("Neither abuse_ipdb_api_key flag nor ABUSE_IPDB_API_KEY_FILE environment variable is set. AbuseIPDB reporting will be disabled.") + } + } + if *prometheusEnabled { if *connType == "tcp6" && *prometheusHost == "0.0.0.0" { *prometheusHost = "[::]" @@ -155,7 +283,7 @@ func main() { connPorts = append(connPorts, defaultPort) } for _, connPort := range connPorts { - startAccepting(*maxClients, *connType, *connHost, connPort, interval, clients, records) + startAccepting(*maxClients, *connType, *connHost, connPort, interval, clients, records, *abuseipdbeEnabled, apiKey) } for { if *prometheusCleanUnseenSeconds <= 0 { diff --git a/metrics/metrics.go b/metrics/metrics.go index c754360..93e3c80 100644 --- a/metrics/metrics.go +++ b/metrics/metrics.go @@ -96,10 +96,11 @@ func InitPrometheus(prometheusHost, prometheusPort, prometheusEntry string) { } const ( - RecordEntryTypeStart = iota - RecordEntryTypeSend = iota - RecordEntryTypeStop = iota - RecordEntryTypeClean = iota + RecordEntryTypeStart = iota + RecordEntryTypeSend = iota + RecordEntryTypeStop = iota + RecordEntryTypeClean = iota + RecordEntryTypeReport = iota ) type RecordEntry struct { @@ -108,6 +109,7 @@ type RecordEntry struct { LocalPort string MillisecondsSpent int64 BytesSent int + Message string } func StartRecording(maxClients int64, prometheusEnabled bool, prometheusCleanUnseenSeconds int, geoOption geoip.GeoOption) chan RecordEntry {