From 03327339827723d1176bc7be397ffe96df513cf5 Mon Sep 17 00:00:00 2001 From: paspo Date: Fri, 10 Apr 2026 23:54:45 +0200 Subject: [PATCH 1/8] Added healthcheck --- Dockerfile | 2 ++ health/health.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 11 +++++++++ 3 files changed, 72 insertions(+) create mode 100644 health/health.go diff --git a/Dockerfile b/Dockerfile index 2bba7bb..7ff3af7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,8 @@ LABEL org.opencontainers.image.licenses=GPLv3 COPY --from=build /endlessh/endlessh /endlessh EXPOSE 2222 2112 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD ["/endlessh", "-healthcheck"] USER nobody ENTRYPOINT ["/endlessh"] CMD ["-logtostderr", "-v=1"] diff --git a/health/health.go b/health/health.go new file mode 100644 index 0000000..f29bf95 --- /dev/null +++ b/health/health.go @@ -0,0 +1,59 @@ +// Copyright (C) 2026 Paolo Asperti +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +package health + +import ( + "net" + "os" + "time" + + "github.com/golang/glog" +) + +const ( + DefaultPort = "51000" + DefaultTimeout = 3 * time.Second +) + +func StartListener(port string) { + go func() { + l, err := net.Listen("tcp", "127.0.0.1:"+port) + if err != nil { + glog.Errorf("Error listening for healthcheck on 127.0.0.1:%v: %v", port, err) + os.Exit(1) + } + defer l.Close() + for { + conn, err := l.Accept() + if err != nil { + glog.Errorf("Error accepting healthcheck connection: %v", err) + os.Exit(1) + } + conn.Close() + } + }() +} + +func Probe(port string) bool { + timeout := DefaultTimeout + conn, err := net.DialTimeout("tcp", "127.0.0.1:"+port, timeout) + if err != nil { + return false + } + conn.Close() + return true +} diff --git a/main.go b/main.go index 2407950..d09b8f9 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,7 @@ package main import ( "endlessh-go/client" "endlessh-go/geoip" + "endlessh-go/health" "endlessh-go/metrics" "flag" "fmt" @@ -145,6 +146,8 @@ func main() { maxMindDbFileName := flag.String("max_mind_db", "", "Path to the MaxMind DB file.") proxyProtocolEnabled := flag.Bool("proxy_protocol_enabled", false, "Enable PROXY protocol support. This causes the server to expect PROXY protocol headers on incoming connections.") proxyProtocolReadHeaderTimeout := flag.Int("proxy_protocol_read_header_timeout_ms", 200, "Timeout for reading the PROXY protocol header in milliseconds. If the connection does not send a valid PROXY protocol header in this time, the header is ignored.") + healthcheckPort := flag.String("healthcheck_port", health.DefaultPort, "TCP port for container healthcheck; accepts connectionsand closes without logging or metrics") + healthcheck := flag.Bool("healthcheck", false, "Dial healthcheck_port on 127.0.0.1 and exit 0 if reachable (for container healthcheck)") flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), "Usage of %v \n", os.Args[0]) @@ -152,6 +155,13 @@ func main() { } flag.Parse() + if *healthcheck { + if !health.Probe(*healthcheckPort) { + os.Exit(1) + } + os.Exit(0) + } + if *prometheusEnabled { if *connType == "tcp6" && *prometheusHost == "0.0.0.0" { *prometheusHost = "[::]" @@ -183,6 +193,7 @@ func main() { if len(connPorts) == 0 { connPorts = append(connPorts, defaultPort) } + health.StartListener(*healthcheckPort) for _, connPort := range connPorts { startAccepting(*maxClients, *connType, *connHost, connPort, interval, clients, records, *proxyProtocolEnabled, *proxyProtocolReadHeaderTimeout) } From a21aa69bc34948e4790502f21733e9d537edadf0 Mon Sep 17 00:00:00 2001 From: paspo Date: Sun, 21 Jun 2026 14:32:53 +0200 Subject: [PATCH 2/8] added optional host to healthcheck endpoint --- health/health.go | 13 ++++++++----- main.go | 13 +++++++++---- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/health/health.go b/health/health.go index f29bf95..00c64c1 100644 --- a/health/health.go +++ b/health/health.go @@ -25,15 +25,17 @@ import ( ) const ( + DefaultHost = "127.0.0.1" DefaultPort = "51000" DefaultTimeout = 3 * time.Second ) -func StartListener(port string) { +func StartListener(host, port string) { + addr := host + ":" + port go func() { - l, err := net.Listen("tcp", "127.0.0.1:"+port) + l, err := net.Listen("tcp", addr) if err != nil { - glog.Errorf("Error listening for healthcheck on 127.0.0.1:%v: %v", port, err) + glog.Errorf("Error listening for healthcheck on %v: %v", addr, err) os.Exit(1) } defer l.Close() @@ -48,9 +50,10 @@ func StartListener(port string) { }() } -func Probe(port string) bool { +func Probe(host, port string) bool { + addr := host + ":" + port timeout := DefaultTimeout - conn, err := net.DialTimeout("tcp", "127.0.0.1:"+port, timeout) + conn, err := net.DialTimeout("tcp", addr, timeout) if err != nil { return false } diff --git a/main.go b/main.go index d09b8f9..89b0447 100644 --- a/main.go +++ b/main.go @@ -146,8 +146,9 @@ func main() { maxMindDbFileName := flag.String("max_mind_db", "", "Path to the MaxMind DB file.") proxyProtocolEnabled := flag.Bool("proxy_protocol_enabled", false, "Enable PROXY protocol support. This causes the server to expect PROXY protocol headers on incoming connections.") proxyProtocolReadHeaderTimeout := flag.Int("proxy_protocol_read_header_timeout_ms", 200, "Timeout for reading the PROXY protocol header in milliseconds. If the connection does not send a valid PROXY protocol header in this time, the header is ignored.") - healthcheckPort := flag.String("healthcheck_port", health.DefaultPort, "TCP port for container healthcheck; accepts connectionsand closes without logging or metrics") - healthcheck := flag.Bool("healthcheck", false, "Dial healthcheck_port on 127.0.0.1 and exit 0 if reachable (for container healthcheck)") + healthcheckHost := flag.String("healthcheck_host", health.DefaultHost, "The address for container healthcheck") + healthcheckPort := flag.String("healthcheck_port", health.DefaultPort, "TCP port for container healthcheck; accepts connection and closes without logging or updating metrics") + healthcheck := flag.Bool("healthcheck", false, "Dial healthcheck_host:healthcheck_port and exit 0 if reachable (for container healthcheck)") flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), "Usage of %v \n", os.Args[0]) @@ -155,8 +156,12 @@ func main() { } flag.Parse() + if *connType == "tcp6" && *healthcheckHost == "0.0.0.0" { + *healthcheckHost = "[::]" + } + if *healthcheck { - if !health.Probe(*healthcheckPort) { + if !health.Probe(*healthcheckHost, *healthcheckPort) { os.Exit(1) } os.Exit(0) @@ -193,7 +198,7 @@ func main() { if len(connPorts) == 0 { connPorts = append(connPorts, defaultPort) } - health.StartListener(*healthcheckPort) + health.StartListener(*healthcheckHost, *healthcheckPort) for _, connPort := range connPorts { startAccepting(*maxClients, *connType, *connHost, connPort, interval, clients, records, *proxyProtocolEnabled, *proxyProtocolReadHeaderTimeout) } From c13e99b020abeb4fb8ba223766ef42bd641bfd5b Mon Sep 17 00:00:00 2001 From: paspo Date: Sun, 21 Jun 2026 17:31:16 +0200 Subject: [PATCH 3/8] healthcheck: json response --- health/health.go | 58 ++++++++++++++++++++++++++++++++++-------------- main.go | 4 ++-- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/health/health.go b/health/health.go index 00c64c1..9843921 100644 --- a/health/health.go +++ b/health/health.go @@ -17,7 +17,9 @@ package health import ( + "encoding/json" "net" + "net/http" "os" "time" @@ -27,36 +29,58 @@ import ( const ( DefaultHost = "127.0.0.1" DefaultPort = "51000" + DefaultPath = "/health" DefaultTimeout = 3 * time.Second ) +type response struct { + Status string `json:"status"` // always "ok" + Uptime float64 `json:"uptime"` // in seconds +} + +var startTime time.Time + func StartListener(host, port string) { - addr := host + ":" + port + startTime = time.Now() + mux := http.NewServeMux() + mux.HandleFunc(DefaultPath, handleHealth) + addr := net.JoinHostPort(host, port) go func() { - l, err := net.Listen("tcp", addr) - if err != nil { + glog.Infof("Starting healthcheck on http://%v%v", addr, DefaultPath) + if err := http.ListenAndServe(addr, mux); err != nil { glog.Errorf("Error listening for healthcheck on %v: %v", addr, err) os.Exit(1) } - defer l.Close() - for { - conn, err := l.Accept() - if err != nil { - glog.Errorf("Error accepting healthcheck connection: %v", err) - os.Exit(1) - } - conn.Close() - } }() } +func handleHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response{ + Status: "ok", + Uptime: time.Since(startTime).Seconds(), + }) +} + func Probe(host, port string) bool { - addr := host + ":" + port - timeout := DefaultTimeout - conn, err := net.DialTimeout("tcp", addr, timeout) + addr := net.JoinHostPort(host, port) + url := "http://" + addr + DefaultPath + client := &http.Client{Timeout: DefaultTimeout} + resp, err := client.Get(url) if err != nil { return false } - conn.Close() - return true + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false + } + var body response + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return false + } + return body.Status == "ok" } diff --git a/main.go b/main.go index 89b0447..d3d2ac6 100644 --- a/main.go +++ b/main.go @@ -147,8 +147,8 @@ func main() { proxyProtocolEnabled := flag.Bool("proxy_protocol_enabled", false, "Enable PROXY protocol support. This causes the server to expect PROXY protocol headers on incoming connections.") proxyProtocolReadHeaderTimeout := flag.Int("proxy_protocol_read_header_timeout_ms", 200, "Timeout for reading the PROXY protocol header in milliseconds. If the connection does not send a valid PROXY protocol header in this time, the header is ignored.") healthcheckHost := flag.String("healthcheck_host", health.DefaultHost, "The address for container healthcheck") - healthcheckPort := flag.String("healthcheck_port", health.DefaultPort, "TCP port for container healthcheck; accepts connection and closes without logging or updating metrics") - healthcheck := flag.Bool("healthcheck", false, "Dial healthcheck_host:healthcheck_port and exit 0 if reachable (for container healthcheck)") + healthcheckPort := flag.String("healthcheck_port", health.DefaultPort, "HTTP port for container healthcheck; serves JSON with status and uptime at /health") + healthcheck := flag.Bool("healthcheck", false, "GET healthcheck_host:healthcheck_port/health and exit 1 if status is not ok or timeout is exceeded (for container healthcheck)") flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), "Usage of %v \n", os.Args[0]) From 3a81ad2410ffcfd99be2059ad5424de02d08bd04 Mon Sep 17 00:00:00 2001 From: paspo Date: Sun, 21 Jun 2026 17:38:22 +0200 Subject: [PATCH 4/8] readme update for healthcheck --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 0c641e9..ec9907a 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ Usage of ./endlessh-go Enable prometheus -geoip_supplier string Supplier to obtain Geohash of IPs. Possible values are "off", "ip-api", "max-mind-db" (default "off") + -healthcheck + GET healthcheck_host:healthcheck_port/health and exit 1 if status is not ok or timeout is exceeded (for container healthcheck) + -healthcheck_host string + The address for container healthcheck (default "127.0.0.1") + -healthcheck_port string + HTTP port for container healthcheck; serves JSON with status and uptime at /health (default "51000") -host string SSH listening address (default "0.0.0.0") -interval_ms int @@ -121,6 +127,23 @@ The endlessh-go server stores the geohash of attackers as a label on `endlessh_c 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. +## Healthcheck + +The endlessh-go server exposes an HTTP health endpoint while the server is running. By default it listens on `127.0.0.1:51000` at `/health` and returns a JSON like this: + +```json +{ + "status": "ok", + "uptime": 123.456789012 +} +``` + +`status` is always "ok". + +`uptime` is the number of seconds since the server started. The host and port can be changed via `-healthcheck_host` and `-healthcheck_port`. + +The [docker image](https://hub.docker.com/r/shizunge/endlessh-go) includes a built-in `HEALTHCHECK` that runs every 30 seconds. + ## Dashboard The dashboard requires Grafana 8.2. From e7d2f5239dc796206f83812abc8d5c01d35b1d3e Mon Sep 17 00:00:00 2001 From: paspo Date: Fri, 3 Jul 2026 11:50:46 +0200 Subject: [PATCH 5/8] Move to above line 195. Avoid interrupt checking connPorts and using connPorts. As suggested by @shizunge --- main.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index d3d2ac6..9da12a1 100644 --- a/main.go +++ b/main.go @@ -190,6 +190,9 @@ func main() { }) clients := startSending(*maxClients, *bannerMaxLength, records) + // Start the healthcheck listener. + health.StartListener(*healthcheckHost, *healthcheckPort) + interval := time.Duration(*intervalMs) * time.Millisecond // Listen for incoming connections. if *connType == "tcp6" && *connHost == "0.0.0.0" { @@ -198,7 +201,6 @@ func main() { if len(connPorts) == 0 { connPorts = append(connPorts, defaultPort) } - health.StartListener(*healthcheckHost, *healthcheckPort) for _, connPort := range connPorts { startAccepting(*maxClients, *connType, *connHost, connPort, interval, clients, records, *proxyProtocolEnabled, *proxyProtocolReadHeaderTimeout) } From b9adb28e90f838ceda6a7dd2118c81d3a3fd3dad Mon Sep 17 00:00:00 2001 From: paspo Date: Fri, 3 Jul 2026 18:18:48 +0200 Subject: [PATCH 6/8] support use of random free port for healthcheck --- main.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 9da12a1..47e0e0e 100644 --- a/main.go +++ b/main.go @@ -190,7 +190,15 @@ func main() { }) clients := startSending(*maxClients, *bannerMaxLength, records) - // Start the healthcheck listener. + if *healthcheckPort == "0" || *healthcheckPort == "" { + l, err := net.Listen("tcp", *healthcheckHost+":0") + if err != nil { + glog.Fatalf("Failed to pick a free healthcheck port: %v", err) + } + actualPort := l.Addr().(*net.TCPAddr).Port + *healthcheckPort = strconv.Itoa(actualPort) + l.Close() + } health.StartListener(*healthcheckHost, *healthcheckPort) interval := time.Duration(*intervalMs) * time.Millisecond From bff5ed3f7b63ed8603595b51d98a9f3c5ff65ed1 Mon Sep 17 00:00:00 2001 From: paspo Date: Fri, 3 Jul 2026 18:20:00 +0200 Subject: [PATCH 7/8] fix tests --- endlessh_integration_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/endlessh_integration_test.go b/endlessh_integration_test.go index c177c96..577359c 100644 --- a/endlessh_integration_test.go +++ b/endlessh_integration_test.go @@ -46,6 +46,7 @@ func TestEndlesshIntegration_MultiplePorts(t *testing.T) { args := []string{"run", "main.go", "-interval_ms=100", "-max_clients=10", + "-healthcheck_port=0", "-logtostderr", "-v=1", } @@ -88,7 +89,7 @@ func TestEndlesshIntegration_MultiplePorts(t *testing.T) { func TestEndlesshIntegration_TarpitBehavior(t *testing.T) { var stderr bytes.Buffer - cmd := exec.Command("go", "run", "main.go", "-port=0", "-interval_ms=5000", "-max_clients=10", "-logtostderr", "-v=1") + cmd := exec.Command("go", "run", "main.go", "-port=0", "-healthcheck_port=0", "-interval_ms=5000", "-max_clients=10", "-logtostderr", "-v=1") cmd.Stderr = &stderr if err := cmd.Start(); err != nil { t.Fatalf("Failed to start server: %v", err) @@ -144,7 +145,7 @@ func TestEndlesshIntegration_TarpitBehavior(t *testing.T) { func TestEndlesshIntegration_Concurrency(t *testing.T) { maxClients := 5 var stderr bytes.Buffer - cmd := exec.Command("go", "run", "main.go", "-port=0", "-interval_ms=1000", fmt.Sprintf("-max_clients=%d", maxClients), "-logtostderr", "-v=1") + cmd := exec.Command("go", "run", "main.go", "-port=0", "-healthcheck_port=0", "-interval_ms=1000", fmt.Sprintf("-max_clients=%d", maxClients), "-logtostderr", "-v=1") cmd.Stderr = &stderr if err := cmd.Start(); err != nil { @@ -263,6 +264,7 @@ func TestEndlesshIntegration_PrometheusMetrics(t *testing.T) { cmd := exec.Command( "go", "run", "main.go", "-port=0", + "-healthcheck_port=0", "-enable_prometheus", "-prometheus_port=0", "-interval_ms=100", From db2a1b137233cbd7a90ba88b10e0f4452da0c6a6 Mon Sep 17 00:00:00 2001 From: paspo Date: Sat, 11 Jul 2026 00:05:44 +0200 Subject: [PATCH 8/8] healthcheck disabled by default, except in docker image --- Dockerfile | 2 +- endlessh_integration_test.go | 6 ++---- main.go | 26 ++++++++++++++------------ 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7ff3af7..6450ecf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,4 +19,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD ["/endlessh", "-healthcheck"] USER nobody ENTRYPOINT ["/endlessh"] -CMD ["-logtostderr", "-v=1"] +CMD ["-logtostderr", "-v=1", "-enable_healthcheck"] diff --git a/endlessh_integration_test.go b/endlessh_integration_test.go index 577359c..c177c96 100644 --- a/endlessh_integration_test.go +++ b/endlessh_integration_test.go @@ -46,7 +46,6 @@ func TestEndlesshIntegration_MultiplePorts(t *testing.T) { args := []string{"run", "main.go", "-interval_ms=100", "-max_clients=10", - "-healthcheck_port=0", "-logtostderr", "-v=1", } @@ -89,7 +88,7 @@ func TestEndlesshIntegration_MultiplePorts(t *testing.T) { func TestEndlesshIntegration_TarpitBehavior(t *testing.T) { var stderr bytes.Buffer - cmd := exec.Command("go", "run", "main.go", "-port=0", "-healthcheck_port=0", "-interval_ms=5000", "-max_clients=10", "-logtostderr", "-v=1") + cmd := exec.Command("go", "run", "main.go", "-port=0", "-interval_ms=5000", "-max_clients=10", "-logtostderr", "-v=1") cmd.Stderr = &stderr if err := cmd.Start(); err != nil { t.Fatalf("Failed to start server: %v", err) @@ -145,7 +144,7 @@ func TestEndlesshIntegration_TarpitBehavior(t *testing.T) { func TestEndlesshIntegration_Concurrency(t *testing.T) { maxClients := 5 var stderr bytes.Buffer - cmd := exec.Command("go", "run", "main.go", "-port=0", "-healthcheck_port=0", "-interval_ms=1000", fmt.Sprintf("-max_clients=%d", maxClients), "-logtostderr", "-v=1") + cmd := exec.Command("go", "run", "main.go", "-port=0", "-interval_ms=1000", fmt.Sprintf("-max_clients=%d", maxClients), "-logtostderr", "-v=1") cmd.Stderr = &stderr if err := cmd.Start(); err != nil { @@ -264,7 +263,6 @@ func TestEndlesshIntegration_PrometheusMetrics(t *testing.T) { cmd := exec.Command( "go", "run", "main.go", "-port=0", - "-healthcheck_port=0", "-enable_prometheus", "-prometheus_port=0", "-interval_ms=100", diff --git a/main.go b/main.go index 47e0e0e..9392536 100644 --- a/main.go +++ b/main.go @@ -138,6 +138,7 @@ func main() { 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)) prometheusEnabled := flag.Bool("enable_prometheus", false, "Enable prometheus") + healthcheckEnabled := flag.Bool("enable_healthcheck", false, "Enable healthcheck") prometheusHost := flag.String("prometheus_host", "0.0.0.0", "The address for prometheus") prometheusPort := flag.String("prometheus_port", "2112", "The port for prometheus") prometheusEntry := flag.String("prometheus_entry", "metrics", "Entry point for prometheus") @@ -156,10 +157,6 @@ func main() { } flag.Parse() - if *connType == "tcp6" && *healthcheckHost == "0.0.0.0" { - *healthcheckHost = "[::]" - } - if *healthcheck { if !health.Probe(*healthcheckHost, *healthcheckPort) { os.Exit(1) @@ -190,16 +187,21 @@ func main() { }) clients := startSending(*maxClients, *bannerMaxLength, records) - if *healthcheckPort == "0" || *healthcheckPort == "" { - l, err := net.Listen("tcp", *healthcheckHost+":0") - if err != nil { - glog.Fatalf("Failed to pick a free healthcheck port: %v", err) + if *healthcheckEnabled { + if *connType == "tcp6" && *healthcheckHost == "0.0.0.0" { + *healthcheckHost = "[::]" } - actualPort := l.Addr().(*net.TCPAddr).Port - *healthcheckPort = strconv.Itoa(actualPort) - l.Close() + if *healthcheckPort == "0" || *healthcheckPort == "" { + l, err := net.Listen("tcp", *healthcheckHost+":0") + if err != nil { + glog.Fatalf("Failed to pick a free healthcheck port: %v", err) + } + actualPort := l.Addr().(*net.TCPAddr).Port + *healthcheckPort = strconv.Itoa(actualPort) + l.Close() + } + health.StartListener(*healthcheckHost, *healthcheckPort) } - health.StartListener(*healthcheckHost, *healthcheckPort) interval := time.Duration(*intervalMs) * time.Millisecond // Listen for incoming connections.