v0.26.1 per-client connection caps
This commit is contained in:
1 parent
ee01cb00fe
commit
cdd4fc01a7
36 files changed
+795
-417
No files matched your search
@@ -0,0 +1,155 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user