Add per-IP rate limiting and Unix socket support for gRPC admin API
Rate limiting: per-source-IP connection rate limiter in the firewall layer with configurable limit and sliding window. Blocklisted IPs are rejected before rate limit evaluation to avoid wasting quota. Unix socket: the gRPC admin API can now listen on a Unix domain socket (no TLS required), secured by file permissions (0600), as a simpler alternative for local-only access. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
81
internal/firewall/ratelimit.go
Normal file
81
internal/firewall/ratelimit.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rateLimitEntry struct {
|
||||
count atomic.Int64
|
||||
start atomic.Int64 // UnixNano
|
||||
}
|
||||
|
||||
type rateLimiter struct {
|
||||
limit int64
|
||||
window time.Duration
|
||||
entries sync.Map // netip.Addr → *rateLimitEntry
|
||||
now func() time.Time
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newRateLimiter(limit int64, window time.Duration) *rateLimiter {
|
||||
rl := &rateLimiter{
|
||||
limit: limit,
|
||||
window: window,
|
||||
now: time.Now,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
go rl.cleanup()
|
||||
return rl
|
||||
}
|
||||
|
||||
// Allow checks whether the given address is within its rate limit window.
|
||||
// Returns false if the address has exceeded the limit.
|
||||
func (rl *rateLimiter) Allow(addr netip.Addr) bool {
|
||||
now := rl.now().UnixNano()
|
||||
|
||||
val, _ := rl.entries.LoadOrStore(addr, &rateLimitEntry{})
|
||||
entry := val.(*rateLimitEntry)
|
||||
|
||||
windowStart := entry.start.Load()
|
||||
if now-windowStart >= rl.window.Nanoseconds() {
|
||||
// Window expired — reset. Intentionally non-atomic across the two
|
||||
// stores; worst case a few extra connections slip through at the
|
||||
// boundary, which is acceptable for a connection rate limiter.
|
||||
entry.start.Store(now)
|
||||
entry.count.Store(1)
|
||||
return true
|
||||
}
|
||||
|
||||
n := entry.count.Add(1)
|
||||
return n <= rl.limit
|
||||
}
|
||||
|
||||
// Stop terminates the cleanup goroutine.
|
||||
func (rl *rateLimiter) Stop() {
|
||||
close(rl.done)
|
||||
}
|
||||
|
||||
func (rl *rateLimiter) cleanup() {
|
||||
ticker := time.NewTicker(rl.window)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-rl.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
cutoff := rl.now().Add(-2 * rl.window).UnixNano()
|
||||
rl.entries.Range(func(key, value any) bool {
|
||||
entry := value.(*rateLimitEntry)
|
||||
if entry.start.Load() < cutoff {
|
||||
rl.entries.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user