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
+179
View File
@@ -0,0 +1,179 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package dataplane
import (
"encoding/binary"
"net"
"net/netip"
"testing"
"time"
"echo-lot.app/server/internal/session"
)
// parseReportPart decodes one TRAIN_REPORT payload back into rows, checking the header.
func parseReportPart(t *testing.T, b []byte) (id uint32, received int, part, parts int, truncated bool, rows []session.TrainEntry) {
t.Helper()
if len(b) < trainReportHeader {
t.Fatalf("report part shorter than its header: %d", len(b))
}
id = binary.BigEndian.Uint32(b[0:4])
received = int(binary.BigEndian.Uint32(b[4:8]))
part = int(binary.BigEndian.Uint16(b[8:10]))
parts = int(binary.BigEndian.Uint16(b[10:12]))
truncated = b[12]&1 != 0
n := int(binary.BigEndian.Uint16(b[14:16]))
if want := trainReportHeader + n*trainReportRow; len(b) != want {
t.Fatalf("part length %d, want %d for %d rows", len(b), want, n)
}
off := trainReportHeader
rows = make([]session.TrainEntry, n)
for i := range rows {
rows[i].Seq = binary.BigEndian.Uint32(b[off+i*4:])
}
off += n * 4
for i := range rows {
rows[i].TRxNs = int64(binary.BigEndian.Uint64(b[off+i*8:]))
}
off += n * 8
for i := range rows {
rows[i].Size = binary.BigEndian.Uint16(b[off+i*2:])
}
off += n * 2
for i := range rows {
rows[i].TTL = b[off+i]
}
off += n
for i := range rows {
rows[i].DSCP = b[off+i]
}
off += n
for i := range rows {
rows[i].ECN = b[off+i]
}
return
}
func TestBuildTrainReportSplitsAndRoundTrips(t *testing.T) {
const count = 250 // enough to need several parts
train := session.Train{ID: 42, Received: count, Truncated: true}
for i := 0; i < count; i++ {
train.Entries = append(train.Entries, session.TrainEntry{
Seq: uint32(i), TRxNs: int64(i) * 1_000_000, Size: uint16(100 + i),
TTL: 64, DSCP: 46, ECN: 1,
})
}
parts := buildTrainReport(train)
if len(parts) < 2 {
t.Fatalf("250 rows should not fit one ≤%d-byte datagram", trainReportMaxDatagram)
}
var got []session.TrainEntry
for i, p := range parts {
if HeaderSize+len(p) > trainReportMaxDatagram {
t.Fatalf("part %d would be a %d-byte datagram, cap is %d", i, HeaderSize+len(p), trainReportMaxDatagram)
}
id, received, part, total, truncated, rows := parseReportPart(t, p)
if id != 42 || received != count || part != i || total != len(parts) || !truncated {
t.Fatalf("part %d header: id=%d received=%d part=%d/%d truncated=%v",
i, id, received, part, total, truncated)
}
got = append(got, rows...)
}
if len(got) != count {
t.Fatalf("round-tripped %d rows, want %d", len(got), count)
}
for i, r := range got {
want := train.Entries[i]
if r != want {
t.Fatalf("row %d = %+v, want %+v", i, r, want)
}
}
}
func TestBuildTrainReportEmptyTrainStillAnswers(t *testing.T) {
parts := buildTrainReport(session.Train{ID: 9})
if len(parts) != 1 {
t.Fatalf("empty train: %d parts, want 1 — 'nothing arrived' is the answer, not silence", len(parts))
}
id, received, _, total, _, rows := parseReportPart(t, parts[0])
if id != 9 || received != 0 || total != 1 || len(rows) != 0 {
t.Fatalf("empty report: id=%d received=%d parts=%d rows=%d", id, received, total, len(rows))
}
}
func TestTrainDataThenReportOverTheWire(t *testing.T) {
mgr, addr := startServer(t)
sess, _, err := mgr.New("dev1", "credential-ikm", netip.MustParseAddr("127.0.0.1"))
if err != nil {
t.Fatal(err)
}
client, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(addr))
if err != nil {
t.Fatal(err)
}
defer client.Close()
client.SetDeadline(time.Now().Add(3 * time.Second))
// A short train: id 5 in payload[0:4], plus padding.
const trainID, count = 5, 4
for i := 0; i < count; i++ {
payload := make([]byte, 60)
binary.BigEndian.PutUint32(payload[0:4], trainID)
if _, err := client.Write(craft(t, sess, TypeTrainData, uint32(i+1), payload)); err != nil {
t.Fatal(err)
}
}
// TRAIN_DATA must be silent (spec §3.2).
client.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
if _, err := client.Read(make([]byte, 1500)); err == nil {
t.Fatal("TRAIN_DATA got a response, want none")
}
// Ask for the report.
reqPayload := make([]byte, 4)
binary.BigEndian.PutUint32(reqPayload, trainID)
client.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := client.Write(craft(t, sess, TypeTrainReportReq, 100, reqPayload)); err != nil {
t.Fatal(err)
}
buf := make([]byte, 2000)
n, err := client.Read(buf)
if err != nil {
t.Fatalf("no TRAIN_REPORT: %v", err)
}
if buf[4] != TypeTrainReport {
t.Fatalf("type = %#x, want TRAIN_REPORT", buf[4])
}
id, received, part, parts, truncated, rows := parseReportPart(t, buf[HeaderSize:n])
if id != trainID || received != count || part != 0 || parts != 1 || truncated {
t.Fatalf("report header: id=%d received=%d part=%d/%d truncated=%v", id, received, part, parts, truncated)
}
if len(rows) != count {
t.Fatalf("%d rows, want %d", len(rows), count)
}
for i, r := range rows {
if r.Seq != uint32(i+1) {
t.Fatalf("row %d seq = %d, want %d", i, r.Seq, i+1)
}
if r.Size != HeaderSize+60 {
t.Fatalf("row %d size = %d, want %d", i, r.Size, HeaderSize+60)
}
}
// Unknown train id: one zero-row report, not silence.
binary.BigEndian.PutUint32(reqPayload, 999)
if _, err := client.Write(craft(t, sess, TypeTrainReportReq, 101, reqPayload)); err != nil {
t.Fatal(err)
}
n, err = client.Read(buf)
if err != nil {
t.Fatalf("no report for unknown train: %v", err)
}
id, received, _, _, _, rows = parseReportPart(t, buf[HeaderSize:n])
if id != 999 || received != 0 || len(rows) != 0 {
t.Fatalf("unknown-train report: id=%d received=%d rows=%d", id, received, len(rows))
}
}