mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-29 21:07:54 +02:00
ssh.ClientConfig.Timeout only covers the TCP connect. A peer that accepts the connection but never sends an SSH banner blocked ssh.NewClientConn forever, wedging the system's updater goroutine and leaking the socket. Set a deadline on the conn for the handshake and clear it afterward.
215 lines
6.4 KiB
Go
215 lines
6.4 KiB
Go
//go:build testing
|
|
|
|
package systems
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"errors"
|
|
"net"
|
|
"sync"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// 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
|
|
})
|
|
})
|
|
}
|
|
|
|
// closedConn stands in for a connection whose peer has gone away: opening a
|
|
// channel fails rather than succeeding, which is what NewSession does on a
|
|
// client that closeSSHConnection has already closed.
|
|
type closedConn struct{ ssh.Conn }
|
|
|
|
func (closedConn) OpenChannel(string, []byte) (ssh.Channel, <-chan *ssh.Request, error) {
|
|
return nil, nil, errors.New("use of closed network connection")
|
|
}
|
|
|
|
func (closedConn) Close() error { return nil }
|
|
|
|
// TestCreateSessionDuringClose covers issue #2157: the background SMART fetch
|
|
// creates a session while the updater can be tearing the same connection down,
|
|
// so session creation must not read the client field after it is cleared.
|
|
func TestCreateSessionDuringClose(t *testing.T) {
|
|
for range 500 {
|
|
sys := &System{ctx: t.Context()}
|
|
sys.client.Store(&ssh.Client{Conn: closedConn{}})
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
session, err := sys.createSessionWithTimeout(time.Second)
|
|
assert.Nil(t, session)
|
|
assert.Error(t, err, "a closed connection must surface an error, not a session")
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
sys.closeSSHConnection()
|
|
}()
|
|
wg.Wait()
|
|
}
|
|
}
|
|
|
|
// TestDialSSHHandshakeTimeout covers a peer that accepts the TCP connection but
|
|
// never sends an SSH banner. Without a handshake deadline the dial blocks the
|
|
// updater forever (GHSA-h9jh-29rh-w464).
|
|
func TestDialSSHHandshakeTimeout(t *testing.T) {
|
|
prev := sshHandshakeTimeout
|
|
sshHandshakeTimeout = 200 * time.Millisecond
|
|
t.Cleanup(func() { sshHandshakeTimeout = prev })
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
defer ln.Close()
|
|
|
|
accepted := make(chan net.Conn, 1)
|
|
go func() {
|
|
conn, err := ln.Accept()
|
|
if err == nil {
|
|
accepted <- conn
|
|
}
|
|
}()
|
|
|
|
config := &ssh.ClientConfig{
|
|
User: "u",
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
Timeout: 4 * time.Second,
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
client, err := dialSSHWithKeepAlive("tcp", ln.Addr().String(), config)
|
|
if client != nil {
|
|
client.Close()
|
|
}
|
|
done <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
assert.Error(t, err, "a silent peer must fail the handshake")
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("dial blocked on a peer that never sends an SSH banner")
|
|
}
|
|
|
|
// the hub must close its side of the connection
|
|
conn := <-accepted
|
|
defer conn.Close()
|
|
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
_, err = conn.Read(make([]byte, 256))
|
|
for err == nil {
|
|
_, err = conn.Read(make([]byte, 256))
|
|
}
|
|
var netErr net.Error
|
|
assert.False(t, errors.As(err, &netErr) && netErr.Timeout(), "hub should close the connection, got %v", err)
|
|
}
|
|
|
|
// TestDialSSHClearsHandshakeDeadline ensures the handshake deadline does not
|
|
// carry over to the established connection, which is reused for many updates.
|
|
func TestDialSSHClearsHandshakeDeadline(t *testing.T) {
|
|
prev := sshHandshakeTimeout
|
|
sshHandshakeTimeout = 200 * time.Millisecond
|
|
t.Cleanup(func() { sshHandshakeTimeout = prev })
|
|
|
|
_, hostPriv, err := ed25519.GenerateKey(rand.Reader)
|
|
require.NoError(t, err)
|
|
hostSigner, err := ssh.NewSignerFromKey(hostPriv)
|
|
require.NoError(t, err)
|
|
_, clientPriv, err := ed25519.GenerateKey(rand.Reader)
|
|
require.NoError(t, err)
|
|
clientSigner, err := ssh.NewSignerFromKey(clientPriv)
|
|
require.NoError(t, err)
|
|
|
|
serverConfig := &ssh.ServerConfig{
|
|
PublicKeyCallback: func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { return nil, nil },
|
|
}
|
|
serverConfig.AddHostKey(hostSigner)
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
defer ln.Close()
|
|
|
|
go func() {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
_, chans, reqs, err := ssh.NewServerConn(conn, serverConfig)
|
|
if err != nil {
|
|
return
|
|
}
|
|
go ssh.DiscardRequests(reqs)
|
|
for newChan := range chans {
|
|
ch, chReqs, err := newChan.Accept()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
go ssh.DiscardRequests(chReqs)
|
|
ch.Close()
|
|
}
|
|
}()
|
|
|
|
config := &ssh.ClientConfig{
|
|
User: "u",
|
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(clientSigner)},
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
Timeout: 4 * time.Second,
|
|
}
|
|
client, err := dialSSHWithKeepAlive("tcp", ln.Addr().String(), config)
|
|
require.NoError(t, err)
|
|
defer client.Close()
|
|
|
|
// wait past the handshake deadline; the connection must still be usable
|
|
time.Sleep(3 * sshHandshakeTimeout)
|
|
session, err := client.NewSession()
|
|
require.NoError(t, err, "connection should outlive the handshake deadline")
|
|
session.Close()
|
|
}
|