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:
2026-10-26 00:32:31 +08:00
parent cba7d2366b
commit ebae420ab1
27 changed files with 3745 additions and 56 deletions
+271
View File
@@ -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
}