tests/ssh_connection_test.go
Rama master · réplica de lectura del repositorio original.
package main
import (
"context"
"testing"
"time"
"github.com/moby/moby/client"
"github.com/shellhub-io/shellhub/pkg/models"
"github.com/shellhub-io/shellhub/tests/environment"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
const sessionReaperCron = "* * * * *"
// TestSSHConnectionBehaviour covers what a legacy-mode client sees of the connection itself rather
// than of the session it runs: the cipher it negotiates, the banner that explains a refusal, and
// how the session ends when either side ends it.
func TestSSHConnectionBehaviour(t *testing.T) {
t.Parallel()
ctx := context.Background()
compose := newSSHEnvironment(t, ctx, legacyNamespace)
signer := registerDeviceKey(t, ctx, compose)
_, device := startAcceptedAgent(t, ctx, compose)
t.Run("each cipher the server offers is the one negotiated", func(t *testing.T) {
for _, cipher := range []string{
"aes128-gcm@openssh.com",
"aes256-gcm@openssh.com",
"chacha20-poly1305@openssh.com",
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
} {
t.Run(cipher, func(t *testing.T) {
conn := dialClient(t, t.Context(), compose.SSHAddress(), &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // the test stack's host key is ephemeral
Config: ssh.Config{Ciphers: []string{cipher}},
})
defer conn.Close() //nolint:errcheck // the test is over once the command answered
negotiated := conn.Conn.(ssh.AlgorithmsConnMetadata).Algorithms() //nolint:forcetypeassert // every x/crypto client connection carries its algorithms
assert.Equal(t, cipher, negotiated.Read.Cipher)
assert.Equal(t, cipher, negotiated.Write.Cipher)
assert.Equal(t, cipher, runOnDevice(t, conn, "echo -n "+cipher))
})
}
})
t.Run("a cipher the server does not offer fails the handshake", func(t *testing.T) {
err := handshakeWith(t.Context(), compose.SSHAddress(), &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // the test stack's host key is ephemeral
Config: ssh.Config{Ciphers: []string{"aes128-cbc"}},
})
require.ErrorContains(t, err, "ssh: no common algorithm for client to server cipher")
})
t.Run("a banner tells the client its SSHID is malformed", func(t *testing.T) {
banner := dialForBanner(t, compose, "root-without-a-namespace", signer)
assert.Contains(t, banner, "SSHID Format Error")
assert.Contains(t, banner, "Correct format: username@namespace.device@host")
})
t.Run("a banner tells the client the device is offline", func(t *testing.T) {
agent, offline := startAcceptedAgent(t, ctx, compose)
require.NoError(t, agent.Stop(ctx, nil))
compose.AwaitDeviceOffline(t, offline.UID)
banner := dialForBanner(t, compose, deviceSSHID(offline), signer)
assert.Contains(t, banner, "Connection Failed")
assert.Contains(t, banner, "The target device is offline or cannot be reached.")
})
t.Run("a client that disconnects leaves no command running on the device", func(t *testing.T) {
const command = "sleep 637"
before := currentSessions(t, ctx, compose)
var conn *ssh.Client
session := sessionAfter(t, ctx, compose, before, func() {
conn = dialDevice(t, ctx, compose, device, signer)
sess, err := conn.NewSession()
require.NoError(t, err)
require.NoError(t, sess.Start(command))
})
observer := dialDevice(t, ctx, compose, device, signer)
defer observer.Close() //nolint:errcheck // the test is over once the device was read
const isRunning = "pgrep -f '[s]leep 637' > /dev/null && echo running || echo gone"
require.EventuallyWithT(t, func(tt *assert.CollectT) {
output, err := deviceOutput(observer, isRunning)
assert.NoError(tt, err, output)
assert.Equal(tt, "running\n", output)
}, 30*time.Second, time.Second)
require.NoError(t, conn.Close())
requireSessionActive(t, ctx, compose, session.UID, false)
require.EventuallyWithT(t, func(tt *assert.CollectT) {
output, err := deviceOutput(observer, isRunning)
assert.NoError(tt, err, output)
assert.Equal(tt, "gone\n", output)
}, 30*time.Second, time.Second)
})
t.Run("a session the server is asked to close ends on the client", func(t *testing.T) {
before := currentSessions(t, ctx, compose)
var (
conn *ssh.Client
sess *ssh.Session
)
session := sessionAfter(t, ctx, compose, before, func() {
conn = dialDevice(t, ctx, compose, device, signer)
var err error
sess, err = conn.NewSession()
require.NoError(t, err)
require.NoError(t, sess.RequestPty("xterm", 24, 80, ssh.TerminalModes{}))
require.NoError(t, sess.Shell())
})
defer conn.Close() //nolint:errcheck // only a failed run reaches this with the connection still open
ended := make(chan error, 1)
go func() { ended <- sess.Wait() }()
disconnected := make(chan struct{})
go func() {
defer close(disconnected)
_ = conn.Wait()
}()
resp, err := compose.R(ctx).
SetBody(map[string]string{"device": device.UID}).
Post("/api/sessions/" + session.UID + "/close")
require.NoError(t, err)
require.Equal(t, 202, resp.StatusCode(), resp.String())
select {
case err := <-ended:
var exit *ssh.ExitError
require.ErrorAs(t, err, &exit, "the shell ended without the client being told how")
assert.Equal(t, "HUP", exit.Signal())
assert.Equal(t, "shellhub: the session was ended on the device", exit.Msg())
case <-time.After(30 * time.Second):
t.Fatal("the session the server was asked to close is still open on the client")
}
select {
case <-disconnected:
case <-time.After(30 * time.Second):
t.Fatal("the server ended the session but kept the client's connection open")
}
requireSessionActive(t, ctx, compose, session.UID, false)
})
}
// TestSSHServerRestart covers what survives the server process going away: the agent's tunnel,
// rebuilt against the new process, and the sessions it was serving, which only the reaper can
// retire once their keep-alive stops. A session still served is kept alive past the same window.
func TestSSHServerRestart(t *testing.T) {
t.Parallel()
ctx := context.Background()
compose := newConfiguredSSHEnvironment(t, ctx,
environment.New(t, run).WithEnv("SHELLHUB_SESSION_KEEPALIVE_TIMEOUT", "2m").WithCronTrigger(),
legacyNamespace)
signer := registerDeviceKey(t, ctx, compose)
_, device := startAcceptedAgent(t, ctx, compose)
_, orphaned := openSession(t, ctx, compose, device, signer, "")
server := compose.Service(environment.ServiceServer)
require.NoError(t, server.Stop(ctx, nil))
require.NoError(t, server.Start(ctx))
requireSessionActive(t, ctx, compose, orphaned.UID, true)
t.Run("the agent reconnects and serves new sessions", func(t *testing.T) {
conn := dialDeviceWithin(t, ctx, compose, device, signer, 90*time.Second)
defer conn.Close() //nolint:errcheck // the test is over once the command answered
assert.Equal(t, "after the restart", runOnDevice(t, conn, "echo -n after the restart"))
})
t.Run("a keep-alive keeps an idle session past the reaper's window", func(t *testing.T) {
_, idle := openSession(t, ctx, compose, device, signer, "")
compose.AgeSessionKeepAlive(t, orphaned.UID, 10*time.Minute)
compose.AgeSessionKeepAlive(t, idle.UID, 10*time.Minute)
awaitSession(t, ctx, compose, idle.UID, time.Minute, func(tt *assert.CollectT, session models.Session) {
assert.WithinDuration(tt, time.Now(), session.LastSeen, time.Minute, "no keep-alive refreshed the idle session") //nolint:forbidigo // compares a stamp the server wrote with its own wall clock
})
compose.RunCron(t, sessionReaperCron)
requireSessionActive(t, ctx, compose, orphaned.UID, false)
requireSessionActive(t, ctx, compose, idle.UID, true)
})
}
// TestSSHUnresponsiveDevice covers a device that holds its tunnel but never dials back: a V1 agent
// paused as soon as it comes online, whose kernel keeps the tunnel's socket open while the agent
// reads nothing from it. The tunnel is pinged once a minute, so it outlives the server's 30 second
// wait for the dial-back by about half a minute; the client fails at that wait, while the device
// still holds its tunnel, and does not wait out the 180 seconds the server allows a handshake.
func TestSSHUnresponsiveDevice(t *testing.T) {
t.Parallel()
const (
tunnelPingInterval = 59 * time.Second
failWithin = 45 * time.Second
)
ctx := context.Background()
compose := newConfiguredSSHEnvironment(t, ctx,
environment.New(t, run).WithEnv("SHELLHUB_TUNNEL_PING_INTERVAL", tunnelPingInterval.String()),
legacyNamespace)
signer := registerDeviceKey(t, ctx, compose)
agent, device := startAcceptedAgent(t, ctx, compose, NewAgentContainerWithConnectionVersion(1))
docker := newDockerClient(t)
_, err := docker.ContainerPause(ctx, agent.GetContainerID(), client.ContainerPauseOptions{})
require.NoError(t, err)
t.Cleanup(func() {
_, _ = docker.ContainerUnpause(context.Background(), agent.GetContainerID(), client.ContainerUnpauseOptions{})
})
mark := compose.ServerLogMark(t)
dialCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()
started := time.Now() //nolint:forbidigo // measures how long a real client waits
err = handshakeWith(dialCtx, compose.SSHAddress(), &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // the test stack's host key is ephemeral
})
waited := time.Since(started)
require.Error(t, err, "the client reached a device that cannot answer")
t.Logf("the client failed after %s: %v", waited, err)
assert.Less(t, waited, failWithin, "the client waited past the server's dial-back bound")
assert.True(t, requireDevice(t, compose, device.UID).Online, "the tunnel closed before the client failed, so the dial-back bound was not what ended the wait")
compose.AwaitServerLogLine(t, mark, "sshid="+deviceSSHID(device), "the device holds a tunnel but did not answer")
}
func requireAccessDenied(t *testing.T, compose *environment.DockerCompose, sshid string, signer ssh.Signer, reasons ...string) {
t.Helper()
mark := compose.ServerLogMark(t)
assert.Contains(t, dialForBanner(t, compose, sshid, signer), "Access Denied")
compose.AwaitServerLogLine(t, mark, append([]string{"sshid=" + sshid}, reasons...)...)
}
func dialForBanner(t *testing.T, compose *environment.DockerCompose, sshid string, signer ssh.Signer) string {
t.Helper()
var banner string
err := handshakeWith(t.Context(), compose.SSHAddress(), &ssh.ClientConfig{
User: sshid,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // the test stack's host key is ephemeral
BannerCallback: func(message string) error {
banner += message
return nil
},
})
require.Error(t, err, "a connection the server explains with a banner is refused")
return banner
}