package client import ( "bytes" "context" "encoding/json" "fmt" "io" "math/rand" "net/http" "net/url" "strconv" "strings" "time" ) // TunnelInfo is the API's tunnel record. type TunnelInfo struct { ID string `json:"id"` Hostname string `json:"hostname"` State string `json:"state"` CertificateID string `json:"certificateID,omitempty"` } // CertState is the API's certificate state union. type CertState struct { Type string `json:"type"` Token string `json:"token,omitempty"` Key string `json:"key,omitempty"` Certificate string `json:"certificate,omitempty"` Chain string `json:"chain,omitempty"` Expiry string `json:"expiry,omitempty"` Reason string `json:"reason,omitempty"` } // CertificateInfo is the API's certificate envelope. type CertificateInfo struct { ID string `json:"id"` State CertState `json:"state"` } // APIError is a non-2xx API answer, including the contract's retry hints. type APIError struct { Status int Tag string Message string RetryAfter time.Duration } func (e *APIError) Error() string { if e.Tag != "" { return fmt.Sprintf("%s: %s", e.Tag, e.Message) } return fmt.Sprintf("api returned HTTP %d: %s", e.Status, e.Message) } // APIClient talks to the tunnel server's HTTP API. type APIClient struct { Base string HTTP *http.Client } func NewAPIClient(base string) *APIClient { return &APIClient{ Base: strings.TrimRight(base, "/"), HTTP: &http.Client{Timeout: 30 * time.Second}, } } type repeat int const ( repeatIdempotent repeat = iota // GET, same-CSR bind: retry network errors and 5xx repeatOnce // create/delete: only when the request cannot have arrived ) func (c *APIClient) do(ctx context.Context, method, path string, body any, token string, repeat repeat) ([]byte, error) { var payload []byte if body != nil { var err error payload, err = json.Marshal(body) if err != nil { return nil, err } } for attempt := 0; ; attempt++ { req, err := http.NewRequestWithContext(ctx, method, c.Base+path, bytes.NewReader(payload)) if err != nil { return nil, err } if body != nil { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := c.HTTP.Do(req) if err != nil { if attempt < 6 && repeat == repeatIdempotent && ctx.Err() == nil { if !sleep(ctx, backoff(attempt, 0)) { return nil, ctx.Err() } continue } return nil, err } data, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) resp.Body.Close() if readErr != nil { return nil, readErr } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return data, nil } apiErr := parseAPIError(resp, data) if attempt < 6 && transient(resp.StatusCode, repeat) && ctx.Err() == nil { if !sleep(ctx, backoff(attempt, apiErr.RetryAfter)) { return nil, ctx.Err() } continue } return nil, apiErr } } func parseAPIError(resp *http.Response, data []byte) *APIError { e := &APIError{Status: resp.StatusCode} var body struct { Tag string `json:"_tag"` Message string `json:"message"` } if json.Unmarshal(data, &body) == nil { e.Tag, e.Message = body.Tag, body.Message } if e.Message == "" { e.Message = strings.TrimSpace(string(data)) if e.Message == "" { e.Message = http.StatusText(resp.StatusCode) } } if after := resp.Header.Get("Retry-After"); after != "" { if seconds, err := strconv.Atoi(after); err == nil { e.RetryAfter = time.Duration(seconds) * time.Second } } return e } func transient(status int, repeat repeat) bool { if status == 429 { return true } if repeat == repeatIdempotent { return status >= 500 && status <= 599 } switch status { case 503, 521, 522, 523, 525: return true } return false } func backoff(attempt int, retryAfter time.Duration) time.Duration { if retryAfter > 0 { if retryAfter > 8*time.Second { retryAfter = 8 * time.Second } return retryAfter } wait := 250 * time.Millisecond << attempt if wait > 8*time.Second { wait = 8 * time.Second } // Jitter between half and all of the delay. half := int64(wait / 2) return time.Duration(half + rand.Int63n(half+1)) } func sleep(ctx context.Context, d time.Duration) bool { timer := time.NewTimer(d) defer timer.Stop() select { case <-ctx.Done(): return false case <-timer.C: return true } } // CreateTunnel creates a new tunnel and returns its one-time token. func (c *APIClient) CreateTunnel(ctx context.Context) (TunnelInfo, string, error) { data, err := c.do(ctx, http.MethodPost, "/api/tunnel", struct{}{}, "", repeatOnce) if err != nil { return TunnelInfo{}, "", err } var out struct { Tunnel TunnelInfo `json:"tunnel"` Token string `json:"token"` } if err := json.Unmarshal(data, &out); err != nil { return TunnelInfo{}, "", fmt.Errorf("create tunnel: %w", err) } if out.Tunnel.ID == "" || out.Token == "" { return TunnelInfo{}, "", fmt.Errorf("create tunnel: incomplete response") } return out.Tunnel, out.Token, nil } // GetTunnel reads a tunnel. func (c *APIClient) GetTunnel(ctx context.Context, id, token string) (TunnelInfo, error) { data, err := c.do(ctx, http.MethodGet, "/api/tunnel/"+url.PathEscape(id), nil, token, repeatIdempotent) if err != nil { return TunnelInfo{}, err } var out TunnelInfo if err := json.Unmarshal(data, &out); err != nil { return TunnelInfo{}, fmt.Errorf("get tunnel: %w", err) } return out, nil } // BindCertificate submits the tunnel's CSR. Re-binding the same CSR is // idempotent and is retried. func (c *APIClient) BindCertificate(ctx context.Context, id, token, csr string) (CertificateInfo, error) { data, err := c.do(ctx, http.MethodPost, "/api/tunnel/"+url.PathEscape(id)+"/certificate", map[string]string{"csr": csr}, token, repeatIdempotent) if err != nil { return CertificateInfo{}, err } return decodeCertificate(data) } // GetCertificate reads the current certificate state. func (c *APIClient) GetCertificate(ctx context.Context, id, token string) (CertificateInfo, error) { data, err := c.do(ctx, http.MethodGet, "/api/tunnel/"+url.PathEscape(id)+"/certificate", nil, token, repeatIdempotent) if err != nil { return CertificateInfo{}, err } return decodeCertificate(data) } func decodeCertificate(data []byte) (CertificateInfo, error) { var out CertificateInfo if err := json.Unmarshal(data, &out); err != nil { return CertificateInfo{}, fmt.Errorf("certificate response: %w", err) } return out, nil } // DeleteTunnel deletes a tunnel. func (c *APIClient) DeleteTunnel(ctx context.Context, id, token string) error { _, err := c.do(ctx, http.MethodDelete, "/api/tunnel/"+url.PathEscape(id), nil, token, repeatOnce) return err } // WebSocketURL returns the bridge URL for a tunnel. func (c *APIClient) WebSocketURL(id string) (string, error) { u, err := url.Parse(c.Base) if err != nil { return "", err } if u.Scheme == "https" { u.Scheme = "wss" } else { u.Scheme = "ws" } u.Path = "/api/tunnel/" + url.PathEscape(id) + "/connect" u.RawQuery = "" u.Fragment = "" return u.String(), nil }