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
@@ -42,6 +42,9 @@ type Session struct {
|
||||
connectBack []ConnectBackResult
|
||||
throughput []ThroughputReport
|
||||
upstream UpstreamCounter
|
||||
// Upstream trains (spec §3.2), buffered apart from udpObs — see train.go for why the
|
||||
// flat ring must not be the only home of a 5000-packet train.
|
||||
trains []*Train
|
||||
}
|
||||
|
||||
const obsCap = 4096
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package session
|
||||
|
||||
// Upstream trains (spec §3.2): TRAIN_DATA packets get no per-packet response, so the server's
|
||||
// received view is the only record of what survived the upstream path. It is kept per train —
|
||||
// NOT in the flat udpObs ring, whose 4096-entry cap would silently roll the head of a 5000-packet
|
||||
// train out from under the report that is about to be requested. Losing the head quietly turns
|
||||
// real early-train packets into phantom loss; a bounded buffer that says when it overflowed
|
||||
// (Truncated, mirroring the measurement schema's evidence_truncated) keeps the numbers honest.
|
||||
|
||||
// TrainEntry is the server's received view of one train packet. TTL/DSCP/ECN use 0xFF for
|
||||
// "not observed" (spec §3.3), same sentinel as the echo observation block.
|
||||
type TrainEntry struct {
|
||||
Seq uint32
|
||||
TRxNs int64 // server clock, session epoch
|
||||
Size uint16
|
||||
TTL uint8
|
||||
DSCP uint8
|
||||
ECN uint8
|
||||
}
|
||||
|
||||
// Train is the received view of one upstream train, keyed by the id the client put in the
|
||||
// TRAIN_DATA payload.
|
||||
type Train struct {
|
||||
ID uint32
|
||||
// Received counts every packet of the train, including any the entry buffer no longer holds;
|
||||
// the loss figure must come from this, not from len(Entries).
|
||||
Received int
|
||||
Truncated bool
|
||||
Entries []TrainEntry
|
||||
}
|
||||
|
||||
const (
|
||||
// trainCap comfortably holds the largest train the client-side action bounds allow (5000
|
||||
// packets, matching the downtrain clamp). Overflow keeps the head and sets Truncated: the
|
||||
// early packets are the ones a ring would drop, and the tail's absence is at least declared.
|
||||
trainCap = 8192
|
||||
// maxTrains bounds one session's train memory (~8×8k×24 B ≈ 1.5 MiB worst case). The oldest
|
||||
// train is evicted for a new one because reports are requested train-by-train, right after
|
||||
// each train — an id still being sent to is always the one worth keeping.
|
||||
maxTrains = 8
|
||||
)
|
||||
|
||||
// RecordTrainPacket appends one received TRAIN_DATA packet to its train's buffer.
|
||||
func (s *Session) RecordTrainPacket(trainID uint32, e TrainEntry) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var t *Train
|
||||
for _, c := range s.trains {
|
||||
if c.ID == trainID {
|
||||
t = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if t == nil {
|
||||
if len(s.trains) >= maxTrains {
|
||||
s.trains = s.trains[1:]
|
||||
}
|
||||
t = &Train{ID: trainID}
|
||||
s.trains = append(s.trains, t)
|
||||
}
|
||||
t.Received++
|
||||
if len(t.Entries) >= trainCap {
|
||||
t.Truncated = true
|
||||
return
|
||||
}
|
||||
t.Entries = append(t.Entries, e)
|
||||
}
|
||||
|
||||
// TrainView returns a copy of one train. A missing id reports ok=false; the caller decides
|
||||
// whether "never saw it" is an error or (for a report request) the answer itself.
|
||||
func (s *Session) TrainView(trainID uint32) (Train, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, t := range s.trains {
|
||||
if t.ID == trainID {
|
||||
cp := *t
|
||||
cp.Entries = append([]TrainEntry(nil), t.Entries...)
|
||||
return cp, true
|
||||
}
|
||||
}
|
||||
return Train{}, false
|
||||
}
|
||||
|
||||
// Trains returns copies of every train witnessed in this session, oldest first.
|
||||
func (s *Session) Trains() []Train {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]Train, 0, len(s.trains))
|
||||
for _, t := range s.trains {
|
||||
cp := *t
|
||||
cp.Entries = append([]TrainEntry(nil), t.Entries...)
|
||||
out = append(out, cp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package session
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTrainBufferKeepsHeadAndDeclaresTruncation(t *testing.T) {
|
||||
s := &Session{}
|
||||
over := trainCap + 100
|
||||
for i := 0; i < over; i++ {
|
||||
s.RecordTrainPacket(7, TrainEntry{Seq: uint32(i), Size: 64})
|
||||
}
|
||||
tr, ok := s.TrainView(7)
|
||||
if !ok {
|
||||
t.Fatal("train not found")
|
||||
}
|
||||
if tr.Received != over {
|
||||
t.Fatalf("Received = %d, want %d — the count must include unbuffered packets", tr.Received, over)
|
||||
}
|
||||
if len(tr.Entries) != trainCap {
|
||||
t.Fatalf("buffered %d entries, want the cap %d", len(tr.Entries), trainCap)
|
||||
}
|
||||
if !tr.Truncated {
|
||||
t.Fatal("overflow must be declared, not silent")
|
||||
}
|
||||
// The head must survive: it is what a ring buffer would have lost.
|
||||
if tr.Entries[0].Seq != 0 || tr.Entries[trainCap-1].Seq != trainCap-1 {
|
||||
t.Fatalf("buffer kept seqs %d..%d, want the head 0..%d",
|
||||
tr.Entries[0].Seq, tr.Entries[trainCap-1].Seq, trainCap-1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrainEvictionDropsOldestTrain(t *testing.T) {
|
||||
s := &Session{}
|
||||
for id := uint32(0); id < maxTrains+2; id++ {
|
||||
s.RecordTrainPacket(id, TrainEntry{Seq: 1})
|
||||
}
|
||||
if _, ok := s.TrainView(0); ok {
|
||||
t.Fatal("oldest train should have been evicted")
|
||||
}
|
||||
if _, ok := s.TrainView(1); ok {
|
||||
t.Fatal("second-oldest train should have been evicted")
|
||||
}
|
||||
if _, ok := s.TrainView(maxTrains + 1); !ok {
|
||||
t.Fatal("newest train missing")
|
||||
}
|
||||
if got := len(s.Trains()); got != maxTrains {
|
||||
t.Fatalf("holding %d trains, want %d", got, maxTrains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrainViewReturnsACopy(t *testing.T) {
|
||||
s := &Session{}
|
||||
s.RecordTrainPacket(3, TrainEntry{Seq: 10})
|
||||
tr, _ := s.TrainView(3)
|
||||
tr.Entries[0].Seq = 99
|
||||
again, _ := s.TrainView(3)
|
||||
if again.Entries[0].Seq != 10 {
|
||||
t.Fatal("TrainView leaked the internal slice")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user