Merge pull request #221 from shizunge/prometheus

Rename enable_healthcheck to healthcheck_enabled. Add prometheus_enabled.
This commit is contained in:
Shizun Ge
2026-07-25 08:27:19 -07:00
committed by GitHub
11 changed files with 251 additions and 83 deletions
+1 -39
View File
@@ -10,57 +10,19 @@ on:
- 'README.md' - 'README.md'
- 'LICENSE' - 'LICENSE'
workflow_dispatch: workflow_dispatch:
env:
PLATFORMS: "linux/amd64,linux/arm64,linux/arm/v7"
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v7 uses: actions/setup-go@v7
with: with:
go-version: '1.26.1' go-version: '1.26.1'
cache: true cache: true
- name: Install dependencies - name: Install dependencies
run: go mod download run: go mod download
- name: Run Tests - name: Run Tests
run: go test -v ./... run: go test -v ./...
build_container_image:
name: Build Docker image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4.2.0
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: |
ghcr.io/${{ github.repository }}-development
tags: |
type=raw,value=dev-{{date 'X'}}
type=raw,value=latest
type=ref,event=branch
type=edge,branch=main
- name: Build
uses: docker/build-push-action@v7.3.0
with:
platforms: ${{ env.PLATFORMS }}
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
+1 -3
View File
@@ -21,21 +21,19 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v7 uses: actions/setup-go@v7
with: with:
go-version: '1.26.1' go-version: '1.26.1'
cache: true cache: true
- name: Install dependencies - name: Install dependencies
run: go mod download run: go mod download
- name: Run Tests - name: Run Tests
run: go test -v ./... run: go test -v ./...
build_and_push: build_and_push:
name: Build and push Docker image name: Build and push Docker image
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test
if: ${{ github.actor != 'dependabot[bot]' }} if: ${{ github.actor != 'dependabot[bot]' }}
steps: steps:
- name: Checkout code - name: Checkout code
+16
View File
@@ -8,9 +8,25 @@ env:
PLATFORMS: "linux/amd64,linux/arm64,linux/arm/v7" PLATFORMS: "linux/amd64,linux/arm64,linux/arm/v7"
jobs: jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: '1.26.1'
cache: true
- name: Install dependencies
run: go mod download
- name: Run Tests
run: go test -v ./...
build_and_push: build_and_push:
name: Build and push Docker image name: Build and push Docker image
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v7
+1 -1
View File
@@ -19,4 +19,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["/endlessh", "-healthcheck"] CMD ["/endlessh", "-healthcheck"]
USER nobody USER nobody
ENTRYPOINT ["/endlessh"] ENTRYPOINT ["/endlessh"]
CMD ["-logtostderr", "-v=1", "-enable_healthcheck"] CMD ["-logtostderr", "-v=1", "-healthcheck_enabled"]
+10 -6
View File
@@ -55,15 +55,17 @@ Usage of ./endlessh-go
-conn_type string -conn_type string
Connection type. Possible values are tcp, tcp4, tcp6 (default "tcp") Connection type. Possible values are tcp, tcp4, tcp6 (default "tcp")
-enable_prometheus -enable_prometheus
Enable prometheus Enable prometheus (deprecated, use prometheus_enabled)
-geoip_supplier string -geoip_supplier string
Supplier to obtain Geohash of IPs. Possible values are "off", "ip-api", "max-mind-db" (default "off") Supplier to obtain Geohash of IPs. Possible values are "off", "ip-api", "max-mind-db" (default "off")
-healthcheck -healthcheck
GET healthcheck_host:healthcheck_port/health and exit 1 if status is not ok or timeout is exceeded (for container healthcheck) Perform healthcheck and exit. GET healthcheck_host:healthcheck_port/health and exit 1 if status is not ok or timeout is exceeded.
-healthcheck_enabled
Enable healthcheck
-healthcheck_host string -healthcheck_host string
The address for container healthcheck (default "127.0.0.1") The address for healthcheck. (default "127.0.0.1")
-healthcheck_port string -healthcheck_port string
HTTP port for container healthcheck; serves JSON with status and uptime at /health (default "51000") HTTP port for healthcheck; Serves JSON with status and uptime at /health. (default "51000")
-host string -host string
SSH listening address (default "0.0.0.0") SSH listening address (default "0.0.0.0")
-interval_ms int -interval_ms int
@@ -88,6 +90,8 @@ Usage of ./endlessh-go
SSH listening port. You may provide multiple -port flags to listen to multiple ports. (default "2222") SSH listening port. You may provide multiple -port flags to listen to multiple ports. (default "2222")
-prometheus_clean_unseen_seconds int -prometheus_clean_unseen_seconds int
Remove series if the IP is not seen for the given time. Set to 0 to disable. (default 0) Remove series if the IP is not seen for the given time. Set to 0 to disable. (default 0)
-prometheus_enabled
Enable prometheus
-prometheus_entry string -prometheus_entry string
Entry point for prometheus (default "metrics") Entry point for prometheus (default "metrics")
-prometheus_host string -prometheus_host string
@@ -119,7 +123,7 @@ Endlessh-go exports the following Prometheus metrics.
| endlessh_client_open_count | count | Number of connections of clients. <br> Labels: <br> <ul><li> `ip`: Remote IP of the client </li> <li> `local_port`: Local port the program listens to </li> <li> `country`: Country of the IP </li> <li> `location`: Country, Region, and City </li> <li> `geohash`: Geohash of the location </li></ul> | | endlessh_client_open_count | count | Number of connections of clients. <br> Labels: <br> <ul><li> `ip`: Remote IP of the client </li> <li> `local_port`: Local port the program listens to </li> <li> `country`: Country of the IP </li> <li> `location`: Country, Region, and City </li> <li> `geohash`: Geohash of the location </li></ul> |
| endlessh_client_trapped_time_seconds | count | Seconds a client spends on endlessh. <br> Labels: <br> <ul><li> `ip`: Remote IP of the client </li> <li> `local_port`: Local port the program listens to </li></ul> | | endlessh_client_trapped_time_seconds | count | Seconds a client spends on endlessh. <br> Labels: <br> <ul><li> `ip`: Remote IP of the client </li> <li> `local_port`: Local port the program listens to </li></ul> |
The metrics is off by default, you can turn it via the CLI argument `-enable_prometheus`. The metrics is off by default, you can turn it via the CLI argument `-prometheus_enabled`.
It listens to port `2112` and entry point is `/metrics` by default. The port and entry point can be changed via CLI arguments. It listens to port `2112` and entry point is `/metrics` by default. The port and entry point can be changed via CLI arguments.
@@ -129,7 +133,7 @@ You could also use an offline GeoIP database from [MaxMind](https://www.maxmind.
## Healthcheck ## 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: The endlessh-go server exposes an HTTP health endpoint when the `-healthcheck_enabled` flag is set. By default it listens on `127.0.0.1:51000` at `/health` and returns a JSON like this:
```json ```json
{ {
+59 -1
View File
@@ -263,7 +263,7 @@ func TestEndlesshIntegration_PrometheusMetrics(t *testing.T) {
cmd := exec.Command( cmd := exec.Command(
"go", "run", "main.go", "go", "run", "main.go",
"-port=0", "-port=0",
"-enable_prometheus", "-prometheus_enabled",
"-prometheus_port=0", "-prometheus_port=0",
"-interval_ms=100", "-interval_ms=100",
"-logtostderr", "-v=1", "-logtostderr", "-v=1",
@@ -322,3 +322,61 @@ func TestEndlesshIntegration_PrometheusMetrics(t *testing.T) {
t.Errorf("Expected bytes metric not found:\n%s", body) t.Errorf("Expected bytes metric not found:\n%s", body)
} }
} }
func TestEndlesshIntegration_Healthcheck(t *testing.T) {
var stderr bytes.Buffer
cmd := exec.Command(
"go", "run", "main.go",
"-port=0",
"-healthcheck_enabled",
"-healthcheck_port=0",
"-interval_ms=100",
"-logtostderr", "-v=1",
)
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
t.Fatalf("Failed to start server: %v", err)
}
defer cmd.Process.Kill()
if !waitForLogMatch(&stderr, "Starting healthcheck on http", waitForListenTimeout) {
t.Fatalf("Healthcheck listener did not start: %s", stderr.String())
}
reHealth := regexp.MustCompile(`Starting healthcheck on http://.*:(\d+)/health`)
healthMatch := reHealth.FindStringSubmatch(stderr.String())
if len(healthMatch) < 2 {
t.Fatalf("Could not parse healthcheck port: %s", stderr.String())
}
healthPort := healthMatch[1]
// 1. Verify healthcheck HTTP endpoint returns valid JSON
resp, err := net.Dial("tcp", "localhost:"+healthPort)
if err != nil {
t.Fatalf("Failed to connect to healthcheck endpoint: %v", err)
}
fmt.Fprintf(resp, "GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n")
buf := make([]byte, 8192)
n, _ := resp.Read(buf)
body := string(buf[:n])
resp.Close()
if !strings.Contains(body, `"status":"ok"`) {
t.Errorf("Expected status:ok in health response: %s", body)
}
// 2. Run the probe mode (-healthcheck) and verify it exits with 0
probeCmd := exec.Command(
"go", "run", "main.go",
"-healthcheck",
"-healthcheck_port="+healthPort,
)
var probeStderr bytes.Buffer
probeCmd.Stderr = &probeStderr
if err := probeCmd.Run(); err != nil {
t.Errorf("Healthcheck probe failed: %v, stderr: %s", err, probeStderr.String())
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ services:
- -interval_ms=1000 - -interval_ms=1000
- -logtostderr - -logtostderr
- -v=1 - -v=1
- -enable_prometheus - -prometheus_enabled
- -geoip_supplier=ip-api - -geoip_supplier=ip-api
networks: networks:
- example_network - example_network
+1 -1
View File
@@ -14,7 +14,7 @@ spec:
- -interval_ms=1000 - -interval_ms=1000
- -logtostderr - -logtostderr
- -v=1 - -v=1
- -enable_prometheus - -prometheus_enabled
- -geoip_supplier=ip-api - -geoip_supplier=ip-api
- -host=[::] - -host=[::]
- -prometheus_host=[::] - -prometheus_host=[::]
+27
View File
@@ -21,6 +21,7 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"strconv"
"time" "time"
"github.com/golang/glog" "github.com/golang/glog"
@@ -84,3 +85,29 @@ func Probe(host, port string) bool {
} }
return body.Status == "ok" return body.Status == "ok"
} }
// SetupHealthcheck probes or starts the healthcheck listener as configured.
func SetupHealthcheck(healthcheck bool, healthcheckEnabled bool, connType string, healthcheckHost, healthcheckPort *string) {
if healthcheck {
if !Probe(*healthcheckHost, *healthcheckPort) {
os.Exit(1)
}
os.Exit(0)
}
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()
}
StartListener(*healthcheckHost, *healthcheckPort)
}
}
+32 -31
View File
@@ -131,25 +131,35 @@ const defaultPort = "2222"
var connPorts arrayStrings var connPorts arrayStrings
func main() { func main() {
// Core SSH server flags
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))
connType := flag.String("conn_type", "tcp", "Connection type. Possible values are tcp, tcp4, tcp6")
intervalMs := flag.Int("interval_ms", 1000, "Message millisecond delay") intervalMs := flag.Int("interval_ms", 1000, "Message millisecond delay")
bannerMaxLength := flag.Int64("line_length", 32, "Maximum banner line length") bannerMaxLength := flag.Int64("line_length", 32, "Maximum banner line length")
maxClients := flag.Int64("max_clients", 4096, "Maximum number of clients") 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") // PROXY protocol flags
flag.Var(&connPorts, "port", fmt.Sprintf("SSH listening port. You may provide multiple -port flags to listen to multiple ports. (default %q)", defaultPort)) proxyProtocolEnabled := flag.Bool("proxy_protocol_enabled", false, "Enable PROXY protocol support. This causes the server to expect PROXY protocol headers on incoming connections.")
prometheusEnabled := flag.Bool("enable_prometheus", false, "Enable prometheus") 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.")
healthcheckEnabled := flag.Bool("enable_healthcheck", false, "Enable healthcheck")
// Prometheus metrics flags
prometheusEnabledOld := flag.Bool("enable_prometheus", false, "Enable prometheus (deprecated, use prometheus_enabled)")
prometheusEnabledNew := flag.Bool("prometheus_enabled", false, "Enable prometheus")
prometheusHost := flag.String("prometheus_host", "0.0.0.0", "The address for prometheus") prometheusHost := flag.String("prometheus_host", "0.0.0.0", "The address for prometheus")
prometheusPort := flag.String("prometheus_port", "2112", "The port for prometheus") prometheusPort := flag.String("prometheus_port", "2112", "The port for prometheus")
prometheusEntry := flag.String("prometheus_entry", "metrics", "Entry point for prometheus") prometheusEntry := flag.String("prometheus_entry", "metrics", "Entry point for prometheus")
prometheusCleanUnseenSeconds := flag.Int("prometheus_clean_unseen_seconds", 0, "Remove series if the IP is not seen for the given time. Set to 0 to disable. (default 0)") prometheusCleanUnseenSeconds := flag.Int("prometheus_clean_unseen_seconds", 0, "Remove series if the IP is not seen for the given time. Set to 0 to disable. (default 0)")
// GeoIP flags
geoipSupplier := flag.String("geoip_supplier", "off", "Supplier to obtain Geohash of IPs. Possible values are \"off\", \"ip-api\", \"max-mind-db\"") geoipSupplier := flag.String("geoip_supplier", "off", "Supplier to obtain Geohash of IPs. Possible values are \"off\", \"ip-api\", \"max-mind-db\"")
maxMindDbFileName := flag.String("max_mind_db", "", "Path to the MaxMind DB file.") 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.") // Healthcheck flags
healthcheckHost := flag.String("healthcheck_host", health.DefaultHost, "The address for container healthcheck") healthcheckEnabled := flag.Bool("healthcheck_enabled", false, "Enable healthcheck")
healthcheckPort := flag.String("healthcheck_port", health.DefaultPort, "HTTP port for container healthcheck; serves JSON with status and uptime at /health") healthcheckHost := flag.String("healthcheck_host", health.DefaultHost, "The address for healthcheck.")
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)") healthcheckPort := flag.String("healthcheck_port", health.DefaultPort, "HTTP port for healthcheck; Serves JSON with status and uptime at /health.")
healthcheck := flag.Bool("healthcheck", false, "Perform healthcheck and exit. GET healthcheck_host:healthcheck_port/health and exit 1 if status is not ok or timeout is exceeded.")
flag.Usage = func() { flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %v \n", os.Args[0]) fmt.Fprintf(flag.CommandLine.Output(), "Usage of %v \n", os.Args[0])
@@ -157,14 +167,20 @@ func main() {
} }
flag.Parse() flag.Parse()
if *healthcheck { health.SetupHealthcheck(*healthcheck, *healthcheckEnabled, *connType, healthcheckHost, healthcheckPort)
if !health.Probe(*healthcheckHost, *healthcheckPort) {
os.Exit(1) prometheusEnabled := *prometheusEnabledNew
prometheusEnableSet := false
flag.Visit(func(f *flag.Flag) {
if f.Name == "prometheus_enabled" {
prometheusEnableSet = true
} }
os.Exit(0) })
if !prometheusEnableSet {
prometheusEnabled = *prometheusEnabledOld
} }
if *prometheusEnabled { if prometheusEnabled {
if *connType == "tcp6" && *prometheusHost == "0.0.0.0" { if *connType == "tcp6" && *prometheusHost == "0.0.0.0" {
*prometheusHost = "[::]" *prometheusHost = "[::]"
} }
@@ -180,28 +196,13 @@ func main() {
metrics.InitPrometheus(*prometheusHost, *prometheusPort, *prometheusEntry) metrics.InitPrometheus(*prometheusHost, *prometheusPort, *prometheusEntry)
} }
records := metrics.StartRecording(*maxClients, *prometheusEnabled, *prometheusCleanUnseenSeconds, records := metrics.StartRecording(*maxClients, prometheusEnabled, *prometheusCleanUnseenSeconds,
geoip.GeoOption{ geoip.GeoOption{
GeoipSupplier: *geoipSupplier, GeoipSupplier: *geoipSupplier,
MaxMindDbFileName: *maxMindDbFileName, MaxMindDbFileName: *maxMindDbFileName,
}) })
clients := startSending(*maxClients, *bannerMaxLength, records) 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 interval := time.Duration(*intervalMs) * time.Millisecond
// Listen for incoming connections. // Listen for incoming connections.
+102
View File
@@ -0,0 +1,102 @@
package metrics
import (
"testing"
"time"
)
func TestUpdatablePriorityQueue_PushAndPop(t *testing.T) {
pq := NewUpdatablePriorityQueue()
now := time.Now()
pq.Update("item1", now.Add(2*time.Second))
pq.Update("item2", now.Add(1*time.Second))
pq.Update("item3", now.Add(3*time.Second))
if pq.pq.Len() != 3 {
t.Fatalf("Expected length 3, got %d", pq.pq.Len())
}
first := pq.Pop()
if first == nil {
t.Fatal("Pop returned nil")
}
if first.Key != "item2" {
t.Errorf("Expected first to be item2, got %s", first.Key)
}
second := pq.Pop()
if second.Key != "item1" {
t.Errorf("Expected second to be item1, got %s", second.Key)
}
third := pq.Pop()
if third.Key != "item3" {
t.Errorf("Expected third to be item3, got %s", third.Key)
}
if pq.Pop() != nil {
t.Error("Expected pop on empty pq to return nil")
}
}
func TestUpdatablePriorityQueue_UpdateExisting(t *testing.T) {
pq := NewUpdatablePriorityQueue()
now := time.Now()
pq.Update("item1", now.Add(2*time.Second))
pq.Update("item2", now.Add(3*time.Second))
// Update item2 to have an earlier time
pq.Update("item2", now.Add(1*time.Second))
first := pq.Peek()
if first == nil {
t.Fatal("Peek returned nil")
}
if first.Key != "item2" {
t.Errorf("Expected first to be item2 after update, got %s", first.Key)
}
popFirst := pq.Pop()
if popFirst.Key != "item2" {
t.Errorf("Expected popped first to be item2, got %s", popFirst.Key)
}
popSecond := pq.Pop()
if popSecond.Key != "item1" {
t.Errorf("Expected popped second to be item1, got %s", popSecond.Key)
}
}
func TestUpdatablePriorityQueue_UpdateToLater(t *testing.T) {
pq := NewUpdatablePriorityQueue()
now := time.Now()
pq.Update("item1", now.Add(1*time.Second))
pq.Update("item2", now.Add(2*time.Second))
// Update item1 to have a later time than item2
pq.Update("item1", now.Add(3*time.Second))
first := pq.Pop()
if first == nil || first.Key != "item2" {
t.Errorf("Expected first to be item2, got %v", first)
}
second := pq.Pop()
if second == nil || second.Key != "item1" {
t.Errorf("Expected second to be item1, got %v", second)
}
}
func TestUpdatablePriorityQueue_Empty(t *testing.T) {
pq := NewUpdatablePriorityQueue()
if pq.Peek() != nil {
t.Error("Expected Peek on empty pq to return nil")
}
if pq.Pop() != nil {
t.Error("Expected Pop on empty pq to return nil")
}
}