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:
mrambossek
2026-08-02 13:04:54 +02:00
co-authored by Claude Opus 5
parent f6849f8e6a
commit 8118e213ae
26 changed files with 1390 additions and 61 deletions
+98
View File
@@ -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
}