Add the native Go client and finish M3 hardening
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.
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user