Files
Gary HansenandClaude Fable 5 d8ef805a6a feat(api): rate limiting, webhook telemetry, server fingerprints, region
- per-client-IP token bucket on POST /api/traverse (EXPLOREDNS_RATE_LIMIT,
  default 30/1h; direct localhost exempt, proxied clients are not)
- optional usage webhooks (EXPLOREDNS_WEBHOOK_URL): start/complete JSON
  events, fire-and-forget with 5s timeout + one retry so a dead receiver
  never delays a job
- post-traversal version.bind fingerprinting exposed at
  GET /api/traverse/{id}/servers (pending until ready) and announced via
  an SSE "servers" event; never delays job completion
- /api/health reports the serving Fly region (FLY_REGION) for observing
  anycast routing from a roaming client

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:31:34 +10:00

121 lines
3.1 KiB
Go

package api
import (
"fmt"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// Default per-IP rate limit for POST /api/traverse, overridable via
// EXPLOREDNS_RATE_LIMIT ("N/duration", e.g. "30/1h" or "10/10m").
const (
defaultRateLimitCount = 30
defaultRateLimitWindow = time.Hour
)
// rateLimiter is an in-memory token-bucket limiter keyed by client IP.
// Each bucket starts full with limit tokens and refills continuously at
// limit tokens per window.
type rateLimiter struct {
limit int
window time.Duration
mu sync.Mutex
buckets map[string]*tokenBucket
now func() time.Time // overridable in tests
}
type tokenBucket struct {
tokens float64
last time.Time
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
return &rateLimiter{
limit: limit,
window: window,
buckets: make(map[string]*tokenBucket),
now: time.Now,
}
}
// allow reports whether one request from ip fits within the limit,
// consuming a token when it does.
func (rl *rateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := rl.now()
b, ok := rl.buckets[ip]
if !ok {
b = &tokenBucket{tokens: float64(rl.limit), last: now}
rl.buckets[ip] = b
} else {
refill := now.Sub(b.last).Seconds() * float64(rl.limit) / rl.window.Seconds()
b.tokens = min(b.tokens+refill, float64(rl.limit))
b.last = now
}
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// sweep drops buckets idle for at least one full window; such buckets
// would be full again anyway, so dropping them loses nothing.
func (rl *rateLimiter) sweep() {
rl.mu.Lock()
defer rl.mu.Unlock()
cutoff := rl.now().Add(-rl.window)
for ip, b := range rl.buckets {
if b.last.Before(cutoff) {
delete(rl.buckets, ip)
}
}
}
// String renders the limit for error messages, e.g. "30 requests per 1h0m0s".
func (rl *rateLimiter) String() string {
return fmt.Sprintf("%d requests per %s", rl.limit, rl.window)
}
// rateLimitExempt reports whether r may bypass the rate limit: direct
// connections from loopback (the SPA dev loop, tests, health tooling).
// Proxied requests are never exempt — when Fly-Client-IP or
// X-Forwarded-For is present, RemoteAddr is just the proxy, so the real
// client IP must be limited even though the socket peer is local.
func rateLimitExempt(r *http.Request) bool {
if r.Header.Get("Fly-Client-IP") != "" || r.Header.Get("X-Forwarded-For") != "" {
return false
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// parseRateLimit parses "N/duration" (e.g. "30/1h"), falling back to the
// defaults when v is empty or invalid.
func parseRateLimit(v string) (int, time.Duration) {
parts := strings.SplitN(v, "/", 2)
if len(parts) != 2 {
return defaultRateLimitCount, defaultRateLimitWindow
}
n, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil || n <= 0 {
return defaultRateLimitCount, defaultRateLimitWindow
}
d, err := time.ParseDuration(strings.TrimSpace(parts[1]))
if err != nil || d <= 0 {
return defaultRateLimitCount, defaultRateLimitWindow
}
return n, d
}