Skip to content

Commit b4a56b3

Browse files
acmoreclaude
andcommitted
feat: add proxy health monitoring utilities (monitoredCopy, watchdog, TCP tuning)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 57c5826 commit b4a56b3

2 files changed

Lines changed: 99 additions & 1 deletion

File tree

internal/cli/proxy_health.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"errors"
6+
"io"
7+
"log/slog"
8+
"net"
9+
"sync/atomic"
10+
"syscall"
11+
"time"
12+
)
13+
14+
// ErrProxyHealthDisconnect is a sentinel error returned when the proxy
15+
// exits due to a health-check-triggered disconnection. It should be
16+
// suppressed from stderr in main.go since the ssh client will report
17+
// the disconnect naturally.
18+
var ErrProxyHealthDisconnect = errors.New("proxy health disconnect")
19+
20+
// monitoredReader wraps an io.Reader and updates an atomic timestamp
21+
// on every successful Read.
22+
type monitoredReader struct {
23+
r io.Reader
24+
lastData *atomic.Int64
25+
}
26+
27+
func (m *monitoredReader) Read(p []byte) (int, error) {
28+
n, err := m.r.Read(p)
29+
if n > 0 {
30+
m.lastData.Store(time.Now().UnixNano())
31+
}
32+
return n, err
33+
}
34+
35+
// monitoredCopy copies from src to dst, updating lastData on every
36+
// successful read. Returns bytes copied and any error.
37+
func monitoredCopy(dst io.Writer, src io.Reader, lastData *atomic.Int64) (int64, error) {
38+
return io.Copy(dst, &monitoredReader{r: src, lastData: lastData})
39+
}
40+
41+
// proxyDataFlowWatchdog checks lastData every checkInterval. If no data
42+
// has flowed for idleThreshold, it closes conn and returns.
43+
// It also returns when ctx is cancelled.
44+
func proxyDataFlowWatchdog(ctx context.Context, conn net.Conn, lastData *atomic.Int64, checkInterval, idleThreshold time.Duration) {
45+
ticker := time.NewTicker(checkInterval)
46+
defer ticker.Stop()
47+
for {
48+
select {
49+
case <-ctx.Done():
50+
return
51+
case <-ticker.C:
52+
last := lastData.Load()
53+
if last == 0 {
54+
continue // no data yet, skip
55+
}
56+
idle := time.Since(time.Unix(0, last))
57+
if idle >= idleThreshold {
58+
slog.Info("ssh-proxy data flow watchdog: idle timeout",
59+
"idle", idle.Round(time.Millisecond),
60+
"threshold", idleThreshold,
61+
)
62+
_ = conn.Close()
63+
return
64+
}
65+
}
66+
}
67+
}
68+
69+
// setTCPKeepAliveProxyTuning reduces the TCP keepalive period and
70+
// optionally sets TCP_KEEPCNT where supported.
71+
func setTCPKeepAliveProxyTuning(conn net.Conn) {
72+
tc, ok := conn.(*net.TCPConn)
73+
if !ok {
74+
return
75+
}
76+
_ = tc.SetKeepAlive(true)
77+
_ = tc.SetKeepAlivePeriod(5 * time.Second)
78+
79+
// Try to set TCP_KEEPCNT=2 via syscall. This is best-effort.
80+
raw, err := tc.SyscallConn()
81+
if err != nil {
82+
slog.Debug("ssh-proxy: failed to get syscall conn for TCP_KEEPCNT", "error", err)
83+
return
84+
}
85+
var sysErr error
86+
err = raw.Control(func(fd uintptr) {
87+
sysErr = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_TCP, syscall.TCP_KEEPCNT, 2)
88+
})
89+
if err != nil || sysErr != nil {
90+
slog.Debug("ssh-proxy: TCP_KEEPCNT not set (unsupported or failed)",
91+
"controlErr", err, "sysErr", sysErr)
92+
}
93+
}

internal/cli/proxy_health_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,12 @@ func TestSetTCPKeepAliveProxyTuningSmoke(t *testing.T) {
152152
t.Fatal(err)
153153
}
154154
defer ln.Close()
155-
go func() { c, _ := ln.Accept(); _ = c.Close() }()
155+
go func() {
156+
c, err := ln.Accept()
157+
if err == nil {
158+
_ = c.Close()
159+
}
160+
}()
156161

157162
conn, err := net.Dial("tcp", ln.Addr().String())
158163
if err != nil {

0 commit comments

Comments
 (0)