Files
echolot/server/internal/dataplane/granted.go
T
mrambossekandClaude Opus 5 8118e213ae 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>
2026-08-02 13:04:54 +02:00

146 lines
5.5 KiB
Go

// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package dataplane
import (
"encoding/binary"
"fmt"
"time"
"echo-lot.app/server/internal/session"
)
// Server-to-client sends that exceed the request size, and are therefore only legal under an
// asymmetric grant (spec §3.4). Every one of them:
// - targets the session's observed data-plane source, fixed when the grant was created;
// - stops the moment the grant's byte/rate/time budget is exhausted;
// - is HMAC-signed with the session key, so the client can tell our packets from injected ones.
// DownTrain sends `count` DOWNTRAIN_DATA packets of `sizeBytes` spaced `intervalUs` apart. The
// client measures downstream loss, reordering and jitter from what arrives — the direction an
// upstream-only train cannot see. Returns how many packets actually went out (the grant may cut
// it short, which is itself reportable).
//
// dscp ≥ 0 marks the burst (spec §5 downtrain `dscp`): downstream DSCP survival is the half the
// client cannot produce itself. Best-effort off Linux — see withTOS/TOSSupported; the action
// response has already told the client whether the marking was applied.
func (s *Server) DownTrain(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs, dscp int) (int, error) {
target := sess.DataSource()
if !target.IsValid() {
return 0, fmt.Errorf("no observed data-plane source")
}
conn := s.connFor(target, sess.DataLocal())
if conn == nil {
return 0, fmt.Errorf("no data-plane socket matches target family")
}
if sizeBytes < HeaderSize+16 {
sizeBytes = HeaderSize + 16
}
payload := make([]byte, sizeBytes-HeaderSize)
// Payload [8:16] carries the action id on every granted packet, so arriving traffic can be
// attributed to the action that caused it (spec §5/§9: test.params.action_id).
putActionID(payload, g.ActionID)
sent := 0
burst := func() error {
for i := 0; i < count; i++ {
if !g.Allow(sizeBytes) {
break // budget or rate exhausted — stop, do not sleep it off
}
// Sequence + send timestamp in the payload head so the client can order and time them
// even when packets arrive out of order.
binary.BigEndian.PutUint32(payload[0:4], uint32(i))
binary.BigEndian.PutUint32(payload[4:8], uint32(time.Since(s.start).Microseconds()))
s.send(conn, target, sess, TypeDownTrainData, uint32(i), payload)
sent++
if intervalUs > 0 && i < count-1 {
time.Sleep(time.Duration(intervalUs) * time.Microsecond)
}
}
return nil
}
if dscp >= 0 && TOSSupported {
// Same shared-socket borrow as the DF window: dfMu keeps a concurrent burst from riding
// along with — or clearing — this marking.
s.dfMu.Lock()
defer s.dfMu.Unlock()
err := withTOS(conn, dscp, burst) // sent must be read after the burst ran, not before
return sent, err
}
err := burst()
return sent, err
}
// BigSendResult records what happened to one requested size. `Sent` false with an EMSGSIZE-ish
// Err means *we* could not put it on the wire (the datagram exceeds our own egress MTU with DF
// set) — the client must not read its absence as a path limit, so this is reported, not hidden.
type BigSendResult struct {
SizeBytes int `json:"size_bytes"`
Seq int `json:"seq"`
Sent bool `json:"sent"`
Err string `json:"err,omitempty"`
}
// BigSend transmits one datagram per requested size so the client can see which sizes survive the
// *downstream* path — the mtu.pmtud_down / mtu.frag_delivery evidence. The client cannot produce
// this itself: only the far end can emit a large packet toward it.
//
// With df set, the DF bit is forced for the whole burst, so nothing fragments and the largest
// size that arrives IS the downstream path MTU. Without it, the kernel fragments freely and the
// result only says whether fragments get through — a different (also useful) measurement, and
// the reason the two are separate test types.
func (s *Server) BigSend(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]BigSendResult, error) {
target := sess.DataSource()
if !target.IsValid() {
return nil, fmt.Errorf("no observed data-plane source")
}
conn := s.connFor(target, sess.DataLocal())
if conn == nil {
return nil, fmt.Errorf("no data-plane socket matches target family")
}
results := make([]BigSendResult, 0, len(sizes))
burst := func() error {
for i, size := range sizes {
if size < HeaderSize+16 {
size = HeaderSize + 16
}
if size > 9000 { // jumbo ceiling; beyond this the kernel will refuse anyway
size = 9000
}
if !g.Allow(size) {
break
}
payload := make([]byte, size-HeaderSize)
// Echo the intended size into the payload so a truncated/fragmented arrival is
// still attributable to the size we meant to send.
binary.BigEndian.PutUint32(payload[0:4], uint32(size))
// [8:16]: the action id, as on every granted packet (spec §5 correlation).
putActionID(payload, g.ActionID)
err := s.sendErr(conn, target, sess, TypeBigSend, uint32(i), payload)
results = append(results, BigSendResult{
SizeBytes: size, Seq: i, Sent: err == nil, Err: errString(err),
})
time.Sleep(20 * time.Millisecond) // keep bursts from being read as congestion loss
}
return nil
}
if df && dfSupported {
s.dfMu.Lock()
defer s.dfMu.Unlock()
if err := withDF(conn, burst); err != nil {
return results, err
}
return results, nil
}
return results, burst()
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}