From c45d27113ec5011879ba371d1188f2b373f7e55c Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Sun, 21 Jun 2026 13:02:10 +0800 Subject: [PATCH] fix(dialer): evict dead SSHD sessions before reuse to prevent channel-open failure (#382) When an SSH session behind an SSHD dialer dies silently (NAT timeout, server disconnect, idle TCP drop), IsClosed() may still return false because the health-check goroutine hasn't detected the failure yet. Returning this dead cached session to the connector causes: ssh: unexpected packet in response to channel open: Add a Ping(req)-based liveness check before returning a cached session. If the ping fails, evict the session so the next Dial rebuilds a fresh connection. This matches the dead-session eviction pattern already used by the SSH, KCP, QUIC, and ICMP dialers. Also export the ping-as-health-check as Session.Ping(timeout) so it can be used by external health probes. Retain the existing ping() helper unmodified. --- dialer/sshd/dialer.go | 12 ++++++++++++ internal/util/ssh/session.go | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/dialer/sshd/dialer.go b/dialer/sshd/dialer.go index 25b0a213..dd3f690f 100644 --- a/dialer/sshd/dialer.go +++ b/dialer/sshd/dialer.go @@ -63,6 +63,18 @@ func (d *sshdDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO delete(d.sessions, addr) // session is dead ok = false } + if ok { + // Health check: verify the cached session is still alive before + // returning it. A dead-but-not-yet-detected session (e.g. idle + // connection silently dropped by NAT) would cause "ssh: unexpected + // packet in response to channel open" later in the connector. + if err := session.Ping(d.md.keepaliveTimeout); err != nil { + d.options.Logger.Debugf("sshd session ping failed: %v, recreating", err) + session.Close() + delete(d.sessions, addr) + ok = false + } + } if !ok { var options dialer.DialOptions for _, opt := range opts { diff --git a/internal/util/ssh/session.go b/internal/util/ssh/session.go index 0922f2bb..64b457b0 100644 --- a/internal/util/ssh/session.go +++ b/internal/util/ssh/session.go @@ -114,6 +114,25 @@ func (s *Session) Keepalive(interval, timeout time.Duration, retries int) { } } +// Ping checks if the SSH session is alive by sending a keepalive request. +// It blocks until a response is received or the timeout expires. +// A nil error indicates the session is healthy. +func (s *Session) Ping(timeout time.Duration) error { + if timeout <= 0 { + timeout = defaultKeepaliveTimeout + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + select { + case err := <-s.ping(): + return err + case <-ctx.Done(): + return ctx.Err() + } +} + func (s *Session) ping() <-chan error { ch := make(chan error, 1) go func() {