tests/ssh_test.go
Rama master · réplica de lectura del repositorio original.
package main
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/bramvdbogaerde/go-scp"
"github.com/moby/moby/api/types/container"
"github.com/pkg/sftp"
"github.com/shellhub-io/shellhub/pkg/api/requests"
"github.com/shellhub-io/shellhub/pkg/models"
"github.com/shellhub-io/shellhub/tests/environment"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"golang.org/x/crypto/ssh"
)
const (
ShellHubAgentUsername = environment.AgentUsername
ShellHubAgentPassword = environment.AgentPassword
)
const (
ShellHubUsername = "test"
ShellHubPassword = "password"
ShellHubNamespaceName = "testspace"
ShellHubNamespace = "00000000-0000-4000-0000-000000000000"
ShellHubEmail = "test@ossystems.com.br"
)
type NewAgentContainerOption func(envs map[string]string)
func NewAgentContainerWithIdentity(identity string) NewAgentContainerOption {
return func(envs map[string]string) {
envs["SHELLHUB_PREFERRED_IDENTITY"] = identity
}
}
func NewAgentContainerWithConnectionVersion(version int) NewAgentContainerOption {
return func(envs map[string]string) {
envs["SHELLHUB_TRANSPORT_VERSION"] = strconv.Itoa(version)
}
}
func NewAgentContainerWithTenant(tenant string) NewAgentContainerOption {
return func(envs map[string]string) {
envs["SHELLHUB_TENANT_ID"] = tenant
}
}
func NewAgentContainerWithHostname(hostname string) NewAgentContainerOption {
return func(envs map[string]string) {
envs["SHELLHUB_PREFERRED_HOSTNAME"] = hostname
}
}
func NewAgentContainerWithTenantOnly(tenant string) NewAgentContainerOption {
return func(envs map[string]string) {
delete(envs, "SHELLHUB_PROVISIONING_KEY")
envs["SHELLHUB_TENANT_ID"] = tenant
}
}
// NewAgentContainerWithProvisioningKey drops the tenant id, so the device proves the key alone
// resolved the namespace.
func NewAgentContainerWithProvisioningKey(key string) NewAgentContainerOption {
return func(envs map[string]string) {
delete(envs, "SHELLHUB_TENANT_ID")
envs["SHELLHUB_PROVISIONING_KEY"] = key
}
}
// NewAgentContainerUnpaired drops the tenant id, so the agent asks for a pairing code and joins the
// namespace of whoever accepts it.
func NewAgentContainerUnpaired() NewAgentContainerOption {
return func(envs map[string]string) {
delete(envs, "SHELLHUB_TENANT_ID")
delete(envs, "SHELLHUB_PROVISIONING_KEY")
}
}
func envOr(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func agentBuildLog() io.Writer {
if os.Getenv("SHELLHUB_E2E_AGENT_CONTEXT") != "" || os.Getenv("SHELLHUB_E2E_AGENT_DOCKERFILE") != "" {
return os.Stderr
}
return nil
}
type agentImageBuild struct {
done chan struct{}
tag string
err error
}
var agentImages struct {
sync.Mutex
builds map[string]*agentImageBuild
}
func buildAgentImage(ctx context.Context, version string, tags ...string) (string, error) {
return cachedAgentImage(ctx, strings.Join(append([]string{version}, tags...), "-"), environment.AgentBuild{
Repository: os.Getenv("SHELLHUB_E2E_AGENT_IMAGE"),
Context: envOr("SHELLHUB_E2E_AGENT_CONTEXT", ".."),
Dockerfile: os.Getenv("SHELLHUB_E2E_AGENT_DOCKERFILE"),
Version: version,
Tags: tags,
Output: agentBuildLog(),
})
}
func cachedAgentImage(ctx context.Context, key string, build environment.AgentBuild) (string, error) {
agentImages.Lock()
inflight, ok := agentImages.builds[key]
if !ok {
if agentImages.builds == nil {
agentImages.builds = make(map[string]*agentImageBuild)
}
inflight = &agentImageBuild{done: make(chan struct{})}
agentImages.builds[key] = inflight
}
agentImages.Unlock()
if ok {
select {
case <-inflight.done:
return inflight.tag, inflight.err
case <-ctx.Done():
return "", ctx.Err()
}
}
defer close(inflight.done)
inflight.tag, inflight.err = environment.BuildAgentImage(ctx, run, build)
if inflight.err != nil {
agentImages.Lock()
delete(agentImages.builds, key)
agentImages.Unlock()
}
return inflight.tag, inflight.err
}
func NewAgentContainer(ctx context.Context, relayID string, opts ...NewAgentContainerOption) (testcontainers.Container, error) {
return NewAgentContainerAtVersion(ctx, relayID, "", opts...)
}
// NewAgentContainerAtVersion builds an agent that reports version instead of "latest" and returns
// its container, created but not started. Each version is built under its own tag, so the agent
// every other test starts keeps its tag; an empty version builds that default agent. A version
// builds once per run unless its build fails, and a caller asking for a version another caller is
// building waits for that build without blocking callers of other versions. It returns the error
// of the build it waited for, even one that the building caller's context ended, or ctx.Err()
// when ctx ends first, or the error of a container creation that fails, bounded by ctx.
func NewAgentContainerAtVersion(ctx context.Context, relayID, version string, opts ...NewAgentContainerOption) (testcontainers.Container, error) {
image, err := buildAgentImage(ctx, version)
if err != nil {
return nil, err
}
return newAgentContainer(ctx, relayID, image, opts...)
}
func newAgentContainer(ctx context.Context, relayID, image string, opts ...NewAgentContainerOption) (testcontainers.Container, error) {
req, err := agentContainerRequest(relayID, image, opts...)
if err != nil {
return nil, err
}
return createAgentContainer(ctx, req)
}
func agentContainerRequest(relayID, image string, opts ...NewAgentContainerOption) (testcontainers.ContainerRequest, error) {
raw := make([]byte, 6)
if _, err := rand.Read(raw); err != nil {
return testcontainers.ContainerRequest{}, err
}
identity := hex.EncodeToString(raw)
envs := map[string]string{
"SHELLHUB_PREFERRED_IDENTITY": identity,
"SHELLHUB_PREFERRED_HOSTNAME": "agent-" + identity,
"SHELLHUB_SERVER_ADDRESS": "http://localhost",
"SHELLHUB_TENANT_ID": "00000000-0000-4000-0000-000000000000",
"SHELLHUB_PRIVATE_KEY": "/tmp/shellhub.key",
"SHELLHUB_LOG_FORMAT": "json",
"SHELLHUB_KEEPALIVE_INTERVAL": "1",
"SHELLHUB_LOG_LEVEL": "trace",
"SHELLHUB_TRANSPORT_VERSION": "2",
}
for _, opt := range opts {
opt(envs)
}
return testcontainers.ContainerRequest{
Image: image,
Env: envs,
Labels: run.Labels(),
HostConfigModifier: func(hc *container.HostConfig) {
hc.NetworkMode = container.NetworkMode("container:" + relayID)
},
}, nil
}
func createAgentContainer(ctx context.Context, req testcontainers.ContainerRequest) (testcontainers.Container, error) {
return testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Logger: log.New(io.Discard, "", log.LstdFlags),
})
}
func TestSSHNewNamespace(t *testing.T) {
t.Parallel()
ctx := context.Background()
compose := newSSHEnvironment(t, ctx, newNamespace)
_, device := startAcceptedAgent(t, ctx, compose)
sshid := deviceSSHID(device)
addr := compose.SSHAddress()
t.Run("password authentication is not offered", func(t *testing.T) {
config := &ssh.ClientConfig{
User: sshid,
Auth: []ssh.AuthMethod{ssh.Password(ShellHubAgentPassword)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
_, err := ssh.Dial("tcp", addr, config)
require.Error(t, err)
require.Contains(t, err.Error(), "attempted methods []")
})
t.Run("authenticate with an enrolled identity", func(t *testing.T) {
signer, data := newSigner(t)
compose.EnrollIdentity(t, "integration", data)
config := &ssh.ClientConfig{
User: sshid,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
var conn *ssh.Client
require.EventuallyWithT(t, func(tt *assert.CollectT) {
var err error
conn, err = ssh.Dial("tcp", addr, config)
assert.NoError(tt, err)
}, 30*time.Second, 1*time.Second)
_ = conn.Close()
})
}
func TestSSHConnectionV1(t *testing.T) {
t.Parallel()
testSSHWithVersion(t, 1)
}
func TestSSHConnectionV2(t *testing.T) {
t.Parallel()
testSSHWithVersion(t, 2)
}
func testSSHWithVersion(t *testing.T, connectionVersion int) {
t.Helper()
type Environment struct {
services *environment.DockerCompose
agent testcontainers.Container
signer ssh.Signer
}
tests := []struct {
name string
options []NewAgentContainerOption
run func(*testing.T, *Environment, *models.Device)
}{
{
name: "reconnect to server",
options: []NewAgentContainerOption{NewAgentContainerWithIdentity("custom-reconnect")},
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
ctx := context.Background()
err := environment.agent.Stop(ctx, nil)
require.NoError(t, err)
environment.services.AwaitDeviceOffline(t, device.UID)
err = environment.agent.Start(ctx)
require.NoError(t, err)
environment.services.AwaitDeviceOnline(t, device.UID)
conn := dialDevice(t, ctx, environment.services, device, environment.signer)
defer conn.Close() //nolint:errcheck // the test is over once the new tunnel answered
assert.Equal(t, "reconnected", runOnDevice(t, conn, "echo -n reconnected"))
},
},
{
name: "authenticate with password",
options: []NewAgentContainerOption{NewAgentContainerWithIdentity("custom-password")},
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
skipUnlessAgentAcceptsPasswords(t)
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.Password(ShellHubAgentPassword),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
var conn *ssh.Client
require.EventuallyWithT(t, func(tt *assert.CollectT) {
var err error
conn, err = ssh.Dial("tcp", environment.services.SSHAddress(), config)
assert.NoError(tt, err)
}, 30*time.Second, 1*time.Second)
_ = conn.Close()
},
},
{
name: "fail to authenticate with password",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
skipUnlessAgentAcceptsPasswords(t)
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.Password("wrongpassword"),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
err := handshakeWith(t.Context(), environment.services.SSHAddress(), config)
require.ErrorContains(t, err, "ssh: unable to authenticate, attempted methods [none password]")
},
},
{
name: "authenticate with public key",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
ctx := context.Background()
signer := registerDeviceKey(t, ctx, environment.services)
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
_ = conn.Close()
},
},
{
name: "fail to authenticate with public key",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
signer, _ := newSigner(t)
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
err := handshakeWith(t.Context(), environment.services.SSHAddress(), config)
require.ErrorContains(t, err, "ssh: unable to authenticate, attempted methods [none publickey]")
},
},
{
name: "connection EXEC and a SHELL on same connection",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
var conn *ssh.Client
require.EventuallyWithT(t, func(tt *assert.CollectT) {
var err error
conn, err = ssh.Dial("tcp", environment.services.SSHAddress(), config)
assert.NoError(tt, err)
}, 30*time.Second, 1*time.Second)
{
sess, err := conn.NewSession()
require.NoError(t, err)
output, err := sess.Output(`echo -n "test"`)
require.NoError(t, err)
assert.Equal(t, "test", string(output))
_ = sess.Close()
}
{
sess, err := conn.NewSession()
require.NoError(t, err)
err = sess.RequestPty("xterm", 100, 100, ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
})
require.NoError(t, err)
err = sess.Shell()
require.NoError(t, err)
_ = sess.Close()
}
_ = conn.Close()
},
},
{
name: "connection EXEC",
options: []NewAgentContainerOption{NewAgentContainerWithIdentity("custom-exec")},
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
var conn *ssh.Client
require.EventuallyWithT(t, func(tt *assert.CollectT) {
var err error
conn, err = ssh.Dial("tcp", environment.services.SSHAddress(), config)
assert.NoError(tt, err)
}, 30*time.Second, 1*time.Second)
sess, err := conn.NewSession()
require.NoError(t, err)
output, err := sess.Output(`echo -n "test"`)
require.NoError(t, err)
assert.Equal(t, "test", string(output))
_ = sess.Close()
_ = conn.Close()
},
},
{
name: "connection EXEC with non zero status code",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
var conn *ssh.Client
require.EventuallyWithT(t, func(tt *assert.CollectT) {
var err error
conn, err = ssh.Dial("tcp", environment.services.SSHAddress(), config)
assert.NoError(tt, err)
}, 30*time.Second, 1*time.Second)
sess, err := conn.NewSession()
require.NoError(t, err)
var status *ssh.ExitError
output, err := sess.CombinedOutput(`echo -n "test" 1>&2; exit 142`)
require.ErrorAs(t, err, &status)
assert.Equal(t, 142, status.ExitStatus())
assert.Equal(t, "test", string(output))
_ = sess.Close()
_ = conn.Close()
},
},
{
name: "connection SFTP to download file",
options: []NewAgentContainerOption{},
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
var conn *ssh.Client
require.EventuallyWithT(t, func(tt *assert.CollectT) {
var err error
conn, err = ssh.Dial("tcp", environment.services.SSHAddress(), config)
assert.NoError(tt, err)
}, 30*time.Second, 1*time.Second)
sess, err := sftp.NewClient(conn)
require.NoError(t, err)
received, err := sess.OpenFile("/etc/os-release", os.O_RDONLY)
require.NoError(t, err)
var data string
_, err = fmt.Fscanf(received, "%s", &data)
require.NoError(t, err)
assert.Contains(t, data, "Alpine")
_ = sess.Close()
_ = conn.Close()
},
},
{
name: "connection SCP to upload file",
options: []NewAgentContainerOption{},
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
var conn *ssh.Client
require.EventuallyWithT(t, func(tt *assert.CollectT) {
var err error
conn, err = ssh.Dial("tcp", environment.services.SSHAddress(), config)
assert.NoError(tt, err)
}, 30*time.Second, 1*time.Second)
sess, err := scp.NewClientBySSH(conn)
require.NoError(t, err)
const content = "uploaded through scp\n"
err = sess.CopyFilePassThru(t.Context(), strings.NewReader(content), "/tmp/scp-uploaded", "0640", nil)
require.NoError(t, err)
sess.Close()
assert.Equal(t, content, runOnDevice(t, conn, "cat /tmp/scp-uploaded"))
assert.Equal(t, "640\n", runOnDevice(t, conn, "stat -c %a /tmp/scp-uploaded"))
_ = conn.Close()
},
},
{
name: "connection SCP to download file",
options: []NewAgentContainerOption{},
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
var conn *ssh.Client
require.EventuallyWithT(t, func(tt *assert.CollectT) {
var err error
conn, err = ssh.Dial("tcp", environment.services.SSHAddress(), config)
assert.NoError(tt, err)
}, 30*time.Second, 1*time.Second)
const content = "downloaded through scp\n"
runOnDevice(t, conn, "printf %s '"+content+"' > /tmp/scp-downloaded")
sess, err := scp.NewClientBySSH(conn)
require.NoError(t, err)
var file bytes.Buffer
err = sess.CopyFromRemotePassThru(t.Context(), &file, "/tmp/scp-downloaded", nil)
require.NoError(t, err)
sess.Close()
assert.Equal(t, content, file.String())
_ = conn.Close()
},
},
{
name: "connection EXEC of a shell builtin",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
conn := dialDevice(t, t.Context(), environment.services, device, environment.signer)
defer conn.Close() //nolint:errcheck // the test is over once the builtin answered
sess, err := conn.NewSession()
require.NoError(t, err)
defer sess.Close() //nolint:errcheck // Output already waited for the exit status
output, err := sess.Output("cd /var/log && pwd && type -t cd")
require.NoError(t, err)
assert.Equal(t, "/var/log\nbuiltin\n", string(output))
},
},
{
name: "connection SFTP to make a directory",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
conn := dialDevice(t, t.Context(), environment.services, device, environment.signer)
defer conn.Close() //nolint:errcheck // the test is over once the directory is read back
client, err := sftp.NewClient(conn)
require.NoError(t, err)
defer client.Close() //nolint:errcheck // the test reads its outcome over an exec channel, so a failed close leaves nothing to assert
require.NoError(t, client.Mkdir("/tmp/sftp-made"))
assert.Equal(t, "directory\n", runOnDevice(t, conn, "stat -c %F /tmp/sftp-made"))
},
},
{
name: "connection SFTP to list a directory",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
conn := dialDevice(t, t.Context(), environment.services, device, environment.signer)
defer conn.Close() //nolint:errcheck // the test is over once the listing is read
runOnDevice(t, conn, "mkdir /tmp/sftp-listed && printf 12345 > /tmp/sftp-listed/file && mkdir /tmp/sftp-listed/dir")
client, err := sftp.NewClient(conn)
require.NoError(t, err)
defer client.Close() //nolint:errcheck // the listing is already read
entries, err := client.ReadDir("/tmp/sftp-listed")
require.NoError(t, err)
listed := map[string]os.FileInfo{}
for _, entry := range entries {
listed[entry.Name()] = entry
}
require.Len(t, listed, 2)
require.Contains(t, listed, "file")
require.Contains(t, listed, "dir")
assert.False(t, listed["file"].IsDir())
assert.Equal(t, int64(5), listed["file"].Size())
assert.True(t, listed["dir"].IsDir())
},
},
{
name: "connection SCP to upload a directory recursively",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
conn := dialDevice(t, t.Context(), environment.services, device, environment.signer)
defer conn.Close() //nolint:errcheck // the test is over once the tree is read back
sendSCPTree(t, conn, "/tmp", scpDirectory{
name: "scp-tree",
mode: "0750",
files: map[string]string{
"top.txt": "at the top",
},
dirs: []scpDirectory{{
name: "nested",
mode: "0700",
files: map[string]string{
"deep.txt": "one level down",
},
}},
})
assert.Equal(t, "at the top|one level down", runOnDevice(t, conn,
`printf '%s|%s' "$(cat /tmp/scp-tree/top.txt)" "$(cat /tmp/scp-tree/nested/deep.txt)"`))
assert.Equal(t, "750 700\n", runOnDevice(t, conn, "stat -c %a /tmp/scp-tree /tmp/scp-tree/nested | paste -sd ' '"))
},
},
{
name: "direct tcpip port redirect",
options: []NewAgentContainerOption{},
run: func(t *testing.T, env *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(env.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", env.services.SSHAddress(), config)
require.NoError(t, err)
type Data struct {
DestAddr string
DestPort uint32
OriginAddr string
OriginPort uint32
}
orig, err := strconv.Atoi(environment.ReservePort(t))
require.NoError(t, err)
data := Data{
DestAddr: "127.0.0.1",
DestPort: 80,
OriginAddr: "127.0.0.1",
OriginPort: uint32(orig), //nolint:gosec
}
ch, _, err := conn.OpenChannel("direct-tcpip", ssh.Marshal(data))
require.NoError(t, err)
_, err = ch.Write([]byte("GET / HTTP/1.0\r\n\r\n"))
require.NoError(t, err)
status, err := bufio.NewReader(ch).ReadString('\n')
require.NoError(t, err)
require.True(t, strings.HasPrefix(status, "HTTP/1."), status)
_ = ch.Close()
_ = conn.Close()
},
},
{
name: "connection EXEC outlives a silent pause",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
sess, err := conn.NewSession()
require.NoError(t, err)
output, err := sess.CombinedOutput("sleep 3 && echo -n 'still alive'")
require.NoError(t, err)
assert.Equal(t, "still alive", string(output))
_ = sess.Close()
_ = conn.Close()
},
},
{
name: "connection SFTP to upload large file",
options: []NewAgentContainerOption{},
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
defer conn.Close() //nolint:errcheck
client, err := sftp.NewClient(conn)
require.NoError(t, err)
defer client.Close() //nolint:errcheck
fileSize := 10 * 1024 * 1024 // 10MB
randomData := make([]byte, fileSize)
rand.Read(randomData)
tempFile, err := os.CreateTemp(t.TempDir(), "large-file-test-*.bin")
require.NoError(t, err)
defer os.Remove(tempFile.Name()) //nolint:errcheck
_, err = tempFile.Write(randomData)
require.NoError(t, err)
_ = tempFile.Close()
localFile, err := os.Open(tempFile.Name())
require.NoError(t, err)
defer localFile.Close() //nolint:errcheck
remoteFile, err := client.Create("/tmp/large-file-test.bin")
require.NoError(t, err)
written, err := io.Copy(remoteFile, localFile)
require.NoError(t, err)
assert.Equal(t, int64(fileSize), written)
require.NoError(t, remoteFile.Close())
sum := sha256.Sum256(randomData)
received := strings.Fields(runOnDevice(t, conn, "sha256sum /tmp/large-file-test.bin"))
require.NotEmpty(t, received)
assert.Equal(t, hex.EncodeToString(sum[:]), received[0])
},
},
{
name: "OpenSSH scp in SFTP mode uploads a file and exits 0",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
execInAgent(t, environment.agent, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", "/tmp/scp-key")
registerAuthorizedKey(t, t.Context(), environment.services, "openssh-scp", execInAgent(t, environment.agent, "cat", "/tmp/scp-key.pub"))
execInAgent(t, environment.agent, "sh", "-c", "head -c 1048576 /dev/urandom > /tmp/scp-source")
execInAgent(t, environment.agent, "scp", "-s",
"-i", "/tmp/scp-key",
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "User="+deviceSSHID(device),
"-P", "2222",
"/tmp/scp-source", "server:/tmp/scp-received",
)
sent := strings.Fields(execInAgent(t, environment.agent, "sha256sum", "/tmp/scp-source"))
received := strings.Fields(execInAgent(t, environment.agent, "sha256sum", "/tmp/scp-received"))
require.NotEmpty(t, sent)
require.NotEmpty(t, received)
assert.Equal(t, sent[0], received[0])
},
},
{
name: "connection EXEC with large output",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
defer conn.Close() //nolint:errcheck
sess, err := conn.NewSession()
require.NoError(t, err)
defer sess.Close() //nolint:errcheck
output, err := sess.Output("yes X | tr -d '\n' | head -c 1048576")
require.NoError(t, err)
assert.Len(t, output, 1024*1024)
for _, b := range output {
assert.Equal(t, byte('X'), b)
}
},
},
{
name: "connection EXEC with separate stdout and stderr",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
defer conn.Close() //nolint:errcheck
sess, err := conn.NewSession()
require.NoError(t, err)
defer sess.Close() //nolint:errcheck
var stdout, stderr bytes.Buffer
sess.Stdout = &stdout
sess.Stderr = &stderr
err = sess.Run(`echo -n "to stdout"; echo -n "to stderr" 1>&2; exit 7`)
var status *ssh.ExitError
require.ErrorAs(t, err, &status)
assert.Equal(t, 7, status.ExitStatus())
assert.Equal(t, "to stdout", stdout.String())
assert.Equal(t, "to stderr", stderr.String())
},
},
{
name: "connection EXEC delivers a signal to the command",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
sess, output := startInterruptReporter(t, environment.services, environment.signer, device, false)
require.NoError(t, sess.Signal(ssh.SIGINT))
requireInterruptReported(t, sess, output)
},
},
{
name: "connection EXEC with Pty delivers a signal to the command",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
sess, output := startInterruptReporter(t, environment.services, environment.signer, device, true)
require.NoError(t, sess.Signal(ssh.SIGINT))
requireInterruptReported(t, sess, output)
},
},
{
name: "connection SHELL with Pty delivers a signal to the foreground job",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
sess := openSignalSession(t, environment.services, environment.signer, device, true)
stdin, err := sess.StdinPipe()
require.NoError(t, err)
stdout, err := sess.StdoutPipe()
require.NoError(t, err)
require.NoError(t, sess.Shell())
_, err = io.WriteString(stdin, `signal-reporter INT; echo "STA""TUS=$?"; exit`+"\n")
require.NoError(t, err)
output := bufio.NewReader(stdout)
var seen strings.Builder
for !strings.Contains(seen.String(), "READY") {
line, err := output.ReadString('\n')
seen.WriteString(line)
require.NoError(t, err, "the job never started: %q", seen.String())
}
require.NoError(t, sess.Signal(ssh.SIGINT))
rest := readRemaining(t, output, "the shell never finished the line after the signal")
assert.Contains(t, rest, "GOT-INT")
assert.Contains(t, rest, "STATUS=3")
},
},
{
name: "connection EXEC answers a signal that asks for a reply",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
sess, output := startInterruptReporter(t, environment.services, environment.signer, device, false)
replied := make(chan bool, 1)
go func() {
ok, err := sess.SendRequest("signal", true, ssh.Marshal(struct{ Signal string }{Signal: string(ssh.SIGINT)}))
replied <- err == nil && ok
}()
select {
case ok := <-replied:
assert.True(t, ok, "the signal was not accepted")
case <-time.After(10 * time.Second):
t.Fatal("a signal asking for a reply never got one")
}
requireInterruptReported(t, sess, output)
},
},
{
name: "connection EXEC with large stderr",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
defer conn.Close() //nolint:errcheck
sess, err := conn.NewSession()
require.NoError(t, err)
defer sess.Close() //nolint:errcheck
var stdout, stderr bytes.Buffer
sess.Stdout = &stdout
sess.Stderr = &stderr
done := make(chan error, 1)
go func() {
done <- sess.Run("yes E | tr -d '\n' | head -c 1048576 1>&2; echo -n done")
}()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(60 * time.Second):
t.Fatal("writing 1MiB to stderr never completed")
}
assert.Equal(t, 1024*1024, stderr.Len())
assert.Equal(t, "done", stdout.String())
},
},
{
name: "connection SHELL with Pty reports the exit code",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
defer conn.Close() //nolint:errcheck
sess, err := conn.NewSession()
require.NoError(t, err)
defer sess.Close() //nolint:errcheck
stdin, err := sess.StdinPipe()
require.NoError(t, err)
require.NoError(t, sess.RequestPty("xterm", 24, 80, ssh.TerminalModes{}))
require.NoError(t, sess.Shell())
_, err = io.WriteString(stdin, "exit 42\n")
require.NoError(t, err)
done := make(chan error, 1)
go func() { done <- sess.Wait() }()
select {
case err := <-done:
var status *ssh.ExitError
require.ErrorAs(t, err, &status)
assert.Equal(t, 42, status.ExitStatus())
case <-time.After(60 * time.Second):
t.Fatal("a shell with a pty never reported an exit status")
}
},
},
{
name: "connection SHELL with Pty reports a successful exit",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
defer conn.Close() //nolint:errcheck
sess, err := conn.NewSession()
require.NoError(t, err)
defer sess.Close() //nolint:errcheck
stdin, err := sess.StdinPipe()
require.NoError(t, err)
require.NoError(t, sess.RequestPty("xterm", 24, 80, ssh.TerminalModes{}))
require.NoError(t, sess.Shell())
_, err = io.WriteString(stdin, "exit 0\n")
require.NoError(t, err)
done := make(chan error, 1)
go func() { done <- sess.Wait() }()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(60 * time.Second):
t.Fatal("a shell with a pty never reported an exit status")
}
},
},
{
name: "connection SHELL without Pty",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
defer conn.Close() //nolint:errcheck
sess, err := conn.NewSession()
require.NoError(t, err)
defer sess.Close() //nolint:errcheck
stdin, err := sess.StdinPipe()
require.NoError(t, err)
var stdout, stderr bytes.Buffer
sess.Stdout = &stdout
sess.Stderr = &stderr
require.NoError(t, sess.Shell())
_, err = io.WriteString(stdin, "echo -n heredoc_out\necho -n heredoc_err 1>&2\nexit 5\n")
require.NoError(t, err)
require.NoError(t, stdin.Close())
done := make(chan error, 1)
go func() { done <- sess.Wait() }()
select {
case err := <-done:
var status *ssh.ExitError
require.ErrorAs(t, err, &status)
assert.Equal(t, 5, status.ExitStatus())
case <-time.After(60 * time.Second):
t.Fatal("a shell without a pty never produced anything")
}
assert.Equal(t, "heredoc_out", stdout.String())
assert.Equal(t, "heredoc_err", stderr.String())
},
},
{
name: "connection keepalive reaches the client",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
reqs := openShellWatchingGlobalRequests(t, t.Context(), environment.services.SSHAddress(), &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
})
awaitGlobalRequest(t, reqs, "keepalive", 60*time.Second)
},
},
{
name: "connection EXEC with environment variables",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
defer conn.Close() //nolint:errcheck
sess, err := conn.NewSession()
require.NoError(t, err)
defer sess.Close() //nolint:errcheck
err = sess.Setenv("LC_TEST", "allowed")
require.NoError(t, err)
err = sess.Setenv("TEST_VAR1", "blocked")
require.NoError(t, err)
output, err := sess.Output("echo -n $LC_TEST:$TEST_VAR1")
require.NoError(t, err)
assert.Equal(t, "allowed:", string(output))
},
},
{
name: "terminal window size change",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
defer conn.Close() //nolint:errcheck
sess, err := conn.NewSession()
require.NoError(t, err)
defer sess.Close() //nolint:errcheck
err = sess.RequestPty("xterm", 24, 80, ssh.TerminalModes{
ssh.ECHO: 0,
})
require.NoError(t, err)
require.NoError(t, sess.WindowChange(48, 160))
resize := startResizeReporter(t, sess)
for _, size := range [][2]int{{40, 120}, {24, 80}, {50, 200}, {30, 100}} {
resize(size[0], size[1])
}
},
},
{
name: "connection EXEC with invalid command",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
defer conn.Close() //nolint:errcheck
sess, err := conn.NewSession()
require.NoError(t, err)
defer sess.Close() //nolint:errcheck
_, err = sess.Output("this-command-does-not-exist")
require.Error(t, err)
var exitErr *ssh.ExitError
require.ErrorAs(t, err, &exitErr)
assert.NotEqual(t, 0, exitErr.ExitStatus())
},
},
{
name: "handling special characters in commands and output",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
defer conn.Close() //nolint:errcheck
sess, err := conn.NewSession()
require.NoError(t, err)
defer sess.Close() //nolint:errcheck
specialChars := "!@#$%^&*()_+{}[]|;:,.<>/?`~"
output, err := sess.Output(fmt.Sprintf("echo -n '%s'", specialChars))
require.NoError(t, err)
assert.Equal(t, specialChars, string(output))
unicodeChars := "こんにちは世界 ñáéíóú 你好世界"
sess2, err := conn.NewSession()
require.NoError(t, err)
defer sess2.Close() //nolint:errcheck
output, err = sess2.Output(fmt.Sprintf("echo -n '%s'", unicodeChars))
require.NoError(t, err)
assert.Equal(t, unicodeChars, string(output))
},
},
{
name: "multiple concurrent SSH sessions",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
const numConnections = 5
var wg sync.WaitGroup
errors := make(chan error, numConnections)
for i := range numConnections {
wg.Add(1)
go func(id int) {
defer wg.Done()
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
if err != nil {
errors <- fmt.Errorf("connection %d failed: %w", id, err)
return
}
defer conn.Close() //nolint:errcheck
sess, err := conn.NewSession()
if err != nil {
errors <- fmt.Errorf("session %d failed: %w", id, err)
return
}
defer sess.Close() //nolint:errcheck
expected := fmt.Sprintf("session-%d", id)
output, err := sess.Output(fmt.Sprintf("echo -n '%s'", expected))
if err != nil {
errors <- fmt.Errorf("command %d failed: %w", id, err)
return
}
if string(output) != expected {
errors <- fmt.Errorf("unexpected output from session %d: got %q, want %q", id, string(output), expected)
}
}(i)
}
wg.Wait()
close(errors)
for err := range errors {
require.NoError(t, err)
}
},
},
{
name: "connection verifies the server's host key",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
hostKey := environment.services.SSHHostKey(t)
conn := dialClient(t, t.Context(), environment.services.SSHAddress(), &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{ssh.PublicKeys(environment.signer)},
HostKeyCallback: ssh.FixedHostKey(hostKey),
})
_ = conn.Close()
impostor, _ := newSigner(t)
err := handshakeWith(t.Context(), environment.services.SSHAddress(), &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{ssh.PublicKeys(environment.signer)},
HostKeyCallback: ssh.FixedHostKey(impostor.PublicKey()),
})
require.ErrorContains(t, err, "ssh: host key mismatch")
},
},
{
name: "connection with subsystem request (sftp)",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
defer conn.Close() //nolint:errcheck
sess, err := conn.NewSession()
require.NoError(t, err)
defer sess.Close() //nolint:errcheck
err = sess.RequestSubsystem("sftp")
require.NoError(t, err)
stdin, err := sess.StdinPipe()
require.NoError(t, err)
stdout, err := sess.StdoutPipe()
require.NoError(t, err)
initPacket := []byte{0, 0, 0, 5, 1, 0, 0, 0, 3} // SSH_FXP_INIT with version 3
_, err = stdin.Write(initPacket)
require.NoError(t, err)
response := make([]byte, 9)
n, err := stdout.Read(response)
require.NoError(t, err)
assert.Equal(t, 9, n)
assert.Equal(t, byte(2), response[4]) // SSH_FXP_VERSION
},
},
{
name: "connection with pseudo-terminal modes",
run: func(t *testing.T, environment *Environment, device *models.Device) {
t.Helper()
config := &ssh.ClientConfig{
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{
ssh.PublicKeys(environment.signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
}
conn, err := ssh.Dial("tcp", environment.services.SSHAddress(), config)
require.NoError(t, err)
defer conn.Close() //nolint:errcheck
sess, err := conn.NewSession()
require.NoError(t, err)
defer sess.Close() //nolint:errcheck
modes := ssh.TerminalModes{
ssh.ECHO: 0,
ssh.VINTR: 0x07,
}
require.NoError(t, sess.RequestPty("xterm-256color", 24, 80, modes))
output, err := sess.Output("stty -a")
require.NoError(t, err, string(output))
settings := strings.Fields(string(output))
assert.Contains(t, settings, "-echo", "echo is still on: %s", output)
assert.Contains(t, string(output), "intr = ^G", "the interrupt character is not the one asked for: %s", output)
},
},
}
ctx := context.Background()
compose := newSSHEnvironment(t, ctx, legacyNamespace)
signer := registerDeviceKey(t, ctx, compose)
for _, tc := range tests {
test := tc
t.Run(test.name, func(tt *testing.T) {
opts := append([]NewAgentContainerOption{
NewAgentContainerWithConnectionVersion(connectionVersion),
}, test.options...)
agent, device := startAcceptedAgent(tt, ctx, compose, opts...)
test.run(tt, &Environment{
services: compose,
agent: agent,
signer: signer,
}, device)
})
}
}
func openSignalSession(t *testing.T, compose *environment.DockerCompose, signer ssh.Signer, device *models.Device, pty bool) *ssh.Session {
t.Helper()
conn := dialDevice(t, t.Context(), compose, device, signer)
t.Cleanup(func() { _ = conn.Close() })
sess, err := conn.NewSession()
require.NoError(t, err)
t.Cleanup(func() { _ = sess.Close() })
if pty {
require.NoError(t, sess.RequestPty("xterm", 24, 80, ssh.TerminalModes{}))
}
return sess
}
func startInterruptReporter(t *testing.T, compose *environment.DockerCompose, signer ssh.Signer, device *models.Device, pty bool) (*ssh.Session, *bufio.Reader) {
t.Helper()
sess := openSignalSession(t, compose, signer, device, pty)
stdout, err := sess.StdoutPipe()
require.NoError(t, err)
require.NoError(t, sess.Start("exec signal-reporter INT"))
output := bufio.NewReader(stdout)
line, err := output.ReadString('\n')
require.NoError(t, err)
require.Equal(t, "READY", strings.TrimSpace(line))
return sess, output
}
func requireInterruptReported(t *testing.T, sess *ssh.Session, output *bufio.Reader) {
t.Helper()
rest := readRemaining(t, output, "the command never reported the interrupt")
var status *ssh.ExitError
require.ErrorAs(t, sess.Wait(), &status, "the command finished without reporting the interrupt: %q", rest)
assert.Equal(t, 3, status.ExitStatus())
assert.Equal(t, "GOT-INT", strings.TrimSpace(rest))
}
func readRemaining(t *testing.T, output io.Reader, never string) string {
t.Helper()
type read struct {
rest []byte
err error
}
finished := make(chan read, 1)
go func() {
rest, err := io.ReadAll(output)
finished <- read{rest, err}
}()
select {
case got := <-finished:
require.NoError(t, got.err)
return string(got.rest)
case <-time.After(30 * time.Second):
t.Fatal(never)
return ""
}
}
func newSigner(t *testing.T) (ssh.Signer, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
pub, err := ssh.NewPublicKey(&key.PublicKey)
require.NoError(t, err)
signer, err := ssh.NewSignerFromKey(key)
require.NoError(t, err)
return signer, string(ssh.MarshalAuthorizedKey(pub))
}
func registerDeviceKey(t *testing.T, ctx context.Context, compose *environment.DockerCompose) ssh.Signer {
t.Helper()
signer, data := newSigner(t)
registerAuthorizedKey(t, ctx, compose, "e2e", data)
return signer
}
func registerAuthorizedKey(t *testing.T, ctx context.Context, compose *environment.DockerCompose, name, data string) {
t.Helper()
resp, err := compose.R(ctx).
SetBody(&requests.PublicKeyCreate{
Name: name,
Username: ".*",
Data: []byte(data),
Filter: requests.PublicKeyFilter{
Hostname: ".*",
},
}).
Post("/api/sshkeys/public-keys")
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode(), resp.String())
}
func skipUnlessAgentAcceptsPasswords(t *testing.T) {
t.Helper()
if os.Getenv("SHELLHUB_E2E_AGENT_PASSWORD") == "false" {
t.Skip("SHELLHUB_E2E_AGENT_PASSWORD=false: the agent under test does not accept passwords")
}
}
func deviceSSHID(device *models.Device) string {
return ShellHubAgentUsername + "@" + ShellHubNamespaceName + "." + device.Name
}
type namespaceModel bool
const (
legacyNamespace namespaceModel = true
newNamespace namespaceModel = false
)
func newSSHEnvironment(t *testing.T, ctx context.Context, model namespaceModel) *environment.DockerCompose {
t.Helper()
return newConfiguredSSHEnvironment(t, ctx, environment.New(t, run), model)
}
func newConfiguredSSHEnvironment(t *testing.T, ctx context.Context, configurator *environment.DockerComposeConfigurator, model namespaceModel) *environment.DockerCompose {
t.Helper()
compose := configurator.Up(ctx)
t.Cleanup(compose.Down)
compose.NewUser(t, ShellHubUsername, ShellHubEmail, ShellHubPassword)
ownNamespace(t, ctx, compose, model)
return compose
}
var harnessProvisioningKeys sync.Map
const harnessProvisioningKeyName = "harness"
func useManualProvisioningKey(t *testing.T, compose *environment.DockerCompose) string {
t.Helper()
key := compose.CreateProvisioningKey(t, &requests.CreateProvisioningKey{
Name: harnessProvisioningKeyName,
Mode: string(models.ProvisioningKeyModeManual),
})
harnessProvisioningKeys.Store(compose, key.Key)
return key.Key
}
func harnessProvisioningKey(compose *environment.DockerCompose) (string, bool) {
key, ok := harnessProvisioningKeys.Load(compose)
if !ok {
return "", false
}
return key.(string), true //nolint:forcetypeassert // the map holds nothing but the keys useManualProvisioningKey stores
}
func requireHarnessProvisioningKey(t *testing.T, compose *environment.DockerCompose) string {
t.Helper()
key, ok := harnessProvisioningKey(compose)
require.True(t, ok, "the stack's namespace is legacy and has no provisioning key")
return key
}
func withHarnessCredential(compose *environment.DockerCompose) NewAgentContainerOption {
return func(envs map[string]string) {
if key, ok := harnessProvisioningKey(compose); ok {
NewAgentContainerWithProvisioningKey(key)(envs)
}
}
}
func harnessDeviceAuthRequest(t *testing.T, compose *environment.DockerCompose, hostname, mac string) requests.DeviceAuth {
t.Helper()
req := newDeviceAuthRequest(t, hostname, mac)
if key, ok := harnessProvisioningKey(compose); ok {
req.TenantID = ""
req.ProvisioningKey = key
}
return req
}
func ownNamespace(t *testing.T, ctx context.Context, compose *environment.DockerCompose, model namespaceModel) {
t.Helper()
if model == legacyNamespace {
compose.NewLegacyNamespace(t, ShellHubUsername, ShellHubNamespaceName, ShellHubNamespace)
} else {
compose.NewNamespace(t, ShellHubUsername, ShellHubNamespaceName, ShellHubNamespace)
}
auth := models.UserAuthResponse{}
require.EventuallyWithT(t, func(tt *assert.CollectT) {
resp, err := compose.R(ctx).
SetBody(map[string]string{
"username": ShellHubUsername,
"password": ShellHubPassword,
}).
SetResult(&auth).
Post("/api/login")
assert.Equal(tt, 200, resp.StatusCode())
assert.NoError(tt, err)
}, 30*time.Second, 1*time.Second)
compose.JWT(auth.Token)
if model == newNamespace {
useManualProvisioningKey(t, compose)
}
}
func startAgent(t *testing.T, ctx context.Context, compose *environment.DockerCompose, opts ...NewAgentContainerOption) testcontainers.Container {
t.Helper()
opts = append([]NewAgentContainerOption{withHarnessCredential(compose)}, opts...)
agent, err := NewAgentContainer(ctx, compose.Service(environment.ServiceRelay).GetContainerID(), opts...)
require.NoError(t, err)
return runAgent(t, ctx, agent)
}
func startCustomAgent(t *testing.T, ctx context.Context, compose *environment.DockerCompose, customize func(*testcontainers.ContainerRequest), opts ...NewAgentContainerOption) testcontainers.Container {
t.Helper()
image, err := buildAgentImage(ctx, "")
require.NoError(t, err)
opts = append([]NewAgentContainerOption{withHarnessCredential(compose)}, opts...)
req, err := agentContainerRequest(compose.Service(environment.ServiceRelay).GetContainerID(), image, opts...)
require.NoError(t, err)
customize(&req)
agent, err := createAgentContainer(ctx, req)
require.NoError(t, err)
return runAgent(t, ctx, agent)
}
func runAgent(t *testing.T, ctx context.Context, agent testcontainers.Container) testcontainers.Container {
t.Helper()
require.NoError(t, agent.Start(ctx))
t.Cleanup(func() {
_ = agent.Stop(context.Background(), nil)
_ = agent.Terminate(context.Background())
})
return agent
}
func startAcceptedAgent(t *testing.T, ctx context.Context, compose *environment.DockerCompose, opts ...NewAgentContainerOption) (testcontainers.Container, *models.Device) {
t.Helper()
agent := startAgent(t, ctx, compose, opts...)
return agent, acceptPendingDevice(t, compose)
}
func acceptPendingDevice(t *testing.T, compose *environment.DockerCompose) *models.Device {
t.Helper()
pending := compose.AwaitDeviceWithStatus(t, models.DeviceStatusPending)
compose.UpdateDeviceStatus(t, pending.UID, environment.DeviceActionAccept)
device := compose.AwaitDeviceOnline(t, pending.UID)
return &device
}