diff --git a/Dockerfile b/Dockerfile index 2bba7bb..6450ecf 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"] +CMD ["-logtostderr", "-v=1", "-enable_healthcheck"] 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. diff --git a/health/health.go b/health/health.go new file mode 100644 index 0000000..9843921 --- /dev/null +++ b/health/health.go @@ -0,0 +1,86 @@ +// 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 ( + "encoding/json" + "net" + "net/http" + "os" + "time" + + "github.com/golang/glog" +) + +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) { + startTime = time.Now() + mux := http.NewServeMux() + mux.HandleFunc(DefaultPath, handleHealth) + addr := net.JoinHostPort(host, port) + go func() { + 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) + } + }() +} + +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 := net.JoinHostPort(host, port) + url := "http://" + addr + DefaultPath + client := &http.Client{Timeout: DefaultTimeout} + resp, err := client.Get(url) + if err != nil { + return false + } + 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 2407950..9392536 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" @@ -137,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") @@ -145,6 +147,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.") + healthcheckHost := flag.String("healthcheck_host", health.DefaultHost, "The address 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]) @@ -152,6 +157,13 @@ func main() { } flag.Parse() + if *healthcheck { + if !health.Probe(*healthcheckHost, *healthcheckPort) { + os.Exit(1) + } + os.Exit(0) + } + if *prometheusEnabled { if *connType == "tcp6" && *prometheusHost == "0.0.0.0" { *prometheusHost = "[::]" @@ -175,6 +187,22 @@ func main() { }) clients := startSending(*maxClients, *bannerMaxLength, records) + if *healthcheckEnabled { + if *connType == "tcp6" && *healthcheckHost == "0.0.0.0" { + *healthcheckHost = "[::]" + } + 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 // Listen for incoming connections. if *connType == "tcp6" && *connHost == "0.0.0.0" {