Letting the kernel fragment an oversized datagram answers one question — do
fragments get through. It cannot answer the more interesting one, because the
kernel always emits them in order, first one first.
The classic middlebox fault is exactly about that ordering. Only the first
fragment carries the UDP header, and therefore the ports; a stateful firewall
or NAT that has not seen it has no flow to match the rest against, and many
drop them. That is invisible to any in-order test and shows up in the field as
"large DNS answers fail on this network" or "the tunnel breaks when the MTU
drops" — it works until the network reorders, then fails intermittently, which
is the hardest kind of fault to chase.
So the server now builds the fragments itself (raw socket, IP_HDRINCL) and
controls their order: in_order as a baseline, reversed, and first-fragment-last.
The datagram is assembled and signed whole before being cut up, so what the
client reassembles is indistinguishable from an ordinary packet — otherwise it
would be measuring our sender rather than the path.
Two details that would silently produce wrong answers:
- The UDP checksum is computed rather than left zero. A zero-checksum datagram
is dropped by some middleboxes, and that drop would be recorded as a
fragmentation failure, which is the wrong conclusion entirely.
- Fragment offsets are in 8-byte units, so non-final fragments are rounded to
a multiple of 8. A 100-byte fragment is not an error, it is a datagram no
host will ever reassemble.
frag-send is advertised only when a raw socket can actually be opened — checked
by opening one, since a permission model has more ways to say no than a
capability bit has to say yes.
Fragment header arithmetic is unit-tested (reassembly coverage, MF flags, shared
IP ID, 8-byte offsets, checksum verification), cross-compiled and run on Linux
since the code is build-tagged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
278 lines
9.4 KiB
Go
278 lines
9.4 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
// 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.
|
|
package dataplane
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/netip"
|
|
"sync"
|
|
"time"
|
|
|
|
"echo-lot.app/server/internal/session"
|
|
)
|
|
|
|
const (
|
|
Magic = "ELT1"
|
|
HeaderSize = 32
|
|
|
|
TypeEchoReq = 0x01
|
|
TypeEchoResp = 0x02
|
|
TypeTimesyncReq = 0x07
|
|
TypeTimesyncRsp = 0x08
|
|
TypeMtuProbe = 0x09
|
|
TypeMtuAck = 0x0A
|
|
TypeDelayedEcho = 0x0B
|
|
// Server->client under an asymmetric grant (spec §3.4/§5).
|
|
TypeDownTrainData = 0x06
|
|
TypeBigSend = 0x0C
|
|
// TypeFragData is delivered only after IP reassembly, so its arrival IS the measurement.
|
|
TypeFragData = 0x0D
|
|
)
|
|
|
|
type Server struct {
|
|
Sessions *session.Manager
|
|
// 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
|
|
|
|
mu sync.Mutex
|
|
conns []*net.UDPConn
|
|
|
|
// dfMu serialises DF windows: the listening socket is shared by every session on that
|
|
// family, so two concurrent big_sends must not overlap their DF on/off transitions.
|
|
dfMu sync.Mutex
|
|
}
|
|
|
|
// 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.
|
|
func (s *Server) Serve(conn *net.UDPConn) error {
|
|
s.mu.Lock()
|
|
if s.start.IsZero() {
|
|
s.start = time.Now()
|
|
}
|
|
s.conns = append(s.conns, conn)
|
|
s.mu.Unlock()
|
|
buf := make([]byte, 65535)
|
|
for {
|
|
n, raddr, err := conn.ReadFromUDPAddrPort(buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tRx := time.Since(s.start).Nanoseconds()
|
|
s.handle(conn, raddr, buf[:n], tRx)
|
|
}
|
|
}
|
|
|
|
// connFor picks a retained socket whose family matches the target.
|
|
// connFor picks the socket to send to target from.
|
|
//
|
|
// When the session recorded which local address it has been talking to (local), that socket wins
|
|
// outright. Falling back to "any socket of the right family" is only correct for a single-homed
|
|
// server: on a multi-homed one it sends from a sibling address the client's NAT has no mapping
|
|
// for, the packets are dropped in transit, and the client reports downstream loss that does not
|
|
// exist. That bug is invisible in a lab with one address, which is exactly why this is explicit.
|
|
func (s *Server) connFor(target, local netip.AddrPort) *net.UDPConn {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if local.IsValid() {
|
|
for _, c := range s.conns {
|
|
if c.LocalAddr().(*net.UDPAddr).AddrPort() == local {
|
|
return c
|
|
}
|
|
}
|
|
}
|
|
want4 := target.Addr().Unmap().Is4()
|
|
for _, c := range s.conns {
|
|
la := c.LocalAddr().(*net.UDPAddr).AddrPort()
|
|
if la.Addr().Unmap().Is4() == want4 {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SendDelayedEcho fires one DELAYED_ECHO packet at the session's observed
|
|
// data-plane source (spec §5: the NAT-mapping-lifetime primitive). The
|
|
// payload carries the action id for correlation.
|
|
func (s *Server) SendDelayedEcho(sess *session.Session, actionID string) error {
|
|
target := sess.DataSource()
|
|
if !target.IsValid() {
|
|
return fmt.Errorf("session has no observed data-plane source yet")
|
|
}
|
|
conn := s.connFor(target, sess.DataLocal())
|
|
if conn == nil {
|
|
return fmt.Errorf("no data-plane socket matches target family")
|
|
}
|
|
s.send(conn, target, sess, TypeDelayedEcho, 0, []byte(actionID))
|
|
return nil
|
|
}
|
|
|
|
// 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) {
|
|
if len(pkt) < HeaderSize || string(pkt[0:4]) != Magic {
|
|
return
|
|
}
|
|
typ := pkt[4]
|
|
payloadLen := binary.BigEndian.Uint16(pkt[6:8])
|
|
if int(HeaderSize+payloadLen) > len(pkt) {
|
|
return
|
|
}
|
|
var prefix [8]byte
|
|
copy(prefix[:], pkt[8:16])
|
|
seq := binary.BigEndian.Uint32(pkt[16:20])
|
|
|
|
sess := s.Sessions.ByWirePrefix(prefix)
|
|
if sess == nil {
|
|
return
|
|
}
|
|
mac := hmac.New(sha256.New, sess.Key[:])
|
|
mac.Write(pkt[0:28])
|
|
mac.Write(pkt[HeaderSize : HeaderSize+int(payloadLen)])
|
|
if !hmac.Equal(mac.Sum(nil)[:4], pkt[28:32]) {
|
|
return
|
|
}
|
|
if !sess.CheckSeq(seq) {
|
|
return
|
|
}
|
|
sess.NoteDataSource(raddr)
|
|
if la, ok := conn.LocalAddr().(*net.UDPAddr); ok {
|
|
sess.NoteDataLocal(la.AddrPort())
|
|
}
|
|
sess.RecordUDP(session.UDPObservation{
|
|
Seq: seq, TRxNs: tRxNs, TTxNs: time.Since(s.start).Nanoseconds(),
|
|
Src: raddr.String(), Size: len(pkt), Type: typ,
|
|
})
|
|
|
|
switch typ {
|
|
case TypeEchoReq:
|
|
s.echoResp(conn, raddr, sess, pkt, seq, tRxNs)
|
|
case TypeTimesyncReq:
|
|
s.timesyncResp(conn, raddr, sess, pkt, seq, tRxNs)
|
|
case TypeMtuProbe:
|
|
s.mtuAck(conn, raddr, sess, seq, len(pkt))
|
|
default:
|
|
slog.Debug("unhandled data-plane type", "type", typ)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// black hole from which sizes stop being acknowledged. The ACK is tiny, so it
|
|
// can never amplify regardless of probe size.
|
|
func (s *Server) mtuAck(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, seq uint32, received int) {
|
|
var payload [4]byte
|
|
binary.BigEndian.PutUint32(payload[:], uint32(received))
|
|
s.send(conn, raddr, sess, TypeMtuAck, seq, payload[:])
|
|
}
|
|
|
|
// Observation block (spec §3.3), fixed 40 bytes appended to the RESP header:
|
|
//
|
|
// 0 8 t_rx_ns (server clock, process epoch)
|
|
// 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)
|
|
// 35 1 received DSCP/ECN byte (0xFF = not observed)
|
|
// 36 4 received size
|
|
func observation(tRxNs, tTxNs int64, src netip.AddrPort, rcvd int) []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
|
|
binary.BigEndian.PutUint32(b[36:40], uint32(rcvd))
|
|
return b
|
|
}
|
|
|
|
// 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))
|
|
max := len(req)
|
|
if max < HeaderSize {
|
|
return
|
|
}
|
|
payload := obs
|
|
if HeaderSize+len(payload) > max {
|
|
payload = payload[:max-HeaderSize]
|
|
}
|
|
s.send(conn, raddr, sess, TypeEchoResp, seq, payload)
|
|
}
|
|
|
|
// timesyncResp: payload = client t1 (echoed back) + t2 (rx) + t3 (tx), spec §3.2.
|
|
func (s *Server) timesyncResp(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, req []byte, seq uint32, tRxNs int64) {
|
|
payload := make([]byte, 24)
|
|
copy(payload[0:8], req[20:28]) // client's t_ns from the request header
|
|
binary.BigEndian.PutUint64(payload[8:16], uint64(tRxNs))
|
|
binary.BigEndian.PutUint64(payload[16:24], uint64(time.Since(s.start).Nanoseconds()))
|
|
s.send(conn, raddr, sess, TypeTimesyncRsp, seq, payload)
|
|
}
|
|
|
|
func (s *Server) send(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, typ byte, seq uint32, payload []byte) {
|
|
_ = s.sendErr(conn, raddr, sess, typ, seq, payload)
|
|
}
|
|
|
|
// sendErr is send with the write error surfaced. Only the DF-mode big_send cares: there an
|
|
// EMSGSIZE means our own egress MTU refused the datagram, which is a different fact from the
|
|
// client not receiving it.
|
|
func (s *Server) sendErr(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, typ byte, seq uint32, payload []byte) error {
|
|
pkt := s.buildPacket(sess, typ, seq, payload)
|
|
_, err := conn.WriteToUDPAddrPort(pkt, raddr)
|
|
return err
|
|
}
|
|
|
|
// buildPacket assembles and signs an ELT1 packet without sending it.
|
|
//
|
|
// Split out for the crafted-fragment path, which needs the bytes so it can cut them up itself.
|
|
// What arrives after reassembly must be indistinguishable from an ordinary packet, or the client
|
|
// would be measuring our sender rather than the path — so it goes through exactly this function.
|
|
func (s *Server) buildPacket(sess *session.Session, typ byte, seq uint32, payload []byte) []byte {
|
|
pkt := make([]byte, HeaderSize+len(payload))
|
|
copy(pkt[0:4], Magic)
|
|
pkt[4] = typ
|
|
binary.BigEndian.PutUint16(pkt[6:8], uint16(len(payload)))
|
|
idBytes := sess.ID[:16] // hex chars of the 8-byte prefix
|
|
for i := 0; i < 8; i++ {
|
|
pkt[8+i] = hexByte(idBytes[i*2], idBytes[i*2+1])
|
|
}
|
|
binary.BigEndian.PutUint32(pkt[16:20], seq)
|
|
binary.BigEndian.PutUint64(pkt[20:28], uint64(time.Since(s.start).Nanoseconds()))
|
|
copy(pkt[HeaderSize:], payload)
|
|
mac := hmac.New(sha256.New, sess.Key[:])
|
|
mac.Write(pkt[0:28])
|
|
mac.Write(payload)
|
|
copy(pkt[28:32], mac.Sum(nil)[:4])
|
|
return pkt
|
|
}
|
|
|
|
func hexByte(hi, lo byte) byte {
|
|
h := func(c byte) byte {
|
|
switch {
|
|
case c >= '0' && c <= '9':
|
|
return c - '0'
|
|
case c >= 'a' && c <= 'f':
|
|
return c - 'a' + 10
|
|
}
|
|
return 0
|
|
}
|
|
return h(hi)<<4 | h(lo)
|
|
}
|