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
@@ -2,15 +2,15 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package dataplane implements the binary UDP probe protocol (spec §3):
|
||||
// 32-byte header, HMAC gate, anti-replay, ECHO with observation block.
|
||||
// Skeleton scope: ECHO_REQ/ECHO_RESP and TIMESYNC only; trains, MTU probes
|
||||
// and delayed echo land with the corresponding client tests.
|
||||
// 32-byte header, HMAC gate, anti-replay, ECHO with observation block,
|
||||
// upstream trains with columnar reports, and the granted server->client sends.
|
||||
package dataplane
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"echo-lot.app/server/internal/ratelimit"
|
||||
"echo-lot.app/server/internal/session"
|
||||
)
|
||||
|
||||
@@ -25,9 +26,14 @@ const (
|
||||
Magic = "ELT1"
|
||||
HeaderSize = 32
|
||||
|
||||
TypeEchoReq = 0x01
|
||||
TypeEchoResp = 0x02
|
||||
TypeTimesyncReq = 0x07
|
||||
TypeEchoReq = 0x01
|
||||
TypeEchoResp = 0x02
|
||||
// Upstream trains (spec §3.2): DATA gets no per-packet response; REPORT_REQ fetches the
|
||||
// server's received view as one or more REPORT datagrams (train.go).
|
||||
TypeTrainData = 0x03
|
||||
TypeTrainReportReq = 0x04
|
||||
TypeTrainReport = 0x05
|
||||
TypeTimesyncReq = 0x07
|
||||
TypeTimesyncRsp = 0x08
|
||||
TypeMtuProbe = 0x09
|
||||
TypeMtuAck = 0x0A
|
||||
@@ -47,6 +53,12 @@ const (
|
||||
|
||||
type Server struct {
|
||||
Sessions *session.Manager
|
||||
// Spec §2.5 ceilings on verified traffic, silent-drop (nil = no ceiling). Charged after the
|
||||
// HMAC gate so an unauthenticated flood cannot spend anyone's budget, keyed per source
|
||||
// address AND per device credential so neither one hot address nor one hot credential can
|
||||
// crowd out the rest.
|
||||
PacketRate *ratelimit.Limiter // tokens are packets
|
||||
ByteRate *ratelimit.Limiter // tokens are bytes
|
||||
// Epoch for server-side t_rx/t_tx: process start; observation consumers
|
||||
// only need differences plus the timesync exchange, not absolute time.
|
||||
start time.Time
|
||||
@@ -59,6 +71,34 @@ type Server struct {
|
||||
dfMu sync.Mutex
|
||||
}
|
||||
|
||||
// pktMeta is what the kernel told us about one received datagram beyond its bytes (spec §3.3:
|
||||
// received TTL, DSCP, ECN). 0xFF means "not observed": non-Linux hosts and datagrams whose
|
||||
// cmsg never arrived keep the sentinel rather than inventing a value.
|
||||
type pktMeta struct {
|
||||
TTL uint8
|
||||
TOS uint8 // the whole DSCP/ECN byte; DSCP = TOS>>2, ECN = TOS&3
|
||||
}
|
||||
|
||||
const metaUnavailable = 0xFF
|
||||
|
||||
func (m pktMeta) dscp() uint8 {
|
||||
if m.TOS == metaUnavailable {
|
||||
return metaUnavailable
|
||||
}
|
||||
return m.TOS >> 2
|
||||
}
|
||||
|
||||
func (m pktMeta) ecn() uint8 {
|
||||
if m.TOS == metaUnavailable {
|
||||
return metaUnavailable
|
||||
}
|
||||
return m.TOS & 0x3
|
||||
}
|
||||
|
||||
// oobCap fits the two cmsgs (TTL + TOS, each ≤ CMSG_SPACE(4)) with headroom for whatever else
|
||||
// the kernel decides to attach.
|
||||
const oobCap = 64
|
||||
|
||||
// Serve runs the read loop for one socket; call once per bound address.
|
||||
// The socket is retained so actions (delayed echo) can pick a family-matching
|
||||
// sender later.
|
||||
@@ -69,14 +109,16 @@ func (s *Server) Serve(conn *net.UDPConn) error {
|
||||
}
|
||||
s.conns = append(s.conns, conn)
|
||||
s.mu.Unlock()
|
||||
enableRecvMeta(conn)
|
||||
buf := make([]byte, 65535)
|
||||
oob := make([]byte, oobCap)
|
||||
for {
|
||||
n, raddr, err := conn.ReadFromUDPAddrPort(buf)
|
||||
n, oobn, _, raddr, err := conn.ReadMsgUDPAddrPort(buf, oob)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tRx := time.Since(s.start).Nanoseconds()
|
||||
s.handle(conn, raddr, buf[:n], tRx)
|
||||
s.handle(conn, raddr, buf[:n], tRx, parseMeta(oob[:oobn]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +168,7 @@ func (s *Server) SendDelayedEcho(sess *session.Session, actionID string) error {
|
||||
|
||||
// handle enforces spec §3.1/§3.4: unknown prefix, bad HMAC, expired session,
|
||||
// replayed seq → silent drop, never a response.
|
||||
func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRxNs int64) {
|
||||
func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRxNs int64, meta pktMeta) {
|
||||
if len(pkt) < HeaderSize || string(pkt[0:4]) != Magic {
|
||||
return
|
||||
}
|
||||
@@ -149,6 +191,12 @@ func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRx
|
||||
if !hmac.Equal(mac.Sum(nil)[:4], pkt[28:32]) {
|
||||
return
|
||||
}
|
||||
// Spec §2.5: over-ceiling traffic is silently dropped (probes tolerate loss by design).
|
||||
// After the HMAC gate so a spoofed flood cannot drain a victim's budget; before the replay
|
||||
// window so a dropped packet's seq stays usable for a resend.
|
||||
if !s.allowUDP(raddr, sess.Device, len(pkt)) {
|
||||
return
|
||||
}
|
||||
if !sess.CheckSeq(seq) {
|
||||
return
|
||||
}
|
||||
@@ -169,9 +217,16 @@ func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRx
|
||||
Src: raddr.String(), Size: len(pkt), Type: typ,
|
||||
})
|
||||
|
||||
payload := pkt[HeaderSize : HeaderSize+int(payloadLen)]
|
||||
switch typ {
|
||||
case TypeEchoReq:
|
||||
s.echoResp(conn, raddr, sess, pkt, seq, tRxNs)
|
||||
s.echoResp(conn, raddr, sess, pkt, seq, tRxNs, meta)
|
||||
case TypeTrainData:
|
||||
// No response (spec §3.2): the train is upstream-only; its received view is fetched
|
||||
// afterwards via TRAIN_REPORT_REQ or the observations API.
|
||||
recordTrain(sess, payload, seq, len(pkt), tRxNs, meta)
|
||||
case TypeTrainReportReq:
|
||||
s.trainReport(conn, raddr, sess, payload)
|
||||
case TypeTimesyncReq:
|
||||
s.timesyncResp(conn, raddr, sess, pkt, seq, tRxNs)
|
||||
case TypeMtuProbe:
|
||||
@@ -181,6 +236,16 @@ func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRx
|
||||
}
|
||||
}
|
||||
|
||||
// allowUDP charges the §2.5 packet and byte buckets, per source address and per credential.
|
||||
func (s *Server) allowUDP(raddr netip.AddrPort, device string, size int) bool {
|
||||
ipKey, credKey := "ip:"+raddr.Addr().String(), "cred:"+device
|
||||
okA, _ := s.PacketRate.Allow(ipKey)
|
||||
okC, _ := s.PacketRate.Allow(credKey)
|
||||
okAB, _ := s.ByteRate.AllowN(ipKey, float64(size))
|
||||
okCB, _ := s.ByteRate.AllowN(credKey, float64(size))
|
||||
return okA && okC && okAB && okCB
|
||||
}
|
||||
|
||||
// mtuAck replies to an MTU_PROBE with a small MTU_ACK carrying the total
|
||||
// datagram size the server actually received (spec §3.2). The client sends
|
||||
// DF-flagged probes of increasing size and binary-searches the path MTU / a
|
||||
@@ -198,28 +263,43 @@ func (s *Server) mtuAck(conn *net.UDPConn, raddr netip.AddrPort, sess *session.S
|
||||
// 8 8 t_tx_ns
|
||||
// 16 16 observed source IP (v4-mapped when v4)
|
||||
// 32 2 observed source port
|
||||
// 34 1 received TTL (0xFF = not observed yet; needs recvmsg cmsgs)
|
||||
// 34 1 received TTL (0xFF = not observed; cmsgs unavailable on this host)
|
||||
// 35 1 received DSCP/ECN byte (0xFF = not observed)
|
||||
// 36 4 received size
|
||||
func observation(tRxNs, tTxNs int64, src netip.AddrPort, rcvd int) []byte {
|
||||
func observation(tRxNs, tTxNs int64, src netip.AddrPort, rcvd int, meta pktMeta) []byte {
|
||||
b := make([]byte, 40)
|
||||
binary.BigEndian.PutUint64(b[0:8], uint64(tRxNs))
|
||||
binary.BigEndian.PutUint64(b[8:16], uint64(tTxNs))
|
||||
a16 := src.Addr().As16()
|
||||
copy(b[16:32], a16[:])
|
||||
binary.BigEndian.PutUint16(b[32:34], src.Port())
|
||||
b[34], b[35] = 0xFF, 0xFF
|
||||
b[34], b[35] = meta.TTL, meta.TOS
|
||||
binary.BigEndian.PutUint32(b[36:40], uint32(rcvd))
|
||||
return b
|
||||
}
|
||||
|
||||
// putActionID writes a grant's action id into payload[8:16] — the correlation the spec promises
|
||||
// (§5: "an action_id echoed in resulting data-plane packets"), consumed by the client as
|
||||
// test.params.action_id (§9). Bytes [0:8] stay with the packet type; [8:16] is reserved for this
|
||||
// across every granted type, so the client needs one rule, not five.
|
||||
func putActionID(payload []byte, actionID string) {
|
||||
if len(payload) < 16 {
|
||||
return
|
||||
}
|
||||
raw, err := hex.DecodeString(actionID)
|
||||
if err != nil || len(raw) != 8 {
|
||||
return // a malformed id yields zero bytes, not a crash mid-burst
|
||||
}
|
||||
copy(payload[8:16], raw)
|
||||
}
|
||||
|
||||
// echoResp mirrors the request header (type flipped), appends the observation
|
||||
// block, and re-HMACs with the session key. Anti-amplification: the response
|
||||
// is capped at the request size (spec §3.4) — the observation block replaces
|
||||
// padding rather than growing the datagram; if the request was smaller than
|
||||
// header+observation, the block is truncated to fit.
|
||||
func (s *Server) echoResp(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, req []byte, seq uint32, tRxNs int64) {
|
||||
obs := observation(tRxNs, time.Since(s.start).Nanoseconds(), raddr, len(req))
|
||||
func (s *Server) echoResp(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, req []byte, seq uint32, tRxNs int64, meta pktMeta) {
|
||||
obs := observation(tRxNs, time.Since(s.start).Nanoseconds(), raddr, len(req), meta)
|
||||
max := len(req)
|
||||
if max < HeaderSize {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user