2022-02-21 20:36:29 +00:00
|
|
|
package codersdk
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"encoding/json"
|
2022-09-01 01:09:44 +00:00
|
|
|
"errors"
|
2022-02-21 20:36:29 +00:00
|
|
|
"fmt"
|
2022-03-28 19:31:03 +00:00
|
|
|
"io"
|
2022-04-18 22:40:25 +00:00
|
|
|
"net"
|
2022-02-21 20:36:29 +00:00
|
|
|
"net/http"
|
2022-04-11 21:06:15 +00:00
|
|
|
"net/http/cookiejar"
|
2022-09-01 01:09:44 +00:00
|
|
|
"net/netip"
|
2022-10-25 00:46:24 +00:00
|
|
|
"net/url"
|
2022-09-26 17:56:04 +00:00
|
|
|
"strconv"
|
2022-09-01 01:09:44 +00:00
|
|
|
"time"
|
2022-02-21 20:36:29 +00:00
|
|
|
|
|
|
|
"cloud.google.com/go/compute/metadata"
|
2022-04-11 21:06:15 +00:00
|
|
|
"github.com/google/uuid"
|
2022-02-21 20:36:29 +00:00
|
|
|
"golang.org/x/xerrors"
|
2022-04-11 21:06:15 +00:00
|
|
|
"nhooyr.io/websocket"
|
2022-09-01 01:09:44 +00:00
|
|
|
"tailscale.com/tailcfg"
|
2022-04-11 21:06:15 +00:00
|
|
|
|
2022-04-18 22:40:25 +00:00
|
|
|
"cdr.dev/slog"
|
2022-09-01 01:09:44 +00:00
|
|
|
"github.com/coder/coder/tailnet"
|
|
|
|
"github.com/coder/retry"
|
2022-02-21 20:36:29 +00:00
|
|
|
)
|
|
|
|
|
2022-10-03 21:01:13 +00:00
|
|
|
type WorkspaceAgentStatus string
|
|
|
|
|
|
|
|
const (
|
|
|
|
WorkspaceAgentConnecting WorkspaceAgentStatus = "connecting"
|
|
|
|
WorkspaceAgentConnected WorkspaceAgentStatus = "connected"
|
|
|
|
WorkspaceAgentDisconnected WorkspaceAgentStatus = "disconnected"
|
2022-11-09 15:27:05 +00:00
|
|
|
WorkspaceAgentTimeout WorkspaceAgentStatus = "timeout"
|
2022-10-03 21:01:13 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
type WorkspaceAgent struct {
|
2023-01-05 14:27:10 +00:00
|
|
|
ID uuid.UUID `json:"id" format:"uuid"`
|
|
|
|
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
|
|
|
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
|
|
|
FirstConnectedAt *time.Time `json:"first_connected_at,omitempty" format:"date-time"`
|
|
|
|
LastConnectedAt *time.Time `json:"last_connected_at,omitempty" format:"date-time"`
|
|
|
|
DisconnectedAt *time.Time `json:"disconnected_at,omitempty" format:"date-time"`
|
|
|
|
Status WorkspaceAgentStatus `json:"status" enums:"connecting,connected,disconnected,timeout"`
|
2022-10-03 21:01:13 +00:00
|
|
|
Name string `json:"name"`
|
2023-01-05 14:27:10 +00:00
|
|
|
ResourceID uuid.UUID `json:"resource_id" format:"uuid"`
|
2022-10-03 21:01:13 +00:00
|
|
|
InstanceID string `json:"instance_id,omitempty"`
|
|
|
|
Architecture string `json:"architecture"`
|
|
|
|
EnvironmentVariables map[string]string `json:"environment_variables"`
|
|
|
|
OperatingSystem string `json:"operating_system"`
|
|
|
|
StartupScript string `json:"startup_script,omitempty"`
|
|
|
|
Directory string `json:"directory,omitempty"`
|
|
|
|
Version string `json:"version"`
|
|
|
|
Apps []WorkspaceApp `json:"apps"`
|
|
|
|
// DERPLatency is mapped by region name (e.g. "New York City", "Seattle").
|
2022-11-09 15:27:05 +00:00
|
|
|
DERPLatency map[string]DERPRegion `json:"latency,omitempty"`
|
|
|
|
ConnectionTimeoutSeconds int32 `json:"connection_timeout_seconds"`
|
2022-11-16 10:53:02 +00:00
|
|
|
TroubleshootingURL string `json:"troubleshooting_url"`
|
2022-10-03 21:01:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type WorkspaceAgentResourceMetadata struct {
|
|
|
|
MemoryTotal uint64 `json:"memory_total"`
|
|
|
|
DiskTotal uint64 `json:"disk_total"`
|
|
|
|
CPUCores uint64 `json:"cpu_cores"`
|
|
|
|
CPUModel string `json:"cpu_model"`
|
|
|
|
CPUMhz float64 `json:"cpu_mhz"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type DERPRegion struct {
|
|
|
|
Preferred bool `json:"preferred"`
|
|
|
|
LatencyMilliseconds float64 `json:"latency_ms"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type WorkspaceAgentInstanceMetadata struct {
|
|
|
|
JailOrchestrator string `json:"jail_orchestrator"`
|
|
|
|
OperatingSystem string `json:"operating_system"`
|
|
|
|
Platform string `json:"platform"`
|
|
|
|
PlatformFamily string `json:"platform_family"`
|
|
|
|
KernelVersion string `json:"kernel_version"`
|
|
|
|
KernelArchitecture string `json:"kernel_architecture"`
|
|
|
|
Cloud string `json:"cloud"`
|
|
|
|
Jail string `json:"jail"`
|
|
|
|
VNC bool `json:"vnc"`
|
|
|
|
}
|
|
|
|
|
2022-09-23 19:51:04 +00:00
|
|
|
// @typescript-ignore GoogleInstanceIdentityToken
|
2022-03-22 19:17:50 +00:00
|
|
|
type GoogleInstanceIdentityToken struct {
|
|
|
|
JSONWebToken string `json:"json_web_token" validate:"required"`
|
|
|
|
}
|
|
|
|
|
2022-09-23 19:51:04 +00:00
|
|
|
// @typescript-ignore AWSInstanceIdentityToken
|
2022-03-28 19:31:03 +00:00
|
|
|
type AWSInstanceIdentityToken struct {
|
|
|
|
Signature string `json:"signature" validate:"required"`
|
|
|
|
Document string `json:"document" validate:"required"`
|
|
|
|
}
|
|
|
|
|
2022-09-23 19:51:04 +00:00
|
|
|
// @typescript-ignore ReconnectingPTYRequest
|
2022-04-19 13:48:13 +00:00
|
|
|
type AzureInstanceIdentityToken struct {
|
|
|
|
Signature string `json:"signature" validate:"required"`
|
|
|
|
Encoding string `json:"encoding" validate:"required"`
|
|
|
|
}
|
|
|
|
|
2022-03-22 19:17:50 +00:00
|
|
|
// WorkspaceAgentAuthenticateResponse is returned when an instance ID
|
|
|
|
// has been exchanged for a session token.
|
2022-09-23 19:51:04 +00:00
|
|
|
// @typescript-ignore WorkspaceAgentAuthenticateResponse
|
2022-03-22 19:17:50 +00:00
|
|
|
type WorkspaceAgentAuthenticateResponse struct {
|
|
|
|
SessionToken string `json:"session_token"`
|
|
|
|
}
|
|
|
|
|
2022-09-01 01:09:44 +00:00
|
|
|
// WorkspaceAgentConnectionInfo returns required information for establishing
|
|
|
|
// a connection with a workspace.
|
2022-09-23 19:51:04 +00:00
|
|
|
// @typescript-ignore WorkspaceAgentConnectionInfo
|
2022-09-01 01:09:44 +00:00
|
|
|
type WorkspaceAgentConnectionInfo struct {
|
|
|
|
DERPMap *tailcfg.DERPMap `json:"derp_map"`
|
|
|
|
}
|
|
|
|
|
2022-09-23 19:51:04 +00:00
|
|
|
// @typescript-ignore PostWorkspaceAgentVersionRequest
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Description x-apidocgen:skip
|
2022-08-31 15:33:50 +00:00
|
|
|
type PostWorkspaceAgentVersionRequest struct {
|
|
|
|
Version string `json:"version"`
|
|
|
|
}
|
|
|
|
|
2022-09-23 19:51:04 +00:00
|
|
|
// @typescript-ignore WorkspaceAgentMetadata
|
|
|
|
type WorkspaceAgentMetadata struct {
|
2022-10-25 00:46:24 +00:00
|
|
|
// GitAuthConfigs stores the number of Git configurations
|
|
|
|
// the Coder deployment has. If this number is >0, we
|
|
|
|
// set up special configuration in the workspace.
|
|
|
|
GitAuthConfigs int `json:"git_auth_configs"`
|
2022-11-04 04:45:43 +00:00
|
|
|
VSCodePortProxyURI string `json:"vscode_port_proxy_uri"`
|
2022-10-24 03:35:08 +00:00
|
|
|
Apps []WorkspaceApp `json:"apps"`
|
2022-09-23 19:51:04 +00:00
|
|
|
DERPMap *tailcfg.DERPMap `json:"derpmap"`
|
|
|
|
EnvironmentVariables map[string]string `json:"environment_variables"`
|
|
|
|
StartupScript string `json:"startup_script"`
|
|
|
|
Directory string `json:"directory"`
|
2022-11-24 12:22:20 +00:00
|
|
|
MOTDFile string `json:"motd_file"`
|
2022-09-23 19:51:04 +00:00
|
|
|
}
|
|
|
|
|
2022-03-07 17:40:54 +00:00
|
|
|
// AuthWorkspaceGoogleInstanceIdentity uses the Google Compute Engine Metadata API to
|
2022-02-21 20:36:29 +00:00
|
|
|
// fetch a signed JWT, and exchange it for a session token for a workspace agent.
|
|
|
|
//
|
|
|
|
// The requesting instance must be registered as a resource in the latest history for a workspace.
|
2022-03-22 19:17:50 +00:00
|
|
|
func (c *Client) AuthWorkspaceGoogleInstanceIdentity(ctx context.Context, serviceAccount string, gcpClient *metadata.Client) (WorkspaceAgentAuthenticateResponse, error) {
|
2022-02-21 20:36:29 +00:00
|
|
|
if serviceAccount == "" {
|
|
|
|
// This is the default name specified by Google.
|
|
|
|
serviceAccount = "default"
|
|
|
|
}
|
|
|
|
if gcpClient == nil {
|
2022-03-24 19:21:05 +00:00
|
|
|
gcpClient = metadata.NewClient(c.HTTPClient)
|
2022-02-21 20:36:29 +00:00
|
|
|
}
|
|
|
|
// "format=full" is required, otherwise the responding payload will be missing "instance_id".
|
|
|
|
jwt, err := gcpClient.Get(fmt.Sprintf("instance/service-accounts/%s/identity?audience=coder&format=full", serviceAccount))
|
|
|
|
if err != nil {
|
2022-03-22 19:17:50 +00:00
|
|
|
return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("get metadata identity: %w", err)
|
2022-02-21 20:36:29 +00:00
|
|
|
}
|
2022-05-17 18:43:19 +00:00
|
|
|
res, err := c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/google-instance-identity", GoogleInstanceIdentityToken{
|
2022-02-21 20:36:29 +00:00
|
|
|
JSONWebToken: jwt,
|
|
|
|
})
|
|
|
|
if err != nil {
|
2022-03-22 19:17:50 +00:00
|
|
|
return WorkspaceAgentAuthenticateResponse{}, err
|
2022-02-21 20:36:29 +00:00
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
2022-03-22 19:17:50 +00:00
|
|
|
return WorkspaceAgentAuthenticateResponse{}, readBodyAsError(res)
|
2022-02-21 20:36:29 +00:00
|
|
|
}
|
2022-03-22 19:17:50 +00:00
|
|
|
var resp WorkspaceAgentAuthenticateResponse
|
2022-02-21 20:36:29 +00:00
|
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
|
|
}
|
2022-03-28 19:31:03 +00:00
|
|
|
|
|
|
|
// AuthWorkspaceAWSInstanceIdentity uses the Amazon Metadata API to
|
|
|
|
// fetch a signed payload, and exchange it for a session token for a workspace agent.
|
|
|
|
//
|
|
|
|
// The requesting instance must be registered as a resource in the latest history for a workspace.
|
|
|
|
func (c *Client) AuthWorkspaceAWSInstanceIdentity(ctx context.Context) (WorkspaceAgentAuthenticateResponse, error) {
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://169.254.169.254/latest/api/token", nil)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, nil
|
|
|
|
}
|
|
|
|
req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "21600")
|
|
|
|
res, err := c.HTTPClient.Do(req)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, err
|
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
token, err := io.ReadAll(res.Body)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("read token: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
req, err = http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.169.254/latest/dynamic/instance-identity/signature", nil)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, nil
|
|
|
|
}
|
|
|
|
req.Header.Set("X-aws-ec2-metadata-token", string(token))
|
|
|
|
res, err = c.HTTPClient.Do(req)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, err
|
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
signature, err := io.ReadAll(res.Body)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("read token: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
req, err = http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.169.254/latest/dynamic/instance-identity/document", nil)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, nil
|
|
|
|
}
|
|
|
|
req.Header.Set("X-aws-ec2-metadata-token", string(token))
|
|
|
|
res, err = c.HTTPClient.Do(req)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, err
|
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
document, err := io.ReadAll(res.Body)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("read token: %w", err)
|
|
|
|
}
|
|
|
|
|
2022-05-17 18:43:19 +00:00
|
|
|
res, err = c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/aws-instance-identity", AWSInstanceIdentityToken{
|
2022-03-28 19:31:03 +00:00
|
|
|
Signature: string(signature),
|
|
|
|
Document: string(document),
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, err
|
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, readBodyAsError(res)
|
|
|
|
}
|
|
|
|
var resp WorkspaceAgentAuthenticateResponse
|
|
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
|
|
}
|
2022-04-11 21:06:15 +00:00
|
|
|
|
2022-04-19 13:48:13 +00:00
|
|
|
// AuthWorkspaceAzureInstanceIdentity uses the Azure Instance Metadata Service to
|
|
|
|
// fetch a signed payload, and exchange it for a session token for a workspace agent.
|
|
|
|
func (c *Client) AuthWorkspaceAzureInstanceIdentity(ctx context.Context) (WorkspaceAgentAuthenticateResponse, error) {
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.169.254/metadata/attested/document?api-version=2020-09-01", nil)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, nil
|
|
|
|
}
|
|
|
|
req.Header.Set("Metadata", "true")
|
|
|
|
res, err := c.HTTPClient.Do(req)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, err
|
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
|
|
|
|
var token AzureInstanceIdentityToken
|
|
|
|
err = json.NewDecoder(res.Body).Decode(&token)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, err
|
|
|
|
}
|
|
|
|
|
2022-05-17 18:43:19 +00:00
|
|
|
res, err = c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/azure-instance-identity", token)
|
2022-04-19 13:48:13 +00:00
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, err
|
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
|
|
return WorkspaceAgentAuthenticateResponse{}, readBodyAsError(res)
|
|
|
|
}
|
|
|
|
var resp WorkspaceAgentAuthenticateResponse
|
|
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
|
|
}
|
|
|
|
|
2022-09-01 01:09:44 +00:00
|
|
|
// WorkspaceAgentMetadata fetches metadata for the currently authenticated workspace agent.
|
2022-09-23 19:51:04 +00:00
|
|
|
func (c *Client) WorkspaceAgentMetadata(ctx context.Context) (WorkspaceAgentMetadata, error) {
|
2022-09-01 01:09:44 +00:00
|
|
|
res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/metadata", nil)
|
|
|
|
if err != nil {
|
2022-09-23 19:51:04 +00:00
|
|
|
return WorkspaceAgentMetadata{}, err
|
2022-09-01 01:09:44 +00:00
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
2022-09-23 19:51:04 +00:00
|
|
|
return WorkspaceAgentMetadata{}, readBodyAsError(res)
|
2022-09-01 01:09:44 +00:00
|
|
|
}
|
2022-09-23 19:51:04 +00:00
|
|
|
var agentMetadata WorkspaceAgentMetadata
|
2022-09-26 17:56:04 +00:00
|
|
|
err = json.NewDecoder(res.Body).Decode(&agentMetadata)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentMetadata{}, err
|
|
|
|
}
|
|
|
|
accessingPort := c.URL.Port()
|
|
|
|
if accessingPort == "" {
|
|
|
|
accessingPort = "80"
|
|
|
|
if c.URL.Scheme == "https" {
|
|
|
|
accessingPort = "443"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
accessPort, err := strconv.Atoi(accessingPort)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentMetadata{}, xerrors.Errorf("convert accessing port %q: %w", accessingPort, err)
|
|
|
|
}
|
|
|
|
// Agents can provide an arbitrary access URL that may be different
|
|
|
|
// that the globally configured one. This breaks the built-in DERP,
|
|
|
|
// which would continue to reference the global access URL.
|
|
|
|
//
|
|
|
|
// This converts all built-in DERPs to use the access URL that the
|
|
|
|
// metadata request was performed with.
|
|
|
|
for _, region := range agentMetadata.DERPMap.Regions {
|
|
|
|
if !region.EmbeddedRelay {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, node := range region.Nodes {
|
|
|
|
if node.STUNOnly {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
node.HostName = c.URL.Hostname()
|
|
|
|
node.DERPPort = accessPort
|
|
|
|
node.ForceHTTP = c.URL.Scheme == "http"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return agentMetadata, nil
|
2022-09-01 01:09:44 +00:00
|
|
|
}
|
|
|
|
|
2022-10-24 03:35:08 +00:00
|
|
|
func (c *Client) ListenWorkspaceAgent(ctx context.Context) (net.Conn, error) {
|
2022-09-01 01:09:44 +00:00
|
|
|
coordinateURL, err := c.URL.Parse("/api/v2/workspaceagents/me/coordinate")
|
2022-04-25 18:30:39 +00:00
|
|
|
if err != nil {
|
2022-09-01 01:09:44 +00:00
|
|
|
return nil, xerrors.Errorf("parse url: %w", err)
|
2022-04-25 18:30:39 +00:00
|
|
|
}
|
2022-09-01 01:09:44 +00:00
|
|
|
jar, err := cookiejar.New(nil)
|
2022-04-25 18:30:39 +00:00
|
|
|
if err != nil {
|
2022-09-01 01:09:44 +00:00
|
|
|
return nil, xerrors.Errorf("create cookie jar: %w", err)
|
2022-04-26 01:03:54 +00:00
|
|
|
}
|
2022-09-01 01:09:44 +00:00
|
|
|
jar.SetCookies(coordinateURL, []*http.Cookie{{
|
|
|
|
Name: SessionTokenKey,
|
2022-11-09 13:31:24 +00:00
|
|
|
Value: c.SessionToken(),
|
2022-09-01 01:09:44 +00:00
|
|
|
}})
|
|
|
|
httpClient := &http.Client{
|
2022-10-17 13:43:30 +00:00
|
|
|
Jar: jar,
|
|
|
|
Transport: c.HTTPClient.Transport,
|
2022-04-25 18:30:39 +00:00
|
|
|
}
|
2022-09-01 01:09:44 +00:00
|
|
|
// nolint:bodyclose
|
2022-09-20 00:46:29 +00:00
|
|
|
conn, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{
|
2022-09-01 01:09:44 +00:00
|
|
|
HTTPClient: httpClient,
|
|
|
|
})
|
|
|
|
if err != nil {
|
2022-09-20 00:46:29 +00:00
|
|
|
if res == nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return nil, readBodyAsError(res)
|
2022-09-01 01:09:44 +00:00
|
|
|
}
|
|
|
|
|
2023-01-23 20:05:29 +00:00
|
|
|
// Ping once every 30 seconds to ensure that the websocket is alive. If we
|
|
|
|
// don't get a response within 30s we kill the websocket and reconnect.
|
|
|
|
// See: https://github.com/coder/coder/pull/5824
|
|
|
|
go func() {
|
|
|
|
tick := 30 * time.Second
|
|
|
|
ticker := time.NewTicker(tick)
|
|
|
|
defer ticker.Stop()
|
|
|
|
defer func() {
|
|
|
|
c.Logger.Debug(ctx, "coordinate pinger exited")
|
|
|
|
}()
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
case start := <-ticker.C:
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, tick)
|
|
|
|
|
|
|
|
err := conn.Ping(ctx)
|
|
|
|
if err != nil {
|
|
|
|
c.Logger.Error(ctx, "workspace agent coordinate ping", slog.Error(err))
|
|
|
|
|
|
|
|
err := conn.Close(websocket.StatusGoingAway, "Ping failed")
|
|
|
|
if err != nil {
|
|
|
|
c.Logger.Error(ctx, "close workspace agent coordinate websocket", slog.Error(err))
|
|
|
|
}
|
|
|
|
|
|
|
|
cancel()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
c.Logger.Debug(ctx, "got coordinate pong", slog.F("took", time.Since(start)))
|
|
|
|
cancel()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
2022-09-01 01:09:44 +00:00
|
|
|
return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil
|
2022-04-11 21:06:15 +00:00
|
|
|
}
|
|
|
|
|
2022-10-17 13:43:30 +00:00
|
|
|
// @typescript-ignore DialWorkspaceAgentOptions
|
|
|
|
type DialWorkspaceAgentOptions struct {
|
|
|
|
Logger slog.Logger
|
|
|
|
// BlockEndpoints forced a direct connection through DERP.
|
2022-12-18 23:50:06 +00:00
|
|
|
BlockEndpoints bool
|
|
|
|
EnableTrafficStats bool
|
2022-10-17 13:43:30 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, options *DialWorkspaceAgentOptions) (*AgentConn, error) {
|
|
|
|
if options == nil {
|
|
|
|
options = &DialWorkspaceAgentOptions{}
|
|
|
|
}
|
2022-09-01 01:09:44 +00:00
|
|
|
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/connection", agentID), nil)
|
2022-06-24 15:25:01 +00:00
|
|
|
if err != nil {
|
2022-09-01 01:09:44 +00:00
|
|
|
return nil, err
|
2022-06-24 15:25:01 +00:00
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
2022-09-01 01:09:44 +00:00
|
|
|
if res.StatusCode != http.StatusOK {
|
|
|
|
return nil, readBodyAsError(res)
|
|
|
|
}
|
|
|
|
var connInfo WorkspaceAgentConnectionInfo
|
|
|
|
err = json.NewDecoder(res.Body).Decode(&connInfo)
|
|
|
|
if err != nil {
|
|
|
|
return nil, xerrors.Errorf("decode conn info: %w", err)
|
2022-06-24 15:25:01 +00:00
|
|
|
}
|
|
|
|
|
2022-09-01 01:09:44 +00:00
|
|
|
ip := tailnet.IP()
|
|
|
|
conn, err := tailnet.NewConn(&tailnet.Options{
|
2022-12-18 23:50:06 +00:00
|
|
|
Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)},
|
|
|
|
DERPMap: connInfo.DERPMap,
|
|
|
|
Logger: options.Logger,
|
|
|
|
BlockEndpoints: options.BlockEndpoints,
|
|
|
|
EnableTrafficStats: options.EnableTrafficStats,
|
2022-09-01 01:09:44 +00:00
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, xerrors.Errorf("create tailnet: %w", err)
|
|
|
|
}
|
2022-06-24 15:25:01 +00:00
|
|
|
|
2022-09-01 01:09:44 +00:00
|
|
|
coordinateURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/coordinate", agentID))
|
2022-06-24 15:25:01 +00:00
|
|
|
if err != nil {
|
2022-09-01 01:09:44 +00:00
|
|
|
return nil, xerrors.Errorf("parse url: %w", err)
|
2022-06-24 15:25:01 +00:00
|
|
|
}
|
|
|
|
jar, err := cookiejar.New(nil)
|
|
|
|
if err != nil {
|
2022-09-01 01:09:44 +00:00
|
|
|
return nil, xerrors.Errorf("create cookie jar: %w", err)
|
2022-06-24 15:25:01 +00:00
|
|
|
}
|
2022-09-01 01:09:44 +00:00
|
|
|
jar.SetCookies(coordinateURL, []*http.Cookie{{
|
2022-07-13 00:15:02 +00:00
|
|
|
Name: SessionTokenKey,
|
2022-11-09 13:31:24 +00:00
|
|
|
Value: c.SessionToken(),
|
2022-06-24 15:25:01 +00:00
|
|
|
}})
|
|
|
|
httpClient := &http.Client{
|
2022-10-17 13:43:30 +00:00
|
|
|
Jar: jar,
|
|
|
|
Transport: c.HTTPClient.Transport,
|
2022-06-24 15:25:01 +00:00
|
|
|
}
|
2022-09-01 01:09:44 +00:00
|
|
|
ctx, cancelFunc := context.WithCancel(ctx)
|
|
|
|
closed := make(chan struct{})
|
2022-09-22 15:14:22 +00:00
|
|
|
first := make(chan error)
|
2022-06-24 15:25:01 +00:00
|
|
|
go func() {
|
2022-09-01 01:09:44 +00:00
|
|
|
defer close(closed)
|
2022-09-22 15:14:22 +00:00
|
|
|
isFirst := true
|
2022-09-01 01:09:44 +00:00
|
|
|
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
|
2022-10-17 13:43:30 +00:00
|
|
|
options.Logger.Debug(ctx, "connecting")
|
2022-09-01 01:09:44 +00:00
|
|
|
// nolint:bodyclose
|
2022-09-22 15:14:22 +00:00
|
|
|
ws, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{
|
2022-09-01 01:09:44 +00:00
|
|
|
HTTPClient: httpClient,
|
|
|
|
// Need to disable compression to avoid a data-race.
|
|
|
|
CompressionMode: websocket.CompressionDisabled,
|
|
|
|
})
|
2022-09-22 15:14:22 +00:00
|
|
|
if isFirst {
|
2022-10-05 00:46:59 +00:00
|
|
|
if res != nil && res.StatusCode == http.StatusConflict {
|
2022-10-03 13:53:11 +00:00
|
|
|
first <- readBodyAsError(res)
|
|
|
|
return
|
|
|
|
}
|
2022-09-22 15:14:22 +00:00
|
|
|
isFirst = false
|
|
|
|
close(first)
|
|
|
|
}
|
2022-10-03 14:51:20 +00:00
|
|
|
if err != nil {
|
|
|
|
if errors.Is(err, context.Canceled) {
|
|
|
|
return
|
|
|
|
}
|
2022-10-17 13:43:30 +00:00
|
|
|
options.Logger.Debug(ctx, "failed to dial", slog.Error(err))
|
2022-10-03 14:51:20 +00:00
|
|
|
continue
|
|
|
|
}
|
2022-09-01 01:09:44 +00:00
|
|
|
sendNode, errChan := tailnet.ServeCoordinator(websocket.NetConn(ctx, ws, websocket.MessageBinary), func(node []*tailnet.Node) error {
|
|
|
|
return conn.UpdateNodes(node)
|
|
|
|
})
|
|
|
|
conn.SetNodeCallback(sendNode)
|
2022-10-17 13:43:30 +00:00
|
|
|
options.Logger.Debug(ctx, "serving coordinator")
|
2022-09-01 01:09:44 +00:00
|
|
|
err = <-errChan
|
|
|
|
if errors.Is(err, context.Canceled) {
|
2022-09-22 18:26:05 +00:00
|
|
|
_ = ws.Close(websocket.StatusGoingAway, "")
|
2022-09-01 01:09:44 +00:00
|
|
|
return
|
2022-06-24 15:25:01 +00:00
|
|
|
}
|
|
|
|
if err != nil {
|
2022-10-17 13:43:30 +00:00
|
|
|
options.Logger.Debug(ctx, "error serving coordinator", slog.Error(err))
|
2022-09-22 18:26:05 +00:00
|
|
|
_ = ws.Close(websocket.StatusGoingAway, "")
|
2022-06-24 15:25:01 +00:00
|
|
|
continue
|
|
|
|
}
|
2022-09-22 18:26:05 +00:00
|
|
|
_ = ws.Close(websocket.StatusGoingAway, "")
|
2022-06-24 15:25:01 +00:00
|
|
|
}
|
|
|
|
}()
|
2022-09-22 15:14:22 +00:00
|
|
|
err = <-first
|
|
|
|
if err != nil {
|
|
|
|
cancelFunc()
|
|
|
|
_ = conn.Close()
|
|
|
|
return nil, err
|
|
|
|
}
|
2022-11-13 17:33:05 +00:00
|
|
|
|
2022-09-23 19:51:04 +00:00
|
|
|
return &AgentConn{
|
2022-09-01 01:09:44 +00:00
|
|
|
Conn: conn,
|
|
|
|
CloseFunc: func() {
|
|
|
|
cancelFunc()
|
|
|
|
<-closed
|
|
|
|
},
|
2022-11-13 17:33:05 +00:00
|
|
|
}, nil
|
2022-06-24 15:25:01 +00:00
|
|
|
}
|
|
|
|
|
2022-04-11 21:06:15 +00:00
|
|
|
// WorkspaceAgent returns an agent by ID.
|
|
|
|
func (c *Client) WorkspaceAgent(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) {
|
2022-05-17 18:43:19 +00:00
|
|
|
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s", id), nil)
|
2022-04-11 21:06:15 +00:00
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgent{}, err
|
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
|
|
return WorkspaceAgent{}, readBodyAsError(res)
|
|
|
|
}
|
|
|
|
var workspaceAgent WorkspaceAgent
|
|
|
|
return workspaceAgent, json.NewDecoder(res.Body).Decode(&workspaceAgent)
|
|
|
|
}
|
2022-04-18 22:40:25 +00:00
|
|
|
|
2022-09-23 19:51:04 +00:00
|
|
|
// PostWorkspaceAgentAppHealth updates the workspace agent app health status.
|
|
|
|
func (c *Client) PostWorkspaceAgentAppHealth(ctx context.Context, req PostWorkspaceAppHealthsRequest) error {
|
|
|
|
res, err := c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/app-health", req)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
|
|
return readBodyAsError(res)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-08-31 15:33:50 +00:00
|
|
|
func (c *Client) PostWorkspaceAgentVersion(ctx context.Context, version string) error {
|
|
|
|
versionReq := PostWorkspaceAgentVersionRequest{Version: version}
|
|
|
|
res, err := c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/version", versionReq)
|
|
|
|
if err != nil {
|
2023-01-18 22:03:11 +00:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
2022-08-31 15:33:50 +00:00
|
|
|
return readBodyAsError(res)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-04-29 22:30:10 +00:00
|
|
|
// 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.
|
2022-11-17 16:57:15 +00:00
|
|
|
func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, reconnect uuid.UUID, height, width uint16, command string) (net.Conn, error) {
|
|
|
|
serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/pty", agentID))
|
2022-04-29 22:30:10 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, xerrors.Errorf("parse url: %w", err)
|
|
|
|
}
|
2022-11-17 16:57:15 +00:00
|
|
|
q := serverURL.Query()
|
|
|
|
q.Set("reconnect", reconnect.String())
|
|
|
|
q.Set("height", strconv.Itoa(int(height)))
|
|
|
|
q.Set("width", strconv.Itoa(int(width)))
|
|
|
|
q.Set("command", command)
|
|
|
|
serverURL.RawQuery = q.Encode()
|
|
|
|
|
2022-04-29 22:30:10 +00:00
|
|
|
jar, err := cookiejar.New(nil)
|
|
|
|
if err != nil {
|
|
|
|
return nil, xerrors.Errorf("create cookie jar: %w", err)
|
|
|
|
}
|
|
|
|
jar.SetCookies(serverURL, []*http.Cookie{{
|
2022-07-13 00:15:02 +00:00
|
|
|
Name: SessionTokenKey,
|
2022-11-09 13:31:24 +00:00
|
|
|
Value: c.SessionToken(),
|
2022-04-29 22:30:10 +00:00
|
|
|
}})
|
|
|
|
httpClient := &http.Client{
|
|
|
|
Jar: jar,
|
|
|
|
}
|
|
|
|
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(ctx, conn, websocket.MessageBinary), nil
|
|
|
|
}
|
|
|
|
|
2022-10-06 12:38:22 +00:00
|
|
|
// WorkspaceAgentListeningPorts returns a list of ports that are currently being
|
|
|
|
// listened on inside the workspace agent's network namespace.
|
|
|
|
func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid.UUID) (ListeningPortsResponse, error) {
|
|
|
|
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/listening-ports", agentID), nil)
|
|
|
|
if err != nil {
|
|
|
|
return ListeningPortsResponse{}, err
|
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
|
|
return ListeningPortsResponse{}, readBodyAsError(res)
|
|
|
|
}
|
|
|
|
var listeningPorts ListeningPortsResponse
|
|
|
|
return listeningPorts, json.NewDecoder(res.Body).Decode(&listeningPorts)
|
|
|
|
}
|
|
|
|
|
2022-09-23 19:51:04 +00:00
|
|
|
// Stats records the Agent's network connection statistics for use in
|
|
|
|
// user-facing metrics and debugging.
|
|
|
|
// @typescript-ignore AgentStats
|
|
|
|
type AgentStats struct {
|
2022-11-18 22:46:53 +00:00
|
|
|
// ConnsByProto is a count of connections by protocol.
|
|
|
|
ConnsByProto map[string]int64 `json:"conns_by_proto"`
|
|
|
|
// NumConns is the number of connections received by an agent.
|
2022-09-23 19:51:04 +00:00
|
|
|
NumConns int64 `json:"num_comms"`
|
2022-11-18 22:46:53 +00:00
|
|
|
// RxPackets is the number of received packets.
|
|
|
|
RxPackets int64 `json:"rx_packets"`
|
|
|
|
// RxBytes is the number of received bytes.
|
|
|
|
RxBytes int64 `json:"rx_bytes"`
|
|
|
|
// TxPackets is the number of transmitted bytes.
|
|
|
|
TxPackets int64 `json:"tx_packets"`
|
|
|
|
// TxBytes is the number of transmitted bytes.
|
|
|
|
TxBytes int64 `json:"tx_bytes"`
|
2022-09-23 19:51:04 +00:00
|
|
|
}
|
|
|
|
|
2022-11-18 22:46:53 +00:00
|
|
|
// @typescript-ignore AgentStatsResponse
|
|
|
|
type AgentStatsResponse struct {
|
|
|
|
// ReportInterval is the duration after which the agent should send stats
|
|
|
|
// again.
|
|
|
|
ReportInterval time.Duration `json:"report_interval"`
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *Client) PostAgentStats(ctx context.Context, stats *AgentStats) (AgentStatsResponse, error) {
|
|
|
|
res, err := c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/report-stats", stats)
|
2022-09-01 19:58:23 +00:00
|
|
|
if err != nil {
|
2022-11-18 22:46:53 +00:00
|
|
|
return AgentStatsResponse{}, xerrors.Errorf("send request: %w", err)
|
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
|
|
return AgentStatsResponse{}, readBodyAsError(res)
|
2022-09-01 19:58:23 +00:00
|
|
|
}
|
|
|
|
|
2022-11-18 22:46:53 +00:00
|
|
|
var interval AgentStatsResponse
|
|
|
|
err = json.NewDecoder(res.Body).Decode(&interval)
|
2022-09-01 19:58:23 +00:00
|
|
|
if err != nil {
|
2022-11-18 22:46:53 +00:00
|
|
|
return AgentStatsResponse{}, xerrors.Errorf("decode stats response: %w", err)
|
2022-09-01 19:58:23 +00:00
|
|
|
}
|
|
|
|
|
2022-11-18 22:46:53 +00:00
|
|
|
return interval, nil
|
|
|
|
}
|
2022-09-01 19:58:23 +00:00
|
|
|
|
2022-11-18 22:46:53 +00:00
|
|
|
// AgentReportStats begins a stat streaming connection with the Coder server.
|
|
|
|
// It is resilient to network failures and intermittent coderd issues.
|
|
|
|
func (c *Client) AgentReportStats(
|
|
|
|
ctx context.Context,
|
|
|
|
log slog.Logger,
|
|
|
|
getStats func() *AgentStats,
|
|
|
|
) (io.Closer, error) {
|
2022-09-01 19:58:23 +00:00
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
|
|
|
|
|
|
go func() {
|
2022-11-18 22:46:53 +00:00
|
|
|
// Immediately trigger a stats push to get the correct interval.
|
|
|
|
timer := time.NewTimer(time.Nanosecond)
|
|
|
|
defer timer.Stop()
|
2022-09-01 19:58:23 +00:00
|
|
|
|
2022-11-18 22:46:53 +00:00
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
case <-timer.C:
|
|
|
|
}
|
2022-09-01 19:58:23 +00:00
|
|
|
|
2022-11-18 22:46:53 +00:00
|
|
|
var nextInterval time.Duration
|
|
|
|
for r := retry.New(100*time.Millisecond, time.Minute); r.Wait(ctx); {
|
|
|
|
resp, err := c.PostAgentStats(ctx, getStats())
|
|
|
|
if err != nil {
|
|
|
|
if !xerrors.Is(err, context.Canceled) {
|
|
|
|
log.Error(ctx, "report stats", slog.Error(err))
|
2022-09-01 19:58:23 +00:00
|
|
|
}
|
2022-11-18 22:46:53 +00:00
|
|
|
continue
|
2022-09-01 19:58:23 +00:00
|
|
|
}
|
2022-11-18 22:46:53 +00:00
|
|
|
|
|
|
|
nextInterval = resp.ReportInterval
|
|
|
|
break
|
2022-09-01 19:58:23 +00:00
|
|
|
}
|
2022-11-18 22:46:53 +00:00
|
|
|
timer.Reset(nextInterval)
|
2022-09-01 19:58:23 +00:00
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
return closeFunc(func() error {
|
|
|
|
cancel()
|
|
|
|
return nil
|
|
|
|
}), nil
|
|
|
|
}
|
2022-10-25 00:46:24 +00:00
|
|
|
|
|
|
|
// GitProvider is a constant that represents the
|
|
|
|
// type of providers that are supported within Coder.
|
|
|
|
// @typescript-ignore GitProvider
|
|
|
|
type GitProvider string
|
|
|
|
|
|
|
|
const (
|
|
|
|
GitProviderAzureDevops = "azure-devops"
|
|
|
|
GitProviderGitHub = "github"
|
|
|
|
GitProviderGitLab = "gitlab"
|
|
|
|
GitProviderBitBucket = "bitbucket"
|
|
|
|
)
|
|
|
|
|
|
|
|
type WorkspaceAgentGitAuthResponse struct {
|
|
|
|
Username string `json:"username"`
|
|
|
|
Password string `json:"password"`
|
|
|
|
URL string `json:"url"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// WorkspaceAgentGitAuth submits a URL to fetch a GIT_ASKPASS username
|
|
|
|
// and password for.
|
|
|
|
// nolint:revive
|
|
|
|
func (c *Client) WorkspaceAgentGitAuth(ctx context.Context, gitURL string, listen bool) (WorkspaceAgentGitAuthResponse, error) {
|
|
|
|
reqURL := "/api/v2/workspaceagents/me/gitauth?url=" + url.QueryEscape(gitURL)
|
|
|
|
if listen {
|
|
|
|
reqURL += "&listen"
|
|
|
|
}
|
|
|
|
res, err := c.Request(ctx, http.MethodGet, reqURL, nil)
|
|
|
|
if err != nil {
|
|
|
|
return WorkspaceAgentGitAuthResponse{}, xerrors.Errorf("execute request: %w", err)
|
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
|
|
return WorkspaceAgentGitAuthResponse{}, readBodyAsError(res)
|
|
|
|
}
|
|
|
|
|
|
|
|
var authResp WorkspaceAgentGitAuthResponse
|
|
|
|
return authResp, json.NewDecoder(res.Body).Decode(&authResp)
|
|
|
|
}
|