mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
Add a ruleguard rule forbidding direct `json.NewDecoder(res.Body).Decode(...)` on `*http.Response` in codersdk packages, so new typed endpoints use `codersdk.ReadBodyAsJSON` and keep returning structured errors for non-JSON bodies. The rule matches both the chained call form and decoders assigned to a variable first. Intentional raw-body paths carry documented `//nolint:gocritic` exceptions: the 16 agent-direct HTTP decodes in `workspacesdk/agentconn.go` route through a single `decodeAgentJSON` helper (agent-direct over tailnet, so `ReadBodyAsJSON`'s reverse proxy/SSO error guidance does not apply), and the Azure IMDS attested-document decode in `agentsdk/azure.go` keeps an inline exception. The two `UseNumber` decoders in `licenses.go` are migrated to a new `codersdk.ReadBodyAsJSONUseNumber`, so `coder licenses add/list` also return structured errors for non-JSON bodies instead of `invalid character '<' looking for beginning of value`. Note for local verification: golangci-lint caches results, so run `golangci-lint cache clean` after modifying `scripts/rules.go` or the rule may silently not fire. Final PR of the stack on #27804, #27857, and #27858. Refs #27044. Stack plan Inventory (full-tree audit): 280 migratable call sites across 47 files; 17 excluded (16 agent-direct HTTP sites in `workspacesdk/agentconn.go`, 1 Azure IMDS decode in `agentsdk/azure.go`). 1. **#27857** `refactor(codersdk): use ReadBodyAsJSON in typed endpoints`: mechanical migration of all sites except `chats.go` (224 sites, 46 files). 2. **#27858** `refactor(codersdk): use shared error helpers in chat endpoints`: migrate the 56 `chats.go` sites and consolidate the duplicated `readRawBodyAsError`/`newResponseError` helpers onto the shared `client.go` error path, with regression tests for the 409 usage-limit flow. 3. **#27859** `chore: forbid direct response body JSON decode in codersdk`: ruleguard rule with documented exceptions for the intentional raw-body paths, plus `ReadBodyAsJSONUseNumber` for the `licenses.go` decoders. Reviewed and updated by Coder Agents on behalf of @dylanhuff-at-coder.
963 lines
30 KiB
Go
963 lines
30 KiB
Go
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
|
|
}
|