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
+45
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -27,8 +28,20 @@ type API struct {
Issuer Issuer // certificate issuer; defaults to the local CA
IssuerName string // "local" or the ACME directory URL
// Limits are optional; a nil limiter allows everything.
CreateLimiter *RateLimiter
BindLimiter *RateLimiter
BindTunnelLimiter *RateLimiter
issueMu sync.Mutex
issuing map[string]bool
createCount atomic.Uint64
bindCount atomic.Uint64
rateLimited atomic.Uint64
issueAttempts atomic.Uint64
issueReady atomic.Uint64
issueFailed atomic.Uint64
}
func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -113,6 +126,14 @@ func (a *API) createTunnel(w http.ResponseWriter, r *http.Request) {
})
return
}
if ok, retry := a.CreateLimiter.Allow(clientIP(r)); !ok {
a.rateLimited.Add(1)
w.Header().Set("Retry-After", retryAfterSeconds(retry))
writeJSON(w, http.StatusTooManyRequests, errRateLimited{
Tag: "RateLimitedError", Message: "Too many tunnels created from this address; try again later",
})
return
}
tunnel, token, err := a.Store.createTunnel(a.Domain)
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, errInvalidRequest{
@@ -120,6 +141,7 @@ func (a *API) createTunnel(w http.ResponseWriter, r *http.Request) {
})
return
}
a.createCount.Add(1)
writeJSON(w, http.StatusCreated, createResponse{
Tunnel: TunnelInfo{ID: tunnel.ID, Hostname: tunnel.Hostname, State: "offline"},
Token: token,
@@ -199,6 +221,17 @@ func (a *API) bindCertificate(w http.ResponseWriter, r *http.Request, id string)
})
return
}
// A bind that reaches here creates a new certificate (or retries a failed
// one), so it counts against the issuance limits.
if ok, retry := a.BindLimiter.Allow(clientIP(r)); !ok {
a.rateLimit(w, retry)
return
}
if ok, retry := a.BindTunnelLimiter.Allow(id); !ok {
a.rateLimit(w, retry)
return
}
a.bindCount.Add(1)
if _, err := parseCSR(csr, tunnel.Hostname); err != nil {
var hostErr errCSRHostname
if errors.As(err, &hostErr) {
@@ -258,6 +291,15 @@ func certState(cert *storedCert) CertState {
}
}
// rateLimit answers a 429 with the contract's error tag and a Retry-After.
func (a *API) rateLimit(w http.ResponseWriter, retryAfter time.Duration) {
a.rateLimited.Add(1)
w.Header().Set("Retry-After", retryAfterSeconds(retryAfter))
writeJSON(w, http.StatusTooManyRequests, errRateLimited{
Tag: "RateLimitedError", Message: "Rate limit exceeded; try again later",
})
}
// issuer returns the configured certificate issuer (the local CA by default).
func (a *API) issuer() Issuer {
if a.Issuer != nil {
@@ -294,6 +336,7 @@ func (a *API) runIssue(tunnelID, hostname, csrPEM, certID string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
log.Printf("issuing certificate for %s via %s", hostname, a.issuerName())
a.issueAttempts.Add(1)
result, err := a.issuer().Issue(ctx, csrPEM, hostname)
// The tunnel may have been deleted, or bound a different CSR, while the
@@ -306,6 +349,7 @@ func (a *API) runIssue(tunnelID, hostname, csrPEM, certID string) {
cert := storedCert{ID: certID, CSR: csrPEM, Issuer: a.issuerName()}
if err != nil {
a.issueFailed.Add(1)
cert.State = "failed"
cert.Reason = err.Error()
if certReady(current.Certificate) {
@@ -322,6 +366,7 @@ func (a *API) runIssue(tunnelID, hostname, csrPEM, certID string) {
cert.Certificate = result.Certificate
cert.Chain = result.Chain
cert.Expiry = expiryString(result.Expiry)
a.issueReady.Add(1)
log.Printf("certificate for %s ready (expires %s)", hostname, cert.Expiry)
}
if err := a.Store.bindCertificate(tunnelID, cert); err != nil {