c3464d692e
- Introduced coalescing logic in `coalescing.go` to handle concurrent identical upstream fetches, including a state machine and response buffering for improved performance. - Implemented a new cache file format in `format.go`, supporting serialization and deserialization of HTTP responses, along with range request handling. - Developed an HTTP handler in `handler.go` to manage requests, including special endpoint handling and metrics reporting. - Added rate limiting functionality in `ratelimit.go` to control per-client and global request rates, enhancing security and performance. - Created service management capabilities in `service.go` to define and manage cacheable services, including user-agent detection. - Updated tests in `steamcache_test.go` to cover new functionalities, ensuring robustness and reliability across the codebase.
179 lines
5.4 KiB
Go
179 lines
5.4 KiB
Go
// steamcache/ratelimit.go
|
|
// Per-client and global concurrency rate limiting, trusted proxy / client IP
|
|
// extraction logic (for safe X-Forwarded-For handling under security hardening),
|
|
// and background cleanup of idle client limiters. The clientRateLimiter wrapper owns
|
|
// the map + cleanup ticker/stop chan (SteamCache delegates; exact shutdown/wg patterns
|
|
// preserved).
|
|
package steamcache
|
|
|
|
import (
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/sync/semaphore"
|
|
)
|
|
|
|
type clientLimiter struct {
|
|
semaphore *semaphore.Weighted
|
|
lastSeen time.Time
|
|
}
|
|
|
|
// isTrustedProxy reports whether ipStr matches any CIDR or IP in trustedProxies list.
|
|
// Used for safe client IP extraction from X-Forwarded-For (rightmost untrusted proxy wins).
|
|
func isTrustedProxy(ipStr string, trustedProxies []string) bool {
|
|
ip := net.ParseIP(strings.TrimSpace(ipStr))
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
for _, c := range trustedProxies {
|
|
c = strings.TrimSpace(c)
|
|
if c == "" {
|
|
continue
|
|
}
|
|
if !strings.Contains(c, "/") {
|
|
if p := net.ParseIP(c); p != nil && p.Equal(ip) {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
if _, n, err := net.ParseCIDR(c); err == nil && n.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getClientIP extracts the client IP address from the request.
|
|
// If trustedProxies is empty (the safe default), always use RemoteAddr only (prevents spoofing).
|
|
// When list non-empty, use rightmost-untrusted from XFF+Remote chain (proper proxy extraction, not naive first XFF).
|
|
// X-Real-IP is ignored for simplicity/safety (XFF is the standard multi-hop header).
|
|
// Security: prevents clients spoofing XFF to bypass per-client rate limits.
|
|
func getClientIP(r *http.Request, trustedProxies []string) string {
|
|
// Normalize remote
|
|
remoteIP := r.RemoteAddr
|
|
if host, _, err := net.SplitHostPort(remoteIP); err == nil {
|
|
remoteIP = host
|
|
}
|
|
|
|
if len(trustedProxies) == 0 {
|
|
// Conservative safe default: never trust forwarded headers (spoof prevention)
|
|
return remoteIP
|
|
}
|
|
|
|
// Build trust chain: XFF parts (left=original client) + direct remote (right=closest)
|
|
chain := []string{}
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
for _, p := range strings.Split(xff, ",") {
|
|
if t := strings.TrimSpace(p); t != "" {
|
|
chain = append(chain, t)
|
|
}
|
|
}
|
|
}
|
|
chain = append(chain, remoteIP)
|
|
|
|
// Walk from right (closest to server) to left; return first (rightmost) non-trusted = real client
|
|
for i := len(chain) - 1; i >= 0; i-- {
|
|
cand := chain[i]
|
|
if !isTrustedProxy(cand, trustedProxies) {
|
|
return cand
|
|
}
|
|
}
|
|
return remoteIP
|
|
}
|
|
|
|
// clientRateLimiter owns the per-client limiters map, its mutex, the
|
|
// background cleanup stop channel, and the max-per-client config.
|
|
// Encapsulates lifecycle of the rate limiter map + its cleanup goroutine
|
|
// coordination (Phase 2). Unexported; same-package only.
|
|
type clientRateLimiter struct {
|
|
mu sync.RWMutex
|
|
limiters map[string]*clientLimiter
|
|
cleanupStop chan struct{}
|
|
maxPerClient int64
|
|
}
|
|
|
|
// newClientRateLimiter constructs with its own stop chan (used by Run/Shutdown
|
|
// via thin delegates on SteamCache to preserve exact goroutine + wg patterns).
|
|
func newClientRateLimiter(maxPerClient int64) *clientRateLimiter {
|
|
return &clientRateLimiter{
|
|
limiters: make(map[string]*clientLimiter),
|
|
cleanupStop: make(chan struct{}),
|
|
maxPerClient: maxPerClient,
|
|
}
|
|
}
|
|
|
|
func (crl *clientRateLimiter) getOrCreate(clientIP string) *clientLimiter {
|
|
crl.mu.Lock()
|
|
defer crl.mu.Unlock()
|
|
|
|
limiter, exists := crl.limiters[clientIP]
|
|
if !exists || time.Since(limiter.lastSeen) > 5*time.Minute {
|
|
// Create new limiter or refresh existing one
|
|
limiter = &clientLimiter{
|
|
semaphore: semaphore.NewWeighted(crl.maxPerClient),
|
|
lastSeen: time.Now(),
|
|
}
|
|
crl.limiters[clientIP] = limiter
|
|
} else {
|
|
limiter.lastSeen = time.Now()
|
|
}
|
|
|
|
return limiter
|
|
}
|
|
|
|
// cleanupOld removes old client limiters to prevent memory leaks.
|
|
// Respects cleanupStop (closed by Shutdown) to allow graceful shutdown
|
|
// without hanging the wg.Wait in Run (historical shutdown hygiene).
|
|
func (crl *clientRateLimiter) cleanupOld() {
|
|
ticker := time.NewTicker(10 * time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-crl.cleanupStop:
|
|
return
|
|
case <-ticker.C:
|
|
crl.mu.Lock()
|
|
now := time.Now()
|
|
for ip, limiter := range crl.limiters {
|
|
if now.Sub(limiter.lastSeen) > 30*time.Minute {
|
|
delete(crl.limiters, ip)
|
|
}
|
|
}
|
|
crl.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// getOrCreateClientLimiter delegates to the owned clientRateLimiter (preserves
|
|
// call sites in handler.go and shutdown coordination exactly).
|
|
func (sc *SteamCache) getOrCreateClientLimiter(clientIP string) *clientLimiter {
|
|
return sc.clientRateLimiter.getOrCreate(clientIP)
|
|
}
|
|
|
|
// cleanupOldClientLimiters delegates to the internal rate limiter's cleanup
|
|
// loop. The goroutine launch + wg + stop-close patterns in Run/Shutdown are
|
|
// unchanged (critical for avoiding past goroutine leak / hang bugs).
|
|
func (sc *SteamCache) cleanupOldClientLimiters() {
|
|
if sc.clientRateLimiter != nil {
|
|
sc.clientRateLimiter.cleanupOld()
|
|
}
|
|
}
|
|
|
|
// stop signals the background cleanup goroutine (started in Run) to exit by
|
|
// closing its stop channel. The exact select+default+close idiom is kept
|
|
// inside the wrapper (preserves all historical shutdown hygiene and wg.Wait
|
|
// behavior with zero change). Called from SteamCache.Shutdown.
|
|
func (crl *clientRateLimiter) stop() {
|
|
if crl == nil || crl.cleanupStop == nil {
|
|
return
|
|
}
|
|
select {
|
|
case <-crl.cleanupStop:
|
|
default:
|
|
close(crl.cleanupStop)
|
|
}
|
|
}
|