- 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>
121 lines
3.1 KiB
Go
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
|
|
}
|