The new cmd/tunnel client keeps the API URL and tunnel identity in ~/.config/tunnel/config.json (0600), provisions the key/CSR and certificate, terminates TLS, proxies to local routes, reloads routes without a restart, and runs as a background service. `tunnel import` takes over an existing official CLI profile, preserving its tunnel ID, token, key and certificate. Server hardening: per-IP and per-tunnel rate limits for creates and binds, a CA cool-down after repeated ACME failures, a startup sweep for stale _acme-challenge records, admin metrics, a 60-second half-close timeout, and separate prioritized control / ordered data bridge queues (with `end` ordered after the final bytes). The dashboard shows the new metrics.
272 lines
7.0 KiB
Go
272 lines
7.0 KiB
Go
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
|
|
}
|