diff --git a/docs/build-status.md b/docs/build-status.md index 4b044d9..b9a59bc 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -254,3 +254,20 @@ caught a finding: **Google applies 0x20 case randomization** (mixed-case qname), not — captured via `case_preserved`. Capabilities now: udp-probe, delayed-echo, connect-back, tcp-echo, stun-5780, canary-dns. Kept the hand-rolled stdlib DNS (no miekg/dns) — validated against independent clients. Deployed via `--self-update` (v0.3.0→v0.3.1, checksum-verified). + +## Server v0.3.2 + v0.3.3 (2026-07-31) +- **v0.3.2 — control-plane security (live on fmr, externally verified):** `POST /v1/echo` + reflects the received request head+body (b64) and observed TLS (version/cipher/SNI/ALPN) — + captured real SNI `fmr-1.echo-lot.app` and an injected header over public TLS1.3; `GET + /v1/tls-reference` returns the served DER chain + pin (cross-checked against the openssl-derived + pin). Optional cleartext echo listener (default off). Capability `http-echo`. +- **v0.3.3 — MTU probe (data plane):** MTU_PROBE (0x09) → small MTU_ACK (0x0A) carrying the + received datagram size; client DF-probes increasing sizes to find path MTU / black holes. ACK + is tiny → never amplifies. Tested. +- **Note on trains:** upstream trains (TRAIN_DATA 0x03) are already observable — every HMAC-valid + packet is recorded (seq/t_rx/size/type) with no per-packet response, so loss/reordering/inter- + arrival are visible via GET observations. The dedicated data-plane TRAIN_REPORT (0x05) is + deferred: §3.4 anti-amplification means it needs an asymmetric grant + columnar multi-datagram + encoding — a focused batch, not a corner to rush. +Remaining spec: tls-echo (ClientHello+JA4), TRAIN_REPORT, big/frag-send, throughput, downtrain; +real admin UI. diff --git a/server/internal/dataplane/udp.go b/server/internal/dataplane/udp.go index 18236e8..4ab9bcb 100644 --- a/server/internal/dataplane/udp.go +++ b/server/internal/dataplane/udp.go @@ -29,6 +29,8 @@ const ( TypeEchoResp = 0x02 TypeTimesyncReq = 0x07 TypeTimesyncRsp = 0x08 + TypeMtuProbe = 0x09 + TypeMtuAck = 0x0A TypeDelayedEcho = 0x0B ) @@ -132,11 +134,24 @@ func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRx 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 diff --git a/server/internal/dataplane/udp_test.go b/server/internal/dataplane/udp_test.go index 4edac0d..22fa7c0 100644 --- a/server/internal/dataplane/udp_test.go +++ b/server/internal/dataplane/udp_test.go @@ -102,6 +102,40 @@ func TestEchoRoundtripObservationAndAntiAmplification(t *testing.T) { } } +func TestMtuProbeAckReportsReceivedSizeAndDoesNotAmplify(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(2 * time.Second)) + + // A large probe: 32 header + 1400 payload. + probe := craft(t, sess, TypeMtuProbe, 1, make([]byte, 1400)) + if _, err := client.Write(probe); err != nil { + t.Fatal(err) + } + buf := make([]byte, 2000) + n, err := client.Read(buf) + if err != nil { + t.Fatalf("no MTU_ACK: %v", err) + } + if buf[4] != TypeMtuAck { + t.Fatalf("type = %#x, want MTU_ACK", buf[4]) + } + if n >= len(probe) { + t.Fatalf("MTU_ACK (%d) must be far smaller than the probe (%d)", n, len(probe)) + } + if got := binary.BigEndian.Uint32(buf[HeaderSize:n]); int(got) != len(probe) { + t.Fatalf("acked size %d, want %d", got, len(probe)) + } +} + func TestDropsReplayBadHmacAndUnknownPrefix(t *testing.T) { mgr, addr := startServer(t) sess, _, err := mgr.New("dev1", "credential-ikm", netip.MustParseAddr("127.0.0.1"))