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
+122
View File
@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package dataplane
// Upstream trains (spec §3.2, types 0x030x05). The client blasts TRAIN_DATA at the server and
// the server answers nothing per-packet — a reply would double the traffic and measure the
// return path at the same time. Afterwards the client asks for the server's received view with
// TRAIN_REPORT_REQ, and gets it back columnar, split across as many TRAIN_REPORT datagrams as
// it takes to stay under a safe size.
//
// Both TRAIN_DATA and TRAIN_REPORT_REQ carry the train id in payload[0:4]; the id is the
// client's to choose, unique within the session.
import (
"encoding/binary"
"net"
"net/netip"
"echo-lot.app/server/internal/session"
)
const (
// trainReportMaxDatagram caps one TRAIN_REPORT datagram at a size that survives every common
// path unfragmented. A report about loss must not itself be lost to MTU.
trainReportMaxDatagram = 1200
trainReportHeader = 16
trainReportRow = 17 // 4 seq + 8 t_rx_ns + 2 size + 1 ttl + 1 dscp + 1 ecn
)
// recordTrain buffers one TRAIN_DATA packet into its train (session-side, bounded — see
// session/train.go). A payload too short to carry the id is unreportable and stays only in the
// flat packet log, which already recorded it.
func recordTrain(sess *session.Session, payload []byte, seq uint32, size int, tRxNs int64, meta pktMeta) {
if len(payload) < 4 {
return
}
sess.RecordTrainPacket(binary.BigEndian.Uint32(payload[0:4]), session.TrainEntry{
Seq: seq, TRxNs: tRxNs, Size: uint16(min(size, 0xFFFF)),
TTL: meta.TTL, DSCP: meta.dscp(), ECN: meta.ecn(),
})
}
// trainReport answers one TRAIN_REPORT_REQ with the full columnar report.
//
// Grant-free on purpose. §3.4 caps ungranted responses at the request size, and a multi-part
// report is larger than the single REPORT_REQ that asked for it — but it cannot amplify: every
// 17-byte row accounts for one HMAC-valid TRAIN_DATA packet of at least HeaderSize+4 bytes this
// session already delivered here, so the whole report is a strict fraction of the traffic it
// describes, and it only ever goes to the session's verified source address. An unknown id gets
// a single zero-row report rather than silence — "nothing arrived" IS the measurement.
func (s *Server) trainReport(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, payload []byte) {
if len(payload) < 4 {
return
}
id := binary.BigEndian.Uint32(payload[0:4])
train, _ := sess.TrainView(id)
train.ID = id
for i, part := range buildTrainReport(train) {
s.send(conn, raddr, sess, TypeTrainReport, uint32(i), part)
}
}
// buildTrainReport lays a train's received view out columnar and cuts it into datagram-sized
// payloads. Layout (mirrored by the client; all big-endian):
//
// 0 4 train_id
// 4 4 received (every packet counted, buffered or not — loss math uses this)
// 8 2 part (0-based)
// 10 2 parts
// 12 1 flags (bit0: buffer overflowed; rows beyond the cap were counted, not kept —
// the schema's evidence_truncated honesty, on the wire)
// 13 1 reserved
// 14 2 n (rows in this part)
// 16 n×4 seq, n×8 t_rx_ns, n×2 size, n×1 ttl, n×1 dscp, n×1 ecn (columns contiguous)
func buildTrainReport(t session.Train) [][]byte {
perPart := (trainReportMaxDatagram - HeaderSize - trainReportHeader) / trainReportRow
parts := (len(t.Entries) + perPart - 1) / perPart
if parts == 0 {
parts = 1 // an empty train still gets its "received: 0" answer
}
out := make([][]byte, 0, parts)
for p := 0; p < parts; p++ {
rows := t.Entries[p*perPart : min((p+1)*perPart, len(t.Entries))]
n := len(rows)
b := make([]byte, trainReportHeader+n*trainReportRow)
binary.BigEndian.PutUint32(b[0:4], t.ID)
binary.BigEndian.PutUint32(b[4:8], uint32(t.Received))
binary.BigEndian.PutUint16(b[8:10], uint16(p))
binary.BigEndian.PutUint16(b[10:12], uint16(parts))
if t.Truncated {
b[12] = 1
}
binary.BigEndian.PutUint16(b[14:16], uint16(n))
off := trainReportHeader
for i, r := range rows {
binary.BigEndian.PutUint32(b[off+i*4:], r.Seq)
}
off += n * 4
for i, r := range rows {
binary.BigEndian.PutUint64(b[off+i*8:], uint64(r.TRxNs))
}
off += n * 8
for i, r := range rows {
binary.BigEndian.PutUint16(b[off+i*2:], r.Size)
}
off += n * 2
for i, r := range rows {
b[off+i] = r.TTL
}
off += n
for i, r := range rows {
b[off+i] = r.DSCP
}
off += n
for i, r := range rows {
b[off+i] = r.ECN
}
out = append(out, b)
}
return out
}