package codersdk import ( "bytes" "context" "crypto/tls" "encoding/json" "errors" "fmt" "io" "mime" "net" "net/http" "net/http/httputil" "net/url" "strings" "sync" "unicode" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/semconv/v1.14.0/httpconv" "golang.org/x/xerrors" "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/websocket" ) // These cookies are Coder-specific. If a new one is added or changed, the name // shouldn't be likely to conflict with any user-application set cookies. // Be sure to strip additional cookies in httpapi.StripCoderCookies! // SessionTokenCookie represents the name of the cookie or query parameter the API key is stored in. // NOTE: This is declared as a var so that we can override it in `develop.sh` if required. var SessionTokenCookie = "coder_session_token" const ( // SessionTokenHeader is the custom header to use for authentication. SessionTokenHeader = "Coder-Session-Token" // OAuth2StateCookie is the name of the cookie that stores the oauth2 state. OAuth2StateCookie = "oauth_state" // OAuth2PKCEVerifier is the name of the cookie that stores the oauth2 PKCE // verifier. This is the raw verifier that when hashed, will match the challenge // sent in the initial oauth2 request. OAuth2PKCEVerifier = "oauth_pkce_verifier" // OAuth2RedirectCookie is the name of the cookie that stores the oauth2 redirect. OAuth2RedirectCookie = "oauth_redirect" // OAuth2RedirectURICookie stores the dynamically computed OIDC redirect_uri // when CODER_OIDC_REDIRECT_ALLOWED_HOSTS is enabled. The same value must be // used for both the authorization request and the token exchange (RFC 6749 // section 4.1.3). OAuth2RedirectURICookie = "oauth_redirect_uri" // PathAppSessionTokenCookie is the name of the cookie that stores an // application-scoped API token on workspace proxy path app domains. //nolint:gosec PathAppSessionTokenCookie = "coder_path_app_session_token" // SubdomainAppSessionTokenCookie is the name of the cookie that stores an // application-scoped API token on subdomain app domains (both the primary // and proxies). // // To avoid conflicts between multiple proxies, we append an underscore and // a hash suffix to the cookie name. //nolint:gosec SubdomainAppSessionTokenCookie = "coder_subdomain_app_session_token" // SignedAppTokenCookie is the name of the cookie that stores a temporary // JWT that can be used to authenticate instead of the app session token. //nolint:gosec SignedAppTokenCookie = "coder_signed_app_token" // SignedAppTokenQueryParameter is the name of the query parameter that // stores a temporary JWT that can be used to authenticate instead of the // session token. This is only acceptable on reconnecting-pty requests, not // apps. // // It has a random suffix to avoid conflict with user query parameters on // apps. //nolint:gosec SignedAppTokenQueryParameter = "coder_signed_app_token_23db1dde" // BypassRatelimitHeader is the custom header to use to bypass ratelimits. // Only owners can bypass rate limits. This is typically used for scale testing. // nolint: gosec BypassRatelimitHeader = "X-Coder-Bypass-Ratelimit" // Note: the use of X- prefix is deprecated, and we should eventually remove // it from BypassRatelimitHeader. // // See: https://datatracker.ietf.org/doc/html/rfc6648. // CLITelemetryHeader contains a base64-encoded representation of the CLI // command that was invoked to produce the request. It is for internal use // only. CLITelemetryHeader = "Coder-CLI-Telemetry" // CoderDesktopTelemetryHeader contains a JSON-encoded representation of Desktop telemetry // fields, including device ID, OS, and Desktop version. CoderDesktopTelemetryHeader = "Coder-Desktop-Telemetry" // ProvisionerDaemonPSK contains the authentication pre-shared key for an external provisioner daemon ProvisionerDaemonPSK = "Coder-Provisioner-Daemon-PSK" // ProvisionerDaemonKey contains the authentication key for an external provisioner daemon ProvisionerDaemonKey = "Coder-Provisioner-Daemon-Key" // AIGatewayKeyHeader contains the authentication key for a standalone AI Gateway replica. AIGatewayKeyHeader = "X-Coder-AI-Governance-Gateway-Key" // BuildVersionHeader contains build information of Coder. BuildVersionHeader = "X-Coder-Build-Version" // EntitlementsWarnings contains active warnings for the user's entitlements. EntitlementsWarningHeader = "X-Coder-Entitlements-Warning" ) // loggableMimeTypes is a list of MIME types that are safe to log // the output of. This is useful for debugging or testing. var loggableMimeTypes = map[string]struct{}{ "application/json": {}, "text/plain": {}, // lots of webserver error pages are HTML "text/html": {}, } type ClientOption func(*Client) // New creates a Coder client for the provided URL. func New(serverURL *url.URL, opts ...ClientOption) *Client { client := &Client{ URL: serverURL, HTTPClient: &http.Client{}, SessionTokenProvider: FixedSessionTokenProvider{}, } for _, opt := range opts { opt(client) } return client } // Client is an HTTP caller for methods to the Coder API. // @typescript-ignore Client type Client struct { // mu protects the fields sessionToken, logger, and logBodies. These // need to be safe for concurrent access. mu sync.RWMutex SessionTokenProvider SessionTokenProvider logger slog.Logger logBodies bool HTTPClient *http.Client URL *url.URL // PlainLogger may be set to log HTTP traffic in a human-readable form. // It uses the LogBodies option. // Deprecated: Use WithPlainLogger to set this. PlainLogger io.Writer // Trace can be enabled to propagate tracing spans to the Coder API. // This is useful for tracking a request end-to-end. // Deprecated: Use WithTrace to set this. Trace bool // DisableDirectConnections forces any connections to workspaces to go // through DERP, regardless of the BlockEndpoints setting on each // connection. // Deprecated: Use WithDisableDirectConnections to set this. DisableDirectConnections bool // derpTLSConfig is an optional TLS config for DERP connections. derpTLSConfig *tls.Config } // Logger returns the logger for the client. func (c *Client) Logger() slog.Logger { c.mu.RLock() defer c.mu.RUnlock() return c.logger } // SetLogger sets the logger for the client. // Deprecated: Use WithLogger to set this. func (c *Client) SetLogger(logger slog.Logger) { c.mu.Lock() defer c.mu.Unlock() c.logger = logger } // LogBodies returns whether requests and response bodies are logged. func (c *Client) LogBodies() bool { c.mu.RLock() defer c.mu.RUnlock() return c.logBodies } // SetLogBodies sets whether to log request and response bodies. // Deprecated: Use WithLogBodies to set this. func (c *Client) SetLogBodies(logBodies bool) { c.mu.Lock() defer c.mu.Unlock() c.logBodies = logBodies } // SessionToken returns the currently set token for the client. func (c *Client) SessionToken() string { c.mu.RLock() defer c.mu.RUnlock() return c.SessionTokenProvider.GetSessionToken() } // SetSessionToken sets a fixed token for the client. // Deprecated: Create a new client using WithSessionToken instead of changing the token after creation. func (c *Client) SetSessionToken(token string) { c.mu.Lock() defer c.mu.Unlock() c.SessionTokenProvider = FixedSessionTokenProvider{SessionToken: token} } func prefixLines(prefix, s []byte) []byte { ss := bytes.NewBuffer(make([]byte, 0, len(s)*2)) for _, line := range bytes.Split(s, []byte("\n")) { _, _ = ss.Write(prefix) _, _ = ss.Write(line) _ = ss.WriteByte('\n') } return ss.Bytes() } // Request performs a HTTP request with the body provided. The caller is // responsible for closing the response body. func (c *Client) Request(ctx context.Context, method, path string, body interface{}, opts ...RequestOption) (*http.Response, error) { opts = append([]RequestOption{c.SessionTokenProvider.AsRequestOption()}, opts...) return c.RequestWithoutSessionToken(ctx, method, path, body, opts...) } // RequestWithoutSessionToken performs a HTTP request. It is similar to Request, but does not set // the session token in the request header, nor does it make a call to the SessionTokenProvider. // This allows session token providers to call this method without causing reentrancy issues. func (c *Client) RequestWithoutSessionToken(ctx context.Context, method, path string, body interface{}, opts ...RequestOption) (*http.Response, error) { if ctx == nil { return nil, xerrors.Errorf("context should not be nil") } ctx, span := tracing.StartSpanWithName(ctx, tracing.FuncNameSkip(1)) defer span.End() serverURL, err := c.URL.Parse(path) if err != nil { return nil, xerrors.Errorf("parse url: %w", err) } var r io.Reader if body != nil { switch data := body.(type) { case io.Reader: r = data case []byte: r = bytes.NewReader(data) default: // Assume JSON in all other cases. buf := bytes.NewBuffer(nil) enc := json.NewEncoder(buf) enc.SetEscapeHTML(false) err = enc.Encode(body) if err != nil { return nil, xerrors.Errorf("encode body: %w", err) } r = buf } } // Copy the request body so we can log it. var reqLogFields []slog.Field c.mu.RLock() logBodies := c.logBodies c.mu.RUnlock() if r != nil && logBodies { reqBody, err := io.ReadAll(r) if err != nil { return nil, xerrors.Errorf("read request body: %w", err) } r = bytes.NewReader(reqBody) reqLogFields = append(reqLogFields, slog.F("body", string(reqBody))) } req, err := http.NewRequestWithContext(ctx, method, serverURL.String(), r) if err != nil { return nil, xerrors.Errorf("create request: %w", err) } if r != nil { req.Header.Set("Content-Type", "application/json") } for _, opt := range opts { opt(req) } span.SetAttributes(httpconv.ClientRequest(req)...) // Inject tracing headers if enabled. if c.Trace { tmp := otel.GetTextMapPropagator() hc := propagation.HeaderCarrier(req.Header) tmp.Inject(ctx, hc) } // We already capture most of this information in the span (minus // the request body which we don't want to capture anyways). ctx = slog.With(ctx, slog.F("method", req.Method), slog.F("url", req.URL.String()), ) tracing.RunWithoutSpan(ctx, func(ctx context.Context) { c.Logger().Debug(ctx, "sdk request", reqLogFields...) }) resp, err := c.HTTPClient.Do(req) // We log after sending the request because the HTTP Transport may modify // the request within Do, e.g. by adding headers. if resp != nil && c.PlainLogger != nil { out, err := httputil.DumpRequest(resp.Request, logBodies) if err != nil { return nil, xerrors.Errorf("dump request: %w", err) } out = prefixLines([]byte("http --> "), out) _, _ = c.PlainLogger.Write(out) } if err != nil { return nil, err } if c.PlainLogger != nil { out, err := httputil.DumpResponse(resp, logBodies) if err != nil { return nil, xerrors.Errorf("dump response: %w", err) } out = prefixLines([]byte("http <-- "), out) _, _ = c.PlainLogger.Write(out) } span.SetAttributes(httpconv.ClientResponse(resp)...) span.SetStatus(httpconv.ClientStatus(resp.StatusCode)) // Copy the response body so we can log it if it's a loggable mime type. var respLogFields []slog.Field if resp.Body != nil && logBodies { mimeType := parseMimeType(resp.Header.Get("Content-Type")) if _, ok := loggableMimeTypes[mimeType]; ok { respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, xerrors.Errorf("copy response body for logs: %w", err) } err = resp.Body.Close() if err != nil { return nil, xerrors.Errorf("close response body: %w", err) } resp.Body = io.NopCloser(bytes.NewReader(respBody)) respLogFields = append(respLogFields, slog.F("body", string(respBody))) } } // See above for why this is not logged to the span. tracing.RunWithoutSpan(ctx, func(ctx context.Context) { c.Logger().Debug(ctx, "sdk response", append(respLogFields, slog.F("status", resp.StatusCode), slog.F("trace_id", resp.Header.Get("X-Trace-Id")), slog.F("span_id", resp.Header.Get("X-Span-Id")), )..., ) }) return resp, err } func (c *Client) Dial(ctx context.Context, path string, opts *websocket.DialOptions) (*websocket.Conn, error) { u, err := c.URL.Parse(path) if err != nil { return nil, err } if opts == nil { opts = &websocket.DialOptions{} } // Propagate the client's HTTP client to the websocket dialer // so that custom TLS configurations (e.g. mesh TLS between // replicas) are used for the handshake request. Without this, // the websocket library falls back to http.DefaultClient. if opts.HTTPClient == nil { opts.HTTPClient = c.HTTPClient } c.SessionTokenProvider.SetDialOption(opts) conn, resp, err := websocket.Dial(ctx, u.String(), opts) if resp != nil && resp.Body != nil { resp.Body.Close() } if err != nil { return nil, err } return conn, nil } // ExpectJSONMime is a helper function that will assert the content type // of the response is application/json. func ExpectJSONMime(res *http.Response) error { contentType := res.Header.Get("Content-Type") mimeType := parseMimeType(contentType) if mimeType != "application/json" { return xerrors.Errorf("unexpected non-JSON response %q", contentType) } return nil } // ReadBodyAsError reads the response as a codersdk.Response, and // wraps it in a codersdk.Error type for easy marshaling. // // This will always return an error, so only call it if the response failed // your expectations. Usually via status code checking. // nolint:staticcheck func ReadBodyAsError(res *http.Response) error { if res == nil { return xerrors.Errorf("no body returned") } defer res.Body.Close() resp, err := io.ReadAll(res.Body) if err != nil { return xerrors.Errorf("read body: %w", err) } if mimeErr := ExpectJSONMime(res); mimeErr != nil { if len(resp) > 2048 { resp = append(resp[:2048], []byte("...")...) } if len(resp) == 0 { resp = []byte("no response body") } return newResponseError(res, Response{ Message: mimeErr.Error(), Detail: string(resp), }) } var m Response err = json.NewDecoder(bytes.NewBuffer(resp)).Decode(&m) if err != nil { if errors.Is(err, io.EOF) { return newResponseError(res, Response{ Message: "empty response body", }) } return xerrors.Errorf("decode body: %w", err) } if m.Message == "" { if len(resp) > 1024 { resp = append(resp[:1024], []byte("...")...) } m.Message = fmt.Sprintf("unexpected status code %d, response has no message", res.StatusCode) m.Detail = string(resp) } return newResponseError(res, m) } // newResponseError wraps an API response in an *Error annotated with // the status code, request method, and request URL from res. For 401 // responses it also sets a helper message suggesting 'coder login'. func newResponseError(res *http.Response, response Response) *Error { var requestMethod, requestURL string if res.Request != nil { requestMethod = res.Request.Method if res.Request.URL != nil { requestURL = res.Request.URL.String() } } var helpMessage string if res.StatusCode == http.StatusUnauthorized { // 401 means the user is not logged in // 403 would mean that the user is not authorized helpMessage = "Try logging in using 'coder login'." } return &Error{ Response: response, statusCode: res.StatusCode, method: requestMethod, url: requestURL, Helper: helpMessage, } } // jsonBodySniffLen bounds how much of a response body is retained while // decoding to identify HTML without buffering the entire response. const jsonBodySniffLen = 512 // htmlResponseHelper suggests the most common causes of receiving HTML // from what should be a Coder API endpoint: a misconfigured Coder URL, // or an intermediary such as a reverse proxy or SSO portal intercepting // API requests. const htmlResponseHelper = "Ensure the Coder URL is correct and that any reverse proxy or SSO in front of it passes /api/v2 requests through to Coder." // ReadBodyAsJSON decodes the response body as JSON into v. It is // intended for typed API endpoints whose accepted responses are always // JSON, replacing direct json.NewDecoder(res.Body).Decode(...) calls. // It must not be used for streaming or non-JSON endpoints such as // WebSockets, server-sent events, or file downloads. // // Bodies that are HTML, empty, or otherwise not valid JSON produce a // structured *Error describing the response instead of a bare decode // error such as "invalid character '<' looking for beginning of value". // Intermediaries like reverse proxies and SSO portals commonly return // such bodies with a 200 status code. // // Valid JSON is decoded even when an intermediary omits or mislabels // the Content-Type header. The exception is a Content-Type declaring // HTML, which is always reported as an invalid response since Coder // API endpoints never serve HTML. The caller remains responsible for // closing the response body. func ReadBodyAsJSON(res *http.Response, v any) error { return decodeBodyAsJSON(res, v, nil) } // ReadBodyAsJSONUseNumber behaves like ReadBodyAsJSON but decodes JSON // numbers into json.Number instead of float64, preserving integer // precision for callers that re-serialize or type-assert numeric // claims, such as license JWT claims. func ReadBodyAsJSONUseNumber(res *http.Response, v any) error { return decodeBodyAsJSON(res, v, func(dec *json.Decoder) { dec.UseNumber() }) } // decodeBodyAsJSON decodes the response body as JSON into v. When // configure is non-nil it is called with the decoder before decoding, // allowing callers to set options such as UseNumber. func decodeBodyAsJSON(res *http.Response, v any, configure func(*json.Decoder)) error { if res == nil || res.Body == nil { return xerrors.New("no response body to decode") } mimeType := parseMimeType(res.Header.Get("Content-Type")) if isHTMLMimeType(mimeType) { return htmlBodyError(res) } body := &responseBodyReader{Reader: res.Body} prefix := &bodyPrefixWriter{} dec := json.NewDecoder(io.TeeReader(body, prefix)) if configure != nil { configure(dec) } err := dec.Decode(v) switch { case err == nil: return nil case body.err != nil && errors.Is(err, body.err): return xerrors.Errorf("read response body: %w", err) case len(prefix.bytes) == 0 && errors.Is(err, io.EOF): return invalidBodyError(res, Response{ Message: "Received an empty response from the Coder API.", Detail: invalidBodyDetail(res), }, "", nil) case isHTMLBody(mimeType, prefix.bytes): return htmlBodyError(res) default: return invalidBodyError(res, Response{ Message: "Received an invalid JSON response from the Coder API.", Detail: fmt.Sprintf("decode body: %s, %s", err.Error(), invalidBodyDetail(res)), }, "", err) } } // htmlBodyError returns the structured *Error reported when the Coder API // response body is HTML rather than JSON. func htmlBodyError(res *http.Response) *Error { return invalidBodyError(res, Response{ Message: "Received an HTML response instead of JSON from the Coder API.", Detail: invalidBodyDetail(res), }, htmlResponseHelper, nil) } // responseBodyReader records non-EOF read errors so decode errors from custom // JSON unmarshallers are not mistaken for transport failures. type responseBodyReader struct { io.Reader err error } func (r *responseBodyReader) Read(p []byte) (int, error) { n, err := r.Reader.Read(p) if err != nil && !errors.Is(err, io.EOF) && r.err == nil { r.err = err } return n, err } // bodyPrefixWriter retains only the beginning of a body while reporting every // byte as written so it can be used with io.TeeReader. type bodyPrefixWriter struct { bytes []byte } func (w *bodyPrefixWriter) Write(p []byte) (int, error) { if remaining := jsonBodySniffLen - len(w.bytes); remaining > 0 { w.bytes = append(w.bytes, p[:min(remaining, len(p))]...) } return len(p), nil } // isHTMLBody reports whether a response body is HTML, either by its // media type or by starting with '<' after optional whitespace. Valid // JSON can never start with '<', so the sniff also identifies HTML // bodies mislabeled with a JSON content type. Markup bodies such as // XML are intentionally classified the same way since the remediation, // fixing the URL or the intermediary, is identical. func isHTMLBody(mimeType string, prefix []byte) bool { if isHTMLMimeType(mimeType) { return true } // Strip a UTF-8 byte order mark, which some intermediaries prepend // to HTML error pages. trimmed := bytes.TrimPrefix(prefix, []byte("\xef\xbb\xbf")) trimmed = bytes.TrimLeftFunc(trimmed, unicode.IsSpace) return len(trimmed) > 0 && trimmed[0] == '<' } func isHTMLMimeType(mimeType string) bool { return mimeType == "text/html" || mimeType == "application/xhtml+xml" } // invalidBodyDetail describes an invalid response body and how it was // reached, for inclusion in an Error Detail. func invalidBodyDetail(res *http.Response) string { var sb strings.Builder if contentType := res.Header.Get("Content-Type"); contentType != "" { _, _ = fmt.Fprintf(&sb, "content type %q", contentType) } else { _, _ = sb.WriteString("no content type") } if orig := originalRequestURL(res); orig != "" && res.Request != nil && res.Request.URL != nil && orig != res.Request.URL.String() { _, _ = fmt.Fprintf(&sb, ", after following redirects from %s", orig) } return sb.String() } // originalRequestURL returns the URL of the first request in the // redirect chain that produced res, or "" when unknown. res.Request // itself is the final request after any redirects. func originalRequestURL(res *http.Response) string { req := res.Request for req != nil && req.Response != nil && req.Response.Request != nil { req = req.Response.Request } if req == nil || req.URL == nil { return "" } return req.URL.String() } // invalidBodyError builds the *Error returned when a response with an // accepted status code does not contain the expected JSON body. func invalidBodyError(res *http.Response, response Response, helper string, cause error) *Error { var requestMethod, requestURL string if res.Request != nil { requestMethod = res.Request.Method if res.Request.URL != nil { requestURL = res.Request.URL.String() } } return &Error{ Response: response, statusCode: res.StatusCode, method: requestMethod, url: requestURL, contentType: res.Header.Get("Content-Type"), cause: cause, invalidBody: true, Helper: helper, } } // Error represents an unaccepted or invalid request to the API. // @typescript-ignore Error type Error struct { Response statusCode int method string url string contentType string // cause is the underlying error, such as a JSON decode failure, // exposed to errors.Is and errors.As via Unwrap. cause error // invalidBody marks errors for responses whose status code was // accepted but whose body was not the expected JSON. Error() then // describes the body rather than an unexpected status code. invalidBody bool Helper string } func (e *Error) StatusCode() int { return e.statusCode } func (e *Error) Method() string { return e.method } func (e *Error) URL() string { return e.url } // ContentType returns the Content-Type header of the response that // produced the error, when known. func (e *Error) ContentType() string { return e.contentType } // Unwrap returns the underlying cause of the error, if any, such as the // JSON decode error for a response body that could not be decoded. func (e *Error) Unwrap() error { return e.cause } func (e *Error) Friendly() string { var sb strings.Builder _, _ = fmt.Fprintf(&sb, "%s. %s", strings.TrimSuffix(e.Message, "."), e.Helper) for _, err := range e.Validations { _, _ = fmt.Fprintf(&sb, "\n- %s: %s", err.Field, err.Detail) } return sb.String() } func (e *Error) Error() string { var builder strings.Builder if e.method != "" && e.url != "" { _, _ = fmt.Fprintf(&builder, "%v %v: ", e.method, e.url) } if e.invalidBody { _, _ = fmt.Fprintf(&builder, "invalid API response (status code %d): %s", e.statusCode, e.Message) } else { _, _ = fmt.Fprintf(&builder, "unexpected status code %d: %s", e.statusCode, e.Message) } if e.Helper != "" { _, _ = fmt.Fprintf(&builder, ": %s", e.Helper) } if e.Detail != "" { _, _ = fmt.Fprintf(&builder, "\n\tError: %s", e.Detail) } for _, err := range e.Validations { _, _ = fmt.Fprintf(&builder, "\n\t%s: %s", err.Field, err.Detail) } return builder.String() } // NewTestError is a helper function to create a Error, setting the internal fields. It's generally only useful for // testing. func NewTestError(statusCode int, method string, u string) *Error { return &Error{ statusCode: statusCode, method: method, url: u, } } // NewError creates a new Error with the response and status code. func NewError(statusCode int, response Response) *Error { return &Error{ statusCode: statusCode, Response: response, } } type closeFunc func() error func (c closeFunc) Close() error { return c() } func parseMimeType(contentType string) string { mimeType, _, err := mime.ParseMediaType(contentType) if err != nil { mimeType = strings.TrimSpace(strings.Split(contentType, ";")[0]) } return mimeType } // Response represents a generic HTTP response. type Response struct { // Message is an actionable message that depicts actions the request took. // These messages should be fully formed sentences with proper punctuation. // Examples: // - "A user has been created." // - "Failed to create a user." Message string `json:"message"` // Detail is a debug message that provides further insight into why the // action failed. This information can be technical and a regular golang // err.Error() text. // - "database: too many open connections" // - "stat: too many open files" Detail string `json:"detail,omitempty"` // Validations are form field-specific friendly error messages. They will be // shown on a form field in the UI. These can also be used to add additional // context if there is a set of errors in the primary 'Message'. Validations []ValidationError `json:"validations,omitempty"` } // ValidationError represents a scoped error to a user input. type ValidationError struct { Field string `json:"field" validate:"required"` Detail string `json:"detail" validate:"required"` } func (e ValidationError) Error() string { return fmt.Sprintf("field: %s detail: %s", e.Field, e.Detail) } var _ error = (*ValidationError)(nil) // CoderDesktopTelemetry represents the telemetry data sent from Coder Desktop clients. // @typescript-ignore CoderDesktopTelemetry type CoderDesktopTelemetry struct { DeviceID string `json:"device_id"` DeviceOS string `json:"device_os"` CoderDesktopVersion string `json:"coder_desktop_version"` } // FromHeader parses the desktop telemetry from the provided header value. // Returns nil if the header is empty or if parsing fails. func (t *CoderDesktopTelemetry) FromHeader(headerValue string) error { if headerValue == "" { return nil } return json.Unmarshal([]byte(headerValue), t) } // IsEmpty returns true if all fields in the telemetry data are empty. func (t *CoderDesktopTelemetry) IsEmpty() bool { return t.DeviceID == "" && t.DeviceOS == "" && t.CoderDesktopVersion == "" } // IsConnectionError is a convenience function for checking if the source of an // error is due to a 'connection refused', 'no such host', etc. func IsConnectionError(err error) bool { var ( // E.g. no such host dnsErr *net.DNSError // Eg. connection refused opErr *net.OpError ) return xerrors.As(err, &dnsErr) || xerrors.As(err, &opErr) } func AsError(err error) (*Error, bool) { var e *Error return e, xerrors.As(err, &e) } // RequestOption is a function that can be used to modify an http.Request. type RequestOption func(*http.Request) // WithQueryParam adds a query parameter to the request. func WithQueryParam(key, value string) RequestOption { return func(r *http.Request) { if value == "" { return } q := r.URL.Query() q.Add(key, value) r.URL.RawQuery = q.Encode() } } // HeaderTransport is a http.RoundTripper that adds some headers to all requests. // @typescript-ignore HeaderTransport type HeaderTransport struct { Transport http.RoundTripper Header http.Header } var _ http.RoundTripper = &HeaderTransport{} func (h *HeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { for k, v := range h.Header { for _, vv := range v { req.Header.Add(k, vv) } } if h.Transport == nil { return http.DefaultTransport.RoundTrip(req) } return h.Transport.RoundTrip(req) } func (h *HeaderTransport) CloseIdleConnections() { type closeIdler interface { CloseIdleConnections() } if tr, ok := h.Transport.(closeIdler); ok { tr.CloseIdleConnections() } } // ClientOptions func WithSessionToken(token string) ClientOption { return func(c *Client) { c.SessionTokenProvider = FixedSessionTokenProvider{SessionToken: token} } } func WithHTTPClient(httpClient *http.Client) ClientOption { return func(c *Client) { c.HTTPClient = httpClient } } func WithLogger(logger slog.Logger) ClientOption { return func(c *Client) { c.logger = logger } } func WithLogBodies() ClientOption { return func(c *Client) { c.logBodies = true } } func WithPlainLogger(plainLogger io.Writer) ClientOption { return func(c *Client) { c.PlainLogger = plainLogger } } func WithTrace() ClientOption { return func(c *Client) { c.Trace = true } } func WithDisableDirectConnections() ClientOption { return func(c *Client) { c.DisableDirectConnections = true } } func WithDERPTLSConfig(cfg *tls.Config) ClientOption { return func(c *Client) { c.derpTLSConfig = cfg } } // DERPTLSConfig returns the optional TLS config for DERP connections. func (c *Client) DERPTLSConfig() *tls.Config { return c.derpTLSConfig }