mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(agent): wire up agentssh server to allow exec into container (#16638)
Builds on top of https://github.com/coder/coder/pull/16623/ and wires up the ReconnectingPTY server. This does nothing to wire up the web terminal yet but the added test demonstrates the functionality working. Other changes: * Refactors and moves the `SystemEnvInfo` interface to the `agent/usershell` package to address follow-up from https://github.com/coder/coder/pull/16623#discussion_r1967580249 * Marks `usershellinfo.Get` as deprecated. Consumers should use the `EnvInfoer` interface instead. --------- Co-authored-by: Mathias Fredriksson <mafredri@gmail.com> Co-authored-by: Danny Kopping <danny@coder.com>
This commit is contained in:
co-authored by
Mathias Fredriksson
Danny Kopping
parent
a3223397cb
commit
172e52317c
@@ -88,6 +88,8 @@ type Options struct {
|
|||||||
BlockFileTransfer bool
|
BlockFileTransfer bool
|
||||||
Execer agentexec.Execer
|
Execer agentexec.Execer
|
||||||
ContainerLister agentcontainers.Lister
|
ContainerLister agentcontainers.Lister
|
||||||
|
|
||||||
|
ExperimentalContainersEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Client interface {
|
type Client interface {
|
||||||
@@ -188,6 +190,8 @@ func New(options Options) Agent {
|
|||||||
metrics: newAgentMetrics(prometheusRegistry),
|
metrics: newAgentMetrics(prometheusRegistry),
|
||||||
execer: options.Execer,
|
execer: options.Execer,
|
||||||
lister: options.ContainerLister,
|
lister: options.ContainerLister,
|
||||||
|
|
||||||
|
experimentalDevcontainersEnabled: options.ExperimentalContainersEnabled,
|
||||||
}
|
}
|
||||||
// Initially, we have a closed channel, reflecting the fact that we are not initially connected.
|
// Initially, we have a closed channel, reflecting the fact that we are not initially connected.
|
||||||
// Each time we connect we replace the channel (while holding the closeMutex) with a new one
|
// Each time we connect we replace the channel (while holding the closeMutex) with a new one
|
||||||
@@ -258,6 +262,8 @@ type agent struct {
|
|||||||
metrics *agentMetrics
|
metrics *agentMetrics
|
||||||
execer agentexec.Execer
|
execer agentexec.Execer
|
||||||
lister agentcontainers.Lister
|
lister agentcontainers.Lister
|
||||||
|
|
||||||
|
experimentalDevcontainersEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *agent) TailnetConn() *tailnet.Conn {
|
func (a *agent) TailnetConn() *tailnet.Conn {
|
||||||
@@ -297,6 +303,9 @@ func (a *agent) init() {
|
|||||||
a.sshServer,
|
a.sshServer,
|
||||||
a.metrics.connectionsTotal, a.metrics.reconnectingPTYErrors,
|
a.metrics.connectionsTotal, a.metrics.reconnectingPTYErrors,
|
||||||
a.reconnectingPTYTimeout,
|
a.reconnectingPTYTimeout,
|
||||||
|
func(s *reconnectingpty.Server) {
|
||||||
|
s.ExperimentalContainersEnabled = a.experimentalDevcontainersEnabled
|
||||||
|
},
|
||||||
)
|
)
|
||||||
go a.runLoop()
|
go a.runLoop()
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-3
@@ -25,8 +25,14 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/goleak"
|
||||||
|
"tailscale.com/net/speedtest"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
|
||||||
"github.com/bramvdbogaerde/go-scp"
|
"github.com/bramvdbogaerde/go-scp"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/ory/dockertest/v3"
|
||||||
|
"github.com/ory/dockertest/v3/docker"
|
||||||
"github.com/pion/udp"
|
"github.com/pion/udp"
|
||||||
"github.com/pkg/sftp"
|
"github.com/pkg/sftp"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
@@ -34,15 +40,13 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/goleak"
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
"golang.org/x/exp/slices"
|
"golang.org/x/exp/slices"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
"tailscale.com/net/speedtest"
|
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
|
|
||||||
"cdr.dev/slog"
|
"cdr.dev/slog"
|
||||||
"cdr.dev/slog/sloggers/slogtest"
|
"cdr.dev/slog/sloggers/slogtest"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/agent"
|
"github.com/coder/coder/v2/agent"
|
||||||
"github.com/coder/coder/v2/agent/agentssh"
|
"github.com/coder/coder/v2/agent/agentssh"
|
||||||
"github.com/coder/coder/v2/agent/agenttest"
|
"github.com/coder/coder/v2/agent/agenttest"
|
||||||
@@ -1761,6 +1765,74 @@ func TestAgent_ReconnectingPTY(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This tests end-to-end functionality of connecting to a running container
|
||||||
|
// and executing a command. It creates a real Docker container and runs a
|
||||||
|
// command. As such, it does not run by default in CI.
|
||||||
|
// You can run it manually as follows:
|
||||||
|
//
|
||||||
|
// CODER_TEST_USE_DOCKER=1 go test -count=1 ./agent -run TestAgent_ReconnectingPTYContainer
|
||||||
|
func TestAgent_ReconnectingPTYContainer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if os.Getenv("CODER_TEST_USE_DOCKER") != "1" {
|
||||||
|
t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
|
||||||
|
pool, err := dockertest.NewPool("")
|
||||||
|
require.NoError(t, err, "Could not connect to docker")
|
||||||
|
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
|
||||||
|
Repository: "busybox",
|
||||||
|
Tag: "latest",
|
||||||
|
Cmd: []string{"sleep", "infnity"},
|
||||||
|
}, func(config *docker.HostConfig) {
|
||||||
|
config.AutoRemove = true
|
||||||
|
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Could not start container")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := pool.Purge(ct)
|
||||||
|
require.NoError(t, err, "Could not stop container")
|
||||||
|
})
|
||||||
|
// Wait for container to start
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
ct, ok := pool.ContainerByName(ct.Container.Name)
|
||||||
|
return ok && ct.Container.State.Running
|
||||||
|
}, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time")
|
||||||
|
|
||||||
|
// nolint: dogsled
|
||||||
|
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||||
|
o.ExperimentalContainersEnabled = true
|
||||||
|
})
|
||||||
|
ac, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "/bin/sh", func(arp *workspacesdk.AgentReconnectingPTYInit) {
|
||||||
|
arp.Container = ct.Container.ID
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to create ReconnectingPTY")
|
||||||
|
defer ac.Close()
|
||||||
|
tr := testutil.NewTerminalReader(t, ac)
|
||||||
|
|
||||||
|
require.NoError(t, tr.ReadUntil(ctx, func(line string) bool {
|
||||||
|
return strings.Contains(line, "#") || strings.Contains(line, "$")
|
||||||
|
}), "find prompt")
|
||||||
|
|
||||||
|
require.NoError(t, json.NewEncoder(ac).Encode(workspacesdk.ReconnectingPTYRequest{
|
||||||
|
Data: "hostname\r",
|
||||||
|
}), "write hostname")
|
||||||
|
require.NoError(t, tr.ReadUntil(ctx, func(line string) bool {
|
||||||
|
return strings.Contains(line, "hostname")
|
||||||
|
}), "find hostname command")
|
||||||
|
|
||||||
|
require.NoError(t, tr.ReadUntil(ctx, func(line string) bool {
|
||||||
|
return strings.Contains(line, ct.Container.Config.Hostname)
|
||||||
|
}), "find hostname output")
|
||||||
|
require.NoError(t, json.NewEncoder(ac).Encode(workspacesdk.ReconnectingPTYRequest{
|
||||||
|
Data: "exit\r",
|
||||||
|
}), "write exit command")
|
||||||
|
|
||||||
|
// Wait for the connection to close.
|
||||||
|
require.ErrorIs(t, tr.ReadUntil(ctx, nil), io.EOF)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAgent_Dial(t *testing.T) {
|
func TestAgent_Dial(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"os/user"
|
"os/user"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -15,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/agent/agentexec"
|
"github.com/coder/coder/v2/agent/agentexec"
|
||||||
|
"github.com/coder/coder/v2/agent/usershell"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
|
|
||||||
"golang.org/x/exp/maps"
|
"golang.org/x/exp/maps"
|
||||||
@@ -37,6 +37,7 @@ func NewDocker(execer agentexec.Execer) Lister {
|
|||||||
// DockerEnvInfoer is an implementation of agentssh.EnvInfoer that returns
|
// DockerEnvInfoer is an implementation of agentssh.EnvInfoer that returns
|
||||||
// information about a container.
|
// information about a container.
|
||||||
type DockerEnvInfoer struct {
|
type DockerEnvInfoer struct {
|
||||||
|
usershell.SystemEnvInfo
|
||||||
container string
|
container string
|
||||||
user *user.User
|
user *user.User
|
||||||
userShell string
|
userShell string
|
||||||
@@ -122,26 +123,13 @@ func EnvInfo(ctx context.Context, execer agentexec.Execer, container, containerU
|
|||||||
return &dei, nil
|
return &dei, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dei *DockerEnvInfoer) CurrentUser() (*user.User, error) {
|
func (dei *DockerEnvInfoer) User() (*user.User, error) {
|
||||||
// Clone the user so that the caller can't modify it
|
// Clone the user so that the caller can't modify it
|
||||||
u := *dei.user
|
u := *dei.user
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*DockerEnvInfoer) Environ() []string {
|
func (dei *DockerEnvInfoer) Shell(string) (string, error) {
|
||||||
// Return a clone of the environment so that the caller can't modify it
|
|
||||||
return os.Environ()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*DockerEnvInfoer) UserHomeDir() (string, error) {
|
|
||||||
// We default the working directory of the command to the user's home
|
|
||||||
// directory. Since this came from inside the container, we cannot guarantee
|
|
||||||
// that this exists on the host. Return the "real" home directory of the user
|
|
||||||
// instead.
|
|
||||||
return os.UserHomeDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (dei *DockerEnvInfoer) UserShell(string) (string, error) {
|
|
||||||
return dei.userShell, nil
|
return dei.userShell, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -502,15 +502,15 @@ func TestDockerEnvInfoer(t *testing.T) {
|
|||||||
dei, err := EnvInfo(ctx, agentexec.DefaultExecer, ct.Container.ID, tt.containerUser)
|
dei, err := EnvInfo(ctx, agentexec.DefaultExecer, ct.Container.ID, tt.containerUser)
|
||||||
require.NoError(t, err, "Expected no error from DockerEnvInfo()")
|
require.NoError(t, err, "Expected no error from DockerEnvInfo()")
|
||||||
|
|
||||||
u, err := dei.CurrentUser()
|
u, err := dei.User()
|
||||||
require.NoError(t, err, "Expected no error from CurrentUser()")
|
require.NoError(t, err, "Expected no error from CurrentUser()")
|
||||||
require.Equal(t, tt.expectedUsername, u.Username, "Expected username to match")
|
require.Equal(t, tt.expectedUsername, u.Username, "Expected username to match")
|
||||||
|
|
||||||
hd, err := dei.UserHomeDir()
|
hd, err := dei.HomeDir()
|
||||||
require.NoError(t, err, "Expected no error from UserHomeDir()")
|
require.NoError(t, err, "Expected no error from UserHomeDir()")
|
||||||
require.NotEmpty(t, hd, "Expected user homedir to be non-empty")
|
require.NotEmpty(t, hd, "Expected user homedir to be non-empty")
|
||||||
|
|
||||||
sh, err := dei.UserShell(tt.containerUser)
|
sh, err := dei.Shell(tt.containerUser)
|
||||||
require.NoError(t, err, "Expected no error from UserShell()")
|
require.NoError(t, err, "Expected no error from UserShell()")
|
||||||
require.Equal(t, tt.expectedUserShell, sh, "Expected user shell to match")
|
require.Equal(t, tt.expectedUserShell, sh, "Expected user shell to match")
|
||||||
|
|
||||||
|
|||||||
+19
-47
@@ -698,45 +698,6 @@ func (s *Server) sftpHandler(logger slog.Logger, session ssh.Session) {
|
|||||||
_ = session.Exit(1)
|
_ = session.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnvInfoer encapsulates external information required by CreateCommand.
|
|
||||||
type EnvInfoer interface {
|
|
||||||
// CurrentUser returns the current user.
|
|
||||||
CurrentUser() (*user.User, error)
|
|
||||||
// Environ returns the environment variables of the current process.
|
|
||||||
Environ() []string
|
|
||||||
// UserHomeDir returns the home directory of the current user.
|
|
||||||
UserHomeDir() (string, error)
|
|
||||||
// UserShell returns the shell of the given user.
|
|
||||||
UserShell(username string) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type systemEnvInfoer struct{}
|
|
||||||
|
|
||||||
var defaultEnvInfoer EnvInfoer = &systemEnvInfoer{}
|
|
||||||
|
|
||||||
// DefaultEnvInfoer returns a default implementation of
|
|
||||||
// EnvInfoer. This reads information using the default Go
|
|
||||||
// implementations.
|
|
||||||
func DefaultEnvInfoer() EnvInfoer {
|
|
||||||
return defaultEnvInfoer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (systemEnvInfoer) CurrentUser() (*user.User, error) {
|
|
||||||
return user.Current()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (systemEnvInfoer) Environ() []string {
|
|
||||||
return os.Environ()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (systemEnvInfoer) UserHomeDir() (string, error) {
|
|
||||||
return userHomeDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (systemEnvInfoer) UserShell(username string) (string, error) {
|
|
||||||
return usershell.Get(username)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateCommand processes raw command input with OpenSSH-like behavior.
|
// CreateCommand processes raw command input with OpenSSH-like behavior.
|
||||||
// If the script provided is empty, it will default to the users shell.
|
// If the script provided is empty, it will default to the users shell.
|
||||||
// This injects environment variables specified by the user at launch too.
|
// This injects environment variables specified by the user at launch too.
|
||||||
@@ -744,17 +705,17 @@ func (systemEnvInfoer) UserShell(username string) (string, error) {
|
|||||||
// alternative implementations for the dependencies of CreateCommand.
|
// alternative implementations for the dependencies of CreateCommand.
|
||||||
// This is useful when creating a command to be run in a separate environment
|
// This is useful when creating a command to be run in a separate environment
|
||||||
// (for example, a Docker container). Pass in nil to use the default.
|
// (for example, a Docker container). Pass in nil to use the default.
|
||||||
func (s *Server) CreateCommand(ctx context.Context, script string, env []string, deps EnvInfoer) (*pty.Cmd, error) {
|
func (s *Server) CreateCommand(ctx context.Context, script string, env []string, ei usershell.EnvInfoer) (*pty.Cmd, error) {
|
||||||
if deps == nil {
|
if ei == nil {
|
||||||
deps = DefaultEnvInfoer()
|
ei = &usershell.SystemEnvInfo{}
|
||||||
}
|
}
|
||||||
currentUser, err := deps.CurrentUser()
|
currentUser, err := ei.User()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerrors.Errorf("get current user: %w", err)
|
return nil, xerrors.Errorf("get current user: %w", err)
|
||||||
}
|
}
|
||||||
username := currentUser.Username
|
username := currentUser.Username
|
||||||
|
|
||||||
shell, err := deps.UserShell(username)
|
shell, err := ei.Shell(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerrors.Errorf("get user shell: %w", err)
|
return nil, xerrors.Errorf("get user shell: %w", err)
|
||||||
}
|
}
|
||||||
@@ -802,7 +763,18 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := s.Execer.PTYCommandContext(ctx, name, args...)
|
// Modify command prior to execution. This will usually be a no-op, but not
|
||||||
|
// always. For example, to run a command in a Docker container, we need to
|
||||||
|
// modify the command to be `docker exec -it <container> <command>`.
|
||||||
|
modifiedName, modifiedArgs := ei.ModifyCommand(name, args...)
|
||||||
|
// Log if the command was modified.
|
||||||
|
if modifiedName != name && slices.Compare(modifiedArgs, args) != 0 {
|
||||||
|
s.logger.Debug(ctx, "modified command",
|
||||||
|
slog.F("before", append([]string{name}, args...)),
|
||||||
|
slog.F("after", append([]string{modifiedName}, modifiedArgs...)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
cmd := s.Execer.PTYCommandContext(ctx, modifiedName, modifiedArgs...)
|
||||||
cmd.Dir = s.config.WorkingDirectory()
|
cmd.Dir = s.config.WorkingDirectory()
|
||||||
|
|
||||||
// If the metadata directory doesn't exist, we run the command
|
// If the metadata directory doesn't exist, we run the command
|
||||||
@@ -810,13 +782,13 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string,
|
|||||||
_, err = os.Stat(cmd.Dir)
|
_, err = os.Stat(cmd.Dir)
|
||||||
if cmd.Dir == "" || err != nil {
|
if cmd.Dir == "" || err != nil {
|
||||||
// Default to user home if a directory is not set.
|
// Default to user home if a directory is not set.
|
||||||
homedir, err := deps.UserHomeDir()
|
homedir, err := ei.HomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerrors.Errorf("get home dir: %w", err)
|
return nil, xerrors.Errorf("get home dir: %w", err)
|
||||||
}
|
}
|
||||||
cmd.Dir = homedir
|
cmd.Dir = homedir
|
||||||
}
|
}
|
||||||
cmd.Env = append(deps.Environ(), env...)
|
cmd.Env = append(ei.Environ(), env...)
|
||||||
cmd.Env = append(cmd.Env, fmt.Sprintf("USER=%s", username))
|
cmd.Env = append(cmd.Env, fmt.Sprintf("USER=%s", username))
|
||||||
|
|
||||||
// Set SSH connection environment variables (these are also set by OpenSSH
|
// Set SSH connection environment variables (these are also set by OpenSSH
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ type fakeEnvInfoer struct {
|
|||||||
UserShellFn func(string) (string, error)
|
UserShellFn func(string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeEnvInfoer) CurrentUser() (u *user.User, err error) {
|
func (f *fakeEnvInfoer) User() (u *user.User, err error) {
|
||||||
return f.CurrentUserFn()
|
return f.CurrentUserFn()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,14 +132,18 @@ func (f *fakeEnvInfoer) Environ() []string {
|
|||||||
return f.EnvironFn()
|
return f.EnvironFn()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeEnvInfoer) UserHomeDir() (string, error) {
|
func (f *fakeEnvInfoer) HomeDir() (string, error) {
|
||||||
return f.UserHomeDirFn()
|
return f.UserHomeDirFn()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeEnvInfoer) UserShell(u string) (string, error) {
|
func (f *fakeEnvInfoer) Shell(u string) (string, error) {
|
||||||
return f.UserShellFn(u)
|
return f.UserShellFn(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (*fakeEnvInfoer) ModifyCommand(cmd string, args ...string) (string, []string) {
|
||||||
|
return cmd, args
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewServer_CloseActiveConnections(t *testing.T) {
|
func TestNewServer_CloseActiveConnections(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import (
|
|||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"cdr.dev/slog"
|
"cdr.dev/slog"
|
||||||
|
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||||
"github.com/coder/coder/v2/agent/agentssh"
|
"github.com/coder/coder/v2/agent/agentssh"
|
||||||
|
"github.com/coder/coder/v2/agent/usershell"
|
||||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,20 +28,26 @@ type Server struct {
|
|||||||
connCount atomic.Int64
|
connCount atomic.Int64
|
||||||
reconnectingPTYs sync.Map
|
reconnectingPTYs sync.Map
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
|
|
||||||
|
ExperimentalContainersEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer returns a new ReconnectingPTY server
|
// NewServer returns a new ReconnectingPTY server
|
||||||
func NewServer(logger slog.Logger, commandCreator *agentssh.Server,
|
func NewServer(logger slog.Logger, commandCreator *agentssh.Server,
|
||||||
connectionsTotal prometheus.Counter, errorsTotal *prometheus.CounterVec,
|
connectionsTotal prometheus.Counter, errorsTotal *prometheus.CounterVec,
|
||||||
timeout time.Duration,
|
timeout time.Duration, opts ...func(*Server),
|
||||||
) *Server {
|
) *Server {
|
||||||
return &Server{
|
s := &Server{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
commandCreator: commandCreator,
|
commandCreator: commandCreator,
|
||||||
connectionsTotal: connectionsTotal,
|
connectionsTotal: connectionsTotal,
|
||||||
errorsTotal: errorsTotal,
|
errorsTotal: errorsTotal,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
}
|
}
|
||||||
|
for _, o := range opts {
|
||||||
|
o(s)
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Serve(ctx, hardCtx context.Context, l net.Listener) (retErr error) {
|
func (s *Server) Serve(ctx, hardCtx context.Context, l net.Listener) (retErr error) {
|
||||||
@@ -116,7 +124,7 @@ func (s *Server) handleConn(ctx context.Context, logger slog.Logger, conn net.Co
|
|||||||
}
|
}
|
||||||
|
|
||||||
connectionID := uuid.NewString()
|
connectionID := uuid.NewString()
|
||||||
connLogger := logger.With(slog.F("message_id", msg.ID), slog.F("connection_id", connectionID))
|
connLogger := logger.With(slog.F("message_id", msg.ID), slog.F("connection_id", connectionID), slog.F("container", msg.Container), slog.F("container_user", msg.ContainerUser))
|
||||||
connLogger.Debug(ctx, "starting handler")
|
connLogger.Debug(ctx, "starting handler")
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -158,8 +166,17 @@ func (s *Server) handleConn(ctx context.Context, logger slog.Logger, conn net.Co
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var ei usershell.EnvInfoer
|
||||||
|
if s.ExperimentalContainersEnabled && msg.Container != "" {
|
||||||
|
dei, err := agentcontainers.EnvInfo(ctx, s.commandCreator.Execer, msg.Container, msg.ContainerUser)
|
||||||
|
if err != nil {
|
||||||
|
return xerrors.Errorf("get container env info: %w", err)
|
||||||
|
}
|
||||||
|
ei = dei
|
||||||
|
s.logger.Info(ctx, "got container env info", slog.F("container", msg.Container))
|
||||||
|
}
|
||||||
// Empty command will default to the users shell!
|
// Empty command will default to the users shell!
|
||||||
cmd, err := s.commandCreator.CreateCommand(ctx, msg.Command, nil, nil)
|
cmd, err := s.commandCreator.CreateCommand(ctx, msg.Command, nil, ei)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.errorsTotal.WithLabelValues("create_command").Add(1)
|
s.errorsTotal.WithLabelValues("create_command").Add(1)
|
||||||
return xerrors.Errorf("create command: %w", err)
|
return xerrors.Errorf("create command: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package usershell
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HomeDir returns the home directory of the current user, giving
|
||||||
|
// priority to the $HOME environment variable.
|
||||||
|
// Deprecated: use EnvInfoer.HomeDir() instead.
|
||||||
|
func HomeDir() (string, error) {
|
||||||
|
// First we check the environment.
|
||||||
|
homedir, err := os.UserHomeDir()
|
||||||
|
if err == nil {
|
||||||
|
return homedir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// As a fallback, we try the user information.
|
||||||
|
u, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
return "", xerrors.Errorf("current user: %w", err)
|
||||||
|
}
|
||||||
|
return u.HomeDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvInfoer encapsulates external information about the environment.
|
||||||
|
type EnvInfoer interface {
|
||||||
|
// User returns the current user.
|
||||||
|
User() (*user.User, error)
|
||||||
|
// Environ returns the environment variables of the current process.
|
||||||
|
Environ() []string
|
||||||
|
// HomeDir returns the home directory of the current user.
|
||||||
|
HomeDir() (string, error)
|
||||||
|
// Shell returns the shell of the given user.
|
||||||
|
Shell(username string) (string, error)
|
||||||
|
// ModifyCommand modifies the command and arguments before execution based on
|
||||||
|
// the environment. This is useful for executing a command inside a container.
|
||||||
|
// In the default case, the command and arguments are returned unchanged.
|
||||||
|
ModifyCommand(name string, args ...string) (string, []string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemEnvInfo encapsulates the information about the environment
|
||||||
|
// just using the default Go implementations.
|
||||||
|
type SystemEnvInfo struct{}
|
||||||
|
|
||||||
|
func (SystemEnvInfo) User() (*user.User, error) {
|
||||||
|
return user.Current()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SystemEnvInfo) Environ() []string {
|
||||||
|
return os.Environ()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SystemEnvInfo) HomeDir() (string, error) {
|
||||||
|
return HomeDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SystemEnvInfo) Shell(username string) (string, error) {
|
||||||
|
return Get(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SystemEnvInfo) ModifyCommand(name string, args ...string) (string, []string) {
|
||||||
|
return name, args
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Get returns the $SHELL environment variable.
|
// Get returns the $SHELL environment variable.
|
||||||
|
// Deprecated: use SystemEnvInfo.UserShell instead.
|
||||||
func Get(username string) (string, error) {
|
func Get(username string) (string, error) {
|
||||||
// This command will output "UserShell: /bin/zsh" if successful, we
|
// This command will output "UserShell: /bin/zsh" if successful, we
|
||||||
// can ignore the error since we have fallback behavior.
|
// can ignore the error since we have fallback behavior.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Get returns the /etc/passwd entry for the username provided.
|
// Get returns the /etc/passwd entry for the username provided.
|
||||||
|
// Deprecated: use SystemEnvInfo.UserShell instead.
|
||||||
func Get(username string) (string, error) {
|
func Get(username string) (string, error) {
|
||||||
contents, err := os.ReadFile("/etc/passwd")
|
contents, err := os.ReadFile("/etc/passwd")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package usershell
|
|||||||
import "os/exec"
|
import "os/exec"
|
||||||
|
|
||||||
// Get returns the command prompt binary name.
|
// Get returns the command prompt binary name.
|
||||||
|
// Deprecated: use SystemEnvInfo.UserShell instead.
|
||||||
func Get(username string) (string, error) {
|
func Get(username string) (string, error) {
|
||||||
_, err := exec.LookPath("pwsh.exe")
|
_, err := exec.LookPath("pwsh.exe")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -351,6 +351,8 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
|
|||||||
BlockFileTransfer: blockFileTransfer,
|
BlockFileTransfer: blockFileTransfer,
|
||||||
Execer: execer,
|
Execer: execer,
|
||||||
ContainerLister: containerLister,
|
ContainerLister: containerLister,
|
||||||
|
|
||||||
|
ExperimentalContainersEnabled: devcontainersEnabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
|
promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
|
||||||
|
|||||||
@@ -653,6 +653,8 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
|||||||
reconnect := parser.RequiredNotEmpty("reconnect").UUID(values, uuid.New(), "reconnect")
|
reconnect := parser.RequiredNotEmpty("reconnect").UUID(values, uuid.New(), "reconnect")
|
||||||
height := parser.UInt(values, 80, "height")
|
height := parser.UInt(values, 80, "height")
|
||||||
width := parser.UInt(values, 80, "width")
|
width := parser.UInt(values, 80, "width")
|
||||||
|
container := parser.String(values, "", "container")
|
||||||
|
containerUser := parser.String(values, "", "container_user")
|
||||||
if len(parser.Errors) > 0 {
|
if len(parser.Errors) > 0 {
|
||||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
Message: "Invalid query parameters.",
|
Message: "Invalid query parameters.",
|
||||||
@@ -690,7 +692,10 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
log.Debug(ctx, "dialed workspace agent")
|
log.Debug(ctx, "dialed workspace agent")
|
||||||
ptNetConn, err := agentConn.ReconnectingPTY(ctx, reconnect, uint16(height), uint16(width), r.URL.Query().Get("command"))
|
ptNetConn, err := agentConn.ReconnectingPTY(ctx, reconnect, uint16(height), uint16(width), r.URL.Query().Get("command"), func(arp *workspacesdk.AgentReconnectingPTYInit) {
|
||||||
|
arp.Container = container
|
||||||
|
arp.ContainerUser = containerUser
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debug(ctx, "dial reconnecting pty server in workspace agent", slog.Error(err))
|
log.Debug(ctx, "dial reconnecting pty server in workspace agent", slog.Error(err))
|
||||||
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial: %s", err))
|
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial: %s", err))
|
||||||
|
|||||||
@@ -93,6 +93,24 @@ type AgentReconnectingPTYInit struct {
|
|||||||
Height uint16
|
Height uint16
|
||||||
Width uint16
|
Width uint16
|
||||||
Command string
|
Command string
|
||||||
|
// Container, if set, will attempt to exec into a running container visible to the agent.
|
||||||
|
// This should be a unique container ID (implementation-dependent).
|
||||||
|
Container string
|
||||||
|
// ContainerUser, if set, will set the target user when execing into a container.
|
||||||
|
// This can be a username or UID, depending on the underlying implementation.
|
||||||
|
// This is ignored if Container is not set.
|
||||||
|
ContainerUser string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentReconnectingPTYInitOption is a functional option for AgentReconnectingPTYInit.
|
||||||
|
type AgentReconnectingPTYInitOption func(*AgentReconnectingPTYInit)
|
||||||
|
|
||||||
|
// AgentReconnectingPTYInitWithContainer sets the container and container user for the reconnecting PTY session.
|
||||||
|
func AgentReconnectingPTYInitWithContainer(container, containerUser string) AgentReconnectingPTYInitOption {
|
||||||
|
return func(init *AgentReconnectingPTYInit) {
|
||||||
|
init.Container = container
|
||||||
|
init.ContainerUser = containerUser
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReconnectingPTYRequest is sent from the client to the server
|
// ReconnectingPTYRequest is sent from the client to the server
|
||||||
@@ -107,7 +125,7 @@ type ReconnectingPTYRequest struct {
|
|||||||
// ReconnectingPTY spawns a new reconnecting terminal session.
|
// ReconnectingPTY spawns a new reconnecting terminal session.
|
||||||
// `ReconnectingPTYRequest` should be JSON marshaled and written to the returned net.Conn.
|
// `ReconnectingPTYRequest` should be JSON marshaled and written to the returned net.Conn.
|
||||||
// Raw terminal output will be read from the returned net.Conn.
|
// Raw terminal output will be read from the returned net.Conn.
|
||||||
func (c *AgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, width uint16, command string) (net.Conn, error) {
|
func (c *AgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, width uint16, command string, initOpts ...AgentReconnectingPTYInitOption) (net.Conn, error) {
|
||||||
ctx, span := tracing.StartSpan(ctx)
|
ctx, span := tracing.StartSpan(ctx)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -119,12 +137,16 @@ func (c *AgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, w
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(AgentReconnectingPTYInit{
|
rptyInit := AgentReconnectingPTYInit{
|
||||||
ID: id,
|
ID: id,
|
||||||
Height: height,
|
Height: height,
|
||||||
Width: width,
|
Width: width,
|
||||||
Command: command,
|
Command: command,
|
||||||
})
|
}
|
||||||
|
for _, o := range initOpts {
|
||||||
|
o(&rptyInit)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(rptyInit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"golang.org/x/xerrors"
|
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/wgengine/capture"
|
"tailscale.com/wgengine/capture"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"cdr.dev/slog"
|
"cdr.dev/slog"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
"github.com/coder/coder/v2/tailnet"
|
"github.com/coder/coder/v2/tailnet"
|
||||||
"github.com/coder/coder/v2/tailnet/proto"
|
"github.com/coder/coder/v2/tailnet/proto"
|
||||||
@@ -305,6 +307,16 @@ type WorkspaceAgentReconnectingPTYOpts struct {
|
|||||||
// issue-reconnecting-pty-signed-token endpoint. If set, the session token
|
// issue-reconnecting-pty-signed-token endpoint. If set, the session token
|
||||||
// on the client will not be sent.
|
// on the client will not be sent.
|
||||||
SignedToken string
|
SignedToken string
|
||||||
|
|
||||||
|
// Experimental: Container, if set, will attempt to exec into a running container
|
||||||
|
// visible to the agent. This should be a unique container ID
|
||||||
|
// (implementation-dependent).
|
||||||
|
// ContainerUser is the user as which to exec into the container.
|
||||||
|
// NOTE: This feature is currently experimental and is currently "opt-in".
|
||||||
|
// In order to use this feature, the agent must have the environment variable
|
||||||
|
// CODER_AGENT_DEVCONTAINERS_ENABLE set to "true".
|
||||||
|
Container string
|
||||||
|
ContainerUser string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentReconnectingPTY spawns a PTY that reconnects using the token provided.
|
// AgentReconnectingPTY spawns a PTY that reconnects using the token provided.
|
||||||
@@ -320,6 +332,12 @@ func (c *Client) AgentReconnectingPTY(ctx context.Context, opts WorkspaceAgentRe
|
|||||||
q.Set("width", strconv.Itoa(int(opts.Width)))
|
q.Set("width", strconv.Itoa(int(opts.Width)))
|
||||||
q.Set("height", strconv.Itoa(int(opts.Height)))
|
q.Set("height", strconv.Itoa(int(opts.Height)))
|
||||||
q.Set("command", opts.Command)
|
q.Set("command", opts.Command)
|
||||||
|
if opts.Container != "" {
|
||||||
|
q.Set("container", opts.Container)
|
||||||
|
}
|
||||||
|
if opts.ContainerUser != "" {
|
||||||
|
q.Set("container_user", opts.ContainerUser)
|
||||||
|
}
|
||||||
// If we're using a signed token, set the query parameter.
|
// If we're using a signed token, set the query parameter.
|
||||||
if opts.SignedToken != "" {
|
if opts.SignedToken != "" {
|
||||||
q.Set(codersdk.SignedAppTokenQueryParameter, opts.SignedToken)
|
q.Set(codersdk.SignedAppTokenQueryParameter, opts.SignedToken)
|
||||||
|
|||||||
Reference in New Issue
Block a user