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:
@@ -60,10 +60,24 @@ type adminOverview struct {
|
||||
Server adminServer `json:"server"`
|
||||
CA adminCA `json:"ca"`
|
||||
Totals adminTotals `json:"totals"`
|
||||
Metrics adminMetrics `json:"metrics"`
|
||||
Tunnels []adminTunnel `json:"tunnels"`
|
||||
Bridges []adminBridge `json:"bridges"`
|
||||
}
|
||||
|
||||
type adminMetrics struct {
|
||||
CreateRequests uint64 `json:"create_requests"`
|
||||
BindRequests uint64 `json:"bind_requests"`
|
||||
RateLimited uint64 `json:"rate_limited"`
|
||||
IssuanceAttempts uint64 `json:"issuance_attempts"`
|
||||
IssuanceReady uint64 `json:"issuance_ready"`
|
||||
IssuanceFailed uint64 `json:"issuance_failed"`
|
||||
CertificatesReady int `json:"certificates_ready"`
|
||||
CertificatesIssuing int `json:"certificates_issuing"`
|
||||
CertificatesFailed int `json:"certificates_failed"`
|
||||
CertificatesRetrying int `json:"certificates_retrying"`
|
||||
}
|
||||
|
||||
// adminAuthorized enforces the bearer ADMIN_TOKEN; 503 when unset.
|
||||
func (a *API) adminAuthorized(w http.ResponseWriter, r *http.Request) bool {
|
||||
if a.AdminToken == "" {
|
||||
@@ -145,10 +159,28 @@ func (a *API) adminOverviewHandler(w http.ResponseWriter, r *http.Request) {
|
||||
entry.CertificateState = certState(t.Certificate).Type
|
||||
entry.CertificateIssuer = t.Certificate.Issuer
|
||||
entry.CertificateError = t.Certificate.Reason
|
||||
switch {
|
||||
case certReady(t.Certificate):
|
||||
overview.Metrics.CertificatesReady++
|
||||
if t.Certificate.State == "failed" {
|
||||
overview.Metrics.CertificatesRetrying++
|
||||
}
|
||||
case t.Certificate.State == "failed":
|
||||
overview.Metrics.CertificatesFailed++
|
||||
default:
|
||||
overview.Metrics.CertificatesIssuing++
|
||||
}
|
||||
}
|
||||
overview.Tunnels = append(overview.Tunnels, entry)
|
||||
}
|
||||
|
||||
overview.Metrics.CreateRequests = a.createCount.Load()
|
||||
overview.Metrics.BindRequests = a.bindCount.Load()
|
||||
overview.Metrics.RateLimited = a.rateLimited.Load()
|
||||
overview.Metrics.IssuanceAttempts = a.issueAttempts.Load()
|
||||
overview.Metrics.IssuanceReady = a.issueReady.Load()
|
||||
overview.Metrics.IssuanceFailed = a.issueFailed.Load()
|
||||
|
||||
writeJSON(w, http.StatusOK, overview)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+106
-19
@@ -166,7 +166,8 @@ type Bridge struct {
|
||||
clientVersion string
|
||||
startedAt time.Time
|
||||
|
||||
outbox chan outbound
|
||||
control chan outbound
|
||||
dataQ chan outbound
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
|
||||
@@ -244,7 +245,8 @@ func (h *Hub) attach(w http.ResponseWriter, r *http.Request, tunnel storedTunnel
|
||||
ws: ws,
|
||||
clientVersion: attach.Client.Version,
|
||||
startedAt: time.Now(),
|
||||
outbox: make(chan outbound, 256),
|
||||
control: make(chan outbound, 64),
|
||||
dataQ: make(chan outbound, 256),
|
||||
done: make(chan struct{}),
|
||||
conns: map[uint32]*visitorConn{},
|
||||
}
|
||||
@@ -295,13 +297,13 @@ func (b *Bridge) openVisitor(conn net.Conn, hello []byte, peer, sni, alpn string
|
||||
open, _ := json.Marshal(openMessage{
|
||||
Type: "open", Conn: id, Peer: peer, SNI: sni, ALPN: alpn,
|
||||
})
|
||||
if !b.send(websocket.MessageText, open) {
|
||||
if !b.sendControl(websocket.MessageText, open) {
|
||||
b.removeConn(id, true)
|
||||
return
|
||||
}
|
||||
for len(hello) > 0 {
|
||||
n := min(len(hello), maxPayload)
|
||||
if !b.send(websocket.MessageBinary, buildFrame(id, hello[:n])) {
|
||||
if !b.sendData(websocket.MessageBinary, buildFrame(id, hello[:n])) {
|
||||
b.removeConn(id, true)
|
||||
return
|
||||
}
|
||||
@@ -317,7 +319,7 @@ func (b *Bridge) pump(v *visitorConn) {
|
||||
n, err := v.conn.Read(buf)
|
||||
v.touch()
|
||||
if n > 0 {
|
||||
if !b.send(websocket.MessageBinary, buildFrame(v.id, buf[:n])) {
|
||||
if !b.sendData(websocket.MessageBinary, buildFrame(v.id, buf[:n])) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -332,15 +334,17 @@ func (b *Bridge) pump(v *visitorConn) {
|
||||
}
|
||||
if err == io.EOF {
|
||||
end, _ := json.Marshal(endMessage{Type: "end", Conn: v.id})
|
||||
b.send(websocket.MessageText, end)
|
||||
b.sendData(websocket.MessageText, end)
|
||||
} else {
|
||||
reset, _ := json.Marshal(resetMessage{Type: "reset", Conn: v.id, Code: ResetIOError})
|
||||
b.send(websocket.MessageText, reset)
|
||||
b.sendControl(websocket.MessageText, reset)
|
||||
b.removeConn(v.id, true)
|
||||
return
|
||||
}
|
||||
if bothEnded {
|
||||
b.removeConn(v.id, false)
|
||||
} else {
|
||||
b.scheduleHalfClose(v.id, v)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -387,7 +391,7 @@ func (b *Bridge) handleControl(raw []byte) {
|
||||
switch msg.Type {
|
||||
case "ping":
|
||||
pong, _ := json.Marshal(pingPongMessage{Type: "pong", TimeSent: msg.TimeSent})
|
||||
b.send(websocket.MessageText, pong)
|
||||
b.sendControl(websocket.MessageText, pong)
|
||||
case "end":
|
||||
if msg.Conn != nil {
|
||||
b.endFromClient(*msg.Conn)
|
||||
@@ -416,19 +420,48 @@ func (b *Bridge) endFromClient(connID uint32) {
|
||||
}
|
||||
if bothEnded {
|
||||
b.removeConn(connID, false)
|
||||
} else {
|
||||
b.scheduleHalfClose(connID, v)
|
||||
}
|
||||
}
|
||||
|
||||
// writeLoop is the only writer on the WebSocket, keeping frames ordered.
|
||||
// writeLoop is the only writer on the WebSocket. Control messages get
|
||||
// priority over queued visitor data; `end` messages travel in the data queue
|
||||
// so they stay behind the final bytes of a response.
|
||||
func (b *Bridge) writeLoop() {
|
||||
ctx := context.Background()
|
||||
write := func(msg outbound) bool {
|
||||
if err := writeWS(ctx, b.ws, msg.typ, msg.data); err != nil {
|
||||
b.close(websocket.StatusInternalError, "write failed")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-b.done:
|
||||
return
|
||||
case msg := <-b.outbox:
|
||||
if err := writeWS(ctx, b.ws, msg.typ, msg.data); err != nil {
|
||||
b.close(websocket.StatusInternalError, "write failed")
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-b.done:
|
||||
return
|
||||
case msg := <-b.control:
|
||||
if !write(msg) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-b.done:
|
||||
return
|
||||
case msg := <-b.control:
|
||||
if !write(msg) {
|
||||
return
|
||||
}
|
||||
case msg := <-b.dataQ:
|
||||
if !write(msg) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -459,28 +492,52 @@ func (b *Bridge) sweeper() {
|
||||
b.mu.Unlock()
|
||||
for _, id := range stale {
|
||||
reset, _ := json.Marshal(resetMessage{Type: "reset", Conn: id, Code: ResetIdleTimeout})
|
||||
b.send(websocket.MessageText, reset)
|
||||
b.sendControl(websocket.MessageText, reset)
|
||||
b.removeConn(id, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// send queues a frame, applying backpressure when the client is slow.
|
||||
// It returns false when the bridge is gone.
|
||||
func (b *Bridge) send(typ websocket.MessageType, data []byte) bool {
|
||||
// sendControl queues a control frame. It never blocks behind visitor data; a
|
||||
// stalled client whose control queue is full is closed.
|
||||
func (b *Bridge) sendControl(typ websocket.MessageType, data []byte) bool {
|
||||
select {
|
||||
case <-b.done:
|
||||
return false
|
||||
case b.outbox <- outbound{typ, data}:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case b.control <- outbound{typ, data}:
|
||||
return true
|
||||
case <-b.done:
|
||||
return false
|
||||
default:
|
||||
b.close(websocket.StatusInternalError, "control queue full")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// trySend queues a frame only if it fits immediately.
|
||||
// sendData queues visitor bytes, blocking (and thus backpressuring the visitor
|
||||
// socket) when the client is slow.
|
||||
func (b *Bridge) sendData(typ websocket.MessageType, data []byte) bool {
|
||||
select {
|
||||
case <-b.done:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case b.dataQ <- outbound{typ, data}:
|
||||
return true
|
||||
case <-b.done:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// trySend queues a control frame only if it fits immediately.
|
||||
func (b *Bridge) trySend(typ websocket.MessageType, data []byte) {
|
||||
select {
|
||||
case b.outbox <- outbound{typ, data}:
|
||||
case b.control <- outbound{typ, data}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -490,6 +547,10 @@ func (b *Bridge) removeConn(id uint32, rst bool) {
|
||||
b.mu.Lock()
|
||||
v := b.conns[id]
|
||||
delete(b.conns, id)
|
||||
if v != nil && v.halfClose != nil {
|
||||
v.halfClose.Stop()
|
||||
v.halfClose = nil
|
||||
}
|
||||
b.mu.Unlock()
|
||||
if v == nil {
|
||||
return
|
||||
@@ -502,6 +563,31 @@ func (b *Bridge) removeConn(id uint32, rst bool) {
|
||||
v.conn.Close()
|
||||
}
|
||||
|
||||
// halfCloseTimeout bounds how long a connection may stay half-closed before
|
||||
// it is reset, so a peer that never closes its side cannot pin a connection.
|
||||
const halfCloseTimeout = 60 * time.Second
|
||||
|
||||
// scheduleHalfClose resets a connection whose one side ended but whose other
|
||||
// side has not, after halfCloseTimeout.
|
||||
func (b *Bridge) scheduleHalfClose(id uint32, v *visitorConn) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if v.halfClose != nil || (v.visitorEnded && v.clientEnded) || b.conns[id] != v {
|
||||
return
|
||||
}
|
||||
v.halfClose = time.AfterFunc(halfCloseTimeout, func() {
|
||||
b.mu.Lock()
|
||||
stillKnown := b.conns[id] == v
|
||||
b.mu.Unlock()
|
||||
if !stillKnown {
|
||||
return
|
||||
}
|
||||
reset, _ := json.Marshal(resetMessage{Type: "reset", Conn: id, Code: ResetIdleTimeout})
|
||||
b.sendControl(websocket.MessageText, reset)
|
||||
b.removeConn(id, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Bridge) touch() {
|
||||
b.lastActivity.Store(time.Now().UnixMilli())
|
||||
}
|
||||
@@ -531,6 +617,7 @@ type visitorConn struct {
|
||||
|
||||
visitorEnded bool // pump saw EOF: no more visitor data
|
||||
clientEnded bool // client sent end: target will send no more
|
||||
halfClose *time.Timer
|
||||
|
||||
lastActivity atomic.Int64
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/acme"
|
||||
)
|
||||
|
||||
// cooldown pauses new ACME orders after repeated CA-side failures. Five
|
||||
// consecutive failures trigger a one-minute pause, doubling up to an hour; a
|
||||
// success clears the state.
|
||||
type cooldown struct {
|
||||
mu sync.Mutex
|
||||
failures int
|
||||
until time.Time
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newCooldown() cooldown {
|
||||
return cooldown{now: time.Now}
|
||||
}
|
||||
|
||||
func (c *cooldown) remaining() time.Duration {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.until.IsZero() {
|
||||
return 0
|
||||
}
|
||||
now := c.now()
|
||||
if !now.Before(c.until) {
|
||||
return 0
|
||||
}
|
||||
return c.until.Sub(now)
|
||||
}
|
||||
|
||||
func (c *cooldown) success() {
|
||||
c.mu.Lock()
|
||||
c.failures = 0
|
||||
c.until = time.Time{}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// failure records a CA-side failure and returns the new pause, if one began.
|
||||
func (c *cooldown) failure() time.Duration {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.failures++
|
||||
if c.failures < 5 {
|
||||
return 0
|
||||
}
|
||||
exponent := c.failures - 5
|
||||
if exponent > 6 {
|
||||
exponent = 6 // cap at one hour before shifting
|
||||
}
|
||||
delay := time.Minute << exponent
|
||||
if delay > time.Hour {
|
||||
delay = time.Hour
|
||||
}
|
||||
c.until = c.now().Add(delay)
|
||||
return delay
|
||||
}
|
||||
|
||||
// caFailure distinguishes failures that point at the CA (network, 5xx, rate
|
||||
// limits) from local DNS/CSR problems and validation errors, which should not
|
||||
// pause issuance.
|
||||
func caFailure(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var hostErr errCSRHostname
|
||||
if errors.As(err, &hostErr) {
|
||||
return false
|
||||
}
|
||||
text := err.Error()
|
||||
if strings.Contains(text, "dns:") || strings.Contains(text, "not a PEM") || strings.Contains(text, "CSR") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(text, "authorization") || strings.Contains(text, "dns-01") {
|
||||
return false
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
return true
|
||||
}
|
||||
var acmeErr *acme.Error
|
||||
if errors.As(err, &acmeErr) {
|
||||
return acmeErr.StatusCode == 429 || acmeErr.StatusCode >= 500
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/acme"
|
||||
)
|
||||
|
||||
func TestCooldown(t *testing.T) {
|
||||
now := time.Now()
|
||||
c := newCooldown()
|
||||
c.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
if d := c.failure(); d != 0 {
|
||||
t.Fatalf("failure %d paused for %v, want no pause", i+1, d)
|
||||
}
|
||||
}
|
||||
if d := c.failure(); d != time.Minute {
|
||||
t.Fatalf("fifth failure pause = %v, want 1m", d)
|
||||
}
|
||||
if d := c.remaining(); d != time.Minute {
|
||||
t.Fatalf("remaining = %v, want 1m", d)
|
||||
}
|
||||
if d := c.failure(); d != 2*time.Minute {
|
||||
t.Fatalf("sixth failure pause = %v, want 2m", d)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
c.failure()
|
||||
}
|
||||
if d := c.failure(); d != time.Hour {
|
||||
t.Fatalf("pause cap = %v, want 1h", d)
|
||||
}
|
||||
|
||||
now = now.Add(2 * time.Hour)
|
||||
if d := c.remaining(); d != 0 {
|
||||
t.Fatalf("remaining after expiry = %v", d)
|
||||
}
|
||||
c.success()
|
||||
if c.failures != 0 {
|
||||
t.Fatalf("success left failures = %d", c.failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCAFailureClassification(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{errors.New("dns: create _acme-challenge: nope"), false},
|
||||
{errors.New("acme: authorization for x is invalid"), false},
|
||||
{errors.New("acme: authorize order: context deadline exceeded"), true},
|
||||
{fmt.Errorf("acme: authorize order: %w", &acme.Error{StatusCode: 503}), true},
|
||||
{fmt.Errorf("acme: authorize order: %w", &acme.Error{StatusCode: 429}), true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := caFailure(c.err); got != c.want {
|
||||
t.Errorf("caFailure(%v) = %v, want %v", c.err, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,13 @@ type DNSProvider interface {
|
||||
DeleteTXT(ctx context.Context, name string) error
|
||||
}
|
||||
|
||||
// ChallengeSweeper is implemented by providers that can remove leftover
|
||||
// challenge records at startup (for example after a process was killed
|
||||
// mid-issuance).
|
||||
type ChallengeSweeper interface {
|
||||
SweepChallenges(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// challengeRecordName is the TXT record name a hostname's DNS-01 challenge
|
||||
// lives at; the wildcard of a hostname shares the same record.
|
||||
func challengeRecordName(hostname string) string {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -36,38 +37,38 @@ func (p *PorkbunDNS) client() *http.Client {
|
||||
return &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
|
||||
func (p *PorkbunDNS) post(ctx context.Context, path string, body map[string]string) error {
|
||||
func (p *PorkbunDNS) post(ctx context.Context, path string, body map[string]string) ([]byte, error) {
|
||||
body["apikey"] = p.APIKey
|
||||
body["secretapikey"] = p.SecretKey
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.base()+path, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := p.client().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("porkbun %s: HTTP %d: %s", path, resp.StatusCode, data)
|
||||
return nil, fmt.Errorf("porkbun %s: HTTP %d: %s", path, resp.StatusCode, data)
|
||||
}
|
||||
var result struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return fmt.Errorf("porkbun %s: unreadable response: %w", path, err)
|
||||
return nil, fmt.Errorf("porkbun %s: unreadable response: %w", path, err)
|
||||
}
|
||||
if result.Status != "SUCCESS" {
|
||||
return fmt.Errorf("porkbun %s: %s: %s", path, result.Status, result.Message)
|
||||
return nil, fmt.Errorf("porkbun %s: %s: %s", path, result.Status, result.Message)
|
||||
}
|
||||
return nil
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *PorkbunDNS) CreateTXT(ctx context.Context, name, value string) error {
|
||||
@@ -75,9 +76,10 @@ func (p *PorkbunDNS) CreateTXT(ctx context.Context, name, value string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.post(ctx, "/dns/create/"+p.Zone, map[string]string{
|
||||
_, err = p.post(ctx, "/dns/create/"+p.Zone, map[string]string{
|
||||
"name": relative, "type": "TXT", "content": value, "ttl": "600",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *PorkbunDNS) DeleteTXT(ctx context.Context, name string) error {
|
||||
@@ -85,5 +87,53 @@ func (p *PorkbunDNS) DeleteTXT(ctx context.Context, name string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.post(ctx, "/dns/deleteByNameType/"+p.Zone+"/TXT/"+relative, map[string]string{})
|
||||
_, err = p.post(ctx, "/dns/deleteByNameType/"+p.Zone+"/TXT/"+relative, map[string]string{})
|
||||
return err
|
||||
}
|
||||
|
||||
type porkbunRecord struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// SweepChallenges removes every TXT record at a _acme-challenge.* name in the
|
||||
// zone. It runs at startup, before this process starts any issuance, so any
|
||||
// such records are leftovers from an interrupted attempt (single-node use).
|
||||
func (p *PorkbunDNS) SweepChallenges(ctx context.Context) (int, error) {
|
||||
data, err := p.post(ctx, "/dns/retrieve/"+p.Zone, map[string]string{})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var result struct {
|
||||
Records []porkbunRecord `json:"records"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return 0, fmt.Errorf("porkbun retrieve: %w", err)
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
removed := 0
|
||||
for _, record := range result.Records {
|
||||
if record.Type != "TXT" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(record.Name, ".")
|
||||
if !strings.HasPrefix(name, "_acme-challenge.") {
|
||||
continue
|
||||
}
|
||||
relative, err := relativeRecordName(name, p.Zone)
|
||||
if err != nil {
|
||||
// Only records inside this zone are ours to delete.
|
||||
continue
|
||||
}
|
||||
if _, done := seen[relative]; done {
|
||||
continue
|
||||
}
|
||||
seen[relative] = struct{}{}
|
||||
if _, err := p.post(ctx, "/dns/deleteByNameType/"+p.Zone+"/TXT/"+relative, map[string]string{}); err != nil {
|
||||
return removed, err
|
||||
}
|
||||
removed++
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
@@ -72,3 +72,46 @@ func TestPorkbunDNS(t *testing.T) {
|
||||
t.Errorf("expected the API error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPorkbunSweepChallenges(t *testing.T) {
|
||||
var deletes []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/dns/retrieve/tzgyn.dev":
|
||||
w.Write([]byte(`{"status":"SUCCESS","records":[
|
||||
{"name":"_acme-challenge.a.tunnel.tzgyn.dev","type":"TXT","content":"one"},
|
||||
{"name":"_acme-challenge.a.tunnel.tzgyn.dev","type":"TXT","content":"two"},
|
||||
{"name":"_acme-challenge.b.tunnel.tzgyn.dev","type":"TXT","content":"three"},
|
||||
{"name":"www.tzgyn.dev","type":"TXT","content":"keep"},
|
||||
{"name":"_acme-challenge.other.example.com","type":"TXT","content":"outside"}
|
||||
]}`))
|
||||
case strings.HasPrefix(r.URL.Path, "/dns/deleteByNameType/"):
|
||||
deletes = append(deletes, r.URL.Path)
|
||||
w.Write([]byte(`{"status":"SUCCESS"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := &PorkbunDNS{APIKey: "pk", SecretKey: "sk", Zone: "tzgyn.dev", BaseURL: srv.URL, HTTP: srv.Client()}
|
||||
count, err := p.SweepChallenges(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("count = %d, want 2", count)
|
||||
}
|
||||
want := []string{
|
||||
"/dns/deleteByNameType/tzgyn.dev/TXT/_acme-challenge.a.tunnel",
|
||||
"/dns/deleteByNameType/tzgyn.dev/TXT/_acme-challenge.b.tunnel",
|
||||
}
|
||||
if len(deletes) != len(want) {
|
||||
t.Fatalf("deletes = %v, want %v", deletes, want)
|
||||
}
|
||||
for i := range want {
|
||||
if deletes[i] != want[i] {
|
||||
t.Errorf("delete[%d] = %q, want %q", i, deletes[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ type acmeIssuer struct {
|
||||
store *Store
|
||||
opts ACMEOptions
|
||||
sem chan struct{} // bounds concurrent ACME orders
|
||||
cool cooldown
|
||||
|
||||
accountMu sync.Mutex // serializes first-use account registration
|
||||
}
|
||||
@@ -85,7 +86,7 @@ func NewACMEIssuer(store *Store, opts ACMEOptions) (Issuer, error) {
|
||||
if opts.DNS == nil {
|
||||
return nil, errors.New("acme: a DNS provider is required")
|
||||
}
|
||||
return &acmeIssuer{store: store, opts: opts, sem: make(chan struct{}, 2)}, nil
|
||||
return &acmeIssuer{store: store, opts: opts, sem: make(chan struct{}, 2), cool: newCooldown()}, nil
|
||||
}
|
||||
|
||||
func (a *acmeIssuer) Instant() bool { return false }
|
||||
@@ -183,18 +184,33 @@ func (a *acmeIssuer) client(ctx context.Context) (*acme.Client, error) {
|
||||
// Issue orders a certificate for the tunnel's names (hostname and, when the
|
||||
// CSR asks for it, its wildcard) using one DNS-01 challenge record shared by
|
||||
// both, then returns the certificate for the client's key.
|
||||
func (a *acmeIssuer) Issue(ctx context.Context, csrPEM, hostname string) (IssuedCert, error) {
|
||||
func (a *acmeIssuer) Issue(ctx context.Context, csrPEM, hostname string) (result IssuedCert, err error) {
|
||||
csr, err := parseCSR(csrPEM, hostname)
|
||||
if err != nil {
|
||||
return IssuedCert{}, err
|
||||
}
|
||||
|
||||
if delay := a.cool.remaining(); delay > 0 {
|
||||
return IssuedCert{}, fmt.Errorf("acme: CA cooling down for %s after repeated failures", delay.Round(time.Second))
|
||||
}
|
||||
|
||||
select {
|
||||
case a.sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return IssuedCert{}, ctx.Err()
|
||||
}
|
||||
defer func() { <-a.sem }()
|
||||
defer func() {
|
||||
if err == nil {
|
||||
a.cool.success()
|
||||
return
|
||||
}
|
||||
if caFailure(err) {
|
||||
if delay := a.cool.failure(); delay > 0 {
|
||||
log.Printf("acme: pausing new orders for %s after repeated CA failures", delay)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
client, err := a.client(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimiter is a fixed-window counter keyed by an arbitrary string (client
|
||||
// address, tunnel ID). A nil limiter allows everything.
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
limit int
|
||||
window time.Duration
|
||||
entries map[string]*rateEntry
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type rateEntry struct {
|
||||
count int
|
||||
reset time.Time
|
||||
}
|
||||
|
||||
// NewRateLimiter returns a limiter allowing limit events per window, or nil
|
||||
// when limit is not positive.
|
||||
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||
if limit <= 0 || window <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &RateLimiter{
|
||||
limit: limit,
|
||||
window: window,
|
||||
entries: map[string]*rateEntry{},
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow records one event for key. When it returns false, retryAfter is the
|
||||
// time left in the current window.
|
||||
func (r *RateLimiter) Allow(key string) (ok bool, retryAfter time.Duration) {
|
||||
if r == nil {
|
||||
return true, 0
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
now := r.now()
|
||||
entry := r.entries[key]
|
||||
if entry == nil || !now.Before(entry.reset) {
|
||||
if len(r.entries) > 4096 {
|
||||
// Keep the map bounded on long-running servers; expired entries
|
||||
// are safe to drop.
|
||||
for k, e := range r.entries {
|
||||
if !now.Before(e.reset) {
|
||||
delete(r.entries, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
entry = &rateEntry{reset: now.Add(r.window)}
|
||||
r.entries[key] = entry
|
||||
}
|
||||
if entry.count >= r.limit {
|
||||
return false, entry.reset.Sub(now)
|
||||
}
|
||||
entry.count++
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// clientIP returns the visitor address, honoring the reverse proxy headers
|
||||
// the API sits behind. It is only meaningful for rate limiting; direct clients
|
||||
// can spoof X-Forwarded-For unless the listener is reachable only through the
|
||||
// proxy.
|
||||
func clientIP(r *http.Request) string {
|
||||
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); net.ParseIP(real) != nil {
|
||||
return real
|
||||
}
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
first := strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||
if net.ParseIP(first) != nil {
|
||||
return first
|
||||
}
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// retryAfterSeconds formats a Retry-After value (at least one second).
|
||||
func retryAfterSeconds(d time.Duration) string {
|
||||
seconds := int(math.Ceil(d.Seconds()))
|
||||
if seconds < 1 {
|
||||
seconds = 1
|
||||
}
|
||||
return strconv.Itoa(seconds)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRateLimiter(t *testing.T) {
|
||||
now := time.Now()
|
||||
limiter := NewRateLimiter(2, time.Hour)
|
||||
limiter.now = func() time.Time { return now }
|
||||
|
||||
if ok, _ := limiter.Allow("a"); !ok {
|
||||
t.Fatal("first event should pass")
|
||||
}
|
||||
if ok, _ := limiter.Allow("a"); !ok {
|
||||
t.Fatal("second event should pass")
|
||||
}
|
||||
ok, retry := limiter.Allow("a")
|
||||
if ok || retry <= 0 || retry > time.Hour {
|
||||
t.Fatalf("third event: ok=%v retry=%v", ok, retry)
|
||||
}
|
||||
if ok, _ := limiter.Allow("b"); !ok {
|
||||
t.Fatal("another key should have its own window")
|
||||
}
|
||||
|
||||
now = now.Add(time.Hour + time.Second)
|
||||
if ok, _ := limiter.Allow("a"); !ok {
|
||||
t.Fatal("a new window should allow events again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
cases := []struct {
|
||||
forwarded, real, remote, want string
|
||||
}{
|
||||
{"203.0.113.9, 10.0.0.1", "", "10.0.0.2:1234", "203.0.113.9"},
|
||||
{"", "198.51.100.7", "10.0.0.2:1234", "198.51.100.7"},
|
||||
{"", "", "192.0.2.5:443", "192.0.2.5"},
|
||||
{"garbage", "", "192.0.2.5:443", "192.0.2.5"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.RemoteAddr = c.remote
|
||||
if c.forwarded != "" {
|
||||
r.Header.Set("X-Forwarded-For", c.forwarded)
|
||||
}
|
||||
if c.real != "" {
|
||||
r.Header.Set("X-Real-IP", c.real)
|
||||
}
|
||||
if got := clientIP(r); got != c.want {
|
||||
t.Errorf("clientIP(%q, %q, %q) = %q, want %q", c.forwarded, c.real, c.remote, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRateLimit(t *testing.T) {
|
||||
store, err := OpenStore("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
api := &API{
|
||||
Store: store, Hub: NewHub(store), Domain: "opentunnel.test", StartedAt: time.Now(),
|
||||
CreateLimiter: NewRateLimiter(1, time.Hour),
|
||||
}
|
||||
srv := httptest.NewServer(api)
|
||||
defer srv.Close()
|
||||
|
||||
resp := request(t, "POST", srv.URL+"/api/tunnel", "", "application/json", `{}`)
|
||||
if resp.StatusCode != 201 {
|
||||
t.Fatalf("first create: %d", resp.StatusCode)
|
||||
}
|
||||
resp = request(t, "POST", srv.URL+"/api/tunnel", "", "application/json", `{}`)
|
||||
body := bodyString(t, resp)
|
||||
if resp.StatusCode != 429 || !strings.Contains(body, `"_tag":"RateLimitedError"`) {
|
||||
t.Fatalf("second create: %d %q", resp.StatusCode, body)
|
||||
}
|
||||
if resp.Header.Get("Retry-After") == "" {
|
||||
t.Error("429 response is missing Retry-After")
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,12 @@ type errUnauthorized struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// errRateLimited is the 429 body used by the reference's limits.
|
||||
type errRateLimited struct {
|
||||
Tag string `json:"_tag"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type errTaggedID struct {
|
||||
Tag string `json:"_tag"`
|
||||
TunnelID string `json:"tunnelID"`
|
||||
|
||||
Reference in New Issue
Block a user