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>
123 lines
4.5 KiB
Go
123 lines
4.5 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
|
||
package dataplane
|
||
|
||
// Upstream trains (spec §3.2, types 0x03–0x05). 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
|
||
}
|