server: upstream trains, observed TTL/DSCP/ECN, rate limits, action ids
Types 0x03/0x04/0x05 land with a bounded columnar train buffer (head kept, truncation declared) and grant-free multi-part reports - a report row is smaller than the packet it answers, so $3.4 holds without a grant. The read loop now collects TTL/TOS cmsgs on Linux, replacing the 0xFF stubs in the observation block with what the kernel saw; downtrain gained a dscp parameter, so DSCP survival is measurable in both directions. Rate limiting ($2.5) exists now: per-credential AND per-source buckets, 429 on the control plane, silent drop on the data plane after the HMAC gate and before the replay window. UDP ceilings default above the largest legitimate run - a limit that clips a real measurement produces a confidently wrong number. Every granted packet carries its action_id at payload[8:16]; overlapping actions were unattributable before. Canary DNS logs now honor the stated 24h privacy default. /admin/enroll-tokens answers the spec's JSON shape. protocol_version 1.0.1 (additive). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f6849f8e6a
commit
8118e213ae
@@ -0,0 +1,100 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package ratelimit implements the spec §2.5 token buckets: per-credential and per-source-IP
|
||||
// ceilings on session creation, actions, UDP packets and bytes. One Limiter holds one policy
|
||||
// (rate + burst) and lazily creates a bucket per key; callers namespace their keys ("cred:…",
|
||||
// "ip:…") so a single Limiter can enforce both axes of the same rule.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limiter is a keyed set of token buckets sharing one rate and burst.
|
||||
//
|
||||
// A nil *Limiter allows everything: the ceilings are configurable down to "off" (config value 0),
|
||||
// and a nil check in one place beats a sentinel policy that every call site must know about.
|
||||
type Limiter struct {
|
||||
rate float64 // tokens per second
|
||||
burst float64
|
||||
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
lastSweep time.Time
|
||||
now func() time.Time // swappable so tests need no sleeping
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// New creates a limiter granting ratePerSec tokens per second per key, holding at most burst.
|
||||
func New(ratePerSec, burst float64) *Limiter {
|
||||
return &Limiter{
|
||||
rate: ratePerSec,
|
||||
burst: burst,
|
||||
buckets: map[string]*bucket{},
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow takes one token for key. See AllowN.
|
||||
func (l *Limiter) Allow(key string) (bool, time.Duration) {
|
||||
return l.AllowN(key, 1)
|
||||
}
|
||||
|
||||
// AllowN takes n tokens for key, reporting whether they were available and — when they were
|
||||
// not — how long until they will be, which is what a control-plane 429 puts in Retry-After.
|
||||
// A refusal consumes nothing: the caller being told to wait must not itself push the wait out.
|
||||
func (l *Limiter) AllowN(key string, n float64) (bool, time.Duration) {
|
||||
if l == nil {
|
||||
return true, 0
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
l.sweepLocked(now)
|
||||
b := l.buckets[key]
|
||||
if b == nil {
|
||||
b = &bucket{tokens: l.burst, last: now}
|
||||
l.buckets[key] = b
|
||||
}
|
||||
b.tokens += now.Sub(b.last).Seconds() * l.rate
|
||||
if b.tokens > l.burst {
|
||||
b.tokens = l.burst
|
||||
}
|
||||
b.last = now
|
||||
if b.tokens >= n {
|
||||
b.tokens -= n
|
||||
return true, 0
|
||||
}
|
||||
return false, time.Duration((n - b.tokens) / l.rate * float64(time.Second))
|
||||
}
|
||||
|
||||
// sweepEvery bounds how often the map is walked; the walk is cheap but there is no point doing
|
||||
// it per packet on the data plane's hot path.
|
||||
const sweepEvery = time.Minute
|
||||
|
||||
// sweepLocked drops buckets that have been idle long enough to be full again. A full bucket
|
||||
// carries no state a fresh one would not, and without the sweep the map grows one entry per
|
||||
// source address ever seen — an attacker-controlled key space must not be an unbounded one.
|
||||
func (l *Limiter) sweepLocked(now time.Time) {
|
||||
if now.Sub(l.lastSweep) < sweepEvery {
|
||||
return
|
||||
}
|
||||
l.lastSweep = now
|
||||
idle := sweepEvery
|
||||
if l.rate > 0 {
|
||||
if refill := time.Duration(l.burst / l.rate * float64(time.Second)); refill > idle {
|
||||
idle = refill
|
||||
}
|
||||
}
|
||||
for k, b := range l.buckets {
|
||||
if now.Sub(b.last) > idle {
|
||||
delete(l.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clockAt pins the limiter to a fake clock so refill is a function of arithmetic, not sleeping.
|
||||
func clockAt(l *Limiter) *time.Time {
|
||||
t := time.Unix(1000, 0)
|
||||
l.now = func() time.Time { return t }
|
||||
return &t
|
||||
}
|
||||
|
||||
func TestBurstThenRefusalThenRefill(t *testing.T) {
|
||||
l := New(1, 3) // 1 token/s, burst 3
|
||||
now := clockAt(l)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if ok, _ := l.Allow("k"); !ok {
|
||||
t.Fatalf("token %d of the burst refused", i)
|
||||
}
|
||||
}
|
||||
ok, wait := l.Allow("k")
|
||||
if ok {
|
||||
t.Fatal("fourth token inside the same instant should be refused")
|
||||
}
|
||||
if wait <= 0 || wait > time.Second {
|
||||
t.Fatalf("retry-after = %v, want (0, 1s]", wait)
|
||||
}
|
||||
|
||||
*now = now.Add(2 * time.Second) // refills 2 tokens
|
||||
if ok, _ := l.Allow("k"); !ok {
|
||||
t.Fatal("refused after refill")
|
||||
}
|
||||
if ok, _ := l.Allow("k"); !ok {
|
||||
t.Fatal("second refilled token refused")
|
||||
}
|
||||
if ok, _ := l.Allow("k"); ok {
|
||||
t.Fatal("third token allowed but only two seconds elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusalConsumesNothing(t *testing.T) {
|
||||
l := New(1, 1)
|
||||
now := clockAt(l)
|
||||
l.Allow("k")
|
||||
// Hammering while empty must not push the refill out.
|
||||
for i := 0; i < 10; i++ {
|
||||
if ok, _ := l.Allow("k"); ok {
|
||||
t.Fatal("allowed while empty")
|
||||
}
|
||||
}
|
||||
*now = now.Add(time.Second)
|
||||
if ok, _ := l.Allow("k"); !ok {
|
||||
t.Fatal("the refused attempts ate the refill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeysAreIndependent(t *testing.T) {
|
||||
l := New(1, 1)
|
||||
clockAt(l)
|
||||
l.Allow("a")
|
||||
if ok, _ := l.Allow("b"); !ok {
|
||||
t.Fatal("draining key a refused key b")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowNChargesBytes(t *testing.T) {
|
||||
l := New(1000, 1000) // e.g. bytes/s
|
||||
clockAt(l)
|
||||
if ok, _ := l.AllowN("k", 900); !ok {
|
||||
t.Fatal("900 of 1000 refused")
|
||||
}
|
||||
if ok, _ := l.AllowN("k", 200); ok {
|
||||
t.Fatal("1100 of 1000 allowed")
|
||||
}
|
||||
if ok, _ := l.AllowN("k", 100); !ok {
|
||||
t.Fatal("the refused 200 consumed the remaining 100")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilLimiterAllowsEverything(t *testing.T) {
|
||||
var l *Limiter
|
||||
if ok, wait := l.AllowN("k", 1e12); !ok || wait != 0 {
|
||||
t.Fatal("nil limiter must be a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepDropsIdleBucketsOnly(t *testing.T) {
|
||||
l := New(1, 3)
|
||||
now := clockAt(l)
|
||||
l.Allow("idle")
|
||||
*now = now.Add(2 * time.Minute)
|
||||
l.Allow("busy") // triggers the sweep; "idle" refilled long ago
|
||||
if _, held := l.buckets["idle"]; held {
|
||||
t.Fatal("idle bucket survived the sweep")
|
||||
}
|
||||
if _, held := l.buckets["busy"]; !held {
|
||||
t.Fatal("active bucket was swept")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user