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:
95
internal/firewall/ratelimit_test.go
Normal file
95
internal/firewall/ratelimit_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRateLimiterAllow(t *testing.T) {
|
||||
rl := newRateLimiter(3, time.Minute)
|
||||
defer rl.Stop()
|
||||
|
||||
addr := netip.MustParseAddr("10.0.0.1")
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if !rl.Allow(addr) {
|
||||
t.Fatalf("call %d: expected Allow=true", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
if rl.Allow(addr) {
|
||||
t.Fatal("call 4: expected Allow=false (over limit)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterDifferentIPs(t *testing.T) {
|
||||
rl := newRateLimiter(2, time.Minute)
|
||||
defer rl.Stop()
|
||||
|
||||
a := netip.MustParseAddr("10.0.0.1")
|
||||
b := netip.MustParseAddr("10.0.0.2")
|
||||
|
||||
// Exhaust a's limit.
|
||||
for i := 0; i < 2; i++ {
|
||||
rl.Allow(a)
|
||||
}
|
||||
if rl.Allow(a) {
|
||||
t.Fatal("a should be rate limited")
|
||||
}
|
||||
|
||||
// b should be independent.
|
||||
if !rl.Allow(b) {
|
||||
t.Fatal("b should not be rate limited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterWindowReset(t *testing.T) {
|
||||
rl := newRateLimiter(2, time.Minute)
|
||||
defer rl.Stop()
|
||||
|
||||
var fakeNow atomic.Int64
|
||||
fakeNow.Store(time.Now().UnixNano())
|
||||
rl.now = func() time.Time {
|
||||
return time.Unix(0, fakeNow.Load())
|
||||
}
|
||||
|
||||
addr := netip.MustParseAddr("10.0.0.1")
|
||||
|
||||
// Exhaust the limit.
|
||||
rl.Allow(addr)
|
||||
rl.Allow(addr)
|
||||
if rl.Allow(addr) {
|
||||
t.Fatal("should be rate limited")
|
||||
}
|
||||
|
||||
// Advance past the window.
|
||||
fakeNow.Add(int64(2 * time.Minute))
|
||||
|
||||
// Should be allowed again.
|
||||
if !rl.Allow(addr) {
|
||||
t.Fatal("should be allowed after window reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterCleanup(t *testing.T) {
|
||||
rl := newRateLimiter(10, 50*time.Millisecond)
|
||||
defer rl.Stop()
|
||||
|
||||
addr := netip.MustParseAddr("10.0.0.1")
|
||||
rl.Allow(addr)
|
||||
|
||||
// Entry should exist.
|
||||
if _, ok := rl.entries.Load(addr); !ok {
|
||||
t.Fatal("entry should exist")
|
||||
}
|
||||
|
||||
// Wait for 2*window + a cleanup cycle to pass.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Entry should have been cleaned up.
|
||||
if _, ok := rl.entries.Load(addr); ok {
|
||||
t.Fatal("stale entry should have been cleaned up")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user