From eba5f93c0e1c4669938423e73f105fb716dfa9b0 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 26 Jul 2026 10:07:06 -0500 Subject: [PATCH 1/2] fix(ssh): loosen agent server keep-alive to survive brief network stalls The host agent SSH server closed the transport after ~10s (5s x 2) of missed keep-alives, far stricter than OpenSSH, so short network stalls tore down the tunnel and the user's SSH session. Default the tolerance to ~120s (15s x 8, OpenSSH-like) and make it configurable via DEVSY_SSH_KEEPALIVE_INTERVAL and DEVSY_SSH_KEEPALIVE_COUNT_MAX. Fixes #759 --- pkg/ssh/server/ssh.go | 44 +++++++++++++++++++++++++------ pkg/ssh/server/ssh_test.go | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 pkg/ssh/server/ssh_test.go diff --git a/pkg/ssh/server/ssh.go b/pkg/ssh/server/ssh.go index ab4a5adb3..8bd1046a1 100644 --- a/pkg/ssh/server/ssh.go +++ b/pkg/ssh/server/ssh.go @@ -92,6 +92,38 @@ 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) { + interval := defaultKeepAliveInterval + if v := os.Getenv(envKeepAliveInterval); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + interval = d + } else if secs, err := strconv.Atoi(v); err == nil && secs > 0 { + interval = time.Duration(secs) * time.Second + } else { + log.Errorf("invalid %s=%q, using default %s", envKeepAliveInterval, v, defaultKeepAliveInterval) + } + } + + countMax := defaultKeepAliveCountMax + if v := os.Getenv(envKeepAliveCountMax); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + countMax = n + } else { + log.Errorf("invalid %s=%q, using default %d", envKeepAliveCountMax, v, defaultKeepAliveCountMax) + } + } + + return interval, countMax +} + type Server interface { Serve(listener net.Listener) error ListenAndServe() error @@ -128,20 +160,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) + } + }) + } +} From b11c9d54fc1f15ad54effe1487b11871dacb4d19 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 26 Jul 2026 10:14:29 -0500 Subject: [PATCH 2/2] refactor(ssh): split keepAliveConfig to satisfy cyclop and golines --- pkg/ssh/server/ssh.go | 49 +++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/pkg/ssh/server/ssh.go b/pkg/ssh/server/ssh.go index 8bd1046a1..d8c00ecb3 100644 --- a/pkg/ssh/server/ssh.go +++ b/pkg/ssh/server/ssh.go @@ -101,27 +101,40 @@ const ( ) func keepAliveConfig() (time.Duration, int) { - interval := defaultKeepAliveInterval - if v := os.Getenv(envKeepAliveInterval); v != "" { - if d, err := time.ParseDuration(v); err == nil && d > 0 { - interval = d - } else if secs, err := strconv.Atoi(v); err == nil && secs > 0 { - interval = time.Duration(secs) * time.Second - } else { - log.Errorf("invalid %s=%q, using default %s", envKeepAliveInterval, v, defaultKeepAliveInterval) - } - } + return keepAliveInterval(), keepAliveCountMax() +} - countMax := defaultKeepAliveCountMax - if v := os.Getenv(envKeepAliveCountMax); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 { - countMax = n - } else { - log.Errorf("invalid %s=%q, using default %d", envKeepAliveCountMax, v, defaultKeepAliveCountMax) - } +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 +} - return interval, countMax +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 {