Files
echolot/server/internal/session/train.go
T
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

99 lines
3.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}