diff --git a/pkg/ssh/server/ssh.go b/pkg/ssh/server/ssh.go index ab4a5adb3..d8c00ecb3 100644 --- a/pkg/ssh/server/ssh.go +++ b/pkg/ssh/server/ssh.go @@ -92,6 +92,51 @@ const ( DefaultUserPort int = 12023 ) +const ( + defaultKeepAliveInterval = 15 * time.Second + defaultKeepAliveCountMax = 8 + + envKeepAliveInterval = "DEVSY_SSH_KEEPALIVE_INTERVAL" + envKeepAliveCountMax = "DEVSY_SSH_KEEPALIVE_COUNT_MAX" +) + +func keepAliveConfig() (time.Duration, int) { + return keepAliveInterval(), keepAliveCountMax() +} + +func keepAliveInterval() time.Duration { + v := os.Getenv(envKeepAliveInterval) + if v == "" { + return defaultKeepAliveInterval + } + if d, err := time.ParseDuration(v); err == nil && d > 0 { + return d + } + if secs, err := strconv.Atoi(v); err == nil && secs > 0 { + return time.Duration(secs) * time.Second + } + log.Errorf( + "invalid %s=%q, using default %s", + envKeepAliveInterval, v, defaultKeepAliveInterval, + ) + return defaultKeepAliveInterval +} + +func keepAliveCountMax() int { + v := os.Getenv(envKeepAliveCountMax) + if v == "" { + return defaultKeepAliveCountMax + } + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + log.Errorf( + "invalid %s=%q, using default %d", + envKeepAliveCountMax, v, defaultKeepAliveCountMax, + ) + return defaultKeepAliveCountMax +} + type Server interface { Serve(listener net.Listener) error ListenAndServe() error @@ -128,20 +173,16 @@ func NewServer( forwardHandler := &ssh.ForwardedTCPHandler{} forwardedUnixHandler := &ssh.ForwardedUnixHandler{} + keepAliveInterval, keepAliveCountMax := keepAliveConfig() server := &server{ shell: sh, workdir: workdir, reuseSock: reuseSock, currentUser: currentUser.Username, sshServer: ssh.Server{ - Addr: addr, - // Keep-alive at the connection level: detects dead peers in - // stdio mode where EOF on stdin can be delayed indefinitely by - // the proxy chain. 5s × 2 = ~10s detection so per-connection - // agent socket dirs are cleaned up well within typical - // test/health-check polling windows. - ClientAliveInterval: 5 * time.Second, - ClientAliveCountMax: 2, + Addr: addr, + ClientAliveInterval: keepAliveInterval, + ClientAliveCountMax: keepAliveCountMax, LocalPortForwardingCallback: func(ctx ssh.Context, dhost string, dport uint32) bool { log.Debugf("Accepted forward: %s:%d", dhost, dport) return true diff --git a/pkg/ssh/server/ssh_test.go b/pkg/ssh/server/ssh_test.go new file mode 100644 index 000000000..30a5a4795 --- /dev/null +++ b/pkg/ssh/server/ssh_test.go @@ -0,0 +1,54 @@ +package server + +import ( + "testing" + "time" +) + +func TestKeepAliveConfig_Defaults(t *testing.T) { + t.Setenv(envKeepAliveInterval, "") + t.Setenv(envKeepAliveCountMax, "") + + interval, countMax := keepAliveConfig() + if interval != defaultKeepAliveInterval { + t.Errorf("interval = %s, want %s", interval, defaultKeepAliveInterval) + } + if countMax != defaultKeepAliveCountMax { + t.Errorf("countMax = %d, want %d", countMax, defaultKeepAliveCountMax) + } + + if tolerance := interval * time.Duration(countMax); tolerance < 60*time.Second { + t.Errorf("keep-alive tolerance %s is too aggressive (want >= 60s)", tolerance) + } +} + +func TestKeepAliveConfig_Overrides(t *testing.T) { + tests := []struct { + name string + interval string + countMax string + wantInterval time.Duration + wantCountMax int + }{ + {"duration string", "30s", "4", 30 * time.Second, 4}, + {"bare seconds", "45", "3", 45 * time.Second, 3}, + {"invalid interval falls back", "nope", "5", defaultKeepAliveInterval, 5}, + {"invalid count falls back", "20s", "0", 20 * time.Second, defaultKeepAliveCountMax}, + {"negative interval falls back", "-5s", "2", defaultKeepAliveInterval, 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(envKeepAliveInterval, tt.interval) + t.Setenv(envKeepAliveCountMax, tt.countMax) + + interval, countMax := keepAliveConfig() + if interval != tt.wantInterval { + t.Errorf("interval = %s, want %s", interval, tt.wantInterval) + } + if countMax != tt.wantCountMax { + t.Errorf("countMax = %d, want %d", countMax, tt.wantCountMax) + } + }) + } +}