server: §3.4 asymmetric grants + downtrain and big_send actions
server-test / test (push) Successful in 29s
server-release / image (push) Successful in 34s
server-release / release (push) Successful in 29s

The grant is the keystone that makes server->client sends safe: created
only by an authenticated control-plane action, bound at creation to the
session's OBSERVED data-plane source (so it can never be aimed at a third
party), and bounded by bytes, average rate and expiry. Sends stop the
moment the budget runs out, so a buggy action cannot become a flood.

Two granted actions on top of it:
- downtrain: N packets at a given size/interval toward the client, with
  seq + send-timestamp in the payload — downstream loss/reorder/jitter,
  which an upstream-only train cannot measure.
- big_send: one datagram per requested size, echoing the intended size in
  the payload — downstream MTU / black-hole evidence the client cannot
  produce for itself (only the far end can emit a large packet toward it).

Tests cover the security properties: no grant without a verified
destination, client requests clamped to server limits, byte budget stops
sending exactly, expiry refuses, and the rate ceiling throttles a burst.
Capabilities gain downtrain + big-send.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 10:09:13 +02:00
co-authored by Claude Opus 5
parent c75a9f5eb7
commit 7e1015c211
7 changed files with 372 additions and 7 deletions
+100
View File
@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package session
import (
"sync"
"time"
)
// Grant is the spec §3.4 "asymmetric grant": the ONLY thing that lets the server send more than
// it receives. Without one, every response is capped at the request size, which is what keeps an
// unauthenticated observer from using this server as a reflector/amplifier.
//
// A grant is created by an authenticated control-plane action (§5) for one session, and is bounded
// three ways: total bytes, send rate, and wall-clock expiry. It is consumed as the data plane
// sends; when the budget runs out the send stops, so a bug in an action cannot turn into an
// unbounded flood.
type Grant struct {
ActionID string
// Destination is fixed at creation to the session's observed data-plane source — a grant can
// never be pointed somewhere else, so it cannot be used to attack a third party.
Dest string
MaxBytes int64
MaxKbps int
ExpiresAt time.Time
mu sync.Mutex
sentBytes int64
started time.Time
}
// GrantLimits are the server-side ceilings an action may not exceed, independent of what the
// client asks for.
type GrantLimits struct {
MaxBytes int64
MaxKbps int
MaxHold time.Duration
}
var DefaultGrantLimits = GrantLimits{
MaxBytes: 8 << 20, // 8 MiB per action
MaxKbps: 50_000, // matches the profile's advertised max_kbps
MaxHold: 30 * time.Second, // an action must finish inside this window
}
// NewGrant clamps the request to the server's limits and binds it to the session's observed
// data-plane source. Returns nil when the session has no observed source yet — refusing to send
// anywhere we have not verifiably received from is the whole point.
func (s *Session) NewGrant(actionID string, wantBytes int64, wantKbps int, lim GrantLimits) *Grant {
dest := s.DataSource()
if !dest.IsValid() {
return nil
}
if wantBytes <= 0 || wantBytes > lim.MaxBytes {
wantBytes = lim.MaxBytes
}
if wantKbps <= 0 || wantKbps > lim.MaxKbps {
wantKbps = lim.MaxKbps
}
g := &Grant{
ActionID: actionID, Dest: dest.String(),
MaxBytes: wantBytes, MaxKbps: wantKbps,
ExpiresAt: time.Now().Add(lim.MaxHold),
started: time.Now(),
}
s.mu.Lock()
s.grants = append(s.grants, g)
s.mu.Unlock()
return g
}
// Allow reports whether n more bytes may be sent now, consuming the budget when they may. It
// enforces the byte ceiling, the expiry, and the average rate (by refusing early sends rather
// than sleeping, so callers stay in control of pacing).
func (g *Grant) Allow(n int) bool {
g.mu.Lock()
defer g.mu.Unlock()
if time.Now().After(g.ExpiresAt) {
return false
}
if g.sentBytes+int64(n) > g.MaxBytes {
return false
}
// Average-rate check: bytes allowed so far = kbps/8 * elapsed_seconds.
elapsed := time.Since(g.started).Seconds()
allowed := float64(g.MaxKbps) * 125 * elapsed // kbps -> bytes/s is kbps*1000/8 = kbps*125
if elapsed > 0.05 && float64(g.sentBytes+int64(n)) > allowed {
return false
}
g.sentBytes += int64(n)
return true
}
// Sent returns how many bytes this grant has consumed.
func (g *Grant) Sent() int64 {
g.mu.Lock()
defer g.mu.Unlock()
return g.sentBytes
}
+94
View File
@@ -0,0 +1,94 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package session
import (
"net/netip"
"testing"
"time"
)
func sessionWithSource(t *testing.T) *Session {
t.Helper()
m := NewManager(time.Minute)
s, _, err := m.New("dev", "cred", netip.MustParseAddr("127.0.0.1"))
if err != nil {
t.Fatal(err)
}
s.NoteDataSource(netip.MustParseAddrPort("127.0.0.1:5000"))
return s
}
// Without an observed data-plane source there is nowhere verified to send, so no grant may exist.
func TestNoGrantWithoutObservedSource(t *testing.T) {
m := NewManager(time.Minute)
s, _, _ := m.New("dev", "cred", netip.MustParseAddr("127.0.0.1"))
if g := s.NewGrant("a1", 1000, 1000, DefaultGrantLimits); g != nil {
t.Fatal("granted a send with no verified destination")
}
}
func TestGrantIsBoundToObservedSourceAndClamped(t *testing.T) {
s := sessionWithSource(t)
g := s.NewGrant("a1", 1<<40, 1<<30, DefaultGrantLimits) // absurd request
if g == nil {
t.Fatal("expected a grant")
}
if g.Dest != "127.0.0.1:5000" {
t.Fatalf("grant destination = %s, want the observed source", g.Dest)
}
if g.MaxBytes != DefaultGrantLimits.MaxBytes || g.MaxKbps != DefaultGrantLimits.MaxKbps {
t.Fatalf("client request was not clamped to server limits: %d bytes / %d kbps",
g.MaxBytes, g.MaxKbps)
}
}
// The byte budget must actually stop sending — this is the anti-amplification guarantee.
func TestGrantStopsAtByteBudget(t *testing.T) {
s := sessionWithSource(t)
g := s.NewGrant("a1", 1000, 0, DefaultGrantLimits)
sent := 0
for i := 0; i < 100; i++ {
if !g.Allow(100) {
break
}
sent += 100
}
if sent != 1000 {
t.Fatalf("sent %d bytes, want exactly the 1000-byte budget", sent)
}
if g.Allow(1) {
t.Fatal("grant allowed a send after the budget was exhausted")
}
if g.Sent() != 1000 {
t.Fatalf("Sent() = %d, want 1000", g.Sent())
}
}
func TestExpiredGrantRefuses(t *testing.T) {
s := sessionWithSource(t)
g := s.NewGrant("a1", 10_000, 0, GrantLimits{MaxBytes: 10_000, MaxKbps: 1000, MaxHold: time.Millisecond})
time.Sleep(5 * time.Millisecond)
if g.Allow(10) {
t.Fatal("expired grant still allowed a send")
}
}
// The rate ceiling must throttle a burst that is well inside the byte budget.
func TestGrantEnforcesRate(t *testing.T) {
s := sessionWithSource(t)
// 8 kbps = 1000 bytes/s. A burst far beyond one second's worth must be refused.
g := s.NewGrant("a1", 1<<20, 8, DefaultGrantLimits)
time.Sleep(60 * time.Millisecond) // let the rate window open a little
sent := 0
for i := 0; i < 1000; i++ {
if !g.Allow(100) {
break
}
sent += 100
}
if sent > 5000 {
t.Fatalf("rate limit let %d bytes through in ~60ms at 8kbps", sent)
}
}
+3
View File
@@ -32,6 +32,9 @@ type Session struct {
maxSeq uint32
window [16]uint64
// Active asymmetric grants (spec §3.4/§5) — the only licence to send more than we receive.
grants []*Grant
// Observations (spec §6): per-packet UDP view + connect-back results.
packetsSeen uint64
udpObs []UDPObservation // ring, newest last, cap obsCap