package membership import ( "net" "net/http" "sync" "time" "golang.org/x/time/rate" ) // ipRateLimiter is a per-source-IP token-bucket rate limiter for the control // plane. It exists to blunt pre-auth flooding: an unauthenticated peer that // hammers the HTTP API (signature verification is not free, and io is bounded // but still real) is throttled before it can amplify load. Like the nonceCache, // this is transport glue specific to unibus, not a registry primitive — the // report 0003 made the same call for the nonce cache (it would only drag a NATS // dependency into the multi-domain registry go.mod for one helper). // // Each distinct IP gets its own golang.org/x/time/rate.Limiter (a standard // token bucket already in the module graph, so no new dependency). Idle buckets // are reaped so the map cannot grow without bound under a churn of source IPs. type ipRateLimiter struct { mu sync.Mutex buckets map[string]*ipBucket r rate.Limit burst int ttl time.Duration } type ipBucket struct { lim *rate.Limiter seen time.Time } // newIPRateLimiter builds a limiter granting r tokens/second with the given // burst per IP. ttl bounds how long an idle bucket is retained before being // reaped. r<=0 disables limiting (Allow always true) so dev/loopback stacks are // unaffected. func newIPRateLimiter(r rate.Limit, burst int, ttl time.Duration) *ipRateLimiter { return &ipRateLimiter{ buckets: make(map[string]*ipBucket), r: r, burst: burst, ttl: ttl, } } // allow reports whether a request from ip may proceed now, consuming one token // on success. A disabled limiter (r<=0) always allows. Reaping of stale buckets // is amortized: it runs only when the map has grown past a small threshold, so // the common path is a single map lookup under the mutex. func (l *ipRateLimiter) allow(ip string, now time.Time) bool { if l == nil || l.r <= 0 { return true } l.mu.Lock() defer l.mu.Unlock() if len(l.buckets) > 1024 { l.reapLocked(now) } b, ok := l.buckets[ip] if !ok { b = &ipBucket{lim: rate.NewLimiter(l.r, l.burst)} l.buckets[ip] = b } b.seen = now return b.lim.AllowN(now, 1) } // reapLocked drops buckets idle for longer than ttl. The caller holds l.mu. func (l *ipRateLimiter) reapLocked(now time.Time) { for ip, b := range l.buckets { if now.Sub(b.seen) > l.ttl { delete(l.buckets, ip) } } } // clientIP extracts the source IP of an HTTP request, stripping the port. It // trusts the transport's RemoteAddr only (no X-Forwarded-For parsing): a public // deployment terminates TLS at this process or behind a proxy that the operator // controls, and honoring an attacker-supplied header would let a single IP fan // its quota across forged identities. If parsing fails the whole RemoteAddr is // used as the key (still a stable per-connection bucket). func clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host }