mirror of
https://github.com/henrygd/beszel.git
synced 2026-08-19 08:47:46 +02:00
fix(hub): re-dial SSH-pull agents when a connection silently dies (#2126)
The per-system updater reused an SSH client across ticks and ran the data exchange with no deadline. If a connection went half-open (dead peer that never sends RST/FIN) or an agent accepted the session but never wrote a response, the read in fetchDataViaSSH blocked forever. Because StartUpdater calls update() synchronously on its ticker, a blocked read froze the whole per-system goroutine: the ticker's subsequent ticks were dropped, no error was returned so the system stayed "up", and the agent was never re-dialed until the hub process restarted. Bound each SSH data exchange with sshOperationTimeout via runWithTimeout: on timeout the connection is torn down (unwinding the blocked read) and a retryable error is returned, so the next tick re-dials. Also enable TCP keep-alive on dialed connections as a backstop for genuine network death. Fixes #2041
This commit is contained in:
56
internal/hub/systems/ssh_timeout_test.go
Normal file
56
internal/hub/systems/ssh_timeout_test.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRunWithTimeout covers the guard added for issue #2041: the per-system SSH
|
||||||
|
// data exchange must never block the updater indefinitely on a dead connection.
|
||||||
|
func TestRunWithTimeout(t *testing.T) {
|
||||||
|
t.Run("returns the operation result when it completes before the timeout", func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
wantErr := errors.New("boom")
|
||||||
|
onTimeoutCalled := false
|
||||||
|
|
||||||
|
retry, err := runWithTimeout(10*time.Second, func() (bool, error) {
|
||||||
|
return true, wantErr
|
||||||
|
}, func() { onTimeoutCalled = true })
|
||||||
|
|
||||||
|
assert.True(t, retry, "should return the operation's retry value")
|
||||||
|
assert.Equal(t, wantErr, err, "should return the operation's error")
|
||||||
|
assert.False(t, onTimeoutCalled, "onTimeout must not fire when the op completes")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("times out and tears down the connection when the op blocks", func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
// unblock simulates a half-open connection: the op is stuck reading a
|
||||||
|
// response that never arrives until the connection is torn down.
|
||||||
|
unblock := make(chan struct{})
|
||||||
|
onTimeoutCalled := false
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
retry, err := runWithTimeout(5*time.Second, func() (bool, error) {
|
||||||
|
<-unblock
|
||||||
|
return false, nil
|
||||||
|
}, func() {
|
||||||
|
onTimeoutCalled = true
|
||||||
|
close(unblock) // tearing down the connection releases the blocked read
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, 5*time.Second, time.Since(start), "should return exactly at the timeout")
|
||||||
|
assert.True(t, retry, "a timeout should be retryable so the next tick re-dials")
|
||||||
|
assert.Error(t, err, "a timeout must surface an error so the system is set down")
|
||||||
|
assert.True(t, onTimeoutCalled, "onTimeout must fire so the dead connection is closed")
|
||||||
|
|
||||||
|
synctest.Wait() // ensure the released op goroutine exits cleanly
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -624,10 +624,17 @@ func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
retry, opErr := func() (bool, error) {
|
// Bound the whole operation. A half-open TCP connection (a dead peer that
|
||||||
|
// never sends RST/FIN) or a wedged agent that accepts the session but
|
||||||
|
// never writes a response would otherwise block the read forever. Because
|
||||||
|
// StartUpdater runs update() synchronously on its ticker, that stalls the
|
||||||
|
// per-system updater indefinitely with no error and no re-dial until the
|
||||||
|
// hub is restarted (issue #2041). On timeout we tear down the connection
|
||||||
|
// so the blocked read unwinds and the system is re-dialed on the next tick.
|
||||||
|
retry, opErr := runWithTimeout(sshOperationTimeout, func() (bool, error) {
|
||||||
defer session.Close()
|
defer session.Close()
|
||||||
return operation(session)
|
return operation(session)
|
||||||
}()
|
}, sys.closeSSHConnection)
|
||||||
|
|
||||||
if opErr == nil {
|
if opErr == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -646,6 +653,43 @@ func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation
|
|||||||
return fmt.Errorf("ssh operation failed")
|
return fmt.Errorf("ssh operation failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sshOperationTimeout bounds a single SSH data exchange (send request, read
|
||||||
|
// response, wait for the remote command to exit). It is more generous than the
|
||||||
|
// session-creation timeout to tolerate briefly slow agents, but is kept well
|
||||||
|
// under the collection interval so a stalled connection is detected and
|
||||||
|
// re-dialed within one cycle (see issue #2041).
|
||||||
|
const sshOperationTimeout = 20 * time.Second
|
||||||
|
|
||||||
|
// runWithTimeout runs op in a goroutine and returns its result, or, if op does
|
||||||
|
// not finish within timeout, calls onTimeout (used to tear down the connection
|
||||||
|
// so a blocked op can unwind) and returns a retryable timeout error. This
|
||||||
|
// guarantees the caller can never block indefinitely on a dead SSH connection.
|
||||||
|
func runWithTimeout(timeout time.Duration, op func() (bool, error), onTimeout func()) (retry bool, err error) {
|
||||||
|
type opResult struct {
|
||||||
|
retry bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
// Buffered so the op goroutine never leaks even when we return on timeout.
|
||||||
|
done := make(chan opResult, 1)
|
||||||
|
go func() {
|
||||||
|
r, e := op()
|
||||||
|
done <- opResult{retry: r, err: e}
|
||||||
|
}()
|
||||||
|
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case res := <-done:
|
||||||
|
return res.retry, res.err
|
||||||
|
case <-timer.C:
|
||||||
|
if onTimeout != nil {
|
||||||
|
onTimeout()
|
||||||
|
}
|
||||||
|
return true, fmt.Errorf("ssh operation timed out after %s", timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// createSSHClient creates a new SSH client for the system
|
// createSSHClient creates a new SSH client for the system
|
||||||
func (s *System) createSSHClient() error {
|
func (s *System) createSSHClient() error {
|
||||||
if s.manager.sshConfig == nil {
|
if s.manager.sshConfig == nil {
|
||||||
@@ -661,7 +705,7 @@ func (s *System) createSSHClient() error {
|
|||||||
host = net.JoinHostPort(host, s.Port)
|
host = net.JoinHostPort(host, s.Port)
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
s.client, err = ssh.Dial(network, host, s.manager.sshConfig)
|
s.client, err = dialSSHWithKeepAlive(network, host, s.manager.sshConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -670,6 +714,34 @@ func (s *System) createSSHClient() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sshKeepAliveInterval is the TCP keep-alive idle interval for SSH connections
|
||||||
|
// to agents. Enabling OS-level keep-alives lets the hub eventually detect a
|
||||||
|
// dead peer on an otherwise idle connection instead of trusting it forever.
|
||||||
|
// This is a backstop for genuine network death; an application-level wedge
|
||||||
|
// (agent process hung while its kernel keeps ACKing) is caught by the
|
||||||
|
// per-operation timeout in runSSHOperation instead (see issue #2041).
|
||||||
|
const sshKeepAliveInterval = 30 * time.Second
|
||||||
|
|
||||||
|
// dialSSHWithKeepAlive dials an SSH connection like ssh.Dial, but enables TCP
|
||||||
|
// keep-alive on the underlying connection so half-open connections are
|
||||||
|
// eventually detected by the operating system.
|
||||||
|
func dialSSHWithKeepAlive(network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
|
||||||
|
dialer := net.Dialer{
|
||||||
|
Timeout: config.Timeout,
|
||||||
|
KeepAlive: sshKeepAliveInterval,
|
||||||
|
}
|
||||||
|
conn, err := dialer.Dial(network, addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ssh.NewClient(sshConn, chans, reqs), nil
|
||||||
|
}
|
||||||
|
|
||||||
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
|
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
|
||||||
// in case of network issues
|
// in case of network issues
|
||||||
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
|
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user