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>
180 lines
5.6 KiB
Go
180 lines
5.6 KiB
Go
// 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))
|
|
}
|
|
}
|