Files
mrambossekandClaude Opus 5 8118e213ae 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>
2026-08-02 13:04:54 +02:00

101 lines
3.0 KiB
Go

// 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)
}
}
}