156 lines
4.0 KiB
Go
156 lines
4.0 KiB
Go
package netacl
|
|
|
|
import (
|
|
"net/netip"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/lixenwraith/logwisp/internal/tokenbucket"
|
|
)
|
|
|
|
const (
|
|
maxClients = 65536 // per table; past it new clients are refused
|
|
slotIdle = time.Minute // without a rate: Release drops a client, the sweep only expired holds
|
|
)
|
|
|
|
// Key is what per-client limits count: the address, or for IPv6 its /64,
|
|
// which one host usually holds whole. Link-local too, per zone: a peer picks
|
|
// any fe80::/64 address, so per address it would escape its budget.
|
|
func Key(addr netip.Addr) string {
|
|
addr = addr.Unmap()
|
|
if !addr.Is6() {
|
|
return addr.String()
|
|
}
|
|
key := netip.PrefixFrom(addr, 64).Masked().String()
|
|
if zone := addr.Zone(); zone != "" {
|
|
key += "%" + zone
|
|
}
|
|
return key
|
|
}
|
|
|
|
// Table limits each client key: an attempt spends a token from its bucket, if
|
|
// the table has a rate, and takes a slot, if it caps slots; a slot is
|
|
// released, or held by an id until done or expired. A full table refuses new
|
|
// keys, failing closed, and a sweep drops the idle ones.
|
|
type Table struct {
|
|
burst, rate float64
|
|
slots int
|
|
idle time.Duration // a bucket's refill: a client idle that long loses nothing when dropped
|
|
|
|
mu sync.Mutex
|
|
clients map[string]*client
|
|
lastSweep time.Time
|
|
}
|
|
|
|
type client struct {
|
|
bucket *tokenbucket.TokenBucket // nil without a rate
|
|
held map[string]time.Time // slots held by id, until their expiry
|
|
taken int // slots taken and not yet held or released
|
|
seen time.Time
|
|
}
|
|
|
|
// NewTable allows each client burst attempts refilled at rate per second (0:
|
|
// no rate) and slots at once (0: no cap)
|
|
func NewTable(burst, rate float64, slots int) *Table {
|
|
t := &Table{burst: burst, rate: rate, slots: slots, idle: slotIdle, clients: make(map[string]*client)}
|
|
if rate > 0 {
|
|
t.idle = time.Duration(burst / rate * float64(time.Second))
|
|
}
|
|
return t
|
|
}
|
|
|
|
// Take admits an attempt by key, false when its tokens or slots are spent or
|
|
// the table is full
|
|
func (t *Table) Take(key string) bool {
|
|
now := time.Now()
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
if now.Sub(t.lastSweep) > t.idle/6 {
|
|
t.sweep(now)
|
|
}
|
|
c := t.clients[key]
|
|
if c == nil {
|
|
if len(t.clients) >= maxClients {
|
|
return false
|
|
}
|
|
c = &client{held: make(map[string]time.Time)}
|
|
if t.rate > 0 {
|
|
c.bucket = tokenbucket.New(t.burst, t.rate)
|
|
}
|
|
t.clients[key] = c
|
|
}
|
|
c.seen = now
|
|
c.expire(now)
|
|
// Taken under this lock: concurrent attempts cannot all pass the check
|
|
if t.slots > 0 && len(c.held)+c.taken >= t.slots || c.bucket != nil && !c.bucket.Allow() {
|
|
return false
|
|
}
|
|
if t.slots > 0 {
|
|
c.taken++
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Hold moves a slot Take took to id, until done or expired
|
|
func (t *Table) Hold(key, id string, until time.Time) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
if c := t.clients[key]; c != nil {
|
|
c.taken--
|
|
c.held[id] = until
|
|
}
|
|
}
|
|
|
|
// Release frees a slot Take took that no id holds
|
|
func (t *Table) Release(key string) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
if c := t.clients[key]; c != nil {
|
|
c.taken--
|
|
if c.idle(time.Now(), t.idle) {
|
|
delete(t.clients, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Done frees the slot id holds
|
|
func (t *Table) Done(key, id string) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
if c := t.clients[key]; c != nil {
|
|
delete(c.held, id)
|
|
}
|
|
}
|
|
|
|
// Refund returns the token of an attempt that turned out not to count
|
|
func (t *Table) Refund(key string) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
if c := t.clients[key]; c != nil && c.bucket != nil {
|
|
c.bucket.Refund(1)
|
|
}
|
|
}
|
|
|
|
func (c *client) expire(now time.Time) {
|
|
for id, expiry := range c.held {
|
|
if now.After(expiry) {
|
|
delete(c.held, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// idle reports a client holding nothing whose bucket, if any, is full again
|
|
func (c *client) idle(now time.Time, after time.Duration) bool {
|
|
return c.taken == 0 && len(c.held) == 0 && (c.bucket == nil || now.Sub(c.seen) > after)
|
|
}
|
|
|
|
func (t *Table) sweep(now time.Time) {
|
|
t.lastSweep = now
|
|
for key, c := range t.clients {
|
|
c.expire(now) // an abandoned hold has no Done
|
|
if c.idle(now, t.idle) {
|
|
delete(t.clients, key)
|
|
}
|
|
}
|
|
}
|