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:
Cian Johnston
2025-02-26 09:03:27 +00:00
committed by GitHub
co-authored by Mathias Fredriksson Danny Kopping
parent a3223397cb
commit 172e52317c
15 changed files with 260 additions and 82 deletions
+9
View File
@@ -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
View File
@@ -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()
+4 -16
View File
@@ -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
View File
@@ -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
+7 -3
View File
@@ -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()
+21 -4
View File
@@ -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)
+66
View File
@@ -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
}
+1
View File
@@ -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.
+1
View File
@@ -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 {
+1
View File
@@ -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 {
+2
View File
@@ -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)
+6 -1
View File
@@ -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))
+25 -3
View File
@@ -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
+20 -2
View File
@@ -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)