Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 49 additions & 8 deletions pkg/ssh/server/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions pkg/ssh/server/ssh_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading