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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user