mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore(codersdk): move all tailscale imports out of codersdk (#12735)
Currently, importing `codersdk` just to interact with the API requires importing tailscale, which causes builds to fail unless manually using our fork.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
package codersdk
|
||||
package healthsdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,15 +6,24 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/derp"
|
||||
"tailscale.com/net/netcheck"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/healthcheck/health"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// @typescript-ignore HealthClient
|
||||
type HealthClient struct {
|
||||
client *codersdk.Client
|
||||
}
|
||||
|
||||
func New(c *codersdk.Client) *HealthClient {
|
||||
return &HealthClient{client: c}
|
||||
}
|
||||
|
||||
type HealthSection string
|
||||
|
||||
// If you add another const below, make sure to add it to HealthSections!
|
||||
@@ -44,34 +53,34 @@ type UpdateHealthSettings struct {
|
||||
DismissedHealthchecks []HealthSection `json:"dismissed_healthchecks"`
|
||||
}
|
||||
|
||||
func (c *Client) DebugHealth(ctx context.Context) (HealthcheckReport, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/debug/health", nil)
|
||||
func (c *HealthClient) DebugHealth(ctx context.Context) (HealthcheckReport, error) {
|
||||
res, err := c.client.Request(ctx, http.MethodGet, "/api/v2/debug/health", nil)
|
||||
if err != nil {
|
||||
return HealthcheckReport{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return HealthcheckReport{}, ReadBodyAsError(res)
|
||||
return HealthcheckReport{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
var rpt HealthcheckReport
|
||||
return rpt, json.NewDecoder(res.Body).Decode(&rpt)
|
||||
}
|
||||
|
||||
func (c *Client) HealthSettings(ctx context.Context) (HealthSettings, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/debug/health/settings", nil)
|
||||
func (c *HealthClient) HealthSettings(ctx context.Context) (HealthSettings, error) {
|
||||
res, err := c.client.Request(ctx, http.MethodGet, "/api/v2/debug/health/settings", nil)
|
||||
if err != nil {
|
||||
return HealthSettings{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return HealthSettings{}, ReadBodyAsError(res)
|
||||
return HealthSettings{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
var settings HealthSettings
|
||||
return settings, json.NewDecoder(res.Body).Decode(&settings)
|
||||
}
|
||||
|
||||
func (c *Client) PutHealthSettings(ctx context.Context, settings HealthSettings) error {
|
||||
res, err := c.Request(ctx, http.MethodPut, "/api/v2/debug/health/settings", settings)
|
||||
func (c *HealthClient) PutHealthSettings(ctx context.Context, settings HealthSettings) error {
|
||||
res, err := c.client.Request(ctx, http.MethodPut, "/api/v2/debug/health/settings", settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -81,7 +90,7 @@ func (c *Client) PutHealthSettings(ctx context.Context, settings HealthSettings)
|
||||
return xerrors.New("health settings not modified")
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return ReadBodyAsError(res)
|
||||
return codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -199,8 +208,8 @@ type ProvisionerDaemonsReport struct {
|
||||
}
|
||||
|
||||
type ProvisionerDaemonsReportItem struct {
|
||||
ProvisionerDaemon `json:"provisioner_daemon"`
|
||||
Warnings []health.Message `json:"warnings"`
|
||||
codersdk.ProvisionerDaemon `json:"provisioner_daemon"`
|
||||
Warnings []health.Message `json:"warnings"`
|
||||
}
|
||||
|
||||
type WebsocketReport struct {
|
||||
@@ -222,5 +231,5 @@ type WorkspaceProxyReport struct {
|
||||
Dismissed bool `json:"dismissed"`
|
||||
Error *string `json:"error"`
|
||||
|
||||
WorkspaceProxies RegionsResponse[WorkspaceProxy] `json:"workspace_proxies"`
|
||||
WorkspaceProxies codersdk.RegionsResponse[codersdk.WorkspaceProxy] `json:"workspace_proxies"`
|
||||
}
|
||||
+28
-443
@@ -3,28 +3,18 @@ package codersdk
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
"nhooyr.io/websocket"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
"github.com/coder/coder/v2/tailnet/proto"
|
||||
"github.com/coder/retry"
|
||||
)
|
||||
|
||||
type WorkspaceAgentStatus string
|
||||
@@ -215,360 +205,28 @@ type DERPRegion struct {
|
||||
LatencyMilliseconds float64 `json:"latency_ms"`
|
||||
}
|
||||
|
||||
// WorkspaceAgentConnectionInfo returns required information for establishing
|
||||
// a connection with a workspace.
|
||||
// @typescript-ignore WorkspaceAgentConnectionInfo
|
||||
type WorkspaceAgentConnectionInfo struct {
|
||||
DERPMap *tailcfg.DERPMap `json:"derp_map"`
|
||||
DERPForceWebSockets bool `json:"derp_force_websockets"`
|
||||
DisableDirectConnections bool `json:"disable_direct_connections"`
|
||||
type WorkspaceAgentLog struct {
|
||||
ID int64 `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
Output string `json:"output"`
|
||||
Level LogLevel `json:"level"`
|
||||
SourceID uuid.UUID `json:"source_id" format:"uuid"`
|
||||
}
|
||||
|
||||
func (c *Client) WorkspaceAgentConnectionInfoGeneric(ctx context.Context) (WorkspaceAgentConnectionInfo, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/connection", nil)
|
||||
if err != nil {
|
||||
return WorkspaceAgentConnectionInfo{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return WorkspaceAgentConnectionInfo{}, ReadBodyAsError(res)
|
||||
}
|
||||
type AgentSubsystem string
|
||||
|
||||
var connInfo WorkspaceAgentConnectionInfo
|
||||
return connInfo, json.NewDecoder(res.Body).Decode(&connInfo)
|
||||
}
|
||||
const (
|
||||
AgentSubsystemEnvbox AgentSubsystem = "envbox"
|
||||
AgentSubsystemEnvbuilder AgentSubsystem = "envbuilder"
|
||||
AgentSubsystemExectrace AgentSubsystem = "exectrace"
|
||||
)
|
||||
|
||||
func (c *Client) WorkspaceAgentConnectionInfo(ctx context.Context, agentID uuid.UUID) (WorkspaceAgentConnectionInfo, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/connection", agentID), nil)
|
||||
if err != nil {
|
||||
return WorkspaceAgentConnectionInfo{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return WorkspaceAgentConnectionInfo{}, ReadBodyAsError(res)
|
||||
}
|
||||
|
||||
var connInfo WorkspaceAgentConnectionInfo
|
||||
return connInfo, json.NewDecoder(res.Body).Decode(&connInfo)
|
||||
}
|
||||
|
||||
// @typescript-ignore DialWorkspaceAgentOptions
|
||||
type DialWorkspaceAgentOptions struct {
|
||||
Logger slog.Logger
|
||||
// BlockEndpoints forced a direct connection through DERP. The Client may
|
||||
// have DisableDirect set which will override this value.
|
||||
BlockEndpoints bool
|
||||
}
|
||||
|
||||
func (c *Client) DialWorkspaceAgent(dialCtx context.Context, agentID uuid.UUID, options *DialWorkspaceAgentOptions) (agentConn *WorkspaceAgentConn, err error) {
|
||||
if options == nil {
|
||||
options = &DialWorkspaceAgentOptions{}
|
||||
}
|
||||
|
||||
connInfo, err := c.WorkspaceAgentConnectionInfo(dialCtx, agentID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get connection info: %w", err)
|
||||
}
|
||||
if connInfo.DisableDirectConnections {
|
||||
options.BlockEndpoints = true
|
||||
}
|
||||
|
||||
ip := tailnet.IP()
|
||||
var header http.Header
|
||||
if headerTransport, ok := c.HTTPClient.Transport.(*HeaderTransport); ok {
|
||||
header = headerTransport.Header
|
||||
}
|
||||
conn, err := tailnet.NewConn(&tailnet.Options{
|
||||
Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)},
|
||||
DERPMap: connInfo.DERPMap,
|
||||
DERPHeader: &header,
|
||||
DERPForceWebSockets: connInfo.DERPForceWebSockets,
|
||||
Logger: options.Logger,
|
||||
BlockEndpoints: c.DisableDirectConnections || options.BlockEndpoints,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create tailnet: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
headers := make(http.Header)
|
||||
tokenHeader := SessionTokenHeader
|
||||
if c.SessionTokenHeader != "" {
|
||||
tokenHeader = c.SessionTokenHeader
|
||||
}
|
||||
headers.Set(tokenHeader, c.SessionToken())
|
||||
|
||||
// New context, separate from dialCtx. We don't want to cancel the
|
||||
// connection if dialCtx is canceled.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer func() {
|
||||
if err != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
coordinateURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/coordinate", agentID))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse url: %w", err)
|
||||
}
|
||||
q := coordinateURL.Query()
|
||||
q.Add("version", proto.CurrentVersion.String())
|
||||
coordinateURL.RawQuery = q.Encode()
|
||||
|
||||
connector := runTailnetAPIConnector(ctx, options.Logger,
|
||||
agentID, coordinateURL.String(),
|
||||
&websocket.DialOptions{
|
||||
HTTPClient: c.HTTPClient,
|
||||
HTTPHeader: headers,
|
||||
// Need to disable compression to avoid a data-race.
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
},
|
||||
conn,
|
||||
)
|
||||
options.Logger.Debug(ctx, "running tailnet API v2+ connector")
|
||||
|
||||
select {
|
||||
case <-dialCtx.Done():
|
||||
return nil, xerrors.Errorf("timed out waiting for coordinator and derp map: %w", dialCtx.Err())
|
||||
case err = <-connector.connected:
|
||||
if err != nil {
|
||||
options.Logger.Error(ctx, "failed to connect to tailnet v2+ API", slog.Error(err))
|
||||
return nil, xerrors.Errorf("start connector: %w", err)
|
||||
}
|
||||
options.Logger.Debug(ctx, "connected to tailnet v2+ API")
|
||||
}
|
||||
|
||||
agentConn = NewWorkspaceAgentConn(conn, WorkspaceAgentConnOptions{
|
||||
AgentID: agentID,
|
||||
CloseFunc: func() error {
|
||||
cancel()
|
||||
<-connector.closed
|
||||
return conn.Close()
|
||||
},
|
||||
})
|
||||
|
||||
if !agentConn.AwaitReachable(dialCtx) {
|
||||
_ = agentConn.Close()
|
||||
return nil, xerrors.Errorf("timed out waiting for agent to become reachable: %w", dialCtx.Err())
|
||||
}
|
||||
|
||||
return agentConn, nil
|
||||
}
|
||||
|
||||
// tailnetConn is the subset of the tailnet.Conn methods that tailnetAPIConnector uses. It is
|
||||
// included so that we can fake it in testing.
|
||||
//
|
||||
// @typescript-ignore tailnetConn
|
||||
type tailnetConn interface {
|
||||
tailnet.Coordinatee
|
||||
SetDERPMap(derpMap *tailcfg.DERPMap)
|
||||
}
|
||||
|
||||
// tailnetAPIConnector dials the tailnet API (v2+) and then uses the API with a tailnet.Conn to
|
||||
//
|
||||
// 1) run the Coordinate API and pass node information back and forth
|
||||
// 2) stream DERPMap updates and program the Conn
|
||||
//
|
||||
// These functions share the same websocket, and so are combined here so that if we hit a problem
|
||||
// we tear the whole thing down and start over with a new websocket.
|
||||
//
|
||||
// @typescript-ignore tailnetAPIConnector
|
||||
type tailnetAPIConnector struct {
|
||||
// We keep track of two contexts: the main context from the caller, and a "graceful" context
|
||||
// that we keep open slightly longer than the main context to give a chance to send the
|
||||
// Disconnect message to the coordinator. That tells the coordinator that we really meant to
|
||||
// disconnect instead of just losing network connectivity.
|
||||
ctx context.Context
|
||||
gracefulCtx context.Context
|
||||
cancelGracefulCtx context.CancelFunc
|
||||
|
||||
logger slog.Logger
|
||||
|
||||
agentID uuid.UUID
|
||||
coordinateURL string
|
||||
dialOptions *websocket.DialOptions
|
||||
conn tailnetConn
|
||||
|
||||
connected chan error
|
||||
isFirst bool
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
// runTailnetAPIConnector creates and runs a tailnetAPIConnector
|
||||
func runTailnetAPIConnector(
|
||||
ctx context.Context, logger slog.Logger,
|
||||
agentID uuid.UUID, coordinateURL string, dialOptions *websocket.DialOptions,
|
||||
conn tailnetConn,
|
||||
) *tailnetAPIConnector {
|
||||
tac := &tailnetAPIConnector{
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
agentID: agentID,
|
||||
coordinateURL: coordinateURL,
|
||||
dialOptions: dialOptions,
|
||||
conn: conn,
|
||||
connected: make(chan error, 1),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
tac.gracefulCtx, tac.cancelGracefulCtx = context.WithCancel(context.Background())
|
||||
go tac.manageGracefulTimeout()
|
||||
go tac.run()
|
||||
return tac
|
||||
}
|
||||
|
||||
// manageGracefulTimeout allows the gracefulContext to last 1 second longer than the main context
|
||||
// to allow a graceful disconnect.
|
||||
func (tac *tailnetAPIConnector) manageGracefulTimeout() {
|
||||
defer tac.cancelGracefulCtx()
|
||||
<-tac.ctx.Done()
|
||||
select {
|
||||
case <-tac.closed:
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
func (tac *tailnetAPIConnector) run() {
|
||||
tac.isFirst = true
|
||||
defer close(tac.closed)
|
||||
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(tac.ctx); {
|
||||
tailnetClient, err := tac.dial()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tac.logger.Debug(tac.ctx, "obtained tailnet API v2+ client")
|
||||
tac.coordinateAndDERPMap(tailnetClient)
|
||||
tac.logger.Debug(tac.ctx, "tailnet API v2+ connection lost")
|
||||
}
|
||||
}
|
||||
|
||||
func (tac *tailnetAPIConnector) dial() (proto.DRPCTailnetClient, error) {
|
||||
tac.logger.Debug(tac.ctx, "dialing Coder tailnet v2+ API")
|
||||
// nolint:bodyclose
|
||||
ws, res, err := websocket.Dial(tac.ctx, tac.coordinateURL, tac.dialOptions)
|
||||
if tac.isFirst {
|
||||
if res != nil && res.StatusCode == http.StatusConflict {
|
||||
err = ReadBodyAsError(res)
|
||||
tac.connected <- err
|
||||
return nil, err
|
||||
}
|
||||
tac.isFirst = false
|
||||
close(tac.connected)
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
tac.logger.Error(tac.ctx, "failed to dial tailnet v2+ API", slog.Error(err))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
client, err := tailnet.NewDRPCClient(
|
||||
websocket.NetConn(tac.gracefulCtx, ws, websocket.MessageBinary),
|
||||
tac.logger,
|
||||
)
|
||||
if err != nil {
|
||||
tac.logger.Debug(tac.ctx, "failed to create DRPCClient", slog.Error(err))
|
||||
_ = ws.Close(websocket.StatusInternalError, "")
|
||||
return nil, err
|
||||
}
|
||||
return client, err
|
||||
}
|
||||
|
||||
// coordinateAndDERPMap uses the provided client to coordinate and stream DERP Maps. It is combined
|
||||
// into one function so that a problem with one tears down the other and triggers a retry (if
|
||||
// appropriate). We multiplex both RPCs over the same websocket, so we want them to share the same
|
||||
// fate.
|
||||
func (tac *tailnetAPIConnector) coordinateAndDERPMap(client proto.DRPCTailnetClient) {
|
||||
defer func() {
|
||||
conn := client.DRPCConn()
|
||||
closeErr := conn.Close()
|
||||
if closeErr != nil &&
|
||||
!xerrors.Is(closeErr, io.EOF) &&
|
||||
!xerrors.Is(closeErr, context.Canceled) &&
|
||||
!xerrors.Is(closeErr, context.DeadlineExceeded) {
|
||||
tac.logger.Error(tac.ctx, "error closing DRPC connection", slog.Error(closeErr))
|
||||
<-conn.Closed()
|
||||
}
|
||||
}()
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tac.coordinate(client)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
dErr := tac.derpMap(client)
|
||||
if dErr != nil && tac.ctx.Err() == nil {
|
||||
// The main context is still active, meaning that we want the tailnet data plane to stay
|
||||
// up, even though we hit some error getting DERP maps on the control plane. That means
|
||||
// we do NOT want to gracefully disconnect on the coordinate() routine. So, we'll just
|
||||
// close the underlying connection. This will trigger a retry of the control plane in
|
||||
// run().
|
||||
client.DRPCConn().Close()
|
||||
// Note that derpMap() logs it own errors, we don't bother here.
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (tac *tailnetAPIConnector) coordinate(client proto.DRPCTailnetClient) {
|
||||
// we use the gracefulCtx here so that we'll have time to send the graceful disconnect
|
||||
coord, err := client.Coordinate(tac.gracefulCtx)
|
||||
if err != nil {
|
||||
tac.logger.Error(tac.ctx, "failed to connect to Coordinate RPC", slog.Error(err))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
cErr := coord.Close()
|
||||
if cErr != nil {
|
||||
tac.logger.Debug(tac.ctx, "error closing Coordinate RPC", slog.Error(cErr))
|
||||
}
|
||||
}()
|
||||
coordination := tailnet.NewRemoteCoordination(tac.logger, coord, tac.conn, tac.agentID)
|
||||
tac.logger.Debug(tac.ctx, "serving coordinator")
|
||||
select {
|
||||
case <-tac.ctx.Done():
|
||||
tac.logger.Debug(tac.ctx, "main context canceled; do graceful disconnect")
|
||||
crdErr := coordination.Close()
|
||||
if crdErr != nil {
|
||||
tac.logger.Warn(tac.ctx, "failed to close remote coordination", slog.Error(err))
|
||||
}
|
||||
case err = <-coordination.Error():
|
||||
if err != nil &&
|
||||
!xerrors.Is(err, io.EOF) &&
|
||||
!xerrors.Is(err, context.Canceled) &&
|
||||
!xerrors.Is(err, context.DeadlineExceeded) {
|
||||
tac.logger.Error(tac.ctx, "remote coordination error", slog.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (tac *tailnetAPIConnector) derpMap(client proto.DRPCTailnetClient) error {
|
||||
s, err := client.StreamDERPMaps(tac.ctx, &proto.StreamDERPMapsRequest{})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to connect to StreamDERPMaps RPC: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
cErr := s.Close()
|
||||
if cErr != nil {
|
||||
tac.logger.Debug(tac.ctx, "error closing StreamDERPMaps RPC", slog.Error(cErr))
|
||||
}
|
||||
}()
|
||||
for {
|
||||
dmp, err := s.Recv()
|
||||
if err != nil {
|
||||
if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
|
||||
return nil
|
||||
}
|
||||
tac.logger.Error(tac.ctx, "error receiving DERP Map", slog.Error(err))
|
||||
return err
|
||||
}
|
||||
tac.logger.Debug(tac.ctx, "got new DERP Map", slog.F("derp_map", dmp))
|
||||
dm := tailnet.DERPMapFromProto(dmp)
|
||||
tac.conn.SetDERPMap(dm)
|
||||
func (s AgentSubsystem) Valid() bool {
|
||||
switch s {
|
||||
case AgentSubsystemEnvbox, AgentSubsystemEnvbuilder, AgentSubsystemExectrace:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,66 +360,18 @@ func (c *Client) IssueReconnectingPTYSignedToken(ctx context.Context, req IssueR
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// @typescript-ignore:WorkspaceAgentReconnectingPTYOpts
|
||||
type WorkspaceAgentReconnectingPTYOpts struct {
|
||||
AgentID uuid.UUID
|
||||
Reconnect uuid.UUID
|
||||
Width uint16
|
||||
Height uint16
|
||||
Command string
|
||||
|
||||
// SignedToken is an optional signed token from the
|
||||
// issue-reconnecting-pty-signed-token endpoint. If set, the session token
|
||||
// on the client will not be sent.
|
||||
SignedToken string
|
||||
type WorkspaceAgentListeningPortsResponse struct {
|
||||
// If there are no ports in the list, nothing should be displayed in the UI.
|
||||
// There must not be a "no ports available" message or anything similar, as
|
||||
// there will always be no ports displayed on platforms where our port
|
||||
// detection logic is unsupported.
|
||||
Ports []WorkspaceAgentListeningPort `json:"ports"`
|
||||
}
|
||||
|
||||
// WorkspaceAgentReconnectingPTY spawns a PTY that reconnects using the token provided.
|
||||
// It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON.
|
||||
// Responses are PTY output that can be rendered.
|
||||
func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, opts WorkspaceAgentReconnectingPTYOpts) (net.Conn, error) {
|
||||
serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/pty", opts.AgentID))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse url: %w", err)
|
||||
}
|
||||
q := serverURL.Query()
|
||||
q.Set("reconnect", opts.Reconnect.String())
|
||||
q.Set("width", strconv.Itoa(int(opts.Width)))
|
||||
q.Set("height", strconv.Itoa(int(opts.Height)))
|
||||
q.Set("command", opts.Command)
|
||||
// If we're using a signed token, set the query parameter.
|
||||
if opts.SignedToken != "" {
|
||||
q.Set(SignedAppTokenQueryParameter, opts.SignedToken)
|
||||
}
|
||||
serverURL.RawQuery = q.Encode()
|
||||
|
||||
// If we're not using a signed token, we need to set the session token as a
|
||||
// cookie.
|
||||
httpClient := c.HTTPClient
|
||||
if opts.SignedToken == "" {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create cookie jar: %w", err)
|
||||
}
|
||||
jar.SetCookies(serverURL, []*http.Cookie{{
|
||||
Name: SessionTokenCookie,
|
||||
Value: c.SessionToken(),
|
||||
}})
|
||||
httpClient = &http.Client{
|
||||
Jar: jar,
|
||||
Transport: c.HTTPClient.Transport,
|
||||
}
|
||||
}
|
||||
conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
if err != nil {
|
||||
if res == nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, ReadBodyAsError(res)
|
||||
}
|
||||
return websocket.NetConn(context.Background(), conn, websocket.MessageBinary), nil
|
||||
type WorkspaceAgentListeningPort struct {
|
||||
ProcessName string `json:"process_name"` // may be empty
|
||||
Network string `json:"network"` // only "tcp" at the moment
|
||||
Port uint16 `json:"port"`
|
||||
}
|
||||
|
||||
// WorkspaceAgentListeningPorts returns a list of ports that are currently being
|
||||
@@ -869,28 +479,3 @@ func (c *Client) WorkspaceAgentLogsAfter(ctx context.Context, agentID uuid.UUID,
|
||||
return nil
|
||||
}), nil
|
||||
}
|
||||
|
||||
type WorkspaceAgentLog struct {
|
||||
ID int64 `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
Output string `json:"output"`
|
||||
Level LogLevel `json:"level"`
|
||||
SourceID uuid.UUID `json:"source_id" format:"uuid"`
|
||||
}
|
||||
|
||||
type AgentSubsystem string
|
||||
|
||||
const (
|
||||
AgentSubsystemEnvbox AgentSubsystem = "envbox"
|
||||
AgentSubsystemEnvbuilder AgentSubsystem = "envbuilder"
|
||||
AgentSubsystemExectrace AgentSubsystem = "exectrace"
|
||||
)
|
||||
|
||||
func (s AgentSubsystem) Valid() bool {
|
||||
switch s {
|
||||
case AgentSubsystemEnvbox, AgentSubsystemEnvbuilder, AgentSubsystemExectrace:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package codersdk
|
||||
package workspacesdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -23,141 +21,40 @@ import (
|
||||
"tailscale.com/net/speedtest"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
)
|
||||
|
||||
// WorkspaceAgentIP is a static IPv6 address with the Tailscale prefix that is used to route
|
||||
// connections from clients to this node. A dynamic address is not required because a Tailnet
|
||||
// client only dials a single agent at a time.
|
||||
//
|
||||
// Deprecated: use tailnet.IP() instead. This is kept for backwards
|
||||
// compatibility with outdated CLI clients and Workspace Proxies that dial it.
|
||||
// See: https://github.com/coder/coder/issues/11819
|
||||
var WorkspaceAgentIP = netip.MustParseAddr("fd7a:115c:a1e0:49d6:b259:b7ac:b1b2:48f4")
|
||||
|
||||
var ErrSkipClose = xerrors.New("skip tailnet close")
|
||||
|
||||
const (
|
||||
WorkspaceAgentSSHPort = tailnet.WorkspaceAgentSSHPort
|
||||
WorkspaceAgentReconnectingPTYPort = tailnet.WorkspaceAgentReconnectingPTYPort
|
||||
WorkspaceAgentSpeedtestPort = tailnet.WorkspaceAgentSpeedtestPort
|
||||
// WorkspaceAgentHTTPAPIServerPort serves a HTTP server with endpoints for e.g.
|
||||
// gathering agent statistics.
|
||||
WorkspaceAgentHTTPAPIServerPort = 4
|
||||
|
||||
// WorkspaceAgentMinimumListeningPort is the minimum port that the listening-ports
|
||||
// endpoint will return to the client, and the minimum port that is accepted
|
||||
// by the proxy applications endpoint. Coder consumes ports 1-4 at the
|
||||
// moment, and we reserve some extra ports for future use. Port 9 and up are
|
||||
// available for the user.
|
||||
//
|
||||
// This is not enforced in the CLI intentionally as we don't really care
|
||||
// *that* much. The user could bypass this in the CLI by using SSH instead
|
||||
// anyways.
|
||||
WorkspaceAgentMinimumListeningPort = 9
|
||||
)
|
||||
|
||||
// WorkspaceAgentIgnoredListeningPorts contains a list of ports to ignore when looking for
|
||||
// running applications inside a workspace. We want to ignore non-HTTP servers,
|
||||
// so we pre-populate this list with common ports that are not HTTP servers.
|
||||
//
|
||||
// This is implemented as a map for fast lookup.
|
||||
var WorkspaceAgentIgnoredListeningPorts = map[uint16]struct{}{
|
||||
0: {},
|
||||
// Ports 1-8 are reserved for future use by the Coder agent.
|
||||
1: {},
|
||||
2: {},
|
||||
3: {},
|
||||
4: {},
|
||||
5: {},
|
||||
6: {},
|
||||
7: {},
|
||||
8: {},
|
||||
// ftp
|
||||
20: {},
|
||||
21: {},
|
||||
// ssh
|
||||
22: {},
|
||||
// telnet
|
||||
23: {},
|
||||
// smtp
|
||||
25: {},
|
||||
// dns over TCP
|
||||
53: {},
|
||||
// pop3
|
||||
110: {},
|
||||
// imap
|
||||
143: {},
|
||||
// bgp
|
||||
179: {},
|
||||
// ldap
|
||||
389: {},
|
||||
636: {},
|
||||
// smtps
|
||||
465: {},
|
||||
// smtp
|
||||
587: {},
|
||||
// ftps
|
||||
989: {},
|
||||
990: {},
|
||||
// imaps
|
||||
993: {},
|
||||
// pop3s
|
||||
995: {},
|
||||
// mysql
|
||||
3306: {},
|
||||
// rdp
|
||||
3389: {},
|
||||
// postgres
|
||||
5432: {},
|
||||
// mongodb
|
||||
27017: {},
|
||||
27018: {},
|
||||
27019: {},
|
||||
28017: {},
|
||||
}
|
||||
|
||||
func init() {
|
||||
if !strings.HasSuffix(os.Args[0], ".test") {
|
||||
return
|
||||
}
|
||||
// Add a thousand more ports to the ignore list during tests so it's easier
|
||||
// to find an available port.
|
||||
for i := 63000; i < 64000; i++ {
|
||||
WorkspaceAgentIgnoredListeningPorts[uint16(i)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// NewWorkspaceAgentConn creates a new WorkspaceAgentConn. `conn` may be unique
|
||||
// NewAgentConn creates a new WorkspaceAgentConn. `conn` may be unique
|
||||
// to the WorkspaceAgentConn, or it may be shared in the case of coderd. If the
|
||||
// conn is shared and closing it is undesirable, you may return ErrNoClose from
|
||||
// opts.CloseFunc. This will ensure the underlying conn is not closed.
|
||||
func NewWorkspaceAgentConn(conn *tailnet.Conn, opts WorkspaceAgentConnOptions) *WorkspaceAgentConn {
|
||||
return &WorkspaceAgentConn{
|
||||
func NewAgentConn(conn *tailnet.Conn, opts AgentConnOptions) *AgentConn {
|
||||
return &AgentConn{
|
||||
Conn: conn,
|
||||
opts: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// WorkspaceAgentConn represents a connection to a workspace agent.
|
||||
// @typescript-ignore WorkspaceAgentConn
|
||||
type WorkspaceAgentConn struct {
|
||||
// AgentConn represents a connection to a workspace agent.
|
||||
// @typescript-ignore AgentConn
|
||||
type AgentConn struct {
|
||||
*tailnet.Conn
|
||||
opts WorkspaceAgentConnOptions
|
||||
opts AgentConnOptions
|
||||
}
|
||||
|
||||
// @typescript-ignore WorkspaceAgentConnOptions
|
||||
type WorkspaceAgentConnOptions struct {
|
||||
// @typescript-ignore AgentConnOptions
|
||||
type AgentConnOptions struct {
|
||||
AgentID uuid.UUID
|
||||
CloseFunc func() error
|
||||
}
|
||||
|
||||
func (c *WorkspaceAgentConn) agentAddress() netip.Addr {
|
||||
func (c *AgentConn) agentAddress() netip.Addr {
|
||||
return tailnet.IPFromUUID(c.opts.AgentID)
|
||||
}
|
||||
|
||||
// AwaitReachable waits for the agent to be reachable.
|
||||
func (c *WorkspaceAgentConn) AwaitReachable(ctx context.Context) bool {
|
||||
func (c *AgentConn) AwaitReachable(ctx context.Context) bool {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
@@ -166,7 +63,7 @@ func (c *WorkspaceAgentConn) AwaitReachable(ctx context.Context) bool {
|
||||
|
||||
// Ping pings the agent and returns the round-trip time.
|
||||
// The bool returns true if the ping was made P2P.
|
||||
func (c *WorkspaceAgentConn) Ping(ctx context.Context) (time.Duration, bool, *ipnstate.PingResult, error) {
|
||||
func (c *AgentConn) Ping(ctx context.Context) (time.Duration, bool, *ipnstate.PingResult, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
@@ -174,7 +71,7 @@ func (c *WorkspaceAgentConn) Ping(ctx context.Context) (time.Duration, bool, *ip
|
||||
}
|
||||
|
||||
// Close ends the connection to the workspace agent.
|
||||
func (c *WorkspaceAgentConn) Close() error {
|
||||
func (c *AgentConn) Close() error {
|
||||
var cerr error
|
||||
if c.opts.CloseFunc != nil {
|
||||
cerr = c.opts.CloseFunc()
|
||||
@@ -188,9 +85,9 @@ func (c *WorkspaceAgentConn) Close() error {
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// WorkspaceAgentReconnectingPTYInit initializes a new reconnecting PTY session.
|
||||
// @typescript-ignore WorkspaceAgentReconnectingPTYInit
|
||||
type WorkspaceAgentReconnectingPTYInit struct {
|
||||
// AgentReconnectingPTYInit initializes a new reconnecting PTY session.
|
||||
// @typescript-ignore AgentReconnectingPTYInit
|
||||
type AgentReconnectingPTYInit struct {
|
||||
ID uuid.UUID
|
||||
Height uint16
|
||||
Width uint16
|
||||
@@ -209,7 +106,7 @@ type ReconnectingPTYRequest struct {
|
||||
// ReconnectingPTY spawns a new reconnecting terminal session.
|
||||
// `ReconnectingPTYRequest` should be JSON marshaled and written to the returned net.Conn.
|
||||
// Raw terminal output will be read from the returned net.Conn.
|
||||
func (c *WorkspaceAgentConn) 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) (net.Conn, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
@@ -217,11 +114,11 @@ func (c *WorkspaceAgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID,
|
||||
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
||||
}
|
||||
|
||||
conn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), WorkspaceAgentReconnectingPTYPort))
|
||||
conn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), AgentReconnectingPTYPort))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := json.Marshal(WorkspaceAgentReconnectingPTYInit{
|
||||
data, err := json.Marshal(AgentReconnectingPTYInit{
|
||||
ID: id,
|
||||
Height: height,
|
||||
Width: width,
|
||||
@@ -244,7 +141,7 @@ func (c *WorkspaceAgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID,
|
||||
|
||||
// SSH pipes the SSH protocol over the returned net.Conn.
|
||||
// This connects to the built-in SSH server in the workspace agent.
|
||||
func (c *WorkspaceAgentConn) SSH(ctx context.Context) (*gonet.TCPConn, error) {
|
||||
func (c *AgentConn) SSH(ctx context.Context) (*gonet.TCPConn, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
@@ -252,12 +149,12 @@ func (c *WorkspaceAgentConn) SSH(ctx context.Context) (*gonet.TCPConn, error) {
|
||||
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
||||
}
|
||||
|
||||
return c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), WorkspaceAgentSSHPort))
|
||||
return c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), AgentSSHPort))
|
||||
}
|
||||
|
||||
// SSHClient calls SSH to create a client that uses a weak cipher
|
||||
// to improve throughput.
|
||||
func (c *WorkspaceAgentConn) SSHClient(ctx context.Context) (*ssh.Client, error) {
|
||||
func (c *AgentConn) SSHClient(ctx context.Context) (*ssh.Client, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
@@ -280,7 +177,7 @@ func (c *WorkspaceAgentConn) SSHClient(ctx context.Context) (*ssh.Client, error)
|
||||
}
|
||||
|
||||
// Speedtest runs a speedtest against the workspace agent.
|
||||
func (c *WorkspaceAgentConn) Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error) {
|
||||
func (c *AgentConn) Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
@@ -288,7 +185,7 @@ func (c *WorkspaceAgentConn) Speedtest(ctx context.Context, direction speedtest.
|
||||
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
||||
}
|
||||
|
||||
speedConn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), WorkspaceAgentSpeedtestPort))
|
||||
speedConn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), AgentSpeedtestPort))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("dial speedtest: %w", err)
|
||||
}
|
||||
@@ -303,7 +200,7 @@ func (c *WorkspaceAgentConn) Speedtest(ctx context.Context, direction speedtest.
|
||||
|
||||
// DialContext dials the address provided in the workspace agent.
|
||||
// The network must be "tcp" or "udp".
|
||||
func (c *WorkspaceAgentConn) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) {
|
||||
func (c *AgentConn) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
@@ -325,39 +222,25 @@ func (c *WorkspaceAgentConn) DialContext(ctx context.Context, network string, ad
|
||||
}
|
||||
}
|
||||
|
||||
type WorkspaceAgentListeningPortsResponse struct {
|
||||
// If there are no ports in the list, nothing should be displayed in the UI.
|
||||
// There must not be a "no ports available" message or anything similar, as
|
||||
// there will always be no ports displayed on platforms where our port
|
||||
// detection logic is unsupported.
|
||||
Ports []WorkspaceAgentListeningPort `json:"ports"`
|
||||
}
|
||||
|
||||
type WorkspaceAgentListeningPort struct {
|
||||
ProcessName string `json:"process_name"` // may be empty
|
||||
Network string `json:"network"` // only "tcp" at the moment
|
||||
Port uint16 `json:"port"`
|
||||
}
|
||||
|
||||
// ListeningPorts lists the ports that are currently in use by the workspace.
|
||||
func (c *WorkspaceAgentConn) ListeningPorts(ctx context.Context) (WorkspaceAgentListeningPortsResponse, error) {
|
||||
func (c *AgentConn) ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgentListeningPortsResponse, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/listening-ports", nil)
|
||||
if err != nil {
|
||||
return WorkspaceAgentListeningPortsResponse{}, xerrors.Errorf("do request: %w", err)
|
||||
return codersdk.WorkspaceAgentListeningPortsResponse{}, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return WorkspaceAgentListeningPortsResponse{}, ReadBodyAsError(res)
|
||||
return codersdk.WorkspaceAgentListeningPortsResponse{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
|
||||
var resp WorkspaceAgentListeningPortsResponse
|
||||
var resp codersdk.WorkspaceAgentListeningPortsResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// DebugMagicsock makes a request to the workspace agent's magicsock debug endpoint.
|
||||
func (c *WorkspaceAgentConn) DebugMagicsock(ctx context.Context) ([]byte, error) {
|
||||
func (c *AgentConn) DebugMagicsock(ctx context.Context) ([]byte, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, "/debug/magicsock", nil)
|
||||
@@ -365,7 +248,7 @@ func (c *WorkspaceAgentConn) DebugMagicsock(ctx context.Context) ([]byte, error)
|
||||
return nil, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, ReadBodyAsError(res)
|
||||
return nil, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
bs, err := io.ReadAll(res.Body)
|
||||
@@ -377,7 +260,7 @@ func (c *WorkspaceAgentConn) DebugMagicsock(ctx context.Context) ([]byte, error)
|
||||
|
||||
// DebugManifest returns the agent's in-memory manifest. Unfortunately this must
|
||||
// be returns as a []byte to avoid an import cycle.
|
||||
func (c *WorkspaceAgentConn) DebugManifest(ctx context.Context) ([]byte, error) {
|
||||
func (c *AgentConn) DebugManifest(ctx context.Context) ([]byte, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, "/debug/manifest", nil)
|
||||
@@ -386,7 +269,7 @@ func (c *WorkspaceAgentConn) DebugManifest(ctx context.Context) ([]byte, error)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, ReadBodyAsError(res)
|
||||
return nil, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
bs, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
@@ -396,7 +279,7 @@ func (c *WorkspaceAgentConn) DebugManifest(ctx context.Context) ([]byte, error)
|
||||
}
|
||||
|
||||
// DebugLogs returns up to the last 10MB of `/tmp/coder-agent.log`
|
||||
func (c *WorkspaceAgentConn) DebugLogs(ctx context.Context) ([]byte, error) {
|
||||
func (c *AgentConn) DebugLogs(ctx context.Context) ([]byte, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, "/debug/logs", nil)
|
||||
@@ -405,7 +288,7 @@ func (c *WorkspaceAgentConn) DebugLogs(ctx context.Context) ([]byte, error) {
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, ReadBodyAsError(res)
|
||||
return nil, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
bs, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
@@ -415,7 +298,7 @@ func (c *WorkspaceAgentConn) DebugLogs(ctx context.Context) ([]byte, error) {
|
||||
}
|
||||
|
||||
// PrometheusMetrics returns a response from the agent's prometheus metrics endpoint
|
||||
func (c *WorkspaceAgentConn) PrometheusMetrics(ctx context.Context) ([]byte, error) {
|
||||
func (c *AgentConn) PrometheusMetrics(ctx context.Context) ([]byte, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, "/debug/prometheus", nil)
|
||||
@@ -424,7 +307,7 @@ func (c *WorkspaceAgentConn) PrometheusMetrics(ctx context.Context) ([]byte, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, ReadBodyAsError(res)
|
||||
return nil, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
bs, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
@@ -434,11 +317,11 @@ func (c *WorkspaceAgentConn) PrometheusMetrics(ctx context.Context) ([]byte, err
|
||||
}
|
||||
|
||||
// apiRequest makes a request to the workspace agent's HTTP API server.
|
||||
func (c *WorkspaceAgentConn) apiRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
|
||||
func (c *AgentConn) apiRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
host := net.JoinHostPort(c.agentAddress().String(), strconv.Itoa(WorkspaceAgentHTTPAPIServerPort))
|
||||
host := net.JoinHostPort(c.agentAddress().String(), strconv.Itoa(AgentHTTPAPIServerPort))
|
||||
url := fmt.Sprintf("http://%s%s", host, path)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
@@ -451,7 +334,7 @@ func (c *WorkspaceAgentConn) apiRequest(ctx context.Context, method, path string
|
||||
|
||||
// apiClient returns an HTTP client that can be used to make
|
||||
// requests to the workspace agent's HTTP API server.
|
||||
func (c *WorkspaceAgentConn) apiClient() *http.Client {
|
||||
func (c *AgentConn) apiClient() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
// Disable keep alives as we're usually only making a single
|
||||
@@ -468,7 +351,7 @@ func (c *WorkspaceAgentConn) apiClient() *http.Client {
|
||||
}
|
||||
|
||||
// Verify that the port is TailnetStatisticsPort.
|
||||
if port != strconv.Itoa(WorkspaceAgentHTTPAPIServerPort) {
|
||||
if port != strconv.Itoa(AgentHTTPAPIServerPort) {
|
||||
return nil, xerrors.Errorf("request %q does not appear to be for http api", addr)
|
||||
}
|
||||
|
||||
@@ -481,7 +364,7 @@ func (c *WorkspaceAgentConn) apiClient() *http.Client {
|
||||
return nil, xerrors.Errorf("parse host addr: %w", err)
|
||||
}
|
||||
|
||||
conn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(ipAddr, WorkspaceAgentHTTPAPIServerPort))
|
||||
conn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(ipAddr, AgentHTTPAPIServerPort))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("dial http api: %w", err)
|
||||
}
|
||||
@@ -492,6 +375,6 @@ func (c *WorkspaceAgentConn) apiClient() *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WorkspaceAgentConn) GetPeerDiagnostics() tailnet.PeerDiagnostics {
|
||||
func (c *AgentConn) GetPeerDiagnostics() tailnet.PeerDiagnostics {
|
||||
return c.Conn.GetPeerDiagnostics(c.opts.AgentID)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package workspacesdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
"nhooyr.io/websocket"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
"github.com/coder/coder/v2/tailnet/proto"
|
||||
"github.com/coder/retry"
|
||||
)
|
||||
|
||||
// tailnetConn is the subset of the tailnet.Conn methods that tailnetAPIConnector uses. It is
|
||||
// included so that we can fake it in testing.
|
||||
//
|
||||
// @typescript-ignore tailnetConn
|
||||
type tailnetConn interface {
|
||||
tailnet.Coordinatee
|
||||
SetDERPMap(derpMap *tailcfg.DERPMap)
|
||||
}
|
||||
|
||||
// tailnetAPIConnector dials the tailnet API (v2+) and then uses the API with a tailnet.Conn to
|
||||
//
|
||||
// 1) run the Coordinate API and pass node information back and forth
|
||||
// 2) stream DERPMap updates and program the Conn
|
||||
//
|
||||
// These functions share the same websocket, and so are combined here so that if we hit a problem
|
||||
// we tear the whole thing down and start over with a new websocket.
|
||||
//
|
||||
// @typescript-ignore tailnetAPIConnector
|
||||
type tailnetAPIConnector struct {
|
||||
// We keep track of two contexts: the main context from the caller, and a "graceful" context
|
||||
// that we keep open slightly longer than the main context to give a chance to send the
|
||||
// Disconnect message to the coordinator. That tells the coordinator that we really meant to
|
||||
// disconnect instead of just losing network connectivity.
|
||||
ctx context.Context
|
||||
gracefulCtx context.Context
|
||||
cancelGracefulCtx context.CancelFunc
|
||||
|
||||
logger slog.Logger
|
||||
|
||||
agentID uuid.UUID
|
||||
coordinateURL string
|
||||
dialOptions *websocket.DialOptions
|
||||
conn tailnetConn
|
||||
|
||||
connected chan error
|
||||
isFirst bool
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
// runTailnetAPIConnector creates and runs a tailnetAPIConnector
|
||||
func runTailnetAPIConnector(
|
||||
ctx context.Context, logger slog.Logger,
|
||||
agentID uuid.UUID, coordinateURL string, dialOptions *websocket.DialOptions,
|
||||
conn tailnetConn,
|
||||
) *tailnetAPIConnector {
|
||||
tac := &tailnetAPIConnector{
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
agentID: agentID,
|
||||
coordinateURL: coordinateURL,
|
||||
dialOptions: dialOptions,
|
||||
conn: conn,
|
||||
connected: make(chan error, 1),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
tac.gracefulCtx, tac.cancelGracefulCtx = context.WithCancel(context.Background())
|
||||
go tac.manageGracefulTimeout()
|
||||
go tac.run()
|
||||
return tac
|
||||
}
|
||||
|
||||
// manageGracefulTimeout allows the gracefulContext to last 1 second longer than the main context
|
||||
// to allow a graceful disconnect.
|
||||
func (tac *tailnetAPIConnector) manageGracefulTimeout() {
|
||||
defer tac.cancelGracefulCtx()
|
||||
<-tac.ctx.Done()
|
||||
select {
|
||||
case <-tac.closed:
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
func (tac *tailnetAPIConnector) run() {
|
||||
tac.isFirst = true
|
||||
defer close(tac.closed)
|
||||
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(tac.ctx); {
|
||||
tailnetClient, err := tac.dial()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tac.logger.Debug(tac.ctx, "obtained tailnet API v2+ client")
|
||||
tac.coordinateAndDERPMap(tailnetClient)
|
||||
tac.logger.Debug(tac.ctx, "tailnet API v2+ connection lost")
|
||||
}
|
||||
}
|
||||
|
||||
func (tac *tailnetAPIConnector) dial() (proto.DRPCTailnetClient, error) {
|
||||
tac.logger.Debug(tac.ctx, "dialing Coder tailnet v2+ API")
|
||||
// nolint:bodyclose
|
||||
ws, res, err := websocket.Dial(tac.ctx, tac.coordinateURL, tac.dialOptions)
|
||||
if tac.isFirst {
|
||||
if res != nil && res.StatusCode == http.StatusConflict {
|
||||
err = codersdk.ReadBodyAsError(res)
|
||||
tac.connected <- err
|
||||
return nil, err
|
||||
}
|
||||
tac.isFirst = false
|
||||
close(tac.connected)
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
tac.logger.Error(tac.ctx, "failed to dial tailnet v2+ API", slog.Error(err))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
client, err := tailnet.NewDRPCClient(
|
||||
websocket.NetConn(tac.gracefulCtx, ws, websocket.MessageBinary),
|
||||
tac.logger,
|
||||
)
|
||||
if err != nil {
|
||||
tac.logger.Debug(tac.ctx, "failed to create DRPCClient", slog.Error(err))
|
||||
_ = ws.Close(websocket.StatusInternalError, "")
|
||||
return nil, err
|
||||
}
|
||||
return client, err
|
||||
}
|
||||
|
||||
// coordinateAndDERPMap uses the provided client to coordinate and stream DERP Maps. It is combined
|
||||
// into one function so that a problem with one tears down the other and triggers a retry (if
|
||||
// appropriate). We multiplex both RPCs over the same websocket, so we want them to share the same
|
||||
// fate.
|
||||
func (tac *tailnetAPIConnector) coordinateAndDERPMap(client proto.DRPCTailnetClient) {
|
||||
defer func() {
|
||||
conn := client.DRPCConn()
|
||||
closeErr := conn.Close()
|
||||
if closeErr != nil &&
|
||||
!xerrors.Is(closeErr, io.EOF) &&
|
||||
!xerrors.Is(closeErr, context.Canceled) &&
|
||||
!xerrors.Is(closeErr, context.DeadlineExceeded) {
|
||||
tac.logger.Error(tac.ctx, "error closing DRPC connection", slog.Error(closeErr))
|
||||
<-conn.Closed()
|
||||
}
|
||||
}()
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tac.coordinate(client)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
dErr := tac.derpMap(client)
|
||||
if dErr != nil && tac.ctx.Err() == nil {
|
||||
// The main context is still active, meaning that we want the tailnet data plane to stay
|
||||
// up, even though we hit some error getting DERP maps on the control plane. That means
|
||||
// we do NOT want to gracefully disconnect on the coordinate() routine. So, we'll just
|
||||
// close the underlying connection. This will trigger a retry of the control plane in
|
||||
// run().
|
||||
client.DRPCConn().Close()
|
||||
// Note that derpMap() logs it own errors, we don't bother here.
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (tac *tailnetAPIConnector) coordinate(client proto.DRPCTailnetClient) {
|
||||
// we use the gracefulCtx here so that we'll have time to send the graceful disconnect
|
||||
coord, err := client.Coordinate(tac.gracefulCtx)
|
||||
if err != nil {
|
||||
tac.logger.Error(tac.ctx, "failed to connect to Coordinate RPC", slog.Error(err))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
cErr := coord.Close()
|
||||
if cErr != nil {
|
||||
tac.logger.Debug(tac.ctx, "error closing Coordinate RPC", slog.Error(cErr))
|
||||
}
|
||||
}()
|
||||
coordination := tailnet.NewRemoteCoordination(tac.logger, coord, tac.conn, tac.agentID)
|
||||
tac.logger.Debug(tac.ctx, "serving coordinator")
|
||||
select {
|
||||
case <-tac.ctx.Done():
|
||||
tac.logger.Debug(tac.ctx, "main context canceled; do graceful disconnect")
|
||||
crdErr := coordination.Close()
|
||||
if crdErr != nil {
|
||||
tac.logger.Warn(tac.ctx, "failed to close remote coordination", slog.Error(err))
|
||||
}
|
||||
case err = <-coordination.Error():
|
||||
if err != nil &&
|
||||
!xerrors.Is(err, io.EOF) &&
|
||||
!xerrors.Is(err, context.Canceled) &&
|
||||
!xerrors.Is(err, context.DeadlineExceeded) {
|
||||
tac.logger.Error(tac.ctx, "remote coordination error", slog.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (tac *tailnetAPIConnector) derpMap(client proto.DRPCTailnetClient) error {
|
||||
s, err := client.StreamDERPMaps(tac.ctx, &proto.StreamDERPMapsRequest{})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to connect to StreamDERPMaps RPC: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
cErr := s.Close()
|
||||
if cErr != nil {
|
||||
tac.logger.Debug(tac.ctx, "error closing StreamDERPMaps RPC", slog.Error(cErr))
|
||||
}
|
||||
}()
|
||||
for {
|
||||
dmp, err := s.Recv()
|
||||
if err != nil {
|
||||
if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
|
||||
return nil
|
||||
}
|
||||
tac.logger.Error(tac.ctx, "error receiving DERP Map", slog.Error(err))
|
||||
return err
|
||||
}
|
||||
tac.logger.Debug(tac.ctx, "got new DERP Map", slog.F("derp_map", dmp))
|
||||
dm := tailnet.DERPMapFromProto(dmp)
|
||||
tac.conn.SetDERPMap(dm)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package workspacesdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
"nhooyr.io/websocket"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
"github.com/coder/coder/v2/tailnet/proto"
|
||||
)
|
||||
|
||||
// AgentIP is a static IPv6 address with the Tailscale prefix that is used to route
|
||||
// connections from clients to this node. A dynamic address is not required because a Tailnet
|
||||
// client only dials a single agent at a time.
|
||||
//
|
||||
// Deprecated: use tailnet.IP() instead. This is kept for backwards
|
||||
// compatibility with outdated CLI clients and Workspace Proxies that dial it.
|
||||
// See: https://github.com/coder/coder/issues/11819
|
||||
var AgentIP = netip.MustParseAddr("fd7a:115c:a1e0:49d6:b259:b7ac:b1b2:48f4")
|
||||
|
||||
var ErrSkipClose = xerrors.New("skip tailnet close")
|
||||
|
||||
const (
|
||||
AgentSSHPort = tailnet.WorkspaceAgentSSHPort
|
||||
AgentReconnectingPTYPort = tailnet.WorkspaceAgentReconnectingPTYPort
|
||||
AgentSpeedtestPort = tailnet.WorkspaceAgentSpeedtestPort
|
||||
// AgentHTTPAPIServerPort serves a HTTP server with endpoints for e.g.
|
||||
// gathering agent statistics.
|
||||
AgentHTTPAPIServerPort = 4
|
||||
|
||||
// AgentMinimumListeningPort is the minimum port that the listening-ports
|
||||
// endpoint will return to the client, and the minimum port that is accepted
|
||||
// by the proxy applications endpoint. Coder consumes ports 1-4 at the
|
||||
// moment, and we reserve some extra ports for future use. Port 9 and up are
|
||||
// available for the user.
|
||||
//
|
||||
// This is not enforced in the CLI intentionally as we don't really care
|
||||
// *that* much. The user could bypass this in the CLI by using SSH instead
|
||||
// anyways.
|
||||
AgentMinimumListeningPort = 9
|
||||
)
|
||||
|
||||
// AgentIgnoredListeningPorts contains a list of ports to ignore when looking for
|
||||
// running applications inside a workspace. We want to ignore non-HTTP servers,
|
||||
// so we pre-populate this list with common ports that are not HTTP servers.
|
||||
//
|
||||
// This is implemented as a map for fast lookup.
|
||||
var AgentIgnoredListeningPorts = map[uint16]struct{}{
|
||||
0: {},
|
||||
// Ports 1-8 are reserved for future use by the Coder agent.
|
||||
1: {},
|
||||
2: {},
|
||||
3: {},
|
||||
4: {},
|
||||
5: {},
|
||||
6: {},
|
||||
7: {},
|
||||
8: {},
|
||||
// ftp
|
||||
20: {},
|
||||
21: {},
|
||||
// ssh
|
||||
22: {},
|
||||
// telnet
|
||||
23: {},
|
||||
// smtp
|
||||
25: {},
|
||||
// dns over TCP
|
||||
53: {},
|
||||
// pop3
|
||||
110: {},
|
||||
// imap
|
||||
143: {},
|
||||
// bgp
|
||||
179: {},
|
||||
// ldap
|
||||
389: {},
|
||||
636: {},
|
||||
// smtps
|
||||
465: {},
|
||||
// smtp
|
||||
587: {},
|
||||
// ftps
|
||||
989: {},
|
||||
990: {},
|
||||
// imaps
|
||||
993: {},
|
||||
// pop3s
|
||||
995: {},
|
||||
// mysql
|
||||
3306: {},
|
||||
// rdp
|
||||
3389: {},
|
||||
// postgres
|
||||
5432: {},
|
||||
// mongodb
|
||||
27017: {},
|
||||
27018: {},
|
||||
27019: {},
|
||||
28017: {},
|
||||
}
|
||||
|
||||
func init() {
|
||||
if !strings.HasSuffix(os.Args[0], ".test") {
|
||||
return
|
||||
}
|
||||
// Add a thousand more ports to the ignore list during tests so it's easier
|
||||
// to find an available port.
|
||||
for i := 63000; i < 64000; i++ {
|
||||
AgentIgnoredListeningPorts[uint16(i)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
client *codersdk.Client
|
||||
}
|
||||
|
||||
func New(c *codersdk.Client) *Client {
|
||||
return &Client{client: c}
|
||||
}
|
||||
|
||||
// AgentConnectionInfo returns required information for establishing
|
||||
// a connection with a workspace.
|
||||
// @typescript-ignore AgentConnectionInfo
|
||||
type AgentConnectionInfo struct {
|
||||
DERPMap *tailcfg.DERPMap `json:"derp_map"`
|
||||
DERPForceWebSockets bool `json:"derp_force_websockets"`
|
||||
DisableDirectConnections bool `json:"disable_direct_connections"`
|
||||
}
|
||||
|
||||
func (c *Client) AgentConnectionInfoGeneric(ctx context.Context) (AgentConnectionInfo, error) {
|
||||
res, err := c.client.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/connection", nil)
|
||||
if err != nil {
|
||||
return AgentConnectionInfo{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return AgentConnectionInfo{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
|
||||
var connInfo AgentConnectionInfo
|
||||
return connInfo, json.NewDecoder(res.Body).Decode(&connInfo)
|
||||
}
|
||||
|
||||
func (c *Client) AgentConnectionInfo(ctx context.Context, agentID uuid.UUID) (AgentConnectionInfo, error) {
|
||||
res, err := c.client.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/connection", agentID), nil)
|
||||
if err != nil {
|
||||
return AgentConnectionInfo{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return AgentConnectionInfo{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
|
||||
var connInfo AgentConnectionInfo
|
||||
return connInfo, json.NewDecoder(res.Body).Decode(&connInfo)
|
||||
}
|
||||
|
||||
// @typescript-ignore DialAgentOptions
|
||||
type DialAgentOptions struct {
|
||||
Logger slog.Logger
|
||||
// BlockEndpoints forced a direct connection through DERP. The Client may
|
||||
// have DisableDirect set which will override this value.
|
||||
BlockEndpoints bool
|
||||
}
|
||||
|
||||
func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options *DialAgentOptions) (agentConn *AgentConn, err error) {
|
||||
if options == nil {
|
||||
options = &DialAgentOptions{}
|
||||
}
|
||||
|
||||
connInfo, err := c.AgentConnectionInfo(dialCtx, agentID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get connection info: %w", err)
|
||||
}
|
||||
if connInfo.DisableDirectConnections {
|
||||
options.BlockEndpoints = true
|
||||
}
|
||||
|
||||
ip := tailnet.IP()
|
||||
var header http.Header
|
||||
if headerTransport, ok := c.client.HTTPClient.Transport.(*codersdk.HeaderTransport); ok {
|
||||
header = headerTransport.Header
|
||||
}
|
||||
conn, err := tailnet.NewConn(&tailnet.Options{
|
||||
Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)},
|
||||
DERPMap: connInfo.DERPMap,
|
||||
DERPHeader: &header,
|
||||
DERPForceWebSockets: connInfo.DERPForceWebSockets,
|
||||
Logger: options.Logger,
|
||||
BlockEndpoints: c.client.DisableDirectConnections || options.BlockEndpoints,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create tailnet: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
headers := make(http.Header)
|
||||
tokenHeader := codersdk.SessionTokenHeader
|
||||
if c.client.SessionTokenHeader != "" {
|
||||
tokenHeader = c.client.SessionTokenHeader
|
||||
}
|
||||
headers.Set(tokenHeader, c.client.SessionToken())
|
||||
|
||||
// New context, separate from dialCtx. We don't want to cancel the
|
||||
// connection if dialCtx is canceled.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer func() {
|
||||
if err != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
coordinateURL, err := c.client.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/coordinate", agentID))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse url: %w", err)
|
||||
}
|
||||
q := coordinateURL.Query()
|
||||
q.Add("version", proto.CurrentVersion.String())
|
||||
coordinateURL.RawQuery = q.Encode()
|
||||
|
||||
connector := runTailnetAPIConnector(ctx, options.Logger,
|
||||
agentID, coordinateURL.String(),
|
||||
&websocket.DialOptions{
|
||||
HTTPClient: c.client.HTTPClient,
|
||||
HTTPHeader: headers,
|
||||
// Need to disable compression to avoid a data-race.
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
},
|
||||
conn,
|
||||
)
|
||||
options.Logger.Debug(ctx, "running tailnet API v2+ connector")
|
||||
|
||||
select {
|
||||
case <-dialCtx.Done():
|
||||
return nil, xerrors.Errorf("timed out waiting for coordinator and derp map: %w", dialCtx.Err())
|
||||
case err = <-connector.connected:
|
||||
if err != nil {
|
||||
options.Logger.Error(ctx, "failed to connect to tailnet v2+ API", slog.Error(err))
|
||||
return nil, xerrors.Errorf("start connector: %w", err)
|
||||
}
|
||||
options.Logger.Debug(ctx, "connected to tailnet v2+ API")
|
||||
}
|
||||
|
||||
agentConn = NewAgentConn(conn, AgentConnOptions{
|
||||
AgentID: agentID,
|
||||
CloseFunc: func() error {
|
||||
cancel()
|
||||
<-connector.closed
|
||||
return conn.Close()
|
||||
},
|
||||
})
|
||||
|
||||
if !agentConn.AwaitReachable(dialCtx) {
|
||||
_ = agentConn.Close()
|
||||
return nil, xerrors.Errorf("timed out waiting for agent to become reachable: %w", dialCtx.Err())
|
||||
}
|
||||
|
||||
return agentConn, nil
|
||||
}
|
||||
|
||||
// @typescript-ignore:WorkspaceAgentReconnectingPTYOpts
|
||||
type WorkspaceAgentReconnectingPTYOpts struct {
|
||||
AgentID uuid.UUID
|
||||
Reconnect uuid.UUID
|
||||
Width uint16
|
||||
Height uint16
|
||||
Command string
|
||||
|
||||
// SignedToken is an optional signed token from the
|
||||
// issue-reconnecting-pty-signed-token endpoint. If set, the session token
|
||||
// on the client will not be sent.
|
||||
SignedToken string
|
||||
}
|
||||
|
||||
// AgentReconnectingPTY spawns a PTY that reconnects using the token provided.
|
||||
// It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON.
|
||||
// Responses are PTY output that can be rendered.
|
||||
func (c *Client) AgentReconnectingPTY(ctx context.Context, opts WorkspaceAgentReconnectingPTYOpts) (net.Conn, error) {
|
||||
serverURL, err := c.client.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/pty", opts.AgentID))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse url: %w", err)
|
||||
}
|
||||
q := serverURL.Query()
|
||||
q.Set("reconnect", opts.Reconnect.String())
|
||||
q.Set("width", strconv.Itoa(int(opts.Width)))
|
||||
q.Set("height", strconv.Itoa(int(opts.Height)))
|
||||
q.Set("command", opts.Command)
|
||||
// If we're using a signed token, set the query parameter.
|
||||
if opts.SignedToken != "" {
|
||||
q.Set(codersdk.SignedAppTokenQueryParameter, opts.SignedToken)
|
||||
}
|
||||
serverURL.RawQuery = q.Encode()
|
||||
|
||||
// If we're not using a signed token, we need to set the session token as a
|
||||
// cookie.
|
||||
httpClient := c.client.HTTPClient
|
||||
if opts.SignedToken == "" {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create cookie jar: %w", err)
|
||||
}
|
||||
jar.SetCookies(serverURL, []*http.Cookie{{
|
||||
Name: codersdk.SessionTokenCookie,
|
||||
Value: c.client.SessionToken(),
|
||||
}})
|
||||
httpClient = &http.Client{
|
||||
Jar: jar,
|
||||
Transport: c.client.HTTPClient.Transport,
|
||||
}
|
||||
}
|
||||
//nolint:bodyclose
|
||||
conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
if err != nil {
|
||||
if res == nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
return websocket.NetConn(context.Background(), conn, websocket.MessageBinary), nil
|
||||
}
|
||||
+3
-2
@@ -1,4 +1,4 @@
|
||||
package codersdk
|
||||
package workspacesdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
"github.com/coder/coder/v2/tailnet/proto"
|
||||
"github.com/coder/coder/v2/tailnet/tailnettest"
|
||||
@@ -50,7 +51,7 @@ func TestTailnetAPIConnector_Disconnects(t *testing.T) {
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
ctx, nc := WebsocketNetConn(r.Context(), sws, websocket.MessageBinary)
|
||||
ctx, nc := codersdk.WebsocketNetConn(r.Context(), sws, websocket.MessageBinary)
|
||||
err = svc.ServeConnV2(ctx, nc, tailnet.StreamID{
|
||||
Name: "client",
|
||||
ID: clientID,
|
||||
@@ -1,4 +1,4 @@
|
||||
package codersdk_test
|
||||
package workspacesdk_test
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
Reference in New Issue
Block a user