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 }