2022-03-29 00:19:28 +00:00
|
|
|
package cli_test
|
|
|
|
|
|
|
|
import (
|
2024-02-08 11:09:42 +00:00
|
|
|
"bufio"
|
2023-01-06 07:52:19 +00:00
|
|
|
"bytes"
|
2022-05-25 18:28:10 +00:00
|
|
|
"context"
|
|
|
|
"crypto/ecdsa"
|
|
|
|
"crypto/elliptic"
|
|
|
|
"crypto/rand"
|
|
|
|
"errors"
|
2023-01-06 07:52:19 +00:00
|
|
|
"fmt"
|
2022-03-30 22:59:54 +00:00
|
|
|
"io"
|
|
|
|
"net"
|
2023-07-20 10:05:39 +00:00
|
|
|
"net/http"
|
|
|
|
"net/http/httptest"
|
2023-01-06 07:52:19 +00:00
|
|
|
"os"
|
|
|
|
"os/exec"
|
2023-11-13 11:14:42 +00:00
|
|
|
"path"
|
2022-05-25 18:28:10 +00:00
|
|
|
"path/filepath"
|
2022-03-30 22:59:54 +00:00
|
|
|
"runtime"
|
2023-01-06 07:52:19 +00:00
|
|
|
"strings"
|
2022-03-29 00:19:28 +00:00
|
|
|
"testing"
|
2022-03-30 22:59:54 +00:00
|
|
|
"time"
|
2022-03-29 00:19:28 +00:00
|
|
|
|
2023-12-08 16:01:13 +00:00
|
|
|
"github.com/google/uuid"
|
2022-05-24 07:58:39 +00:00
|
|
|
"github.com/stretchr/testify/assert"
|
2022-03-29 00:19:28 +00:00
|
|
|
"github.com/stretchr/testify/require"
|
2022-03-30 22:59:54 +00:00
|
|
|
"golang.org/x/crypto/ssh"
|
2022-05-25 18:28:10 +00:00
|
|
|
gosshagent "golang.org/x/crypto/ssh/agent"
|
2024-01-16 19:26:13 +00:00
|
|
|
"golang.org/x/sync/errgroup"
|
2023-11-21 12:22:08 +00:00
|
|
|
"golang.org/x/xerrors"
|
2022-03-29 00:19:28 +00:00
|
|
|
|
2023-02-24 16:16:29 +00:00
|
|
|
"cdr.dev/slog"
|
2022-03-29 00:19:28 +00:00
|
|
|
"cdr.dev/slog/sloggers/slogtest"
|
2022-05-25 18:28:10 +00:00
|
|
|
|
2023-08-18 18:55:43 +00:00
|
|
|
"github.com/coder/coder/v2/agent"
|
2024-01-16 19:26:13 +00:00
|
|
|
"github.com/coder/coder/v2/agent/agentssh"
|
2023-09-26 11:05:19 +00:00
|
|
|
"github.com/coder/coder/v2/agent/agenttest"
|
2023-08-18 18:55:43 +00:00
|
|
|
"github.com/coder/coder/v2/cli/clitest"
|
|
|
|
"github.com/coder/coder/v2/cli/cliui"
|
|
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
2023-11-21 12:22:08 +00:00
|
|
|
"github.com/coder/coder/v2/coderd/database/dbfake"
|
|
|
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
2023-12-08 16:01:13 +00:00
|
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
2023-08-18 18:55:43 +00:00
|
|
|
"github.com/coder/coder/v2/codersdk"
|
2023-12-08 16:01:13 +00:00
|
|
|
"github.com/coder/coder/v2/provisioner/echo"
|
2023-08-18 18:55:43 +00:00
|
|
|
"github.com/coder/coder/v2/provisionersdk/proto"
|
|
|
|
"github.com/coder/coder/v2/pty"
|
|
|
|
"github.com/coder/coder/v2/pty/ptytest"
|
|
|
|
"github.com/coder/coder/v2/testutil"
|
2022-03-29 00:19:28 +00:00
|
|
|
)
|
|
|
|
|
2023-11-21 12:22:08 +00:00
|
|
|
func setupWorkspaceForAgent(t *testing.T, mutations ...func([]*proto.Agent) []*proto.Agent) (*codersdk.Client, database.Workspace, string) {
|
2022-05-25 18:28:10 +00:00
|
|
|
t.Helper()
|
2023-11-21 12:22:08 +00:00
|
|
|
|
|
|
|
client, store := coderdtest.NewWithDatabase(t, nil)
|
2023-07-06 08:43:39 +00:00
|
|
|
client.SetLogger(slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug))
|
2023-11-21 12:22:08 +00:00
|
|
|
first := coderdtest.CreateFirstUser(t, client)
|
|
|
|
userClient, user := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
|
2023-12-01 01:33:04 +00:00
|
|
|
r := dbfake.WorkspaceBuild(t, store, database.Workspace{
|
|
|
|
OrganizationID: first.OrganizationID,
|
|
|
|
OwnerID: user.ID,
|
|
|
|
}).WithAgent(mutations...).Do()
|
2023-11-21 12:22:08 +00:00
|
|
|
|
2023-11-27 10:30:15 +00:00
|
|
|
return userClient, r.Workspace, r.AgentToken
|
2022-05-25 18:28:10 +00:00
|
|
|
}
|
|
|
|
|
2022-03-29 00:19:28 +00:00
|
|
|
func TestSSH(t *testing.T) {
|
|
|
|
t.Parallel()
|
|
|
|
t.Run("ImmediateExit", func(t *testing.T) {
|
|
|
|
t.Parallel()
|
2022-07-27 17:33:00 +00:00
|
|
|
|
2023-11-21 12:22:08 +00:00
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
2023-03-23 22:42:20 +00:00
|
|
|
inv, root := clitest.New(t, "ssh", workspace.Name)
|
2022-03-29 00:19:28 +00:00
|
|
|
clitest.SetupConfig(t, client, root)
|
2023-03-23 22:42:20 +00:00
|
|
|
pty := ptytest.New(t).Attach(inv)
|
2022-07-27 17:33:00 +00:00
|
|
|
|
2022-08-01 12:45:05 +00:00
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
2022-07-27 17:33:00 +00:00
|
|
|
defer cancel()
|
|
|
|
|
2022-05-25 18:28:10 +00:00
|
|
|
cmdDone := tGo(t, func() {
|
2023-03-23 22:42:20 +00:00
|
|
|
err := inv.WithContext(ctx).Run()
|
2022-05-24 07:58:39 +00:00
|
|
|
assert.NoError(t, err)
|
2022-05-25 18:28:10 +00:00
|
|
|
})
|
2022-04-11 23:54:30 +00:00
|
|
|
pty.ExpectMatch("Waiting")
|
2022-08-03 10:37:12 +00:00
|
|
|
|
2023-09-26 11:05:19 +00:00
|
|
|
_ = agenttest.New(t, client.URL, agentToken)
|
|
|
|
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
2022-05-25 18:28:10 +00:00
|
|
|
|
2022-03-29 00:19:28 +00:00
|
|
|
// Shells on Mac, Windows, and Linux all exit shells with the "exit" command.
|
|
|
|
pty.WriteLine("exit")
|
2022-05-25 18:28:10 +00:00
|
|
|
<-cmdDone
|
2022-03-29 00:19:28 +00:00
|
|
|
})
|
2023-12-08 16:01:13 +00:00
|
|
|
t.Run("StartStoppedWorkspace", func(t *testing.T) {
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
authToken := uuid.NewString()
|
|
|
|
ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
|
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
|
|
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
|
|
|
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
|
|
|
|
Parse: echo.ParseComplete,
|
|
|
|
ProvisionPlan: echo.PlanComplete,
|
|
|
|
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
|
|
|
|
})
|
|
|
|
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
|
|
|
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
|
|
|
workspace := coderdtest.CreateWorkspace(t, client, owner.OrganizationID, template.ID)
|
|
|
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
|
|
|
// Stop the workspace
|
|
|
|
workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
|
|
|
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspaceBuild.ID)
|
|
|
|
|
|
|
|
// SSH to the workspace which should autostart it
|
|
|
|
inv, root := clitest.New(t, "ssh", workspace.Name)
|
|
|
|
clitest.SetupConfig(t, client, root)
|
|
|
|
pty := ptytest.New(t).Attach(inv)
|
|
|
|
|
2024-03-26 10:46:58 +00:00
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
|
2023-12-08 16:01:13 +00:00
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
cmdDone := tGo(t, func() {
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
|
|
assert.NoError(t, err)
|
|
|
|
})
|
|
|
|
|
2024-03-26 10:46:58 +00:00
|
|
|
// Delay until workspace is starting, otherwise the agent may be
|
|
|
|
// booted due to outdated build.
|
|
|
|
var err error
|
|
|
|
for {
|
|
|
|
workspace, err = client.Workspace(ctx, workspace.ID)
|
|
|
|
require.NoError(t, err)
|
|
|
|
if workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
time.Sleep(testutil.IntervalFast)
|
|
|
|
}
|
|
|
|
|
2023-12-08 16:01:13 +00:00
|
|
|
// When the agent connects, the workspace was started, and we should
|
|
|
|
// have access to the shell.
|
|
|
|
_ = agenttest.New(t, client.URL, authToken)
|
|
|
|
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
|
|
|
|
|
|
|
// Shells on Mac, Windows, and Linux all exit shells with the "exit" command.
|
|
|
|
pty.WriteLine("exit")
|
|
|
|
<-cmdDone
|
|
|
|
})
|
2024-01-23 17:01:44 +00:00
|
|
|
t.Run("RequireActiveVersion", func(t *testing.T) {
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
authToken := uuid.NewString()
|
|
|
|
ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
|
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
|
|
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleMember())
|
|
|
|
|
|
|
|
echoResponses := &echo.Responses{
|
|
|
|
Parse: echo.ParseComplete,
|
|
|
|
ProvisionPlan: echo.PlanComplete,
|
|
|
|
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
|
|
|
|
}
|
|
|
|
|
|
|
|
version := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, echoResponses)
|
|
|
|
coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, version.ID)
|
|
|
|
template := coderdtest.CreateTemplate(t, ownerClient, owner.OrganizationID, version.ID)
|
|
|
|
|
|
|
|
workspace := coderdtest.CreateWorkspace(t, client, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
|
|
|
|
cwr.AutomaticUpdates = codersdk.AutomaticUpdatesAlways
|
|
|
|
})
|
|
|
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
|
|
|
|
|
|
|
// Stop the workspace
|
|
|
|
workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
|
|
|
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspaceBuild.ID)
|
|
|
|
|
|
|
|
// Update template version
|
2024-02-23 07:04:23 +00:00
|
|
|
authToken2 := uuid.NewString()
|
|
|
|
echoResponses2 := &echo.Responses{
|
|
|
|
Parse: echo.ParseComplete,
|
|
|
|
ProvisionPlan: echo.PlanComplete,
|
|
|
|
ProvisionApply: echo.ProvisionApplyWithAgent(authToken2),
|
|
|
|
}
|
|
|
|
version = coderdtest.UpdateTemplateVersion(t, ownerClient, owner.OrganizationID, echoResponses2, template.ID)
|
2024-01-23 17:01:44 +00:00
|
|
|
coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, version.ID)
|
|
|
|
err := ownerClient.UpdateActiveTemplateVersion(context.Background(), template.ID, codersdk.UpdateActiveTemplateVersion{
|
|
|
|
ID: version.ID,
|
|
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
// SSH to the workspace which should auto-update and autostart it
|
|
|
|
inv, root := clitest.New(t, "ssh", workspace.Name)
|
|
|
|
clitest.SetupConfig(t, client, root)
|
|
|
|
pty := ptytest.New(t).Attach(inv)
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
cmdDone := tGo(t, func() {
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
|
|
assert.NoError(t, err)
|
|
|
|
})
|
|
|
|
|
|
|
|
// When the agent connects, the workspace was started, and we should
|
|
|
|
// have access to the shell.
|
2024-02-23 07:04:23 +00:00
|
|
|
_ = agenttest.New(t, client.URL, authToken2)
|
2024-01-23 17:01:44 +00:00
|
|
|
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
|
|
|
|
|
|
|
// Shells on Mac, Windows, and Linux all exit shells with the "exit" command.
|
|
|
|
pty.WriteLine("exit")
|
|
|
|
<-cmdDone
|
|
|
|
|
|
|
|
// Double-check if workspace's template version is up-to-date
|
|
|
|
workspace, err = client.Workspace(context.Background(), workspace.ID)
|
|
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, version.ID, workspace.TemplateActiveVersionID)
|
|
|
|
assert.Equal(t, workspace.TemplateActiveVersionID, workspace.LatestBuild.TemplateVersionID)
|
|
|
|
assert.False(t, workspace.Outdated)
|
|
|
|
})
|
|
|
|
|
2022-11-09 15:27:05 +00:00
|
|
|
t.Run("ShowTroubleshootingURLAfterTimeout", func(t *testing.T) {
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
wantURL := "https://example.com/troubleshoot"
|
|
|
|
client, workspace, _ := setupWorkspaceForAgent(t, func(a []*proto.Agent) []*proto.Agent {
|
|
|
|
// Unfortunately, one second is the lowest
|
|
|
|
// we can go because 0 disables the feature.
|
|
|
|
a[0].ConnectionTimeoutSeconds = 1
|
|
|
|
a[0].TroubleshootingUrl = wantURL
|
|
|
|
return a
|
|
|
|
})
|
2023-03-23 22:42:20 +00:00
|
|
|
inv, root := clitest.New(t, "ssh", workspace.Name)
|
2022-11-09 15:27:05 +00:00
|
|
|
clitest.SetupConfig(t, client, root)
|
|
|
|
pty := ptytest.New(t)
|
2023-03-23 22:42:20 +00:00
|
|
|
inv.Stdin = pty.Input()
|
|
|
|
inv.Stderr = pty.Output()
|
|
|
|
inv.Stdout = pty.Output()
|
2022-11-09 15:27:05 +00:00
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
cmdDone := tGo(t, func() {
|
2023-03-23 22:42:20 +00:00
|
|
|
err := inv.WithContext(ctx).Run()
|
2023-01-27 17:05:40 +00:00
|
|
|
assert.ErrorIs(t, err, cliui.Canceled)
|
2022-11-09 15:27:05 +00:00
|
|
|
})
|
|
|
|
pty.ExpectMatch(wantURL)
|
|
|
|
cancel()
|
|
|
|
<-cmdDone
|
|
|
|
})
|
2023-04-19 18:32:28 +00:00
|
|
|
|
|
|
|
t.Run("ExitOnStop", func(t *testing.T) {
|
|
|
|
t.Parallel()
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
t.Skip("Windows doesn't seem to clean up the process, maybe #7100 will fix it")
|
|
|
|
}
|
|
|
|
|
2023-11-21 12:22:08 +00:00
|
|
|
store, ps := dbtestutil.NewDB(t)
|
|
|
|
client := coderdtest.New(t, &coderdtest.Options{Pubsub: ps, Database: store})
|
|
|
|
client.SetLogger(slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug))
|
|
|
|
first := coderdtest.CreateFirstUser(t, client)
|
|
|
|
userClient, user := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
|
2023-12-01 01:33:04 +00:00
|
|
|
r := dbfake.WorkspaceBuild(t, store, database.Workspace{
|
2023-11-21 12:22:08 +00:00
|
|
|
OrganizationID: first.OrganizationID,
|
|
|
|
OwnerID: user.ID,
|
2023-11-27 10:30:15 +00:00
|
|
|
}).WithAgent().Do()
|
|
|
|
inv, root := clitest.New(t, "ssh", r.Workspace.Name)
|
2023-11-21 12:22:08 +00:00
|
|
|
clitest.SetupConfig(t, userClient, root)
|
2023-04-19 18:32:28 +00:00
|
|
|
pty := ptytest.New(t).Attach(inv)
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
cmdDone := tGo(t, func() {
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
|
|
assert.Error(t, err)
|
|
|
|
})
|
|
|
|
pty.ExpectMatch("Waiting")
|
|
|
|
|
2023-11-27 10:30:15 +00:00
|
|
|
_ = agenttest.New(t, client.URL, r.AgentToken)
|
|
|
|
coderdtest.AwaitWorkspaceAgents(t, client, r.Workspace.ID)
|
2023-04-19 18:32:28 +00:00
|
|
|
|
|
|
|
// Ensure the agent is connected.
|
|
|
|
pty.WriteLine("echo hell'o'")
|
|
|
|
pty.ExpectMatchContext(ctx, "hello")
|
|
|
|
|
2023-11-29 07:06:04 +00:00
|
|
|
_ = dbfake.WorkspaceBuild(t, store, r.Workspace).
|
2023-11-21 12:22:08 +00:00
|
|
|
Seed(database.WorkspaceBuild{
|
|
|
|
Transition: database.WorkspaceTransitionStop,
|
|
|
|
BuildNumber: 2,
|
|
|
|
}).
|
|
|
|
Pubsub(ps).Do()
|
|
|
|
t.Log("stopped workspace")
|
2023-04-19 18:32:28 +00:00
|
|
|
|
|
|
|
select {
|
|
|
|
case <-cmdDone:
|
|
|
|
case <-ctx.Done():
|
|
|
|
require.Fail(t, "command did not exit in time")
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
2022-03-30 22:59:54 +00:00
|
|
|
t.Run("Stdio", func(t *testing.T) {
|
|
|
|
t.Parallel()
|
2023-11-21 12:22:08 +00:00
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
2022-05-25 18:28:10 +00:00
|
|
|
_, _ = tGoContext(t, func(ctx context.Context) {
|
2022-03-30 22:59:54 +00:00
|
|
|
// Run this async so the SSH command has to wait for
|
|
|
|
// the build and agent to connect!
|
2023-09-26 11:05:19 +00:00
|
|
|
_ = agenttest.New(t, client.URL, agentToken)
|
2022-05-25 18:28:10 +00:00
|
|
|
<-ctx.Done()
|
|
|
|
})
|
2022-03-30 22:59:54 +00:00
|
|
|
|
|
|
|
clientOutput, clientInput := io.Pipe()
|
|
|
|
serverOutput, serverInput := io.Pipe()
|
2022-07-27 17:33:00 +00:00
|
|
|
defer func() {
|
|
|
|
for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} {
|
|
|
|
_ = c.Close()
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
2022-08-01 12:45:05 +00:00
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
2022-07-27 17:33:00 +00:00
|
|
|
defer cancel()
|
2022-03-30 22:59:54 +00:00
|
|
|
|
2023-03-23 22:42:20 +00:00
|
|
|
inv, root := clitest.New(t, "ssh", "--stdio", workspace.Name)
|
2022-03-30 22:59:54 +00:00
|
|
|
clitest.SetupConfig(t, client, root)
|
2023-03-23 22:42:20 +00:00
|
|
|
inv.Stdin = clientOutput
|
|
|
|
inv.Stdout = serverInput
|
|
|
|
inv.Stderr = io.Discard
|
2023-11-14 18:56:27 +00:00
|
|
|
|
2022-05-25 18:28:10 +00:00
|
|
|
cmdDone := tGo(t, func() {
|
2023-03-23 22:42:20 +00:00
|
|
|
err := inv.WithContext(ctx).Run()
|
2022-05-24 07:58:39 +00:00
|
|
|
assert.NoError(t, err)
|
2022-05-25 18:28:10 +00:00
|
|
|
})
|
2022-03-30 22:59:54 +00:00
|
|
|
|
|
|
|
conn, channels, requests, err := ssh.NewClientConn(&stdioConn{
|
|
|
|
Reader: serverOutput,
|
|
|
|
Writer: clientInput,
|
|
|
|
}, "", &ssh.ClientConfig{
|
|
|
|
// #nosec
|
|
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
|
|
})
|
|
|
|
require.NoError(t, err)
|
2022-07-27 17:33:00 +00:00
|
|
|
defer conn.Close()
|
|
|
|
|
2022-03-30 22:59:54 +00:00
|
|
|
sshClient := ssh.NewClient(conn, channels, requests)
|
|
|
|
session, err := sshClient.NewSession()
|
|
|
|
require.NoError(t, err)
|
2022-07-27 17:33:00 +00:00
|
|
|
defer session.Close()
|
|
|
|
|
2022-03-30 22:59:54 +00:00
|
|
|
command := "sh -c exit"
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
command = "cmd.exe /c exit"
|
|
|
|
}
|
|
|
|
err = session.Run(command)
|
|
|
|
require.NoError(t, err)
|
|
|
|
err = sshClient.Close()
|
|
|
|
require.NoError(t, err)
|
|
|
|
_ = clientOutput.Close()
|
2022-05-25 18:28:10 +00:00
|
|
|
|
|
|
|
<-cmdDone
|
|
|
|
})
|
2023-04-19 18:32:28 +00:00
|
|
|
|
2024-02-08 11:09:42 +00:00
|
|
|
t.Run("Stdio_StartStoppedWorkspace_CleanStdout", func(t *testing.T) {
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
authToken := uuid.NewString()
|
|
|
|
ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
|
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
|
|
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
|
|
|
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
|
|
|
|
Parse: echo.ParseComplete,
|
|
|
|
ProvisionPlan: echo.PlanComplete,
|
|
|
|
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
|
|
|
|
})
|
|
|
|
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
|
|
|
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
|
|
|
workspace := coderdtest.CreateWorkspace(t, client, owner.OrganizationID, template.ID)
|
|
|
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
|
|
|
// Stop the workspace
|
|
|
|
workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
|
|
|
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspaceBuild.ID)
|
|
|
|
|
2024-03-26 10:46:58 +00:00
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
|
2024-02-08 11:09:42 +00:00
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
clientStdinR, clientStdinW := io.Pipe()
|
|
|
|
// Here's a simple flowchart for how these pipes are used:
|
|
|
|
//
|
|
|
|
// flowchart LR
|
|
|
|
// A[ProxyCommand] --> B[captureProxyCommandStdoutW]
|
|
|
|
// B --> C[captureProxyCommandStdoutR]
|
|
|
|
// C --> VA[Validate output]
|
|
|
|
// C --> D[proxyCommandStdoutW]
|
|
|
|
// D --> E[proxyCommandStdoutR]
|
|
|
|
// E --> F[SSH Client]
|
|
|
|
proxyCommandStdoutR, proxyCommandStdoutW := io.Pipe()
|
|
|
|
captureProxyCommandStdoutR, captureProxyCommandStdoutW := io.Pipe()
|
|
|
|
closePipes := func() {
|
|
|
|
for _, c := range []io.Closer{clientStdinR, clientStdinW, proxyCommandStdoutR, proxyCommandStdoutW, captureProxyCommandStdoutR, captureProxyCommandStdoutW} {
|
|
|
|
_ = c.Close()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
defer closePipes()
|
|
|
|
tGo(t, func() {
|
|
|
|
<-ctx.Done()
|
|
|
|
closePipes()
|
|
|
|
})
|
|
|
|
|
|
|
|
// Here we start a monitor for the output produced by the proxy command,
|
|
|
|
// which is read by the SSH client. This is done to validate that the
|
|
|
|
// output is clean.
|
|
|
|
proxyCommandOutputBuf := make(chan byte, 4096)
|
|
|
|
tGo(t, func() {
|
|
|
|
defer close(proxyCommandOutputBuf)
|
|
|
|
|
|
|
|
gotHeader := false
|
|
|
|
buf := bytes.Buffer{}
|
|
|
|
r := bufio.NewReader(captureProxyCommandStdoutR)
|
|
|
|
for {
|
|
|
|
b, err := r.ReadByte()
|
|
|
|
if err != nil {
|
|
|
|
if errors.Is(err, io.ErrClosedPipe) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
assert.NoError(t, err, "read byte failed")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if b == '\n' || b == '\r' {
|
|
|
|
out := buf.Bytes()
|
|
|
|
t.Logf("monitorServerOutput: %q (%#x)", out, out)
|
|
|
|
buf.Reset()
|
|
|
|
|
|
|
|
// Ideally we would do further verification, but that would
|
|
|
|
// involve parsing the SSH protocol to look for output that
|
|
|
|
// doesn't belong. This at least ensures that no garbage is
|
|
|
|
// being sent to the SSH client before trying to connect.
|
|
|
|
if !gotHeader {
|
|
|
|
gotHeader = true
|
|
|
|
assert.Equal(t, "SSH-2.0-Go", string(out), "invalid header")
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
_ = buf.WriteByte(b)
|
|
|
|
}
|
|
|
|
select {
|
|
|
|
case proxyCommandOutputBuf <- b:
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
tGo(t, func() {
|
|
|
|
defer proxyCommandStdoutW.Close()
|
|
|
|
|
|
|
|
// Range closed by above goroutine.
|
|
|
|
for b := range proxyCommandOutputBuf {
|
|
|
|
_, err := proxyCommandStdoutW.Write([]byte{b})
|
|
|
|
if err != nil {
|
|
|
|
if errors.Is(err, io.ErrClosedPipe) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
assert.NoError(t, err, "write byte failed")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
// Start the SSH stdio command.
|
|
|
|
inv, root := clitest.New(t, "ssh", "--stdio", workspace.Name)
|
|
|
|
clitest.SetupConfig(t, client, root)
|
|
|
|
inv.Stdin = clientStdinR
|
|
|
|
inv.Stdout = captureProxyCommandStdoutW
|
|
|
|
inv.Stderr = io.Discard
|
|
|
|
|
|
|
|
cmdDone := tGo(t, func() {
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
|
|
assert.NoError(t, err)
|
|
|
|
})
|
|
|
|
|
2024-03-26 10:46:58 +00:00
|
|
|
// Delay until workspace is starting, otherwise the agent may be
|
|
|
|
// booted due to outdated build.
|
|
|
|
var err error
|
|
|
|
for {
|
|
|
|
workspace, err = client.Workspace(ctx, workspace.ID)
|
|
|
|
require.NoError(t, err)
|
|
|
|
if workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
time.Sleep(testutil.IntervalFast)
|
|
|
|
}
|
|
|
|
|
2024-02-22 13:01:06 +00:00
|
|
|
// When the agent connects, the workspace was started, and we should
|
|
|
|
// have access to the shell.
|
|
|
|
_ = agenttest.New(t, client.URL, authToken)
|
2024-02-08 11:09:42 +00:00
|
|
|
|
|
|
|
conn, channels, requests, err := ssh.NewClientConn(&stdioConn{
|
|
|
|
Reader: proxyCommandStdoutR,
|
|
|
|
Writer: clientStdinW,
|
|
|
|
}, "", &ssh.ClientConfig{
|
|
|
|
// #nosec
|
|
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
|
|
defer conn.Close()
|
|
|
|
|
|
|
|
sshClient := ssh.NewClient(conn, channels, requests)
|
|
|
|
session, err := sshClient.NewSession()
|
|
|
|
require.NoError(t, err)
|
|
|
|
defer session.Close()
|
|
|
|
|
|
|
|
command := "sh -c exit"
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
command = "cmd.exe /c exit"
|
|
|
|
}
|
|
|
|
err = session.Run(command)
|
|
|
|
require.NoError(t, err)
|
|
|
|
err = sshClient.Close()
|
|
|
|
require.NoError(t, err)
|
|
|
|
_ = clientStdinR.Close()
|
|
|
|
|
|
|
|
<-cmdDone
|
|
|
|
})
|
|
|
|
|
fix: close ssh sessions gracefully (#10732)
Re-enables TestSSH/RemoteForward_Unix_Signal and addresses the underlying race: we were not closing the remote forward on context expiry, only the session and connection.
However, there is still a more fundamental issue in that we don't have the ability to ensure that TCP sessions are properly terminated before tearing down the Tailnet conn. This is due to the assumption in the sockets API, that the underlying IP interface is long
lived compared with the TCP socket, and thus closing a socket returns immediately and does not wait for the TCP termination handshake --- that is handled async in the tcpip stack. However, this assumption does not hold for us and tailnet, since on shutdown,
we also tear down the tailnet connection, and this can race with the TCP termination.
Closing the remote forward explicitly should prevent forward state from accumulating, since the Close() function waits for a reply from the remote SSH server.
I've also attempted to workaround the TCP/tailnet issue for `--stdio` by using `CloseWrite()` instead of `Close()`. By closing the write side of the connection, half-close the TCP connection, and the server detects this and closes the other direction, which then
triggers our read loop to exit only after the server has had a chance to process the close.
TODO in a stacked PR is to implement this logic for `vscodessh` as well.
2023-11-17 08:43:20 +00:00
|
|
|
t.Run("Stdio_RemoteForward_Signal", func(t *testing.T) {
|
|
|
|
t.Parallel()
|
2023-11-21 12:22:08 +00:00
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
fix: close ssh sessions gracefully (#10732)
Re-enables TestSSH/RemoteForward_Unix_Signal and addresses the underlying race: we were not closing the remote forward on context expiry, only the session and connection.
However, there is still a more fundamental issue in that we don't have the ability to ensure that TCP sessions are properly terminated before tearing down the Tailnet conn. This is due to the assumption in the sockets API, that the underlying IP interface is long
lived compared with the TCP socket, and thus closing a socket returns immediately and does not wait for the TCP termination handshake --- that is handled async in the tcpip stack. However, this assumption does not hold for us and tailnet, since on shutdown,
we also tear down the tailnet connection, and this can race with the TCP termination.
Closing the remote forward explicitly should prevent forward state from accumulating, since the Close() function waits for a reply from the remote SSH server.
I've also attempted to workaround the TCP/tailnet issue for `--stdio` by using `CloseWrite()` instead of `Close()`. By closing the write side of the connection, half-close the TCP connection, and the server detects this and closes the other direction, which then
triggers our read loop to exit only after the server has had a chance to process the close.
TODO in a stacked PR is to implement this logic for `vscodessh` as well.
2023-11-17 08:43:20 +00:00
|
|
|
_, _ = tGoContext(t, func(ctx context.Context) {
|
|
|
|
// Run this async so the SSH command has to wait for
|
|
|
|
// the build and agent to connect!
|
|
|
|
_ = agenttest.New(t, client.URL, agentToken)
|
|
|
|
<-ctx.Done()
|
|
|
|
})
|
|
|
|
|
|
|
|
clientOutput, clientInput := io.Pipe()
|
|
|
|
serverOutput, serverInput := io.Pipe()
|
|
|
|
defer func() {
|
|
|
|
for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} {
|
|
|
|
_ = c.Close()
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
inv, root := clitest.New(t, "ssh", "--stdio", workspace.Name)
|
|
|
|
fsn := clitest.NewFakeSignalNotifier(t)
|
|
|
|
inv = inv.WithTestSignalNotifyContext(t, fsn.NotifyContext)
|
|
|
|
clitest.SetupConfig(t, client, root)
|
|
|
|
inv.Stdin = clientOutput
|
|
|
|
inv.Stdout = serverInput
|
|
|
|
inv.Stderr = io.Discard
|
|
|
|
|
|
|
|
cmdDone := tGo(t, func() {
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
|
|
assert.NoError(t, err)
|
|
|
|
})
|
|
|
|
|
|
|
|
conn, channels, requests, err := ssh.NewClientConn(&stdioConn{
|
|
|
|
Reader: serverOutput,
|
|
|
|
Writer: clientInput,
|
|
|
|
}, "", &ssh.ClientConfig{
|
|
|
|
// #nosec
|
|
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
|
|
defer conn.Close()
|
|
|
|
|
|
|
|
sshClient := ssh.NewClient(conn, channels, requests)
|
|
|
|
|
|
|
|
tmpdir := tempDirUnixSocket(t)
|
|
|
|
|
|
|
|
remoteSock := path.Join(tmpdir, "remote.sock")
|
|
|
|
_, err = sshClient.ListenUnix(remoteSock)
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
fsn.Notify()
|
|
|
|
<-cmdDone
|
|
|
|
fsn.AssertStopped()
|
|
|
|
require.Eventually(t, func() bool {
|
|
|
|
_, err = os.Stat(remoteSock)
|
|
|
|
return xerrors.Is(err, os.ErrNotExist)
|
|
|
|
}, testutil.WaitShort, testutil.IntervalFast)
|
|
|
|
})
|
|
|
|
|
|
|
|
t.Run("Stdio_BrokenConn", func(t *testing.T) {
|
|
|
|
t.Parallel()
|
2023-11-21 12:22:08 +00:00
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
fix: close ssh sessions gracefully (#10732)
Re-enables TestSSH/RemoteForward_Unix_Signal and addresses the underlying race: we were not closing the remote forward on context expiry, only the session and connection.
However, there is still a more fundamental issue in that we don't have the ability to ensure that TCP sessions are properly terminated before tearing down the Tailnet conn. This is due to the assumption in the sockets API, that the underlying IP interface is long
lived compared with the TCP socket, and thus closing a socket returns immediately and does not wait for the TCP termination handshake --- that is handled async in the tcpip stack. However, this assumption does not hold for us and tailnet, since on shutdown,
we also tear down the tailnet connection, and this can race with the TCP termination.
Closing the remote forward explicitly should prevent forward state from accumulating, since the Close() function waits for a reply from the remote SSH server.
I've also attempted to workaround the TCP/tailnet issue for `--stdio` by using `CloseWrite()` instead of `Close()`. By closing the write side of the connection, half-close the TCP connection, and the server detects this and closes the other direction, which then
triggers our read loop to exit only after the server has had a chance to process the close.
TODO in a stacked PR is to implement this logic for `vscodessh` as well.
2023-11-17 08:43:20 +00:00
|
|
|
_, _ = tGoContext(t, func(ctx context.Context) {
|
|
|
|
// Run this async so the SSH command has to wait for
|
|
|
|
// the build and agent to connect!
|
|
|
|
_ = agenttest.New(t, client.URL, agentToken)
|
|
|
|
<-ctx.Done()
|
|
|
|
})
|
|
|
|
|
|
|
|
clientOutput, clientInput := io.Pipe()
|
|
|
|
serverOutput, serverInput := io.Pipe()
|
|
|
|
defer func() {
|
|
|
|
for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} {
|
|
|
|
_ = c.Close()
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
inv, root := clitest.New(t, "ssh", "--stdio", workspace.Name)
|
|
|
|
clitest.SetupConfig(t, client, root)
|
|
|
|
inv.Stdin = clientOutput
|
|
|
|
inv.Stdout = serverInput
|
|
|
|
inv.Stderr = io.Discard
|
|
|
|
|
|
|
|
cmdDone := tGo(t, func() {
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
|
|
assert.NoError(t, err)
|
|
|
|
})
|
|
|
|
|
|
|
|
conn, channels, requests, err := ssh.NewClientConn(&stdioConn{
|
|
|
|
Reader: serverOutput,
|
|
|
|
Writer: clientInput,
|
|
|
|
}, "", &ssh.ClientConfig{
|
|
|
|
// #nosec
|
|
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
|
|
defer conn.Close()
|
|
|
|
|
|
|
|
sshClient := ssh.NewClient(conn, channels, requests)
|
|
|
|
_ = serverOutput.Close()
|
|
|
|
_ = clientInput.Close()
|
|
|
|
select {
|
|
|
|
case <-cmdDone:
|
|
|
|
// OK
|
|
|
|
case <-time.After(testutil.WaitShort):
|
|
|
|
t.Error("timeout waiting for command to exit")
|
|
|
|
}
|
|
|
|
|
|
|
|
_ = sshClient.Close()
|
|
|
|
})
|
|
|
|
|
2023-11-13 11:14:42 +00:00
|
|
|
// Test that we handle OS signals properly while remote forwarding, and don't just leave the TCP
|
|
|
|
// socket hanging.
|
|
|
|
t.Run("RemoteForward_Unix_Signal", func(t *testing.T) {
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
t.Skip("No unix sockets on windows")
|
|
|
|
}
|
|
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitSuperLong)
|
2023-11-21 12:22:08 +00:00
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
2023-11-13 11:14:42 +00:00
|
|
|
_, _ = tGoContext(t, func(ctx context.Context) {
|
|
|
|
// Run this async so the SSH command has to wait for
|
|
|
|
// the build and agent to connect!
|
|
|
|
_ = agenttest.New(t, client.URL, agentToken)
|
|
|
|
<-ctx.Done()
|
|
|
|
})
|
|
|
|
|
|
|
|
tmpdir := tempDirUnixSocket(t)
|
|
|
|
localSock := filepath.Join(tmpdir, "local.sock")
|
|
|
|
l, err := net.Listen("unix", localSock)
|
|
|
|
require.NoError(t, err)
|
|
|
|
defer l.Close()
|
|
|
|
remoteSock := path.Join(tmpdir, "remote.sock")
|
|
|
|
for i := 0; i < 2; i++ {
|
|
|
|
t.Logf("connect %d of 2", i+1)
|
|
|
|
inv, root := clitest.New(t,
|
|
|
|
"ssh",
|
|
|
|
workspace.Name,
|
|
|
|
"--remote-forward",
|
|
|
|
remoteSock+":"+localSock,
|
|
|
|
)
|
|
|
|
fsn := clitest.NewFakeSignalNotifier(t)
|
|
|
|
inv = inv.WithTestSignalNotifyContext(t, fsn.NotifyContext)
|
|
|
|
inv.Stdout = io.Discard
|
|
|
|
inv.Stderr = io.Discard
|
|
|
|
|
|
|
|
clitest.SetupConfig(t, client, root)
|
|
|
|
cmdDone := tGo(t, func() {
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
|
|
assert.Error(t, err)
|
|
|
|
})
|
|
|
|
|
|
|
|
// accept a single connection
|
|
|
|
msgs := make(chan string, 1)
|
|
|
|
go func() {
|
|
|
|
conn, err := l.Accept()
|
|
|
|
if !assert.NoError(t, err) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
msg, err := io.ReadAll(conn)
|
|
|
|
if !assert.NoError(t, err) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
msgs <- string(msg)
|
|
|
|
}()
|
|
|
|
|
|
|
|
// Unfortunately, there is a race in crypto/ssh where it sends the request to forward
|
|
|
|
// unix sockets before it is prepared to receive the response, meaning that even after
|
|
|
|
// the socket exists on the file system, the client might not be ready to accept the
|
|
|
|
// channel.
|
|
|
|
//
|
|
|
|
// https://cs.opensource.google/go/x/crypto/+/master:ssh/streamlocal.go;drc=2fc4c88bf43f0ea5ea305eae2b7af24b2cc93287;l=33
|
|
|
|
//
|
|
|
|
// To work around this, we attempt to send messages in a loop until one succeeds
|
|
|
|
success := make(chan struct{})
|
2023-12-01 07:37:32 +00:00
|
|
|
done := make(chan struct{})
|
2023-11-13 11:14:42 +00:00
|
|
|
go func() {
|
2023-12-01 07:37:32 +00:00
|
|
|
defer close(done)
|
2023-11-13 11:14:42 +00:00
|
|
|
var (
|
|
|
|
conn net.Conn
|
|
|
|
err error
|
|
|
|
)
|
|
|
|
for {
|
|
|
|
time.Sleep(testutil.IntervalMedium)
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
t.Error("timeout")
|
|
|
|
return
|
|
|
|
case <-success:
|
|
|
|
return
|
|
|
|
default:
|
|
|
|
// Ok
|
|
|
|
}
|
|
|
|
conn, err = net.Dial("unix", remoteSock)
|
|
|
|
if err != nil {
|
|
|
|
t.Logf("dial error: %s", err)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
_, err = conn.Write([]byte("test"))
|
|
|
|
if err != nil {
|
|
|
|
t.Logf("write error: %s", err)
|
|
|
|
}
|
|
|
|
err = conn.Close()
|
|
|
|
if err != nil {
|
|
|
|
t.Logf("close error: %s", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
msg := testutil.RequireRecvCtx(ctx, t, msgs)
|
|
|
|
require.Equal(t, "test", msg)
|
|
|
|
close(success)
|
|
|
|
fsn.Notify()
|
|
|
|
<-cmdDone
|
|
|
|
fsn.AssertStopped()
|
2023-12-01 07:37:32 +00:00
|
|
|
// wait for dial goroutine to complete
|
|
|
|
_ = testutil.RequireRecvCtx(ctx, t, done)
|
2023-11-13 11:14:42 +00:00
|
|
|
|
|
|
|
// wait for the remote socket to get cleaned up before retrying,
|
|
|
|
// because cleaning up the socket happens asynchronously, and we
|
|
|
|
// might connect to an old listener on the agent side.
|
|
|
|
require.Eventually(t, func() bool {
|
|
|
|
_, err = os.Stat(remoteSock)
|
|
|
|
return xerrors.Is(err, os.ErrNotExist)
|
|
|
|
}, testutil.WaitShort, testutil.IntervalFast)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
2023-04-19 18:32:28 +00:00
|
|
|
t.Run("StdioExitOnStop", func(t *testing.T) {
|
|
|
|
t.Parallel()
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
t.Skip("Windows doesn't seem to clean up the process, maybe #7100 will fix it")
|
|
|
|
}
|
2023-11-21 12:22:08 +00:00
|
|
|
|
|
|
|
store, ps := dbtestutil.NewDB(t)
|
|
|
|
client := coderdtest.New(t, &coderdtest.Options{Pubsub: ps, Database: store})
|
|
|
|
client.SetLogger(slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug))
|
|
|
|
first := coderdtest.CreateFirstUser(t, client)
|
|
|
|
userClient, user := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
|
2023-12-01 01:33:04 +00:00
|
|
|
r := dbfake.WorkspaceBuild(t, store, database.Workspace{
|
2023-11-21 12:22:08 +00:00
|
|
|
OrganizationID: first.OrganizationID,
|
|
|
|
OwnerID: user.ID,
|
2023-11-27 10:30:15 +00:00
|
|
|
}).WithAgent().Do()
|
2023-11-21 12:22:08 +00:00
|
|
|
|
2023-04-19 18:32:28 +00:00
|
|
|
_, _ = tGoContext(t, func(ctx context.Context) {
|
|
|
|
// Run this async so the SSH command has to wait for
|
2023-06-12 05:18:33 +00:00
|
|
|
// the build and agent to connect.
|
2023-11-27 10:30:15 +00:00
|
|
|
_ = agenttest.New(t, client.URL, r.AgentToken)
|
2023-04-19 18:32:28 +00:00
|
|
|
<-ctx.Done()
|
|
|
|
})
|
|
|
|
|
|
|
|
clientOutput, clientInput := io.Pipe()
|
|
|
|
serverOutput, serverInput := io.Pipe()
|
|
|
|
defer func() {
|
|
|
|
for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} {
|
|
|
|
_ = c.Close()
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
|
|
defer cancel()
|
|
|
|
|
2023-11-27 10:30:15 +00:00
|
|
|
inv, root := clitest.New(t, "ssh", "--stdio", r.Workspace.Name)
|
2023-11-21 12:22:08 +00:00
|
|
|
clitest.SetupConfig(t, userClient, root)
|
2023-04-19 18:32:28 +00:00
|
|
|
inv.Stdin = clientOutput
|
|
|
|
inv.Stdout = serverInput
|
|
|
|
inv.Stderr = io.Discard
|
2023-11-14 18:56:27 +00:00
|
|
|
|
2023-04-19 18:32:28 +00:00
|
|
|
cmdDone := tGo(t, func() {
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
|
|
assert.NoError(t, err)
|
|
|
|
})
|
|
|
|
|
|
|
|
conn, channels, requests, err := ssh.NewClientConn(&stdioConn{
|
|
|
|
Reader: serverOutput,
|
|
|
|
Writer: clientInput,
|
|
|
|
}, "", &ssh.ClientConfig{
|
|
|
|
// #nosec
|
|
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
|
|
defer conn.Close()
|
|
|
|
|
|
|
|
sshClient := ssh.NewClient(conn, channels, requests)
|
|
|
|
defer sshClient.Close()
|
|
|
|
|
|
|
|
session, err := sshClient.NewSession()
|
|
|
|
require.NoError(t, err)
|
|
|
|
defer session.Close()
|
|
|
|
|
|
|
|
err = session.Shell()
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
2023-11-29 07:06:04 +00:00
|
|
|
_ = dbfake.WorkspaceBuild(t, store, r.Workspace).
|
2023-11-21 12:22:08 +00:00
|
|
|
Seed(database.WorkspaceBuild{
|
|
|
|
Transition: database.WorkspaceTransitionStop,
|
|
|
|
BuildNumber: 2,
|
|
|
|
}).
|
|
|
|
Pubsub(ps).
|
|
|
|
Do()
|
|
|
|
t.Log("stopped workspace")
|
2023-04-19 18:32:28 +00:00
|
|
|
|
|
|
|
select {
|
|
|
|
case <-cmdDone:
|
|
|
|
case <-ctx.Done():
|
|
|
|
require.Fail(t, "command did not exit in time")
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
2022-05-25 18:28:10 +00:00
|
|
|
t.Run("ForwardAgent", func(t *testing.T) {
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
t.Skip("Test not supported on windows")
|
|
|
|
}
|
|
|
|
|
2022-06-02 08:13:38 +00:00
|
|
|
t.Parallel()
|
|
|
|
|
2023-11-21 12:22:08 +00:00
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
2022-08-03 13:06:40 +00:00
|
|
|
|
2023-09-26 11:05:19 +00:00
|
|
|
_ = agenttest.New(t, client.URL, agentToken)
|
|
|
|
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
2022-05-25 18:28:10 +00:00
|
|
|
|
|
|
|
// Generate private key.
|
|
|
|
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
|
|
require.NoError(t, err)
|
|
|
|
kr := gosshagent.NewKeyring()
|
|
|
|
kr.Add(gosshagent.AddedKey{
|
|
|
|
PrivateKey: privateKey,
|
|
|
|
})
|
|
|
|
|
|
|
|
// Start up ssh agent listening on unix socket.
|
2023-01-06 07:52:19 +00:00
|
|
|
tmpdir := tempDirUnixSocket(t)
|
2022-05-25 18:28:10 +00:00
|
|
|
agentSock := filepath.Join(tmpdir, "agent.sock")
|
|
|
|
l, err := net.Listen("unix", agentSock)
|
|
|
|
require.NoError(t, err)
|
|
|
|
defer l.Close()
|
|
|
|
_ = tGo(t, func() {
|
|
|
|
for {
|
|
|
|
fd, err := l.Accept()
|
|
|
|
if err != nil {
|
|
|
|
if !errors.Is(err, net.ErrClosed) {
|
2022-08-03 13:06:40 +00:00
|
|
|
assert.NoError(t, err, "listener accept failed")
|
2022-05-25 18:28:10 +00:00
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
err = gosshagent.ServeAgent(kr, fd)
|
|
|
|
if !errors.Is(err, io.EOF) {
|
2022-08-03 13:06:40 +00:00
|
|
|
assert.NoError(t, err, "serve agent failed")
|
2022-05-25 18:28:10 +00:00
|
|
|
}
|
2022-08-03 13:06:40 +00:00
|
|
|
_ = fd.Close()
|
2022-05-25 18:28:10 +00:00
|
|
|
}
|
|
|
|
})
|
|
|
|
|
2022-08-01 12:45:05 +00:00
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
2022-07-27 17:33:00 +00:00
|
|
|
defer cancel()
|
|
|
|
|
2023-03-23 22:42:20 +00:00
|
|
|
inv, root := clitest.New(t,
|
2022-05-25 18:28:10 +00:00
|
|
|
"ssh",
|
|
|
|
workspace.Name,
|
|
|
|
"--forward-agent",
|
2022-06-02 08:13:38 +00:00
|
|
|
"--identity-agent", agentSock, // Overrides $SSH_AUTH_SOCK.
|
2022-05-25 18:28:10 +00:00
|
|
|
)
|
|
|
|
clitest.SetupConfig(t, client, root)
|
2023-03-23 22:42:20 +00:00
|
|
|
pty := ptytest.New(t).Attach(inv)
|
|
|
|
inv.Stderr = pty.Output()
|
2022-05-25 18:28:10 +00:00
|
|
|
cmdDone := tGo(t, func() {
|
2023-03-23 22:42:20 +00:00
|
|
|
err := inv.WithContext(ctx).Run()
|
2022-08-03 13:06:40 +00:00
|
|
|
assert.NoError(t, err, "ssh command failed")
|
2022-05-25 18:28:10 +00:00
|
|
|
})
|
|
|
|
|
2023-01-17 21:38:27 +00:00
|
|
|
// Wait for the prompt or any output really to indicate the command has
|
|
|
|
// started and accepting input on stdin.
|
|
|
|
_ = pty.Peek(ctx, 1)
|
|
|
|
|
2022-05-25 18:28:10 +00:00
|
|
|
// Ensure that SSH_AUTH_SOCK is set.
|
|
|
|
// Linux: /tmp/auth-agent3167016167/listener.sock
|
|
|
|
// macOS: /var/folders/ng/m1q0wft14hj0t3rtjxrdnzsr0000gn/T/auth-agent3245553419/listener.sock
|
2023-09-27 12:34:40 +00:00
|
|
|
pty.WriteLine(`env | grep SSH_AUTH_SOCK=`)
|
2022-05-25 18:28:10 +00:00
|
|
|
pty.ExpectMatch("SSH_AUTH_SOCK=")
|
|
|
|
// Ensure that ssh-add lists our key.
|
|
|
|
pty.WriteLine("ssh-add -L")
|
|
|
|
keys, err := kr.List()
|
2022-08-03 13:06:40 +00:00
|
|
|
require.NoError(t, err, "list keys failed")
|
2022-05-25 18:28:10 +00:00
|
|
|
pty.ExpectMatch(keys[0].String())
|
|
|
|
|
|
|
|
// And we're done.
|
|
|
|
pty.WriteLine("exit")
|
|
|
|
<-cmdDone
|
|
|
|
})
|
2023-05-25 05:07:39 +00:00
|
|
|
|
2023-07-20 10:05:39 +00:00
|
|
|
t.Run("RemoteForward", func(t *testing.T) {
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
t.Skip("Test not supported on windows")
|
|
|
|
}
|
|
|
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
w.Write([]byte("hello world"))
|
|
|
|
}))
|
|
|
|
defer httpServer.Close()
|
|
|
|
|
2023-11-21 12:22:08 +00:00
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
2023-11-20 16:50:09 +00:00
|
|
|
_ = agenttest.New(t, client.URL, agentToken)
|
|
|
|
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
2023-07-20 10:05:39 +00:00
|
|
|
|
|
|
|
inv, root := clitest.New(t,
|
|
|
|
"ssh",
|
|
|
|
workspace.Name,
|
|
|
|
"--remote-forward",
|
|
|
|
"8222:"+httpServer.Listener.Addr().String(),
|
|
|
|
)
|
|
|
|
clitest.SetupConfig(t, client, root)
|
2023-10-11 12:17:04 +00:00
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
|
|
defer cancel()
|
|
|
|
|
2023-07-20 10:05:39 +00:00
|
|
|
cmdDone := tGo(t, func() {
|
|
|
|
err := inv.WithContext(ctx).Run()
|
2023-11-20 16:50:09 +00:00
|
|
|
// fails because we cancel context to close
|
|
|
|
assert.Error(t, err, "ssh command should fail")
|
2023-07-20 10:05:39 +00:00
|
|
|
})
|
|
|
|
|
2023-11-20 16:50:09 +00:00
|
|
|
require.Eventually(t, func() bool {
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:8222/", nil)
|
|
|
|
if !assert.NoError(t, err) {
|
|
|
|
// true exits the loop.
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
if err != nil {
|
|
|
|
t.Logf("HTTP GET http://localhost:8222/ %s", err)
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
|
|
assert.NoError(t, err)
|
|
|
|
assert.EqualValues(t, "hello world", body)
|
|
|
|
return true
|
|
|
|
}, testutil.WaitLong, testutil.IntervalFast)
|
2023-07-20 10:05:39 +00:00
|
|
|
|
|
|
|
// And we're done.
|
2023-11-20 16:50:09 +00:00
|
|
|
cancel()
|
2023-07-20 10:05:39 +00:00
|
|
|
<-cmdDone
|
|
|
|
})
|
|
|
|
|
2024-04-22 10:13:48 +00:00
|
|
|
t.Run("Env", func(t *testing.T) {
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
t.Skip("Test not supported on windows")
|
|
|
|
}
|
|
|
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
|
|
|
_ = agenttest.New(t, client.URL, agentToken)
|
|
|
|
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
|
|
|
|
|
|
|
inv, root := clitest.New(t,
|
|
|
|
"ssh",
|
|
|
|
workspace.Name,
|
|
|
|
"--env",
|
|
|
|
"foo=bar,baz=qux",
|
|
|
|
)
|
|
|
|
clitest.SetupConfig(t, client, root)
|
|
|
|
|
|
|
|
pty := ptytest.New(t).Attach(inv)
|
|
|
|
inv.Stderr = pty.Output()
|
|
|
|
|
|
|
|
// Wait super long so this doesn't flake on -race test.
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
w := clitest.StartWithWaiter(t, inv.WithContext(ctx))
|
|
|
|
defer w.Wait() // We don't care about any exit error (exit code 255: SSH connection ended unexpectedly).
|
|
|
|
|
|
|
|
// Since something was output, it should be safe to write input.
|
|
|
|
// This could show a prompt or "running startup scripts", so it's
|
|
|
|
// not indicative of the SSH connection being ready.
|
|
|
|
_ = pty.Peek(ctx, 1)
|
|
|
|
|
|
|
|
// Ensure the SSH connection is ready by testing the shell
|
|
|
|
// input/output.
|
|
|
|
pty.WriteLine("echo $foo $baz")
|
|
|
|
pty.ExpectMatchContext(ctx, "bar qux")
|
|
|
|
|
|
|
|
// And we're done.
|
|
|
|
pty.WriteLine("exit")
|
|
|
|
})
|
|
|
|
|
2023-10-03 06:39:39 +00:00
|
|
|
t.Run("RemoteForwardUnixSocket", func(t *testing.T) {
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
t.Skip("Test not supported on windows")
|
|
|
|
}
|
|
|
|
|
|
|
|
t.Parallel()
|
|
|
|
|
2023-11-21 12:22:08 +00:00
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
2023-10-03 06:39:39 +00:00
|
|
|
|
|
|
|
_ = agenttest.New(t, client.URL, agentToken)
|
|
|
|
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
tmpdir := tempDirUnixSocket(t)
|
2024-01-16 19:26:13 +00:00
|
|
|
localSock := filepath.Join(tmpdir, "local.sock")
|
|
|
|
l, err := net.Listen("unix", localSock)
|
2023-10-03 06:39:39 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
defer l.Close()
|
fix: close ssh sessions gracefully (#10732)
Re-enables TestSSH/RemoteForward_Unix_Signal and addresses the underlying race: we were not closing the remote forward on context expiry, only the session and connection.
However, there is still a more fundamental issue in that we don't have the ability to ensure that TCP sessions are properly terminated before tearing down the Tailnet conn. This is due to the assumption in the sockets API, that the underlying IP interface is long
lived compared with the TCP socket, and thus closing a socket returns immediately and does not wait for the TCP termination handshake --- that is handled async in the tcpip stack. However, this assumption does not hold for us and tailnet, since on shutdown,
we also tear down the tailnet connection, and this can race with the TCP termination.
Closing the remote forward explicitly should prevent forward state from accumulating, since the Close() function waits for a reply from the remote SSH server.
I've also attempted to workaround the TCP/tailnet issue for `--stdio` by using `CloseWrite()` instead of `Close()`. By closing the write side of the connection, half-close the TCP connection, and the server detects this and closes the other direction, which then
triggers our read loop to exit only after the server has had a chance to process the close.
TODO in a stacked PR is to implement this logic for `vscodessh` as well.
2023-11-17 08:43:20 +00:00
|
|
|
remoteSock := filepath.Join(tmpdir, "remote.sock")
|
2023-10-03 06:39:39 +00:00
|
|
|
|
|
|
|
inv, root := clitest.New(t,
|
|
|
|
"ssh",
|
|
|
|
workspace.Name,
|
|
|
|
"--remote-forward",
|
2024-01-16 19:26:13 +00:00
|
|
|
fmt.Sprintf("%s:%s", remoteSock, localSock),
|
2023-10-03 06:39:39 +00:00
|
|
|
)
|
|
|
|
clitest.SetupConfig(t, client, root)
|
|
|
|
pty := ptytest.New(t).Attach(inv)
|
|
|
|
inv.Stderr = pty.Output()
|
|
|
|
cmdDone := tGo(t, func() {
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
|
|
assert.NoError(t, err, "ssh command failed")
|
|
|
|
})
|
|
|
|
|
|
|
|
// Wait for the prompt or any output really to indicate the command has
|
|
|
|
// started and accepting input on stdin.
|
|
|
|
_ = pty.Peek(ctx, 1)
|
|
|
|
|
|
|
|
// Download the test page
|
fix: close ssh sessions gracefully (#10732)
Re-enables TestSSH/RemoteForward_Unix_Signal and addresses the underlying race: we were not closing the remote forward on context expiry, only the session and connection.
However, there is still a more fundamental issue in that we don't have the ability to ensure that TCP sessions are properly terminated before tearing down the Tailnet conn. This is due to the assumption in the sockets API, that the underlying IP interface is long
lived compared with the TCP socket, and thus closing a socket returns immediately and does not wait for the TCP termination handshake --- that is handled async in the tcpip stack. However, this assumption does not hold for us and tailnet, since on shutdown,
we also tear down the tailnet connection, and this can race with the TCP termination.
Closing the remote forward explicitly should prevent forward state from accumulating, since the Close() function waits for a reply from the remote SSH server.
I've also attempted to workaround the TCP/tailnet issue for `--stdio` by using `CloseWrite()` instead of `Close()`. By closing the write side of the connection, half-close the TCP connection, and the server detects this and closes the other direction, which then
triggers our read loop to exit only after the server has had a chance to process the close.
TODO in a stacked PR is to implement this logic for `vscodessh` as well.
2023-11-17 08:43:20 +00:00
|
|
|
pty.WriteLine(fmt.Sprintf("ss -xl state listening src %s | wc -l", remoteSock))
|
2023-10-03 06:39:39 +00:00
|
|
|
pty.ExpectMatch("2")
|
|
|
|
|
|
|
|
// And we're done.
|
|
|
|
pty.WriteLine("exit")
|
|
|
|
<-cmdDone
|
|
|
|
})
|
|
|
|
|
2024-01-16 19:26:13 +00:00
|
|
|
// Test that we can forward a local unix socket to a remote unix socket and
|
|
|
|
// that new SSH sessions take over the socket without closing active socket
|
|
|
|
// connections.
|
|
|
|
t.Run("RemoteForwardUnixSocketMultipleSessionsOverwrite", func(t *testing.T) {
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
t.Skip("Test not supported on windows")
|
|
|
|
}
|
|
|
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
|
|
|
|
|
|
|
_ = agenttest.New(t, client.URL, agentToken)
|
|
|
|
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
|
|
|
|
|
|
|
// Wait super super long so this doesn't flake on -race test.
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong*2)
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
tmpdir := tempDirUnixSocket(t)
|
|
|
|
|
|
|
|
localSock := filepath.Join(tmpdir, "local.sock")
|
|
|
|
l, err := net.Listen("unix", localSock)
|
|
|
|
require.NoError(t, err)
|
|
|
|
defer l.Close()
|
|
|
|
testutil.Go(t, func() {
|
|
|
|
for {
|
|
|
|
fd, err := l.Accept()
|
|
|
|
if err != nil {
|
|
|
|
if !errors.Is(err, net.ErrClosed) {
|
|
|
|
assert.NoError(t, err, "listener accept failed")
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
testutil.Go(t, func() {
|
|
|
|
defer fd.Close()
|
|
|
|
agentssh.Bicopy(ctx, fd, fd)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
remoteSock := filepath.Join(tmpdir, "remote.sock")
|
|
|
|
|
|
|
|
var done []func() error
|
|
|
|
for i := 0; i < 2; i++ {
|
|
|
|
id := fmt.Sprintf("ssh-%d", i)
|
|
|
|
inv, root := clitest.New(t,
|
|
|
|
"ssh",
|
|
|
|
workspace.Name,
|
|
|
|
"--remote-forward",
|
|
|
|
fmt.Sprintf("%s:%s", remoteSock, localSock),
|
|
|
|
)
|
|
|
|
inv.Logger = inv.Logger.Named(id)
|
|
|
|
clitest.SetupConfig(t, client, root)
|
|
|
|
pty := ptytest.New(t).Attach(inv)
|
|
|
|
inv.Stderr = pty.Output()
|
|
|
|
cmdDone := tGo(t, func() {
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
|
|
assert.NoError(t, err, "ssh command failed: %s", id)
|
|
|
|
})
|
|
|
|
|
|
|
|
// Since something was output, it should be safe to write input.
|
|
|
|
// This could show a prompt or "running startup scripts", so it's
|
|
|
|
// not indicative of the SSH connection being ready.
|
|
|
|
_ = pty.Peek(ctx, 1)
|
|
|
|
|
|
|
|
// Ensure the SSH connection is ready by testing the shell
|
|
|
|
// input/output.
|
|
|
|
pty.WriteLine("echo ping' 'pong")
|
|
|
|
pty.ExpectMatchContext(ctx, "ping pong")
|
|
|
|
|
|
|
|
d := &net.Dialer{}
|
|
|
|
fd, err := d.DialContext(ctx, "unix", remoteSock)
|
|
|
|
require.NoError(t, err, id)
|
|
|
|
|
|
|
|
// Ping / pong to ensure the socket is working.
|
|
|
|
_, err = fd.Write([]byte("hello world"))
|
|
|
|
require.NoError(t, err, id)
|
|
|
|
|
|
|
|
buf := make([]byte, 11)
|
|
|
|
_, err = fd.Read(buf)
|
|
|
|
require.NoError(t, err, id)
|
|
|
|
require.Equal(t, "hello world", string(buf), id)
|
|
|
|
|
|
|
|
done = append(done, func() error {
|
|
|
|
// Redo ping / pong to ensure that the socket
|
|
|
|
// connections still work.
|
|
|
|
_, err := fd.Write([]byte("hello world"))
|
|
|
|
assert.NoError(t, err, id)
|
|
|
|
|
|
|
|
buf := make([]byte, 11)
|
|
|
|
_, err = fd.Read(buf)
|
|
|
|
assert.NoError(t, err, id)
|
|
|
|
assert.Equal(t, "hello world", string(buf), id)
|
|
|
|
|
|
|
|
pty.WriteLine("exit")
|
|
|
|
<-cmdDone
|
|
|
|
return nil
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
var eg errgroup.Group
|
|
|
|
for _, d := range done {
|
|
|
|
eg.Go(d)
|
|
|
|
}
|
|
|
|
err = eg.Wait()
|
|
|
|
require.NoError(t, err)
|
|
|
|
})
|
|
|
|
|
2024-01-19 13:21:10 +00:00
|
|
|
// Test that we can remote forward multiple sockets, whether or not the
|
|
|
|
// local sockets exists at the time of establishing xthe SSH connection.
|
|
|
|
t.Run("RemoteForwardMultipleUnixSockets", func(t *testing.T) {
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
t.Skip("Test not supported on windows")
|
|
|
|
}
|
|
|
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
|
|
|
|
|
|
|
_ = agenttest.New(t, client.URL, agentToken)
|
|
|
|
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
|
|
|
|
|
|
|
// Wait super long so this doesn't flake on -race test.
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
tmpdir := tempDirUnixSocket(t)
|
|
|
|
|
|
|
|
type testSocket struct {
|
|
|
|
local string
|
|
|
|
remote string
|
|
|
|
}
|
|
|
|
|
|
|
|
args := []string{"ssh", workspace.Name}
|
|
|
|
var sockets []testSocket
|
|
|
|
for i := 0; i < 2; i++ {
|
|
|
|
localSock := filepath.Join(tmpdir, fmt.Sprintf("local-%d.sock", i))
|
|
|
|
remoteSock := filepath.Join(tmpdir, fmt.Sprintf("remote-%d.sock", i))
|
|
|
|
sockets = append(sockets, testSocket{
|
|
|
|
local: localSock,
|
|
|
|
remote: remoteSock,
|
|
|
|
})
|
|
|
|
args = append(args, "--remote-forward", fmt.Sprintf("%s:%s", remoteSock, localSock))
|
|
|
|
}
|
|
|
|
|
|
|
|
inv, root := clitest.New(t, args...)
|
|
|
|
clitest.SetupConfig(t, client, root)
|
|
|
|
pty := ptytest.New(t).Attach(inv)
|
|
|
|
inv.Stderr = pty.Output()
|
|
|
|
|
|
|
|
w := clitest.StartWithWaiter(t, inv.WithContext(ctx))
|
|
|
|
defer w.Wait() // We don't care about any exit error (exit code 255: SSH connection ended unexpectedly).
|
|
|
|
|
|
|
|
// Since something was output, it should be safe to write input.
|
|
|
|
// This could show a prompt or "running startup scripts", so it's
|
|
|
|
// not indicative of the SSH connection being ready.
|
|
|
|
_ = pty.Peek(ctx, 1)
|
|
|
|
|
|
|
|
// Ensure the SSH connection is ready by testing the shell
|
|
|
|
// input/output.
|
|
|
|
pty.WriteLine("echo ping' 'pong")
|
|
|
|
pty.ExpectMatchContext(ctx, "ping pong")
|
|
|
|
|
|
|
|
for i, sock := range sockets {
|
|
|
|
i := i
|
|
|
|
// Start the listener on the "local machine".
|
|
|
|
l, err := net.Listen("unix", sock.local)
|
|
|
|
require.NoError(t, err)
|
|
|
|
defer l.Close() //nolint:revive // Defer is fine in this loop, we only run it twice.
|
|
|
|
testutil.Go(t, func() {
|
|
|
|
for {
|
|
|
|
fd, err := l.Accept()
|
|
|
|
if err != nil {
|
|
|
|
if !errors.Is(err, net.ErrClosed) {
|
|
|
|
assert.NoError(t, err, "listener accept failed", i)
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
testutil.Go(t, func() {
|
|
|
|
defer fd.Close()
|
|
|
|
agentssh.Bicopy(ctx, fd, fd)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
// Dial the forwarded socket on the "remote machine".
|
|
|
|
d := &net.Dialer{}
|
|
|
|
fd, err := d.DialContext(ctx, "unix", sock.remote)
|
|
|
|
require.NoError(t, err, i)
|
|
|
|
defer fd.Close() //nolint:revive // Defer is fine in this loop, we only run it twice.
|
|
|
|
|
|
|
|
// Ping / pong to ensure the socket is working.
|
|
|
|
_, err = fd.Write([]byte("hello world"))
|
|
|
|
require.NoError(t, err, i)
|
|
|
|
|
|
|
|
buf := make([]byte, 11)
|
|
|
|
_, err = fd.Read(buf)
|
|
|
|
require.NoError(t, err, i)
|
|
|
|
require.Equal(t, "hello world", string(buf), i)
|
|
|
|
}
|
|
|
|
|
|
|
|
// And we're done.
|
|
|
|
pty.WriteLine("exit")
|
|
|
|
})
|
|
|
|
|
2023-05-25 05:07:39 +00:00
|
|
|
t.Run("FileLogging", func(t *testing.T) {
|
|
|
|
t.Parallel()
|
|
|
|
|
2023-06-12 05:18:33 +00:00
|
|
|
logDir := t.TempDir()
|
2023-05-25 05:07:39 +00:00
|
|
|
|
2023-11-21 12:22:08 +00:00
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
2023-06-12 05:18:33 +00:00
|
|
|
inv, root := clitest.New(t, "ssh", "-l", logDir, workspace.Name)
|
2023-05-25 05:07:39 +00:00
|
|
|
clitest.SetupConfig(t, client, root)
|
|
|
|
pty := ptytest.New(t).Attach(inv)
|
2023-06-12 05:18:33 +00:00
|
|
|
w := clitest.StartWithWaiter(t, inv)
|
2023-05-25 05:07:39 +00:00
|
|
|
|
|
|
|
pty.ExpectMatch("Waiting")
|
|
|
|
|
2023-09-26 11:05:19 +00:00
|
|
|
agenttest.New(t, client.URL, agentToken)
|
|
|
|
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
2023-05-25 05:07:39 +00:00
|
|
|
|
|
|
|
// Shells on Mac, Windows, and Linux all exit shells with the "exit" command.
|
|
|
|
pty.WriteLine("exit")
|
2023-06-12 05:18:33 +00:00
|
|
|
w.RequireSuccess()
|
2023-05-25 05:07:39 +00:00
|
|
|
|
2023-06-12 05:18:33 +00:00
|
|
|
ents, err := os.ReadDir(logDir)
|
2023-05-25 05:07:39 +00:00
|
|
|
require.NoError(t, err)
|
2023-06-12 05:18:33 +00:00
|
|
|
require.Len(t, ents, 1, "expected one file in logdir %s", logDir)
|
2023-05-25 05:07:39 +00:00
|
|
|
})
|
2023-01-23 11:40:41 +00:00
|
|
|
}
|
2023-01-06 07:52:19 +00:00
|
|
|
|
2023-01-23 11:40:41 +00:00
|
|
|
//nolint:paralleltest // This test uses t.Setenv, parent test MUST NOT be parallel.
|
|
|
|
func TestSSH_ForwardGPG(t *testing.T) {
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
// While GPG forwarding from a Windows client works, we currently do
|
|
|
|
// not support forwarding to a Windows workspace. Our tests use the
|
|
|
|
// same platform for the "client" and "workspace" as they run in the
|
|
|
|
// same process.
|
|
|
|
t.Skip("Test not supported on windows")
|
|
|
|
}
|
2023-02-08 17:09:38 +00:00
|
|
|
if testing.Short() {
|
|
|
|
t.SkipNow()
|
|
|
|
}
|
2023-01-06 07:52:19 +00:00
|
|
|
|
2023-01-23 11:40:41 +00:00
|
|
|
// This key is for dean@coder.com.
|
|
|
|
const randPublicKeyFingerprint = "7BDFBA0CC7F5A96537C806C427BC6335EB5117F1"
|
|
|
|
const randPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----
|
2023-01-06 07:52:19 +00:00
|
|
|
|
|
|
|
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
|
|
|
|
p7KeSZdlk47pMBGOfnvEmoQ=
|
|
|
|
=OxHv
|
|
|
|
-----END PGP PUBLIC KEY BLOCK-----`
|
|
|
|
|
2023-01-23 11:40:41 +00:00
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
|
|
defer cancel()
|
2023-01-06 07:52:19 +00:00
|
|
|
|
2023-01-23 11:40:41 +00:00
|
|
|
gpgPath, err := exec.LookPath("gpg")
|
|
|
|
if err != nil {
|
|
|
|
t.Skip("gpg not found")
|
|
|
|
}
|
|
|
|
gpgConfPath, err := exec.LookPath("gpgconf")
|
|
|
|
if err != nil {
|
|
|
|
t.Skip("gpgconf not found")
|
|
|
|
}
|
|
|
|
gpgAgentPath, err := exec.LookPath("gpg-agent")
|
|
|
|
if err != nil {
|
|
|
|
t.Skip("gpg-agent not found")
|
|
|
|
}
|
2023-01-06 07:52:19 +00:00
|
|
|
|
2023-01-23 11:40:41 +00:00
|
|
|
// Setup GPG home directory on the "client".
|
|
|
|
gnupgHomeClient := tempDirUnixSocket(t)
|
|
|
|
t.Setenv("GNUPGHOME", gnupgHomeClient)
|
2023-01-06 07:52:19 +00:00
|
|
|
|
2023-01-23 11:40:41 +00:00
|
|
|
// Get the agent extra socket path.
|
|
|
|
var (
|
|
|
|
stdout = bytes.NewBuffer(nil)
|
|
|
|
stderr = bytes.NewBuffer(nil)
|
|
|
|
)
|
|
|
|
c := exec.CommandContext(ctx, gpgConfPath, "--list-dir", "agent-extra-socket")
|
|
|
|
c.Stdout = stdout
|
|
|
|
c.Stderr = stderr
|
|
|
|
err = c.Run()
|
|
|
|
require.NoError(t, err, "get extra socket path failed: %s", stderr.String())
|
|
|
|
extraSocketPath := strings.TrimSpace(stdout.String())
|
|
|
|
|
|
|
|
// Generate private key non-interactively.
|
|
|
|
genKeyScript := `
|
2023-01-06 07:52:19 +00:00
|
|
|
Key-Type: 1
|
|
|
|
Key-Length: 2048
|
|
|
|
Subkey-Type: 1
|
|
|
|
Subkey-Length: 2048
|
|
|
|
Name-Real: Coder Test
|
|
|
|
Name-Email: test@coder.com
|
|
|
|
Expire-Date: 0
|
|
|
|
%no-protection
|
|
|
|
`
|
2023-01-23 11:40:41 +00:00
|
|
|
c = exec.CommandContext(ctx, gpgPath, "--batch", "--gen-key")
|
|
|
|
c.Stdin = strings.NewReader(genKeyScript)
|
|
|
|
out, err := c.CombinedOutput()
|
|
|
|
require.NoError(t, err, "generate key failed: %s", out)
|
|
|
|
|
|
|
|
// Import a random public key.
|
|
|
|
stdin := strings.NewReader(randPublicKey + "\n")
|
|
|
|
c = exec.CommandContext(ctx, gpgPath, "--import", "-")
|
|
|
|
c.Stdin = stdin
|
|
|
|
out, err = c.CombinedOutput()
|
|
|
|
require.NoError(t, err, "import key failed: %s", out)
|
|
|
|
|
|
|
|
// Set ultimate trust on imported key.
|
|
|
|
stdin = strings.NewReader(randPublicKeyFingerprint + ":6:\n")
|
|
|
|
c = exec.CommandContext(ctx, gpgPath, "--import-ownertrust")
|
|
|
|
c.Stdin = stdin
|
|
|
|
out, err = c.CombinedOutput()
|
|
|
|
require.NoError(t, err, "import ownertrust failed: %s", out)
|
|
|
|
|
|
|
|
// Start the GPG agent.
|
2023-05-03 07:43:05 +00:00
|
|
|
agentCmd := pty.CommandContext(ctx, gpgAgentPath, "--no-detach", "--extra-socket", extraSocketPath)
|
2023-01-23 11:40:41 +00:00
|
|
|
agentCmd.Env = append(agentCmd.Env, "GNUPGHOME="+gnupgHomeClient)
|
|
|
|
agentPTY, agentProc, err := pty.Start(agentCmd, pty.WithPTYOption(pty.WithGPGTTY()))
|
|
|
|
require.NoError(t, err, "launch agent failed")
|
|
|
|
defer func() {
|
|
|
|
_ = agentProc.Kill()
|
|
|
|
_ = agentPTY.Close()
|
|
|
|
}()
|
|
|
|
|
|
|
|
// Get the agent socket path in the "workspace".
|
|
|
|
gnupgHomeWorkspace := tempDirUnixSocket(t)
|
|
|
|
|
|
|
|
stdout = bytes.NewBuffer(nil)
|
|
|
|
stderr = bytes.NewBuffer(nil)
|
|
|
|
c = exec.CommandContext(ctx, gpgConfPath, "--list-dir", "agent-socket")
|
|
|
|
c.Env = append(c.Env, "GNUPGHOME="+gnupgHomeWorkspace)
|
|
|
|
c.Stdout = stdout
|
|
|
|
c.Stderr = stderr
|
|
|
|
err = c.Run()
|
|
|
|
require.NoError(t, err, "get agent socket path in workspace failed: %s", stderr.String())
|
|
|
|
workspaceAgentSocketPath := strings.TrimSpace(stdout.String())
|
|
|
|
require.NotEqual(t, extraSocketPath, workspaceAgentSocketPath, "socket path should be different")
|
|
|
|
|
2023-11-21 12:22:08 +00:00
|
|
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
2023-01-23 11:40:41 +00:00
|
|
|
|
2023-09-26 11:05:19 +00:00
|
|
|
_ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) {
|
|
|
|
o.EnvironmentVariables = map[string]string{
|
2023-01-23 11:40:41 +00:00
|
|
|
"GNUPGHOME": gnupgHomeWorkspace,
|
2023-09-26 11:05:19 +00:00
|
|
|
}
|
2023-01-23 11:40:41 +00:00
|
|
|
})
|
2023-09-26 11:05:19 +00:00
|
|
|
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
2023-01-23 11:40:41 +00:00
|
|
|
|
2023-03-23 22:42:20 +00:00
|
|
|
inv, root := clitest.New(t,
|
2023-01-23 11:40:41 +00:00
|
|
|
"ssh",
|
|
|
|
workspace.Name,
|
|
|
|
"--forward-gpg",
|
|
|
|
)
|
|
|
|
clitest.SetupConfig(t, client, root)
|
|
|
|
tpty := ptytest.New(t)
|
2023-03-23 22:42:20 +00:00
|
|
|
inv.Stdin = tpty.Input()
|
|
|
|
inv.Stdout = tpty.Output()
|
|
|
|
inv.Stderr = tpty.Output()
|
2023-01-23 11:40:41 +00:00
|
|
|
cmdDone := tGo(t, func() {
|
2023-03-23 22:42:20 +00:00
|
|
|
err := inv.WithContext(ctx).Run()
|
2023-01-23 11:40:41 +00:00
|
|
|
assert.NoError(t, err, "ssh command failed")
|
2023-01-06 07:52:19 +00:00
|
|
|
})
|
2023-01-23 11:40:41 +00:00
|
|
|
// Prevent the test from hanging if the asserts below kill the test
|
|
|
|
// early. This will cause the command to exit with an error, which will
|
|
|
|
// let the t.Cleanup'd `<-done` inside of `tGo` exit and not hang.
|
|
|
|
// Without this, the test will hang forever on failure, preventing the
|
|
|
|
// real error from being printed.
|
|
|
|
t.Cleanup(cancel)
|
|
|
|
|
|
|
|
// Wait for the prompt or any output really to indicate the command has
|
|
|
|
// started and accepting input on stdin.
|
|
|
|
_ = tpty.Peek(ctx, 1)
|
|
|
|
|
|
|
|
tpty.WriteLine("echo hello 'world'")
|
|
|
|
tpty.ExpectMatch("hello world")
|
|
|
|
|
|
|
|
// Check the GNUPGHOME was correctly inherited via shell.
|
|
|
|
tpty.WriteLine("env && echo env-''-command-done")
|
|
|
|
match := tpty.ExpectMatch("env--command-done")
|
|
|
|
require.Contains(t, match, "GNUPGHOME="+gnupgHomeWorkspace, match)
|
|
|
|
|
|
|
|
// Get the agent extra socket path in the "workspace" via shell.
|
|
|
|
tpty.WriteLine("gpgconf --list-dir agent-socket && echo gpgconf-''-agentsocket-command-done")
|
|
|
|
tpty.ExpectMatch(workspaceAgentSocketPath)
|
|
|
|
tpty.ExpectMatch("gpgconf--agentsocket-command-done")
|
|
|
|
|
|
|
|
// List the keys in the "workspace".
|
|
|
|
tpty.WriteLine("gpg --list-keys && echo gpg-''-listkeys-command-done")
|
|
|
|
listKeysOutput := tpty.ExpectMatch("gpg--listkeys-command-done")
|
|
|
|
require.Contains(t, listKeysOutput, "[ultimate] Coder Test <test@coder.com>")
|
|
|
|
require.Contains(t, listKeysOutput, "[ultimate] Dean Sheather (work key) <dean@coder.com>")
|
|
|
|
|
|
|
|
// Try to sign something. This demonstrates that the forwarding is
|
|
|
|
// working as expected, since the workspace doesn't have access to the
|
|
|
|
// private key directly and must use the forwarded agent.
|
|
|
|
tpty.WriteLine("echo 'hello world' | gpg --clearsign && echo gpg-''-sign-command-done")
|
|
|
|
tpty.ExpectMatch("BEGIN PGP SIGNED MESSAGE")
|
|
|
|
tpty.ExpectMatch("Hash:")
|
|
|
|
tpty.ExpectMatch("hello world")
|
|
|
|
tpty.ExpectMatch("gpg--sign-command-done")
|
|
|
|
|
|
|
|
// And we're done.
|
|
|
|
tpty.WriteLine("exit")
|
|
|
|
<-cmdDone
|
2022-05-25 18:28:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// tGoContext runs fn in a goroutine passing a context that will be
|
|
|
|
// canceled on test completion and wait until fn has finished executing.
|
|
|
|
// Done and cancel are returned for optionally waiting until completion
|
|
|
|
// or early cancellation.
|
|
|
|
//
|
|
|
|
// NOTE(mafredri): This could be moved to a helper library.
|
|
|
|
func tGoContext(t *testing.T, fn func(context.Context)) (done <-chan struct{}, cancel context.CancelFunc) {
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
doneC := make(chan struct{})
|
|
|
|
t.Cleanup(func() {
|
|
|
|
cancel()
|
|
|
|
<-done
|
|
|
|
})
|
|
|
|
go func() {
|
|
|
|
fn(ctx)
|
|
|
|
close(doneC)
|
|
|
|
}()
|
|
|
|
|
|
|
|
return doneC, cancel
|
|
|
|
}
|
|
|
|
|
|
|
|
// tGo runs fn in a goroutine and waits until fn has completed before
|
|
|
|
// test completion. Done is returned for optionally waiting for fn to
|
|
|
|
// exit.
|
|
|
|
//
|
|
|
|
// NOTE(mafredri): This could be moved to a helper library.
|
|
|
|
func tGo(t *testing.T, fn func()) (done <-chan struct{}) {
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
doneC := make(chan struct{})
|
|
|
|
t.Cleanup(func() {
|
|
|
|
<-doneC
|
2022-03-30 22:59:54 +00:00
|
|
|
})
|
2022-05-25 18:28:10 +00:00
|
|
|
go func() {
|
|
|
|
fn()
|
|
|
|
close(doneC)
|
|
|
|
}()
|
|
|
|
|
|
|
|
return doneC
|
2022-03-30 22:59:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type stdioConn struct {
|
|
|
|
io.Reader
|
|
|
|
io.Writer
|
|
|
|
}
|
|
|
|
|
|
|
|
func (*stdioConn) Close() (err error) {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (*stdioConn) LocalAddr() net.Addr {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (*stdioConn) RemoteAddr() net.Addr {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (*stdioConn) SetDeadline(_ time.Time) error {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (*stdioConn) SetReadDeadline(_ time.Time) error {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (*stdioConn) SetWriteDeadline(_ time.Time) error {
|
|
|
|
return nil
|
2022-03-29 00:19:28 +00:00
|
|
|
}
|
2023-01-06 07:52:19 +00:00
|
|
|
|
|
|
|
// tempDirUnixSocket returns a temporary directory that can safely hold unix
|
|
|
|
// sockets (probably).
|
|
|
|
//
|
|
|
|
// During tests on darwin we hit the max path length limit for unix sockets
|
|
|
|
// pretty easily in the default location, so this function uses /tmp instead to
|
|
|
|
// get shorter paths.
|
|
|
|
func tempDirUnixSocket(t *testing.T) string {
|
|
|
|
t.Helper()
|
|
|
|
if runtime.GOOS == "darwin" {
|
|
|
|
testName := strings.ReplaceAll(t.Name(), "/", "_")
|
|
|
|
dir, err := os.MkdirTemp("/tmp", fmt.Sprintf("coder-test-%s-", testName))
|
|
|
|
require.NoError(t, err, "create temp dir for gpg test")
|
|
|
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
err := os.RemoveAll(dir)
|
|
|
|
assert.NoError(t, err, "remove temp dir", dir)
|
|
|
|
})
|
|
|
|
return dir
|
|
|
|
}
|
|
|
|
|
|
|
|
return t.TempDir()
|
|
|
|
}
|