// 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 }