Ctrl K
ComunidadGuías…

tests/ssh_connection_test.go

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}