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
@@ -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 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
|
||||
}
|
||||
Reference in New Issue
Block a user