// Shared types, registration, and cleanup for RateLimiter + ProgressiveRateLimiter. // Used by both production (!dev) and dev (dev) builds — keep tag-free. package mw import ( "context" "crussell/clock" "os" "strconv" "sync" "time" ) // trustProxyHeaders gates clientIP()'s use of the proxy-set client-IP headers: // CF-Connecting-IP in clientIP(), and (via TrustProxyHeaders) the X-Real-IP // ClientIPFromHeader middleware registered in main.go. It is read once at // startup from the TRUST_PROXY_HEADERS env var and defaults to false. // // nginx (nginx/conf.d/default.conf) resolves the real client IP itself with // the real_ip module (set_real_ip_from + // real_ip_header CF-Connecting-IP), then overwrites both X-Real-IP and // CF-Connecting-IP with the validated $remote_addr — so behind nginx those // headers are the authoritative, unspoofable per-client key. Set // TRUST_PROXY_HEADERS=true for ANY deployment where a trusted proxy (nginx // and/or the Cloudflare edge) sits between clients and this backend and // overwrites these headers itself. It MUST stay false when the backend is // origin-exposed: a client talking directly to the backend could otherwise // rotate X-Real-IP and/or CF-Connecting-IP to bypass per-IP rate limiting. var trustProxyHeaders = func() bool { v, ok := os.LookupEnv("TRUST_PROXY_HEADERS") if !ok { return false } b, err := strconv.ParseBool(v) return err == nil && b }() // TrustProxyHeaders reports whether proxy-set client-IP headers (X-Real-IP, // CF-Connecting-IP) are honored by the rate limiter. main.go uses it to gate // middleware.ClientIPFromHeader("X-Real-IP") on the same flag, so an // origin-exposed backend never registers a middleware that would let a client // forge its own rate-limit key. Both the header trust in clientIP() and the // middleware registration read this single source of truth. func TrustProxyHeaders() bool { return trustProxyHeaders } // RateLimiter implements a simple in-memory rate limiter type RateLimiter struct { requests map[string][]time.Time mu sync.RWMutex limit int window time.Duration } // ProgressiveRateLimiter implements per-IP rate limiting with increasing backoff. // Designed for bot-spam prevention across accounts (not account-specific lockout). type ProgressiveRateLimiter struct { requests map[string]*ipProgressiveState mu sync.RWMutex } type ipProgressiveState struct { // Timestamps of all requests within the tracking window timestamps []time.Time } var ( registeredLimiters []*RateLimiter registeredLimitersMu sync.Mutex ) func registerLimiter(rl *RateLimiter) { registeredLimitersMu.Lock() registeredLimiters = append(registeredLimiters, rl) registeredLimitersMu.Unlock() } // CleanupAllRateLimiters runs Cleanup on every registered RateLimiter. // Called by the centralised jobs scheduler. func CleanupAllRateLimiters(ctx context.Context) (int, error) { registeredLimitersMu.Lock() limiters := make([]*RateLimiter, len(registeredLimiters)) copy(limiters, registeredLimiters) registeredLimitersMu.Unlock() for _, l := range limiters { l.Cleanup() } return 0, nil } // CleanupProgressiveRateLimiter runs Cleanup on the global progressive rate limiter. // Called by the centralised jobs scheduler. func CleanupProgressiveRateLimiter(ctx context.Context) (int, error) { globalProgressiveLimiter.Cleanup() return 0, nil } // Cleanup removes expired entries from the rate limiter map. func (rl *RateLimiter) Cleanup() { rl.mu.Lock() defer rl.mu.Unlock() now := clock.Now() for key, times := range rl.requests { var valid []time.Time for _, t := range times { if now.Sub(t) < rl.window { valid = append(valid, t) } } if len(valid) == 0 { delete(rl.requests, key) } else { rl.requests[key] = valid } } } // Cleanup removes expired entries from the progressive rate limiter map. func (prl *ProgressiveRateLimiter) Cleanup() { prl.mu.Lock() defer prl.mu.Unlock() cutoff := clock.Now().Add(-60 * time.Second) for ip, state := range prl.requests { var valid []time.Time for _, t := range state.timestamps { if t.After(cutoff) { valid = append(valid, t) } } if len(valid) == 0 { delete(prl.requests, ip) } else { state.timestamps = valid } } } var globalProgressiveLimiter = NewProgressiveRateLimiter()