mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat: add client_session_id to ssh connections (#29701)
Add a new /tcp endpoint on the agent that is used to pass along the client session ID (with the existing baggage mechanisms). The agent then can upgrade that into the desired connection type, based on the port. For now that is only the standard SSH port and reconnecting pty. The `ssh`, `ssh --stdio`, `ssh --stdio` over Coder Connect, `exp rpty`, and web terminal paths are all updated to use this new endpoint, and fall back to the old method when the endpoint is not available. That ID is added to the log context, and is also used to populate the session ID column in the connection logs table. There are some logs related to a connection that will not be tagged with the ID (like the "accepted connection" line) because they happen before the http handler gets to run.
This commit is contained in:
+38
-5
@@ -128,10 +128,10 @@ type Options struct {
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
// ConnectRPC211WithRole connects to the Agent API v2.11. The workspace
|
||||
// ConnectRPC212WithRole connects to the Agent API v2.12. The workspace
|
||||
// agent should use role "agent" to enable connection monitoring.
|
||||
ConnectRPC211WithRole(ctx context.Context, role string) (
|
||||
proto.DRPCAgentClient211, tailnetproto.DRPCTailnetClient28, error,
|
||||
ConnectRPC212WithRole(ctx context.Context, role string) (
|
||||
proto.DRPCAgentClient212, tailnetproto.DRPCTailnetClient28, error,
|
||||
)
|
||||
tailnet.DERPMapRewriter
|
||||
agentsdk.RefreshableSessionTokenProvider
|
||||
@@ -1008,7 +1008,7 @@ func (a *agent) run() (retErr error) {
|
||||
// ConnectRPC returns the dRPC connection we use for the Agent and Tailnet v2+ APIs.
|
||||
// We pass role "agent" to enable connection monitoring on the server, which tracks
|
||||
// the agent's connectivity state (first_connected_at, last_connected_at, disconnected_at).
|
||||
aAPI, tAPI, err := a.client.ConnectRPC211WithRole(a.hardCtx, "agent")
|
||||
aAPI, tAPI, err := a.client.ConnectRPC212WithRole(a.hardCtx, "agent")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1838,9 +1838,42 @@ func (a *agent) createTailnet(
|
||||
_ = apiListener.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
upgrader := &httpUpgrader{
|
||||
logger: a.logger,
|
||||
addr: apiListener.Addr(),
|
||||
}
|
||||
sshUpgradeListener := upgrader.listen(workspacesdk.AgentStandardSSHPort)
|
||||
ptyUpgradeListener := upgrader.listen(workspacesdk.AgentReconnectingPTYPort)
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = sshUpgradeListener.Close()
|
||||
_ = ptyUpgradeListener.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
if err = a.trackGoroutine(func() {
|
||||
_ = a.sshServer.Serve(sshUpgradeListener)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = a.trackGoroutine(func() {
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-a.hardCtx.Done():
|
||||
}
|
||||
_ = ptyUpgradeListener.Close()
|
||||
}()
|
||||
_ = a.reconnectingPTYServer.Serve(a.gracefulCtx, a.hardCtx, ptyUpgradeListener)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = a.trackGoroutine(func() {
|
||||
defer apiListener.Close()
|
||||
apiHandler := a.apiHandler()
|
||||
apiHandler := a.apiHandler(upgrader)
|
||||
server := &http.Server{
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
Handler: apiHandler,
|
||||
|
||||
+2
-2
@@ -131,8 +131,8 @@ type eofClient struct {
|
||||
*agenttest.Client
|
||||
}
|
||||
|
||||
func (*eofClient) ConnectRPC211WithRole(context.Context, string) (
|
||||
proto.DRPCAgentClient211, tailnetproto.DRPCTailnetClient28, error,
|
||||
func (*eofClient) ConnectRPC212WithRole(context.Context, string) (
|
||||
proto.DRPCAgentClient212, tailnetproto.DRPCTailnetClient28, error,
|
||||
) {
|
||||
return nil, nil, io.EOF
|
||||
}
|
||||
|
||||
+79
-20
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -222,12 +223,20 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
|
||||
ChannelHandlers: map[string]ssh.ChannelHandler{
|
||||
"direct-tcpip": func(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) {
|
||||
// Wrapper is designed to find and track JetBrains Gateway connections.
|
||||
wrapped := NewJetbrainsChannelWatcher(ctx, s.logger, s.config.ConnectionReporter, newChan, s.startSession)
|
||||
wrapped := NewJetbrainsChannelWatcher(ctx, s.logger,
|
||||
s.config.ConnectionReporter,
|
||||
newChan,
|
||||
s.startSession,
|
||||
clientSessionIDFromContext(ctx),
|
||||
)
|
||||
ssh.DirectTCPIPHandler(srv, conn, wrapped, ctx)
|
||||
},
|
||||
"direct-streamlocal@openssh.com": func(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) {
|
||||
if s.config.BlockLocalPortForwarding {
|
||||
s.logger.Warn(ctx, "unix local port forward blocked")
|
||||
s.logger.Warn(ctx, "unix local port forward blocked",
|
||||
slog.F("remote_addr", conn.RemoteAddr()),
|
||||
slog.F("local_addr", conn.LocalAddr()),
|
||||
slog.F("client_session_id", clientSessionIDFromContext(ctx)))
|
||||
_ = newChan.Reject(gossh.Prohibited, "local port forwarding is disabled")
|
||||
return
|
||||
}
|
||||
@@ -235,19 +244,33 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
|
||||
},
|
||||
"session": ssh.DefaultSessionHandler,
|
||||
},
|
||||
ConnCallback: func(ctx ssh.Context, conn net.Conn) net.Conn {
|
||||
ctx.SetValue(clientSessionIDContextKey{}, ClientSessionIDFromConn(conn))
|
||||
return conn
|
||||
},
|
||||
ConnectionFailedCallback: func(conn net.Conn, err error) {
|
||||
// The conn here is our conn wrapped in a ssh.serverConn, so unwrap to get
|
||||
// the client session ID from the inner conn.
|
||||
clientSessionID := ""
|
||||
val := reflect.ValueOf(conn)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
if val.Kind() == reflect.Struct {
|
||||
f := val.FieldByName("Conn")
|
||||
if f.IsValid() && f.CanInterface() {
|
||||
if inner, ok := f.Interface().(net.Conn); ok {
|
||||
clientSessionID = ClientSessionIDFromConn(inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.logger.Warn(ctx, "ssh connection failed",
|
||||
slog.F("remote_addr", conn.RemoteAddr()),
|
||||
slog.F("local_addr", conn.LocalAddr()),
|
||||
slog.F("client_session_id", clientSessionID),
|
||||
slog.Error(err))
|
||||
metrics.failedConnectionsTotal.Add(1)
|
||||
},
|
||||
ConnectionCompleteCallback: func(conn *gossh.ServerConn, err error) {
|
||||
s.logger.Info(ctx, "ssh connection complete",
|
||||
slog.F("remote_addr", conn.RemoteAddr()),
|
||||
slog.F("local_addr", conn.LocalAddr()),
|
||||
slog.Error(err))
|
||||
},
|
||||
Handler: s.sessionHandler,
|
||||
// HostSigners are intentionally empty, as the host key will
|
||||
// be set before we start listening.
|
||||
@@ -409,12 +432,16 @@ func extractContainerInfo(env []string) (container, containerUser string, filter
|
||||
func (s *Server) sessionHandler(session ssh.Session) {
|
||||
ctx := session.Context()
|
||||
id := uuid.New()
|
||||
clientSessionID := clientSessionIDFromContext(ctx)
|
||||
logger := s.logger.With(
|
||||
slog.F("remote_addr", session.RemoteAddr()),
|
||||
slog.F("local_addr", session.LocalAddr()),
|
||||
// Assigning a random uuid for each session is useful for tracking
|
||||
// logs for the same ssh session.
|
||||
slog.F("id", id.String()),
|
||||
// The client session ID tracks multiple SSH sessions over the lifetime of a
|
||||
// single client (IDE) session, for debugging reconnects/disconnects.
|
||||
slog.F("client_session_id", clientSessionID),
|
||||
)
|
||||
logger.Info(ctx, "handling ssh session")
|
||||
|
||||
@@ -455,10 +482,11 @@ func (s *Server) sessionHandler(session ssh.Session) {
|
||||
reason := "unable to accept new session, server is closing"
|
||||
// Report connection attempt even if we couldn't accept it.
|
||||
connReporter := s.config.ConnectionReporter.Connect(proto.ConnectEvent{
|
||||
ID: id,
|
||||
Type: connectionType,
|
||||
AppName: appName,
|
||||
IP: remoteAddrString,
|
||||
ID: id,
|
||||
Type: connectionType,
|
||||
AppName: appName,
|
||||
IP: remoteAddrString,
|
||||
ClientSessionID: clientSessionID,
|
||||
})
|
||||
defer connReporter.Disconnect(proto.DisconnectEvent{
|
||||
Code: 1,
|
||||
@@ -492,10 +520,11 @@ func (s *Server) sessionHandler(session ssh.Session) {
|
||||
session = scr
|
||||
|
||||
connReporter := s.config.ConnectionReporter.Connect(proto.ConnectEvent{
|
||||
ID: id,
|
||||
Type: connectionType,
|
||||
AppName: appName,
|
||||
IP: remoteAddrString,
|
||||
ID: id,
|
||||
Type: connectionType,
|
||||
AppName: appName,
|
||||
IP: remoteAddrString,
|
||||
ClientSessionID: clientSessionID,
|
||||
})
|
||||
defer func() {
|
||||
logger.Info(ctx, "ssh session closed",
|
||||
@@ -1066,8 +1095,10 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string,
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// Serve starts the server to handle incoming connections on the provided listener.
|
||||
// It returns an error if no host keys are set or if there is an issue accepting connections.
|
||||
// Serve starts the server to handle incoming connections on the provided
|
||||
// listener. It returns an error if no host keys are set or if there is an
|
||||
// issue accepting connections. If the listener returns UpgradedConn, then the
|
||||
// client session ID will be extracted from those connections.
|
||||
func (s *Server) Serve(l net.Listener) (retErr error) {
|
||||
// Ensure we're not mutating HostSigners as we're reading it.
|
||||
s.mu.RLock()
|
||||
@@ -1096,11 +1127,19 @@ func (s *Server) Serve(l net.Listener) (retErr error) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleConn serves a single SSH connection. l is the listener from which the
|
||||
// connection was accepted. If the conn is an UpgradedConn, then the client
|
||||
// session ID will be extracted from it.
|
||||
func (s *Server) handleConn(l net.Listener, c net.Conn) {
|
||||
clientSessionID := ClientSessionIDFromConn(c)
|
||||
logger := s.logger.With(
|
||||
slog.F("remote_addr", c.RemoteAddr()),
|
||||
slog.F("local_addr", c.LocalAddr()),
|
||||
slog.F("listen_addr", l.Addr()))
|
||||
slog.F("listen_addr", l.Addr()),
|
||||
slog.F("client_session_id", clientSessionID))
|
||||
|
||||
defer logger.Info(context.Background(), "ssh connection complete")
|
||||
|
||||
defer c.Close()
|
||||
|
||||
if !s.trackConn(l, c, true) {
|
||||
@@ -1111,7 +1150,6 @@ func (s *Server) handleConn(l net.Listener, c net.Conn) {
|
||||
}
|
||||
defer s.trackConn(l, c, false)
|
||||
logger.Info(context.Background(), "started serving ssh connection")
|
||||
// note: srv.ConnectionCompleteCallback logs completion of the connection
|
||||
s.srv.HandleConn(c)
|
||||
}
|
||||
|
||||
@@ -1402,3 +1440,24 @@ func CoderSigner(seed int64) (gossh.Signer, error) {
|
||||
coderSigner, err := gossh.NewSignerFromKey(coderHostKey)
|
||||
return coderSigner, err
|
||||
}
|
||||
|
||||
type clientSessionIDContextKey struct{}
|
||||
|
||||
func clientSessionIDFromContext(ctx ssh.Context) string {
|
||||
id, _ := ctx.Value(clientSessionIDContextKey{}).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// UpgradedConn is a net.Conn that has a client session ID attached. Listeners
|
||||
// can return these to augment logs for a connection with a client session ID.
|
||||
type UpgradedConn interface {
|
||||
net.Conn
|
||||
ClientSessionID() string
|
||||
}
|
||||
|
||||
func ClientSessionIDFromConn(conn net.Conn) string {
|
||||
if uc, ok := conn.(UpgradedConn); ok {
|
||||
return uc.ClientSessionID()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
+125
-29
@@ -1,4 +1,4 @@
|
||||
// Package agentssh_test provides tests for basic functinoality of the agentssh
|
||||
// Package agentssh_test provides tests for basic functionality of the agentssh
|
||||
// package, more test coverage can be found in the `agent` and `cli` package(s).
|
||||
package agentssh_test
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/coder/v2/testutil/expecter"
|
||||
)
|
||||
@@ -36,44 +37,139 @@ func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
|
||||
}
|
||||
|
||||
type testConnectionReporter struct {
|
||||
mu sync.Mutex
|
||||
connects []proto.ConnectEvent
|
||||
disconnects []proto.DisconnectEvent
|
||||
}
|
||||
|
||||
func (r *testConnectionReporter) Connect(connectEvent proto.ConnectEvent) proto.DisconnectionReporter {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.connects = append(r.connects, connectEvent)
|
||||
return &testDisconnectionReporter{
|
||||
callback: func(disconnectEvent proto.DisconnectEvent) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.disconnects = append(r.disconnects, disconnectEvent)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type testDisconnectionReporter struct {
|
||||
callback func(proto.DisconnectEvent)
|
||||
}
|
||||
|
||||
func (r *testDisconnectionReporter) Disconnect(disconnectEvent proto.DisconnectEvent) {
|
||||
r.callback(disconnectEvent)
|
||||
}
|
||||
|
||||
func TestNewServer_ServeClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
logger := testutil.Logger(t)
|
||||
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil)
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
err = s.UpdateHostSigner(42)
|
||||
assert.NoError(t, err)
|
||||
tests := []struct {
|
||||
name string
|
||||
clientSessionID string
|
||||
}{
|
||||
{
|
||||
name: "NoClientSessionID",
|
||||
},
|
||||
{
|
||||
name: "WithClientSessionID",
|
||||
clientSessionID: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
var (
|
||||
ctx = context.Background()
|
||||
logger = testutil.Logger(t)
|
||||
)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := s.Serve(ln)
|
||||
assert.Error(t, err) // Server is closed.
|
||||
}()
|
||||
reporter := &testConnectionReporter{}
|
||||
|
||||
c := sshClient(t, ln.Addr().String())
|
||||
s, err := agentssh.NewServer(ctx, logger,
|
||||
prometheus.NewRegistry(),
|
||||
afero.NewMemMapFs(),
|
||||
agentexec.DefaultExecer,
|
||||
&agentssh.Config{
|
||||
ConnectionReporter: reporter,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
err = s.UpdateHostSigner(42)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var b bytes.Buffer
|
||||
sess, err := c.NewSession()
|
||||
require.NoError(t, err)
|
||||
sess.Stdout = &b
|
||||
err = sess.Start("echo hello")
|
||||
require.NoError(t, err)
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = sess.Wait()
|
||||
require.NoError(t, err)
|
||||
if tc.clientSessionID != "" {
|
||||
ln = &wrappedListener{
|
||||
ln,
|
||||
tc.clientSessionID,
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, "hello", strings.TrimSpace(b.String()))
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := s.Serve(ln)
|
||||
assert.Error(t, err) // Server is closed.
|
||||
}()
|
||||
|
||||
err = s.Close()
|
||||
require.NoError(t, err)
|
||||
<-done
|
||||
c := sshClient(t, ln.Addr().String())
|
||||
|
||||
var b bytes.Buffer
|
||||
sess, err := c.NewSession()
|
||||
require.NoError(t, err)
|
||||
sess.Stdout = &b
|
||||
err = sess.Start("echo hello")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = sess.Wait()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "hello", strings.TrimSpace(b.String()))
|
||||
|
||||
err = s.Close()
|
||||
require.NoError(t, err)
|
||||
<-done
|
||||
|
||||
// The server will not close until the disconnect has been reported, so
|
||||
// these reports should be populated and safe to access now.
|
||||
require.Len(t, reporter.connects, 1)
|
||||
require.Len(t, reporter.disconnects, 1)
|
||||
// We only need to check the connect report's client session ID;
|
||||
// disconnect inherit the connect report's ID via the closure, so they
|
||||
// will always be the same.
|
||||
require.Equal(t, tc.clientSessionID, reporter.connects[0].ClientSessionID, "connect client session id")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// wrappedListener wraps a net.Listener to augment all connections with the
|
||||
// provided client session ID.
|
||||
type wrappedListener struct {
|
||||
net.Listener
|
||||
clientSessionID string
|
||||
}
|
||||
|
||||
type wrappedConn struct {
|
||||
net.Conn
|
||||
clientSessionID string
|
||||
}
|
||||
|
||||
func (w wrappedConn) ClientSessionID() string { return w.clientSessionID }
|
||||
|
||||
func (ln *wrappedListener) Accept() (net.Conn, error) {
|
||||
conn, err := ln.Listener.Accept()
|
||||
return wrappedConn{
|
||||
Conn: conn,
|
||||
clientSessionID: ln.clientSessionID,
|
||||
}, err
|
||||
}
|
||||
|
||||
func TestNewServer_ExecuteShebang(t *testing.T) {
|
||||
|
||||
@@ -41,7 +41,10 @@ type JetbrainsChannelWatcher struct {
|
||||
func NewJetbrainsChannelWatcher(ctx ssh.Context, logger slog.Logger,
|
||||
connectionReporter proto.ConnectionReporter,
|
||||
newChannel gossh.NewChannel, startSession startSessionFunc,
|
||||
clientSessionID string,
|
||||
) gossh.NewChannel {
|
||||
logger = logger.With(slog.F("client_session_id", clientSessionID))
|
||||
|
||||
d := localForwardChannelData{}
|
||||
if err := gossh.Unmarshal(newChannel.ExtraData(), &d); err != nil {
|
||||
// If the data fails to unmarshal, do nothing.
|
||||
@@ -74,6 +77,7 @@ func NewJetbrainsChannelWatcher(ctx ssh.Context, logger slog.Logger,
|
||||
logger: logger.With(slog.F("destination_port", d.DestPort)),
|
||||
originAddr: d.OriginAddr,
|
||||
connectionReporter: connectionReporter,
|
||||
clientSessionID: clientSessionID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,13 @@ func (c *Client) ConnectRPC211WithRole(ctx context.Context, _ string) (
|
||||
return c.ConnectRPC210(ctx)
|
||||
}
|
||||
|
||||
// v2.12 adds no RPCs, so the v2.11 client satisfies it as-is.
|
||||
func (c *Client) ConnectRPC212WithRole(ctx context.Context, role string) (
|
||||
agentproto.DRPCAgentClient212, proto.DRPCTailnetClient28, error,
|
||||
) {
|
||||
return c.ConnectRPC211WithRole(ctx, role)
|
||||
}
|
||||
|
||||
func (c *Client) ConnectRPC29(ctx context.Context) (
|
||||
agentproto.DRPCAgentClient29, proto.DRPCTailnetClient28, error,
|
||||
) {
|
||||
|
||||
+2
-1
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/coder/coder/v2/httpmw"
|
||||
)
|
||||
|
||||
func (a *agent) apiHandler() http.Handler {
|
||||
func (a *agent) apiHandler(upgradeListener *httpUpgrader) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(
|
||||
httpmw.Recover(a.logger),
|
||||
@@ -68,6 +68,7 @@ func (a *agent) apiHandler() http.Handler {
|
||||
|
||||
r.Get("/api/v0/listening-ports", a.listeningPortsHandler.handler)
|
||||
r.Get("/api/v0/netcheck", a.HandleNetcheck)
|
||||
r.Get("/api/v0/tcp/{port}", upgradeListener.handler)
|
||||
r.Get("/debug/logs", a.HandleHTTPDebugLogs)
|
||||
r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock)
|
||||
r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState)
|
||||
|
||||
@@ -105,3 +105,9 @@ type DRPCAgentClient210 interface {
|
||||
type DRPCAgentClient211 interface {
|
||||
DRPCAgentClient210
|
||||
}
|
||||
|
||||
// DRPCAgentClient212 is the Agent API at v2.12. It adds the ability to track a
|
||||
// client session id with connections. No new RPCs.
|
||||
type DRPCAgentClient212 interface {
|
||||
DRPCAgentClient211
|
||||
}
|
||||
|
||||
@@ -69,9 +69,11 @@ func (s *Server) Serve(ctx, hardCtx context.Context, l net.Listener) (retErr err
|
||||
retErr = err
|
||||
break
|
||||
}
|
||||
clientSessionID := agentssh.ClientSessionIDFromConn(conn)
|
||||
clog := s.logger.With(
|
||||
slog.F("remote", conn.RemoteAddr()),
|
||||
slog.F("local", conn.LocalAddr()))
|
||||
slog.F("local", conn.LocalAddr()),
|
||||
slog.F("client_session_id", clientSessionID))
|
||||
clog.Info(ctx, "accepted conn")
|
||||
|
||||
// It's not safe to assume RemoteAddr() returns a non-nil value. slog.F usage is fine because it correctly
|
||||
@@ -85,9 +87,10 @@ func (s *Server) Serve(ctx, hardCtx context.Context, l net.Listener) (retErr err
|
||||
|
||||
wg.Add(1)
|
||||
connReporter := s.connectionReporter.Connect(proto.ConnectEvent{
|
||||
ID: uuid.New(),
|
||||
Type: proto.Connection_RECONNECTING_PTY,
|
||||
IP: remoteAddrString,
|
||||
ID: uuid.New(),
|
||||
Type: proto.Connection_RECONNECTING_PTY,
|
||||
IP: remoteAddrString,
|
||||
ClientSessionID: clientSessionID,
|
||||
})
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/coderd/util/syncmap"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
type upgradedConn struct {
|
||||
net.Conn
|
||||
clientSessionID string
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func (c *upgradedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
|
||||
func (c *upgradedConn) ClientSessionID() string { return c.clientSessionID }
|
||||
|
||||
type httpUpgradeListener struct {
|
||||
mu sync.Mutex
|
||||
addr net.Addr
|
||||
conn chan *upgradedConn
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func (ln *httpUpgradeListener) Accept() (net.Conn, error) {
|
||||
var c *upgradedConn
|
||||
select {
|
||||
case c = <-ln.conn:
|
||||
case <-ln.closed:
|
||||
return nil, xerrors.Errorf("tcp upgrade listener: %w", net.ErrClosed)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (ln *httpUpgradeListener) Addr() net.Addr { return ln.addr }
|
||||
func (ln *httpUpgradeListener) Close() error {
|
||||
ln.mu.Lock()
|
||||
defer ln.mu.Unlock()
|
||||
select {
|
||||
case <-ln.closed:
|
||||
default:
|
||||
close(ln.closed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type httpUpgrader struct {
|
||||
listeners syncmap.Map[uint16, *httpUpgradeListener]
|
||||
logger slog.Logger
|
||||
addr net.Addr
|
||||
}
|
||||
|
||||
// listen listens for upgrades on the specified port.
|
||||
func (up *httpUpgrader) listen(port uint16) net.Listener {
|
||||
listener, _ := up.listeners.LoadOrStore(port, &httpUpgradeListener{
|
||||
addr: up.addr,
|
||||
closed: make(chan struct{}),
|
||||
conn: make(chan *upgradedConn),
|
||||
})
|
||||
return listener
|
||||
}
|
||||
|
||||
// handler upgrades an HTTP connection based on the port number, passing along
|
||||
// any client session ID that exists on the request context.
|
||||
func (up *httpUpgrader) handler(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
port, err := strconv.ParseUint(chi.URLParam(r, "port"), 10, 16)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Invalid port",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
listener, ok := up.listeners.Load(uint16(port))
|
||||
if !ok {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Unsupported port for upgrading",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("upgrade") == "" {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Missing upgrade header",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.EqualFold(r.Header.Get("upgrade"), "tcp") {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid upgrade header",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
clientSessionID := tracing.ClientSessionID(r)
|
||||
if clientSessionID == "" {
|
||||
up.logger.Warn(ctx, "client session ID is missing")
|
||||
}
|
||||
|
||||
hijacker, ok := rw.(http.Hijacker)
|
||||
if !ok {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Hijacking not supported",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
conn, brw, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to upgrade connection",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = conn.Write([]byte(strings.Join([]string{
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Connection: Upgrade",
|
||||
"Upgrade: tcp",
|
||||
}, "\r\n") + "\r\n\r\n"))
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
uconn := &upgradedConn{
|
||||
Conn: conn,
|
||||
clientSessionID: clientSessionID,
|
||||
r: brw.Reader,
|
||||
}
|
||||
|
||||
t := time.NewTimer(30 * time.Second)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case listener.conn <- uconn:
|
||||
return
|
||||
case <-listener.closed:
|
||||
up.logger.Info(ctx, "listener closed; closing connection")
|
||||
case <-t.C:
|
||||
up.logger.Info(ctx, "listener timed out accepting; closing connection")
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package agent_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestAgent_TCPUpgrade_Error(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
port int
|
||||
portStr string
|
||||
upgrade string
|
||||
error string
|
||||
status int
|
||||
}{
|
||||
{
|
||||
name: "MissingPort",
|
||||
status: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "InvalidPort",
|
||||
portStr: "invalid",
|
||||
status: http.StatusNotFound,
|
||||
error: "Invalid port",
|
||||
},
|
||||
{
|
||||
name: "UnsupportedPort",
|
||||
port: 9999,
|
||||
status: http.StatusNotFound,
|
||||
error: "Unsupported port",
|
||||
},
|
||||
{
|
||||
name: "MissingUpgrade",
|
||||
port: workspacesdk.AgentStandardSSHPort,
|
||||
status: http.StatusBadRequest,
|
||||
error: "Missing upgrade header",
|
||||
},
|
||||
{
|
||||
name: "InvalidUpgrade",
|
||||
upgrade: "invalid",
|
||||
port: workspacesdk.AgentStandardSSHPort,
|
||||
status: http.StatusBadRequest,
|
||||
error: "Invalid upgrade header",
|
||||
},
|
||||
}
|
||||
|
||||
//nolint:dogsled
|
||||
agentConn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DisableKeepAlives: true,
|
||||
DialContext: agentConn.DialContext,
|
||||
},
|
||||
}
|
||||
t.Cleanup(client.CloseIdleConnections)
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
port := tc.portStr
|
||||
if port == "" && tc.port != 0 {
|
||||
port = strconv.Itoa(tc.port)
|
||||
}
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d/api/v0/tcp/%s",
|
||||
workspacesdk.AgentHTTPAPIServerPort, port)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
require.NoError(t, err)
|
||||
if tc.upgrade != "" {
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
req.Header.Set("Upgrade", tc.upgrade)
|
||||
}
|
||||
|
||||
require.True(t, agentConn.AwaitReachable(ctx))
|
||||
res, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer res.Body.Close()
|
||||
|
||||
require.Equal(t, tc.status, res.StatusCode)
|
||||
if tc.error != "" {
|
||||
var apiErr codersdk.Response
|
||||
require.NoError(t, json.NewDecoder(res.Body).Decode(&apiErr))
|
||||
require.Contains(t, apiErr.Message, tc.error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_SSHUpgrade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
name: "EmptyClientSessionID",
|
||||
},
|
||||
{
|
||||
name: "InvalidClientSessionID",
|
||||
id: "invalid",
|
||||
},
|
||||
{
|
||||
name: "ValidClientSessionID",
|
||||
id: "0123456789abcdef0123456789abcdef",
|
||||
expect: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, client, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
require.True(t, conn.AwaitReachable(ctx))
|
||||
|
||||
if tc.id != "" {
|
||||
conn.SetExtraHeaders(http.Header{
|
||||
"baggage": []string{tracing.SessionIDBaggageKey + "=" + tc.id},
|
||||
})
|
||||
}
|
||||
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
stdin, err := session.StdinPipe()
|
||||
require.NoError(t, err)
|
||||
err = session.Shell()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate SSH traffic so the connstats window sees the session.
|
||||
_, err = stdin.Write([]byte("echo test\n"))
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSSHStats(t, stats)
|
||||
_, err = stdin.Write([]byte("exit 0\n"))
|
||||
require.NoError(t, err, "writing exit to stdin")
|
||||
_ = stdin.Close()
|
||||
err = session.Wait()
|
||||
require.NoError(t, err, "waiting for session to exit")
|
||||
|
||||
assertConnectionReport(t, client,
|
||||
proto.ConnectEvent{
|
||||
Type: proto.Connection_SSH,
|
||||
ClientSessionID: tc.expect,
|
||||
},
|
||||
proto.DisconnectEvent{Reason: string(codersdk.DisconnectReasonGraceful)},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ReconnectingPTYUpgrade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
name: "EmptyClientSessionID",
|
||||
},
|
||||
{
|
||||
name: "InvalidClientSessionID",
|
||||
id: "invalid",
|
||||
},
|
||||
{
|
||||
name: "ValidClientSessionID",
|
||||
id: "0123456789abcdef0123456789abcdef",
|
||||
expect: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, client, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
require.True(t, conn.AwaitReachable(ctx))
|
||||
|
||||
if tc.id != "" {
|
||||
conn.SetExtraHeaders(http.Header{
|
||||
"baggage": []string{tracing.SessionIDBaggageKey + "=" + tc.id},
|
||||
})
|
||||
}
|
||||
|
||||
ptyConn, err := conn.ReconnectingPTY(ctx, uuid.New(), 128, 128, "bash")
|
||||
require.NoError(t, err)
|
||||
defer ptyConn.Close()
|
||||
|
||||
data, err := json.Marshal(workspacesdk.ReconnectingPTYRequest{
|
||||
Data: "echo test\r\n",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = ptyConn.Write(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ptyConn.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
assertConnectionReport(t, client,
|
||||
proto.ConnectEvent{
|
||||
Type: proto.Connection_RECONNECTING_PTY,
|
||||
ClientSessionID: tc.expect,
|
||||
},
|
||||
proto.DisconnectEvent{Reason: string(codersdk.DisconnectReasonGraceful)},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func TestConfigSSH(t *testing.T) {
|
||||
break
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
ssh, err := agentConn.SSH(ctx)
|
||||
ssh, err := agentConn.SSHTCPConn(ctx)
|
||||
cancel()
|
||||
assert.NoError(t, err)
|
||||
wg.Add(2)
|
||||
|
||||
@@ -25,6 +25,7 @@ func (r *RootCmd) rptyCommand() *serpent.Command {
|
||||
var args handleRPTYArgs
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Annotations: serpent.Annotations(workspaceCommand).Mark(annotationClientSessionID, ""),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
if r.disableDirect {
|
||||
return xerrors.New("direct connections are disabled, but you can try websocat ;-)")
|
||||
|
||||
+61
-39
@@ -15,6 +15,10 @@ import (
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/connectionlog"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/coder/v2/testutil/expecter"
|
||||
)
|
||||
@@ -22,50 +26,68 @@ import (
|
||||
func TestExpRpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("DefaultCommand", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
randstr := uuid.NewString()
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
inv, root := clitest.New(t, "exp", "rpty", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
stdin := testutil.NewWriterAttachedToInvocation(t, testutil.Logger(t), inv)
|
||||
tests := []struct {
|
||||
name string // test name
|
||||
args []string // args to append
|
||||
stdin string // stdin to write
|
||||
stdout string // expected stdout
|
||||
id string // expected client session id
|
||||
}{
|
||||
{name: "DefaultCommand", stdin: "exit"},
|
||||
{name: "Command", args: []string{"echo", randstr}, stdout: randstr},
|
||||
{name: "ClientSessionID", stdin: "exit", id: "0123456789abcdef0123456789abcdef"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
connLogger := connectionlog.NewFake()
|
||||
client, store := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
ConnectionLogger: connLogger,
|
||||
})
|
||||
client.SetLogger(testutil.Logger(t).Named("client"))
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
|
||||
r.Username = "myuser"
|
||||
})
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
Name: "myworkspace",
|
||||
OrganizationID: first.OrganizationID,
|
||||
OwnerID: user.ID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
||||
args := []string{"exp", "rpty", r.Workspace.Name}
|
||||
args = append(args, tc.args...)
|
||||
inv, root := clitest.New(t, args...)
|
||||
|
||||
cmdDone := tGo(t, func() {
|
||||
err := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
if tc.id != "" {
|
||||
inv.Environ.Set("CODER_TRACE_SESSION_ID", tc.id)
|
||||
}
|
||||
|
||||
stdin := testutil.NewWriterAttachedToInvocation(t, testutil.Logger(t), inv)
|
||||
stdout := expecter.NewAttachedToInvocation(t, inv)
|
||||
|
||||
_ = agenttest.New(t, client.URL, r.AgentToken)
|
||||
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
cmdDone := tGo(t, func() {
|
||||
err := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
if tc.stdin != "" {
|
||||
stdin.WriteLine(tc.stdin)
|
||||
}
|
||||
if tc.stdout != "" {
|
||||
stdout.ExpectMatch(ctx, tc.stdout)
|
||||
}
|
||||
<-cmdDone
|
||||
assertConnLog(t, connLogger, r.Workspace, database.ConnectionTypeReconnectingPty, tc.id)
|
||||
})
|
||||
|
||||
stdin.WriteLine("exit")
|
||||
<-cmdDone
|
||||
})
|
||||
|
||||
t.Run("Command", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
randStr := uuid.NewString()
|
||||
inv, root := clitest.New(t, "exp", "rpty", workspace.Name, "echo", randStr)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
stdout := expecter.NewAttachedToInvocation(t, inv)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
||||
|
||||
cmdDone := tGo(t, func() {
|
||||
err := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
stdout.ExpectMatch(ctx, randStr)
|
||||
<-cmdDone
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
+70
-16
@@ -25,11 +25,12 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/spf13/afero"
|
||||
"go.opentelemetry.io/otel/baggage"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
gosshagent "golang.org/x/crypto/ssh/agent"
|
||||
"golang.org/x/term"
|
||||
"golang.org/x/xerrors"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
"tailscale.com/types/netlogtype"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
@@ -38,6 +39,7 @@ import (
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/cli/cliutil"
|
||||
"github.com/coder/coder/v2/coderd/autobuild/notify"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/coderd/util/maps"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -481,7 +483,16 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
})
|
||||
defer closeUsage()
|
||||
}
|
||||
return runCoderConnectStdio(ctx, fmt.Sprintf("%s:22", coderConnectHost), stdioReader, stdioWriter, stack, logger)
|
||||
return runCoderConnectStdio(ctx, coderConnectOpts{
|
||||
host: coderConnectHost,
|
||||
httpPort: workspacesdk.AgentHTTPAPIServerPort,
|
||||
tcpPort: workspacesdk.AgentStandardSSHPort,
|
||||
stdin: stdioReader,
|
||||
stdout: stdioWriter,
|
||||
stack: stack,
|
||||
logger: logger,
|
||||
clientSessionID: sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,7 +557,7 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
}
|
||||
|
||||
if stdio {
|
||||
rawSSH, err := conn.SSH(ctx)
|
||||
rawSSH, err := conn.SSHTCPConn(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("connect SSH: %w", err)
|
||||
}
|
||||
@@ -1476,7 +1487,7 @@ func (c *closerStack) push(name string, closer io.Closer) error {
|
||||
|
||||
// rawSSHCopier handles copying raw SSH data between the conn and the pair (r, w).
|
||||
type rawSSHCopier struct {
|
||||
conn *gonet.TCPConn
|
||||
conn workspacesdk.TCPConn
|
||||
logger slog.Logger
|
||||
r io.Reader
|
||||
w io.Writer
|
||||
@@ -1484,7 +1495,7 @@ type rawSSHCopier struct {
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newRawSSHCopier(logger slog.Logger, conn *gonet.TCPConn, r io.Reader, w io.Writer) *rawSSHCopier {
|
||||
func newRawSSHCopier(logger slog.Logger, conn workspacesdk.TCPConn, r io.Reader, w io.Writer) *rawSSHCopier {
|
||||
return &rawSSHCopier{conn: conn, logger: logger, r: r, w: w, done: make(chan struct{})}
|
||||
}
|
||||
|
||||
@@ -1721,26 +1732,69 @@ func testOrDefaultDialer(ctx context.Context) coderConnectDialer {
|
||||
return dialer
|
||||
}
|
||||
|
||||
func runCoderConnectStdio(ctx context.Context, addr string, stdin io.Reader, stdout io.Writer, stack *closerStack, logger slog.Logger) error {
|
||||
dialer := testOrDefaultDialer(ctx)
|
||||
type coderConnectOpts struct {
|
||||
host string
|
||||
httpPort uint16
|
||||
tcpPort uint16
|
||||
clientSessionID string
|
||||
stdin io.Reader
|
||||
stdout io.Writer
|
||||
stack *closerStack
|
||||
logger slog.Logger
|
||||
}
|
||||
|
||||
func runCoderConnectStdio(ctx context.Context, opts coderConnectOpts) error {
|
||||
var conn net.Conn
|
||||
if err := retryWithInterval(ctx, logger, sshRetryInterval, sshMaxAttempts, func() error {
|
||||
var err error
|
||||
conn, err = dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("dial coder connect host %q over tcp: %w", addr, err)
|
||||
if err := retryWithInterval(ctx, opts.logger, sshRetryInterval, sshMaxAttempts, func() error {
|
||||
headers := http.Header{}
|
||||
// Propagate any baggage and add the session ID.
|
||||
if opts.clientSessionID != "" {
|
||||
member, err := baggage.NewMemberRaw(tracing.SessionIDBaggageKey, opts.clientSessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bctx := propagation.Baggage{}.Extract(ctx, propagation.HeaderCarrier(headers))
|
||||
bag, err := baggage.FromContext(bctx).SetMember(member)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bctx = baggage.ContextWithBaggage(bctx, bag)
|
||||
propagation.Baggage{}.Inject(bctx, propagation.HeaderCarrier(headers))
|
||||
}
|
||||
return nil
|
||||
|
||||
dialer := testOrDefaultDialer(ctx)
|
||||
client := http.Client{
|
||||
// Redirects are blocked to prevent misuse.
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
// Disable keep alives as we're usually only making a single
|
||||
// request, and this triggers goleak in tests
|
||||
DisableKeepAlives: true,
|
||||
DialContext: dialer.DialContext,
|
||||
},
|
||||
}
|
||||
|
||||
var err error
|
||||
addr := fmt.Sprintf("%s:%d", opts.host, opts.httpPort)
|
||||
conn, err = workspacesdk.DialTCPUpgrade(ctx, addr, &client, headers, workspacesdk.AgentStandardSSHPort)
|
||||
if errors.Is(err, workspacesdk.ErrAgentTCPUpgradeUnsupported) {
|
||||
// Fall back to dialing the port directly.
|
||||
addr = fmt.Sprintf("%s:%d", opts.host, opts.tcpPort)
|
||||
conn, err = dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := stack.push("tcp conn", conn); err != nil {
|
||||
if err := opts.stack.push("tcp conn", conn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentssh.Bicopy(ctx, conn, &StdioRwc{
|
||||
Reader: stdin,
|
||||
Writer: stdout,
|
||||
Reader: opts.stdin,
|
||||
Writer: opts.stdout,
|
||||
})
|
||||
|
||||
return nil
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -278,20 +279,38 @@ func TestCoderConnectStdio(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
server := newSSHServer("127.0.0.1:0")
|
||||
ln, err := net.Listen("tcp", server.server.Addr)
|
||||
require.NoError(t, err)
|
||||
sshSrv := newSSHServer("127.0.0.1:0")
|
||||
defer sshSrv.Close()
|
||||
|
||||
sshLn, err := net.Listen("tcp", sshSrv.server.Addr)
|
||||
require.NoError(t, err)
|
||||
go func() {
|
||||
_ = server.Serve(ln)
|
||||
_ = sshSrv.Serve(sshLn)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = server.Close()
|
||||
})
|
||||
|
||||
sshAddr, ok := sshLn.Addr().(*net.TCPAddr)
|
||||
require.True(t, ok)
|
||||
|
||||
// Force using the fallback.
|
||||
httpSrv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer httpSrv.Close()
|
||||
|
||||
httpAddr, ok := httpSrv.Listener.Addr().(*net.TCPAddr)
|
||||
require.True(t, ok)
|
||||
|
||||
stdioDone := make(chan struct{})
|
||||
go func() {
|
||||
err = runCoderConnectStdio(ctx, ln.Addr().String(), clientOutput, serverInput, stack, logger)
|
||||
err = runCoderConnectStdio(ctx, coderConnectOpts{
|
||||
host: "127.0.0.1",
|
||||
httpPort: uint16(httpAddr.Port), // nolint:gosec // ports fit within uint16
|
||||
tcpPort: uint16(sshAddr.Port), // nolint:gosec // ports fit within uint16
|
||||
stdin: clientOutput,
|
||||
stdout: serverInput,
|
||||
stack: stack,
|
||||
logger: logger,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
close(stdioDone)
|
||||
}()
|
||||
|
||||
+266
-82
@@ -46,12 +46,14 @@ import (
|
||||
"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/connectionlog"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats/workspacestatstest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
"github.com/coder/coder/v2/pty"
|
||||
@@ -79,32 +81,60 @@ func setupWorkspaceForAgent(t *testing.T, mutations ...func([]*proto.Agent) []*p
|
||||
|
||||
func TestSSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("ImmediateExit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := testutil.Logger(t)
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
inv, root := clitest.New(t, "ssh", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
stdout := expecter.NewAttachedToInvocation(t, inv)
|
||||
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
}{
|
||||
{name: "GenerateSessionID"},
|
||||
{name: "ClientSessionID", id: "0123456789abcdef0123456789abcdef"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
connLogger := connectionlog.NewFake()
|
||||
client, store := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
ConnectionLogger: connLogger,
|
||||
})
|
||||
client.SetLogger(testutil.Logger(t).Named("client"))
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
|
||||
r.Username = "myuser"
|
||||
})
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
Name: "myworkspace",
|
||||
OrganizationID: first.OrganizationID,
|
||||
OwnerID: user.ID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
cmdDone := tGo(t, func() {
|
||||
err := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
inv, root := clitest.New(t, "ssh", r.Workspace.Name)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
if tc.id != "" {
|
||||
inv.Environ.Set("CODER_TRACE_SESSION_ID", tc.id)
|
||||
}
|
||||
stdout := expecter.NewAttachedToInvocation(t, inv)
|
||||
stdin := testutil.NewWriterAttachedToInvocation(t, testutil.Logger(t).Named("stdin"), inv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
cmdDone := tGo(t, func() {
|
||||
err := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
stdout.ExpectMatch(ctx, "Waiting")
|
||||
|
||||
_ = agenttest.New(t, client.URL, r.AgentToken)
|
||||
_ = coderdtest.NewWorkspaceAgentWaiter(t, userClient, r.Workspace.ID).Wait()
|
||||
|
||||
// Shells on Mac, Windows, and Linux all exit shells with the "exit" command.
|
||||
stdin.WriteLine("exit")
|
||||
<-cmdDone
|
||||
assertConnLog(t, connLogger, r.Workspace, database.ConnectionTypeSsh, tc.id)
|
||||
})
|
||||
stdout.ExpectMatch(ctx, "Waiting")
|
||||
}
|
||||
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
// Shells on Mac, Windows, and Linux all exit shells with the "exit" command.
|
||||
stdin.WriteLine("exit")
|
||||
<-cmdDone
|
||||
})
|
||||
t.Run("WorkspaceNameInput", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -445,66 +475,6 @@ func TestSSH(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Stdio", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
_, _ = 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(&testutil.ReaderWriterConn{
|
||||
Reader: serverOutput,
|
||||
Writer: clientInput,
|
||||
}, "", &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)
|
||||
_ = clientOutput.Close()
|
||||
|
||||
<-cmdDone
|
||||
})
|
||||
|
||||
t.Run("DeterministicHostKey", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
@@ -1827,6 +1797,98 @@ func TestSSH(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSSH_Stdio(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
useOldAgent bool
|
||||
}{
|
||||
{name: "GenerateSessionID"},
|
||||
{name: "ClientSessionID", id: "0123456789abcdef0123456789abcdef"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
connLogger := connectionlog.NewFake()
|
||||
client, store := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
ConnectionLogger: connLogger,
|
||||
})
|
||||
client.SetLogger(testutil.Logger(t).Named("client"))
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
|
||||
r.Username = "myuser"
|
||||
})
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
Name: "myworkspace",
|
||||
OrganizationID: first.OrganizationID,
|
||||
OwnerID: user.ID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
_, _ = 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, userClient.URL, r.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", r.Workspace.Name)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
if tc.id != "" {
|
||||
inv.Environ.Set("CODER_TRACE_SESSION_ID", tc.id)
|
||||
}
|
||||
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(&testutil.ReaderWriterConn{
|
||||
Reader: serverOutput,
|
||||
Writer: clientInput,
|
||||
}, "", &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)
|
||||
_ = clientOutput.Close()
|
||||
|
||||
<-cmdDone
|
||||
assertConnLog(t, connLogger, r.Workspace, database.ConnectionTypeSsh, tc.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // This test uses t.Setenv, parent test MUST NOT be parallel.
|
||||
func TestSSH_ForwardGPG(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
@@ -2168,6 +2230,99 @@ func TestSSH_Container(t *testing.T) {
|
||||
func TestSSH_CoderConnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
useOldAgent bool
|
||||
}{
|
||||
{name: "GenerateSessionID"},
|
||||
{name: "ClientSessionID", id: "0123456789abcdef0123456789abcdef"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
connLogger := connectionlog.NewFake()
|
||||
client, store := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
ConnectionLogger: connLogger,
|
||||
})
|
||||
client.SetLogger(testutil.Logger(t).Named("client"))
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
|
||||
r.Username = "myuser"
|
||||
})
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
Name: "myworkspace",
|
||||
OrganizationID: first.OrganizationID,
|
||||
OwnerID: user.ID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
_ = agenttest.New(t, userClient.URL, r.AgentToken)
|
||||
resources := coderdtest.NewWorkspaceAgentWaiter(t, userClient, r.Workspace.ID).Wait()
|
||||
|
||||
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.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
// Get an agent conn to use as the Coder Connect dialer.
|
||||
agentConn, err := workspacesdk.New(userClient).DialAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer agentConn.Close()
|
||||
require.True(t, agentConn.AwaitReachable(ctx))
|
||||
|
||||
inv, root := clitest.New(t, "ssh", r.Workspace.Name, "--stdio")
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
if tc.id != "" {
|
||||
inv.Environ.Set("CODER_TRACE_SESSION_ID", tc.id)
|
||||
}
|
||||
ctx = cli.WithTestOnlyCoderConnectDialer(ctx, agentConn)
|
||||
ctx = withCoderConnectRunning(ctx)
|
||||
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(&testutil.ReaderWriterConn{
|
||||
Reader: serverOutput,
|
||||
Writer: clientInput,
|
||||
}, "", &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)
|
||||
_ = clientOutput.Close()
|
||||
|
||||
<-cmdDone
|
||||
assertConnLog(t, connLogger, r.Workspace, database.ConnectionTypeSsh, tc.id)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Enabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
@@ -2195,7 +2350,7 @@ func TestSSH_CoderConnect(t *testing.T) {
|
||||
|
||||
err := testutil.TryReceive(ctx, t, errCh)
|
||||
// Our mock dialer will always fail with this error, if it was called
|
||||
require.ErrorContains(t, err, "dial coder connect host \"dev.myworkspace.myuser.coder:22\" over tcp")
|
||||
require.ErrorContains(t, err, "dial coder connect host \"dev.myworkspace.myuser.coder:4\" over tcp")
|
||||
|
||||
// The network info file should be created since we passed `--stdio`
|
||||
entries, err := afero.ReadDir(fs, "/net")
|
||||
@@ -2603,3 +2758,32 @@ func TestSSH_Completion(t *testing.T) {
|
||||
require.Empty(t, output)
|
||||
})
|
||||
}
|
||||
|
||||
func assertConnLog(t *testing.T,
|
||||
connLogger *connectionlog.FakeConnectionLogger,
|
||||
workspace database.WorkspaceTable,
|
||||
expectedType database.ConnectionType,
|
||||
clientSessionID string,
|
||||
) {
|
||||
t.Helper()
|
||||
// Wait until we get at least one related log.
|
||||
var logs []database.UpsertConnectionLogParams
|
||||
require.Eventually(t, func() bool {
|
||||
for _, log := range connLogger.ConnectionLogs() {
|
||||
if log.Type == expectedType &&
|
||||
log.WorkspaceID == workspace.ID {
|
||||
logs = append(logs, log)
|
||||
}
|
||||
}
|
||||
return len(logs) > 0
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
for _, log := range logs {
|
||||
switch {
|
||||
case clientSessionID != "":
|
||||
require.Equal(t, clientSessionID, log.ClientSessionID.String)
|
||||
default: // Should have generated a client session ID.
|
||||
require.NotEmpty(t, log.ClientSessionID.String)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@ func (r *RootCmd) vscodeSSH() *serpent.Command {
|
||||
})
|
||||
defer closeUsage()
|
||||
|
||||
rawSSH, err := agentConn.SSH(ctx)
|
||||
rawSSH, err := agentConn.SSHTCPConn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@ import (
|
||||
// 32-character hexadecimal string.
|
||||
const SessionIDBaggageKey = "client_session_id"
|
||||
|
||||
type clientSessionIDContextKey struct{}
|
||||
|
||||
// ClientSessionID returns the client session ID tracing middleware handler.
|
||||
func ClientSessionID(r *http.Request) string {
|
||||
user, ok := r.Context().Value(clientSessionIDContextKey{}).(string)
|
||||
if !ok {
|
||||
panic("developer error: tracing middleware not provided")
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// DefaultRoutePatterns are the route patterns coderd and wsproxy trace. The
|
||||
// middleware runs high in the stack so it can capture the entire request, but
|
||||
// only acts on these patterns.
|
||||
@@ -66,6 +77,7 @@ func Middleware(
|
||||
// context. This is done even when tracing is disabled so that logs can
|
||||
// always be correlated by client_session_id.
|
||||
sessionID := sessionIDFromRequest(r)
|
||||
r = r.WithContext(context.WithValue(r.Context(), clientSessionIDContextKey{}, sessionID))
|
||||
if sessionID != "" {
|
||||
r = r.WithContext(slog.With(r.Context(), slog.F("client_session_id", sessionID)))
|
||||
}
|
||||
|
||||
@@ -753,7 +753,13 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := s.Logger.With(slog.F("agent_id", appToken.AgentID))
|
||||
|
||||
clientSessionID := tracing.ClientSessionID(r)
|
||||
|
||||
log := s.Logger.With(
|
||||
slog.F("agent_id", appToken.AgentID),
|
||||
slog.F("client_session_id", clientSessionID),
|
||||
)
|
||||
log.Debug(ctx, "resolved PTY request")
|
||||
|
||||
values := r.URL.Query()
|
||||
@@ -802,6 +808,14 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer release()
|
||||
log.Debug(ctx, "dialed workspace agent")
|
||||
|
||||
// Pass through the client session ID to the agent via the header.
|
||||
if clientSessionID != "" {
|
||||
agentConn.SetExtraHeaders(http.Header{
|
||||
"baggage": []string{tracing.SessionIDBaggageKey + "=" + clientSessionID},
|
||||
})
|
||||
}
|
||||
|
||||
// #nosec G115 - Safe conversion for terminal height/width which are expected to be within uint16 range (0-65535)
|
||||
ptNetConn, err := agentConn.ReconnectingPTY(ctx, reconnect, uint16(height), uint16(width), r.URL.Query().Get("command"), func(arp *workspacesdk.AgentReconnectingPTYInit) {
|
||||
arp.Container = container
|
||||
|
||||
@@ -362,6 +362,19 @@ func (c *Client) ConnectRPC211WithRole(ctx context.Context, role string) (
|
||||
return proto.NewDRPCAgentClient(conn), tailnetproto.NewDRPCTailnetClient(conn), nil
|
||||
}
|
||||
|
||||
// ConnectRPC212WithRole returns a dRPC client to the Agent API v2.12, which
|
||||
// adds the ability to attach a client session ID to connections. Pass role
|
||||
// "agent" for workspace agents to enable connection monitoring.
|
||||
func (c *Client) ConnectRPC212WithRole(ctx context.Context, role string) (
|
||||
proto.DRPCAgentClient212, tailnetproto.DRPCTailnetClient28, error,
|
||||
) {
|
||||
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 12), role)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return proto.NewDRPCAgentClient(conn), tailnetproto.NewDRPCTailnetClient(conn), nil
|
||||
}
|
||||
|
||||
// ConnectRPC connects to the workspace agent API and tailnet API.
|
||||
// It does not send a role query parameter, so the server will apply
|
||||
// its default behavior (currently: enable connection monitoring for
|
||||
|
||||
@@ -125,7 +125,9 @@ type AgentConn interface {
|
||||
UploadChatFile(ctx context.Context, req UploadChatFileRequest) (AgentUploadChatFileResponse, error)
|
||||
EditFiles(ctx context.Context, edits FileEditRequest) (FileEditResponse, error)
|
||||
BundleFiles(ctx context.Context, req BundleFilesRequest) ([]byte, error)
|
||||
// Deprecated: Use SSHTCPConn instead.
|
||||
SSH(ctx context.Context) (*gonet.TCPConn, error)
|
||||
SSHTCPConn(ctx context.Context) (TCPConn, error)
|
||||
SSHClient(ctx context.Context) (*ssh.Client, error)
|
||||
SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error)
|
||||
SSHOnPort(ctx context.Context, port uint16) (*gonet.TCPConn, error)
|
||||
@@ -249,7 +251,16 @@ func (c *agentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, w
|
||||
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
||||
}
|
||||
|
||||
conn, err := c.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), AgentReconnectingPTYPort))
|
||||
c.headersMu.RLock()
|
||||
extraHeaders := c.extraHeaders.Clone()
|
||||
c.headersMu.RUnlock()
|
||||
|
||||
addr := netip.AddrPortFrom(c.agentAddress(), AgentHTTPAPIServerPort)
|
||||
conn, err := DialTCPUpgrade(ctx, addr.String(), c.apiClient(ctx), extraHeaders, AgentReconnectingPTYPort)
|
||||
if errors.Is(err, ErrAgentTCPUpgradeUnsupported) {
|
||||
// Fall back to dialing the port directly.
|
||||
conn, err = c.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), AgentReconnectingPTYPort))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -279,12 +290,38 @@ func (c *agentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, w
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// SSH pipes the SSH protocol over the returned net.Conn.
|
||||
// SSH pipes the SSH protocol over the returned gonet.TCPConn.
|
||||
// This connects to the built-in SSH server in the workspace agent.
|
||||
func (c *agentConn) SSH(ctx context.Context) (*gonet.TCPConn, error) {
|
||||
return c.SSHOnPort(ctx, AgentSSHPort)
|
||||
}
|
||||
|
||||
// SSHTCPConn makes an HTTP request with the client session ID that then
|
||||
// upgrades into an SSH connection. If the agent does not support the endpoint,
|
||||
// falls back to dialing the port directly.
|
||||
func (c *agentConn) SSHTCPConn(ctx context.Context) (TCPConn, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
if !c.AwaitReachable(ctx) {
|
||||
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
||||
}
|
||||
|
||||
c.SendConnectedTelemetry(c.agentAddress(), tailnet.TelemetryApplicationSSH)
|
||||
|
||||
c.headersMu.RLock()
|
||||
extraHeaders := c.extraHeaders.Clone()
|
||||
c.headersMu.RUnlock()
|
||||
|
||||
addr := netip.AddrPortFrom(c.agentAddress(), AgentHTTPAPIServerPort)
|
||||
conn, err := DialTCPUpgrade(ctx, addr.String(), c.apiClient(ctx), extraHeaders, AgentStandardSSHPort)
|
||||
if errors.Is(err, ErrAgentTCPUpgradeUnsupported) {
|
||||
// Fall back to dialing the port directly.
|
||||
return c.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), AgentStandardSSHPort))
|
||||
}
|
||||
return conn, err
|
||||
}
|
||||
|
||||
// SSHOnPort pipes the SSH protocol over the returned net.Conn.
|
||||
// This connects to the built-in SSH server in the workspace agent on the specified port.
|
||||
func (c *agentConn) SSHOnPort(ctx context.Context, port uint16) (*gonet.TCPConn, error) {
|
||||
@@ -299,9 +336,29 @@ func (c *agentConn) SSHOnPort(ctx context.Context, port uint16) (*gonet.TCPConn,
|
||||
return c.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), port))
|
||||
}
|
||||
|
||||
// SSHClient calls SSH to create a client
|
||||
// SSHClient makes an HTTP request with the client session ID that then upgrades
|
||||
// into an SSH client connection. If the agent does not support the endpoint,
|
||||
// falls back to dialing the port directly.
|
||||
func (c *agentConn) SSHClient(ctx context.Context) (*ssh.Client, error) {
|
||||
return c.SSHClientOnPort(ctx, AgentSSHPort)
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
netConn, err := c.SSHTCPConn(ctx)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("ssh: %w", err)
|
||||
}
|
||||
|
||||
sshConn, channels, requests, err := ssh.NewClientConn(netConn, "localhost:22", &ssh.ClientConfig{
|
||||
// SSH host validation isn't helpful, because obtaining a peer
|
||||
// connection already signifies user-intent to dial a workspace.
|
||||
// #nosec
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("ssh conn: %w", err)
|
||||
}
|
||||
|
||||
return ssh.NewClient(sshConn, channels, requests), nil
|
||||
}
|
||||
|
||||
// SSHClientOnPort calls SSH to create a client on a specific port
|
||||
@@ -327,6 +384,82 @@ func (c *agentConn) SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Clie
|
||||
return ssh.NewClient(sshConn, channels, requests), nil
|
||||
}
|
||||
|
||||
type TCPConn interface {
|
||||
net.Conn
|
||||
CloseWrite() error
|
||||
}
|
||||
|
||||
var ErrAgentTCPUpgradeUnsupported = xerrors.New("agent does not support the tcp upgrade endpoint")
|
||||
|
||||
// DialTCPUpgrade dials the agent's /tcp HTTP endpoint which allows sending
|
||||
// extra data to the agent via headers. These must include the client session
|
||||
// ID or the connection will be refused. The agent will then upgrade the
|
||||
// connection based on the port. Returns ErrAgentTCPUpgradeUnsupported if the
|
||||
// agent does not support the endpoint.
|
||||
func DialTCPUpgrade(ctx context.Context, addr string, client *http.Client, headers http.Header, port uint16) (TCPConn, error) {
|
||||
apiURL := fmt.Sprintf("http://%s/api/v0/tcp/%d", addr, port)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("new http api request to %q: %w", apiURL, err)
|
||||
}
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
req.Header.Set("Upgrade", "tcp")
|
||||
|
||||
// The client session ID baggage is found in the extra headers.
|
||||
for k, v := range headers {
|
||||
req.Header[k] = v
|
||||
}
|
||||
|
||||
//nolint:bodyclose // On success the caller is responsible for closing.
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("do upgrade request to %q: %w", apiURL, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
_ = resp.Body.Close()
|
||||
return nil, ErrAgentTCPUpgradeUnsupported
|
||||
}
|
||||
|
||||
respBody := resp.Body
|
||||
conn, ok := respBody.(rawConn)
|
||||
if !ok {
|
||||
_ = respBody.Close()
|
||||
return nil, xerrors.Errorf("response body is not a rawConn: %T", respBody)
|
||||
}
|
||||
|
||||
return &upgradedConn{
|
||||
rawConn: conn,
|
||||
remoteAddr: upgradeAddr{"tcp", addr},
|
||||
// For now setting a fake local address.
|
||||
localAddr: upgradeAddr{"ssh", "ssh"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type rawConn interface {
|
||||
io.ReadWriteCloser
|
||||
CloseWrite() error
|
||||
}
|
||||
|
||||
type upgradedConn struct {
|
||||
rawConn
|
||||
remoteAddr net.Addr
|
||||
localAddr net.Addr
|
||||
}
|
||||
|
||||
func (u *upgradedConn) LocalAddr() net.Addr { return u.localAddr }
|
||||
func (u *upgradedConn) RemoteAddr() net.Addr { return u.remoteAddr }
|
||||
func (*upgradedConn) SetDeadline(_ time.Time) error { return nil }
|
||||
func (*upgradedConn) SetReadDeadline(_ time.Time) error { return nil }
|
||||
func (*upgradedConn) SetWriteDeadline(_ time.Time) error { return nil }
|
||||
|
||||
type upgradeAddr struct {
|
||||
network string
|
||||
addr string
|
||||
}
|
||||
|
||||
func (u upgradeAddr) Network() string { return u.network }
|
||||
func (u upgradeAddr) String() string { return u.addr }
|
||||
|
||||
// Speedtest runs a speedtest against the workspace agent.
|
||||
func (c *agentConn) Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
|
||||
@@ -2,8 +2,10 @@ package workspacesdk_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -185,6 +187,115 @@ func TestAgentConnAppHTTPClientRefusesRedirects(t *testing.T) {
|
||||
require.ErrorIs(t, client.CheckRedirect(req, nil), http.ErrUseLastResponse)
|
||||
}
|
||||
|
||||
func TestAgent_SSHUpgrade_Fallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
derpMap, _ := tailnettest.RunDERPAndSTUN(t)
|
||||
|
||||
clientID := uuid.New()
|
||||
agentID := uuid.New()
|
||||
clientConn, _ := newTailnetConn(t, derpMap, clientID, "client")
|
||||
agentConn, agentIP := newTailnetConn(t, derpMap, agentID, "agent")
|
||||
stitchTailnet(t, map[uuid.UUID]*tailnet.Conn{
|
||||
clientID: clientConn,
|
||||
agentID: agentConn,
|
||||
})
|
||||
|
||||
// Simulate an older agent that does not support the tcp upgrade endpoint.
|
||||
var upgradeHit atomic.Bool
|
||||
upgradeHandler := http.NewServeMux()
|
||||
upgradeHandler.HandleFunc("/api/v0/tcp/", func(rw http.ResponseWriter, r *http.Request) {
|
||||
upgradeHit.Store(true)
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
serveTailnetHTTP(t, agentConn, upgradeHandler)
|
||||
|
||||
ln, err := agentConn.Listen("tcp", fmt.Sprintf(":%d", workspacesdk.AgentStandardSSHPort))
|
||||
require.NoError(t, err)
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
assert.NoError(t, err)
|
||||
defer conn.Close()
|
||||
_, err = conn.Write([]byte("hello world"))
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
conn := workspacesdk.NewAgentConn(clientConn, workspacesdk.AgentConnOptions{
|
||||
AgentID: agentID,
|
||||
})
|
||||
require.True(t, clientConn.AwaitReachable(ctx, agentIP))
|
||||
|
||||
sshConn, err := conn.SSHTCPConn(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshConn.Close()
|
||||
|
||||
b, err := io.ReadAll(sshConn)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, upgradeHit.Load(), "should hit upgrade")
|
||||
require.Equal(t, "hello world", string(b))
|
||||
}
|
||||
|
||||
func TestAgent_ReconnectingPTYUpgrade_Fallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
derpMap, _ := tailnettest.RunDERPAndSTUN(t)
|
||||
|
||||
clientID := uuid.New()
|
||||
agentID := uuid.New()
|
||||
clientConn, _ := newTailnetConn(t, derpMap, clientID, "client")
|
||||
agentConn, agentIP := newTailnetConn(t, derpMap, agentID, "agent")
|
||||
stitchTailnet(t, map[uuid.UUID]*tailnet.Conn{
|
||||
clientID: clientConn,
|
||||
agentID: agentConn,
|
||||
})
|
||||
|
||||
// Simulate an older agent that does not support the tcp upgrade endpoint.
|
||||
var upgradeHit atomic.Bool
|
||||
upgradeHandler := http.NewServeMux()
|
||||
upgradeHandler.HandleFunc("/api/v0/tcp/", func(rw http.ResponseWriter, r *http.Request) {
|
||||
upgradeHit.Store(true)
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
serveTailnetHTTP(t, agentConn, upgradeHandler)
|
||||
|
||||
ln, err := agentConn.Listen("tcp", fmt.Sprintf(":%d", workspacesdk.AgentReconnectingPTYPort))
|
||||
require.NoError(t, err)
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
assert.NoError(t, err)
|
||||
defer conn.Close()
|
||||
// Read in the init message.
|
||||
rawLen := make([]byte, 2)
|
||||
_, err = io.ReadFull(conn, rawLen)
|
||||
assert.NoError(t, err)
|
||||
_, err = io.ReadFull(conn, make([]byte, binary.LittleEndian.Uint16(rawLen)))
|
||||
assert.NoError(t, err)
|
||||
// Reply with some bytes.
|
||||
_, err = conn.Write([]byte("hello world"))
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
conn := workspacesdk.NewAgentConn(clientConn, workspacesdk.AgentConnOptions{
|
||||
AgentID: agentID,
|
||||
})
|
||||
require.True(t, clientConn.AwaitReachable(ctx, agentIP))
|
||||
|
||||
ptyConn, err := conn.ReconnectingPTY(ctx, uuid.New(), 128, 128, "bash")
|
||||
require.NoError(t, err)
|
||||
defer ptyConn.Close()
|
||||
|
||||
b, err := io.ReadAll(ptyConn)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, upgradeHit.Load(), "should hit upgrade")
|
||||
require.Equal(t, "hello world", string(b))
|
||||
}
|
||||
|
||||
func newTailnetConn(t *testing.T, derpMap *tailcfg.DERPMap, id uuid.UUID, name string) (*tailnet.Conn, netip.Addr) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -559,6 +559,21 @@ func (mr *MockAgentConnMockRecorder) SSHOnPort(ctx, port any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SSHOnPort", reflect.TypeOf((*MockAgentConn)(nil).SSHOnPort), ctx, port)
|
||||
}
|
||||
|
||||
// SSHTCPConn mocks base method.
|
||||
func (m *MockAgentConn) SSHTCPConn(ctx context.Context) (workspacesdk.TCPConn, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SSHTCPConn", ctx)
|
||||
ret0, _ := ret[0].(workspacesdk.TCPConn)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// SSHTCPConn indicates an expected call of SSHTCPConn.
|
||||
func (mr *MockAgentConnMockRecorder) SSHTCPConn(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SSHTCPConn", reflect.TypeOf((*MockAgentConn)(nil).SSHTCPConn), ctx)
|
||||
}
|
||||
|
||||
// SetExtraHeaders mocks base method.
|
||||
func (m *MockAgentConn) SetExtraHeaders(h http.Header) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Reference in New Issue
Block a user