mirror of https://github.com/coder/coder.git
521 lines
16 KiB
Go
521 lines
16 KiB
Go
package agentsdk
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"cloud.google.com/go/compute/metadata"
|
|
"golang.org/x/xerrors"
|
|
"nhooyr.io/websocket"
|
|
"tailscale.com/tailcfg"
|
|
|
|
"github.com/coder/retry"
|
|
|
|
"cdr.dev/slog"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/coder/coder/codersdk"
|
|
)
|
|
|
|
// New returns a client that is used to interact with the
|
|
// Coder API from a workspace agent.
|
|
func New(serverURL *url.URL) *Client {
|
|
return &Client{
|
|
SDK: codersdk.New(serverURL),
|
|
}
|
|
}
|
|
|
|
// Client wraps `codersdk.Client` with specific functions
|
|
// scoped to a workspace agent.
|
|
type Client struct {
|
|
SDK *codersdk.Client
|
|
}
|
|
|
|
func (c *Client) SetSessionToken(token string) {
|
|
c.SDK.SetSessionToken(token)
|
|
}
|
|
|
|
type GitSSHKey struct {
|
|
PublicKey string `json:"public_key"`
|
|
PrivateKey string `json:"private_key"`
|
|
}
|
|
|
|
// GitSSHKey will return the user's SSH key pair for the workspace.
|
|
func (c *Client) GitSSHKey(ctx context.Context) (GitSSHKey, error) {
|
|
res, err := c.SDK.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/gitsshkey", nil)
|
|
if err != nil {
|
|
return GitSSHKey{}, xerrors.Errorf("execute request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
return GitSSHKey{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
|
|
var gitSSHKey GitSSHKey
|
|
return gitSSHKey, json.NewDecoder(res.Body).Decode(&gitSSHKey)
|
|
}
|
|
|
|
type Metadata struct {
|
|
// 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"`
|
|
VSCodePortProxyURI string `json:"vscode_port_proxy_uri"`
|
|
Apps []codersdk.WorkspaceApp `json:"apps"`
|
|
DERPMap *tailcfg.DERPMap `json:"derpmap"`
|
|
EnvironmentVariables map[string]string `json:"environment_variables"`
|
|
StartupScript string `json:"startup_script"`
|
|
StartupScriptTimeout time.Duration `json:"startup_script_timeout"`
|
|
Directory string `json:"directory"`
|
|
MOTDFile string `json:"motd_file"`
|
|
}
|
|
|
|
// Metadata fetches metadata for the currently authenticated workspace agent.
|
|
func (c *Client) Metadata(ctx context.Context) (Metadata, error) {
|
|
res, err := c.SDK.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/metadata", nil)
|
|
if err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return Metadata{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
var agentMeta Metadata
|
|
err = json.NewDecoder(res.Body).Decode(&agentMeta)
|
|
if err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
accessingPort := c.SDK.URL.Port()
|
|
if accessingPort == "" {
|
|
accessingPort = "80"
|
|
if c.SDK.URL.Scheme == "https" {
|
|
accessingPort = "443"
|
|
}
|
|
}
|
|
accessPort, err := strconv.Atoi(accessingPort)
|
|
if err != nil {
|
|
return Metadata{}, 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 agentMeta.DERPMap.Regions {
|
|
if !region.EmbeddedRelay {
|
|
continue
|
|
}
|
|
|
|
for _, node := range region.Nodes {
|
|
if node.STUNOnly {
|
|
continue
|
|
}
|
|
node.HostName = c.SDK.URL.Hostname()
|
|
node.DERPPort = accessPort
|
|
node.ForceHTTP = c.SDK.URL.Scheme == "http"
|
|
}
|
|
}
|
|
return agentMeta, nil
|
|
}
|
|
|
|
// Listen connects to the workspace agent coordinate WebSocket
|
|
// that handles connection negotiation.
|
|
func (c *Client) Listen(ctx context.Context) (net.Conn, error) {
|
|
coordinateURL, err := c.SDK.URL.Parse("/api/v2/workspaceagents/me/coordinate")
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("parse url: %w", err)
|
|
}
|
|
jar, err := cookiejar.New(nil)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("create cookie jar: %w", err)
|
|
}
|
|
jar.SetCookies(coordinateURL, []*http.Cookie{{
|
|
Name: codersdk.SessionTokenCookie,
|
|
Value: c.SDK.SessionToken(),
|
|
}})
|
|
httpClient := &http.Client{
|
|
Jar: jar,
|
|
Transport: c.SDK.HTTPClient.Transport,
|
|
}
|
|
// nolint:bodyclose
|
|
conn, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{
|
|
HTTPClient: httpClient,
|
|
})
|
|
if err != nil {
|
|
if res == nil {
|
|
return nil, err
|
|
}
|
|
return nil, codersdk.ReadBodyAsError(res)
|
|
}
|
|
|
|
// 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.SDK.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.SDK.Logger.Error(ctx, "workspace agent coordinate ping", slog.Error(err))
|
|
|
|
err := conn.Close(websocket.StatusGoingAway, "Ping failed")
|
|
if err != nil {
|
|
c.SDK.Logger.Error(ctx, "close workspace agent coordinate websocket", slog.Error(err))
|
|
}
|
|
|
|
cancel()
|
|
return
|
|
}
|
|
|
|
c.SDK.Logger.Debug(ctx, "got coordinate pong", slog.F("took", time.Since(start)))
|
|
cancel()
|
|
}
|
|
}
|
|
}()
|
|
|
|
return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil
|
|
}
|
|
|
|
type PostAppHealthsRequest struct {
|
|
// Healths is a map of the workspace app name and the health of the app.
|
|
Healths map[uuid.UUID]codersdk.WorkspaceAppHealth
|
|
}
|
|
|
|
// PostAppHealth updates the workspace agent app health status.
|
|
func (c *Client) PostAppHealth(ctx context.Context, req PostAppHealthsRequest) error {
|
|
res, err := c.SDK.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 codersdk.ReadBodyAsError(res)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AuthenticateResponse is returned when an instance ID
|
|
// has been exchanged for a session token.
|
|
// @typescript-ignore AuthenticateResponse
|
|
type AuthenticateResponse struct {
|
|
SessionToken string `json:"session_token"`
|
|
}
|
|
|
|
type GoogleInstanceIdentityToken struct {
|
|
JSONWebToken string `json:"json_web_token" validate:"required"`
|
|
}
|
|
|
|
// AuthWorkspaceGoogleInstanceIdentity uses the Google Compute Engine Metadata API to
|
|
// 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.
|
|
func (c *Client) AuthGoogleInstanceIdentity(ctx context.Context, serviceAccount string, gcpClient *metadata.Client) (AuthenticateResponse, error) {
|
|
if serviceAccount == "" {
|
|
// This is the default name specified by Google.
|
|
serviceAccount = "default"
|
|
}
|
|
if gcpClient == nil {
|
|
gcpClient = metadata.NewClient(c.SDK.HTTPClient)
|
|
}
|
|
// "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 {
|
|
return AuthenticateResponse{}, xerrors.Errorf("get metadata identity: %w", err)
|
|
}
|
|
res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/google-instance-identity", GoogleInstanceIdentityToken{
|
|
JSONWebToken: jwt,
|
|
})
|
|
if err != nil {
|
|
return AuthenticateResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return AuthenticateResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
var resp AuthenticateResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
type AWSInstanceIdentityToken struct {
|
|
Signature string `json:"signature" validate:"required"`
|
|
Document string `json:"document" validate:"required"`
|
|
}
|
|
|
|
// 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) AuthAWSInstanceIdentity(ctx context.Context) (AuthenticateResponse, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://169.254.169.254/latest/api/token", nil)
|
|
if err != nil {
|
|
return AuthenticateResponse{}, nil
|
|
}
|
|
req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "21600")
|
|
res, err := c.SDK.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return AuthenticateResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
token, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return AuthenticateResponse{}, 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 AuthenticateResponse{}, nil
|
|
}
|
|
req.Header.Set("X-aws-ec2-metadata-token", string(token))
|
|
res, err = c.SDK.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return AuthenticateResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
signature, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return AuthenticateResponse{}, 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 AuthenticateResponse{}, nil
|
|
}
|
|
req.Header.Set("X-aws-ec2-metadata-token", string(token))
|
|
res, err = c.SDK.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return AuthenticateResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
document, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return AuthenticateResponse{}, xerrors.Errorf("read token: %w", err)
|
|
}
|
|
|
|
res, err = c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/aws-instance-identity", AWSInstanceIdentityToken{
|
|
Signature: string(signature),
|
|
Document: string(document),
|
|
})
|
|
if err != nil {
|
|
return AuthenticateResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return AuthenticateResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
var resp AuthenticateResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
type AzureInstanceIdentityToken struct {
|
|
Signature string `json:"signature" validate:"required"`
|
|
Encoding string `json:"encoding" validate:"required"`
|
|
}
|
|
|
|
// 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) AuthAzureInstanceIdentity(ctx context.Context) (AuthenticateResponse, 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 AuthenticateResponse{}, nil
|
|
}
|
|
req.Header.Set("Metadata", "true")
|
|
res, err := c.SDK.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return AuthenticateResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
var token AzureInstanceIdentityToken
|
|
err = json.NewDecoder(res.Body).Decode(&token)
|
|
if err != nil {
|
|
return AuthenticateResponse{}, err
|
|
}
|
|
|
|
res, err = c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/azure-instance-identity", token)
|
|
if err != nil {
|
|
return AuthenticateResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return AuthenticateResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
var resp AuthenticateResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// ReportStats begins a stat streaming connection with the Coder server.
|
|
// It is resilient to network failures and intermittent coderd issues.
|
|
func (c *Client) ReportStats(
|
|
ctx context.Context,
|
|
log slog.Logger,
|
|
getStats func() *Stats,
|
|
) (io.Closer, error) {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
|
|
go func() {
|
|
// Immediately trigger a stats push to get the correct interval.
|
|
timer := time.NewTimer(time.Nanosecond)
|
|
defer timer.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-timer.C:
|
|
}
|
|
|
|
var nextInterval time.Duration
|
|
for r := retry.New(100*time.Millisecond, time.Minute); r.Wait(ctx); {
|
|
resp, err := c.PostStats(ctx, getStats())
|
|
if err != nil {
|
|
if !xerrors.Is(err, context.Canceled) {
|
|
log.Error(ctx, "report stats", slog.Error(err))
|
|
}
|
|
continue
|
|
}
|
|
|
|
nextInterval = resp.ReportInterval
|
|
break
|
|
}
|
|
timer.Reset(nextInterval)
|
|
}
|
|
}()
|
|
|
|
return closeFunc(func() error {
|
|
cancel()
|
|
return nil
|
|
}), nil
|
|
}
|
|
|
|
// Stats records the Agent's network connection statistics for use in
|
|
// user-facing metrics and debugging.
|
|
type Stats struct {
|
|
// 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.
|
|
NumConns int64 `json:"num_comms"`
|
|
// 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"`
|
|
}
|
|
|
|
type StatsResponse struct {
|
|
// ReportInterval is the duration after which the agent should send stats
|
|
// again.
|
|
ReportInterval time.Duration `json:"report_interval"`
|
|
}
|
|
|
|
func (c *Client) PostStats(ctx context.Context, stats *Stats) (StatsResponse, error) {
|
|
res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/report-stats", stats)
|
|
if err != nil {
|
|
return StatsResponse{}, xerrors.Errorf("send request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return StatsResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
|
|
var interval StatsResponse
|
|
err = json.NewDecoder(res.Body).Decode(&interval)
|
|
if err != nil {
|
|
return StatsResponse{}, xerrors.Errorf("decode stats response: %w", err)
|
|
}
|
|
|
|
return interval, nil
|
|
}
|
|
|
|
type PostLifecycleRequest struct {
|
|
State codersdk.WorkspaceAgentLifecycle `json:"state"`
|
|
}
|
|
|
|
func (c *Client) PostLifecycle(ctx context.Context, req PostLifecycleRequest) error {
|
|
res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/report-lifecycle", req)
|
|
if err != nil {
|
|
return xerrors.Errorf("agent state post request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return codersdk.ReadBodyAsError(res)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type PostVersionRequest struct {
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
func (c *Client) PostVersion(ctx context.Context, version string) error {
|
|
versionReq := PostVersionRequest{Version: version}
|
|
res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/version", versionReq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return codersdk.ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type GitAuthResponse struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// GitAuth submits a URL to fetch a GIT_ASKPASS username and password for.
|
|
// nolint:revive
|
|
func (c *Client) GitAuth(ctx context.Context, gitURL string, listen bool) (GitAuthResponse, error) {
|
|
reqURL := "/api/v2/workspaceagents/me/gitauth?url=" + url.QueryEscape(gitURL)
|
|
if listen {
|
|
reqURL += "&listen"
|
|
}
|
|
res, err := c.SDK.Request(ctx, http.MethodGet, reqURL, nil)
|
|
if err != nil {
|
|
return GitAuthResponse{}, xerrors.Errorf("execute request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
return GitAuthResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
|
|
var authResp GitAuthResponse
|
|
return authResp, json.NewDecoder(res.Body).Decode(&authResp)
|
|
}
|
|
|
|
type closeFunc func() error
|
|
|
|
func (c closeFunc) Close() error {
|
|
return c()
|
|
}
|