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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
94e41fe5b5
commit
d8ef805a6a
@@ -0,0 +1,120 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user