initial commit
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
// Package ratelimit provides per-client token buckets for DNS abuse
|
||||
// protection.
|
||||
//
|
||||
// The limiter is sharded by client address so that a busy resolver does not
|
||||
// serialise every query behind one mutex, and idle buckets are swept
|
||||
// periodically so a flood of unique source addresses cannot grow the map
|
||||
// without bound.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"hash/maphash"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/owen/vibedns/internal/netutil"
|
||||
)
|
||||
|
||||
const shardCount = 32
|
||||
|
||||
// Config controls the limiter.
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
QPS int
|
||||
Burst int
|
||||
Exempt []string
|
||||
}
|
||||
|
||||
// bucket is a token bucket that refills continuously.
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
lastFill time.Time
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
type shard struct {
|
||||
mu sync.Mutex
|
||||
buckets map[netip.Addr]*bucket
|
||||
}
|
||||
|
||||
// Limiter enforces a per-client query rate.
|
||||
type Limiter struct {
|
||||
shards [shardCount]*shard
|
||||
seed maphash.Seed
|
||||
|
||||
mu sync.RWMutex
|
||||
enabled bool
|
||||
qps float64
|
||||
burst float64
|
||||
exempt *netutil.PrefixSet
|
||||
|
||||
allowed atomic.Int64
|
||||
denied atomic.Int64
|
||||
clients atomic.Int64
|
||||
}
|
||||
|
||||
// New creates a limiter.
|
||||
func New(cfg Config) *Limiter {
|
||||
l := &Limiter{seed: maphash.MakeSeed()}
|
||||
for i := range l.shards {
|
||||
l.shards[i] = &shard{buckets: map[netip.Addr]*bucket{}}
|
||||
}
|
||||
l.SetConfig(cfg)
|
||||
return l
|
||||
}
|
||||
|
||||
// SetConfig replaces the limiter configuration.
|
||||
func (l *Limiter) SetConfig(cfg Config) {
|
||||
if cfg.QPS < 1 {
|
||||
cfg.QPS = 1
|
||||
}
|
||||
if cfg.Burst < cfg.QPS {
|
||||
cfg.Burst = cfg.QPS
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.enabled = cfg.Enabled
|
||||
l.qps = float64(cfg.QPS)
|
||||
l.burst = float64(cfg.Burst)
|
||||
l.exempt = netutil.NewPrefixSet(cfg.Exempt)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *Limiter) shardFor(addr netip.Addr) *shard {
|
||||
b, _ := addr.MarshalBinary()
|
||||
h := maphash.Bytes(l.seed, b)
|
||||
return l.shards[h%shardCount]
|
||||
}
|
||||
|
||||
// Allow reports whether a query from addr may be answered.
|
||||
//
|
||||
// Exempt networks — internal infrastructure, by default loopback — are never
|
||||
// limited, so a busy local forwarder cannot be throttled by accident.
|
||||
func (l *Limiter) Allow(addr netip.Addr) bool {
|
||||
l.mu.RLock()
|
||||
enabled, qps, burst, exempt := l.enabled, l.qps, l.burst, l.exempt
|
||||
l.mu.RUnlock()
|
||||
|
||||
if !enabled {
|
||||
return true
|
||||
}
|
||||
if !addr.IsValid() {
|
||||
return true
|
||||
}
|
||||
if exempt.Contains(addr) {
|
||||
l.allowed.Add(1)
|
||||
return true
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
sh := l.shardFor(addr)
|
||||
|
||||
sh.mu.Lock()
|
||||
b, ok := sh.buckets[addr]
|
||||
if !ok {
|
||||
b = &bucket{tokens: burst, lastFill: now}
|
||||
sh.buckets[addr] = b
|
||||
l.clients.Add(1)
|
||||
} else {
|
||||
elapsed := now.Sub(b.lastFill).Seconds()
|
||||
if elapsed > 0 {
|
||||
b.tokens += elapsed * qps
|
||||
if b.tokens > burst {
|
||||
b.tokens = burst
|
||||
}
|
||||
b.lastFill = now
|
||||
}
|
||||
}
|
||||
b.lastSeen = now
|
||||
|
||||
if b.tokens >= 1 {
|
||||
b.tokens--
|
||||
sh.mu.Unlock()
|
||||
l.allowed.Add(1)
|
||||
return true
|
||||
}
|
||||
sh.mu.Unlock()
|
||||
l.denied.Add(1)
|
||||
return false
|
||||
}
|
||||
|
||||
// Sweep drops buckets that have been idle for longer than maxIdle and returns
|
||||
// how many were removed.
|
||||
func (l *Limiter) Sweep(maxIdle time.Duration) int {
|
||||
cutoff := time.Now().Add(-maxIdle)
|
||||
removed := 0
|
||||
for _, sh := range l.shards {
|
||||
sh.mu.Lock()
|
||||
for addr, b := range sh.buckets {
|
||||
if b.lastSeen.Before(cutoff) {
|
||||
delete(sh.buckets, addr)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
sh.mu.Unlock()
|
||||
}
|
||||
l.clients.Add(-int64(removed))
|
||||
return removed
|
||||
}
|
||||
|
||||
// Run sweeps idle buckets until done is closed.
|
||||
func (l *Limiter) Run(done <-chan struct{}, interval, maxIdle time.Duration) {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-t.C:
|
||||
l.Sweep(maxIdle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears every bucket.
|
||||
func (l *Limiter) Reset() {
|
||||
for _, sh := range l.shards {
|
||||
sh.mu.Lock()
|
||||
sh.buckets = map[netip.Addr]*bucket{}
|
||||
sh.mu.Unlock()
|
||||
}
|
||||
l.clients.Store(0)
|
||||
}
|
||||
|
||||
// Stats reports limiter activity.
|
||||
type Stats struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Allowed int64 `json:"allowed"`
|
||||
Denied int64 `json:"denied"`
|
||||
TrackedClients int64 `json:"tracked_clients"`
|
||||
}
|
||||
|
||||
// Stats returns the limiter counters.
|
||||
func (l *Limiter) Stats() Stats {
|
||||
l.mu.RLock()
|
||||
enabled := l.enabled
|
||||
l.mu.RUnlock()
|
||||
return Stats{
|
||||
Enabled: enabled,
|
||||
Allowed: l.allowed.Load(),
|
||||
Denied: l.denied.Load(),
|
||||
TrackedClients: l.clients.Load(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user