diff --git a/docs/build-status.md b/docs/build-status.md index 3818e7c..0d3a25f 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -728,3 +728,49 @@ tenfold asymmetry that a round-trip measurement cannot see at all. 10 unit tests on the arithmetic (a wrong denominator here does not crash, it produces a plausible number pointing at the wrong half of the network) plus the live correlation check. + +### frag_send: crafted IP fragments, so *ordering* is testable (server-v0.6.0, 2026-08-01) +`big_send` with `df=false` answers one question — do fragments get through. It cannot answer the +more interesting one, because the kernel always emits fragments 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 simply drop them. That is invisible to every in-order test, and in +the field it looks like "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 builds the fragments itself (raw socket, `IP_HDRINCL`) and controls their order: +`in_order` (baseline), `reversed` (last fragment first), `first_last` (first fragment held back +250 ms). The datagram is assembled and **signed whole** before being cut up, so what the client +reassembles is indistinguishable from an ordinary packet — otherwise the test would be measuring +our sender rather than the path. New test type `mtu.frag_ordering`; findings +`mtu.fragments_blocked` and `mtu.fragment_reorder_sensitive`. + +Two details that would otherwise produce confidently wrong answers: +- **The UDP checksum is computed, not left zero.** Zero is legal in IPv4 and would be less code, + but zero-checksum datagrams are 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 down 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, +because a permission model has more ways to say no (userns, seccomp, LSM) than a capability bit has +to say yes. fmr runs as root with `cap_net_raw` in its bounding set, so it is available there. + +Fragment ordering runs only after `mtu.frag_delivery` shows fragments arrive at all; otherwise the +three orderings would each report "not delivered" and read as three faults instead of one. + +The header arithmetic is unit-tested (reassembly coverage with no gaps or double-delivery, MF +flags, shared IP ID, 8-byte offsets, checksum verification over odd and even lengths). Because the +code is `//go:build linux`, the tests are **cross-compiled and run on fmr** — there is no Go +toolchain there, so `go test -c` plus scp is the loop. + +Live against fmr: 4 fragments per burst, and all three orderings reassembled — a healthy path, and +the baseline against which a mobile network will be interesting. + +### Testing state (2026-08-01) +Six live tests against fmr, all green, no device involved: `LiveServerTest`, `LiveMeasurement`, +`LiveGranted`, `LiveDownstream`, `LiveUpload`, `LiveCompat`, `LiveEnrollment`. Plus 74 client unit +tests and the full Go suite. Everything in the last several entries is verified from the PC; the +app's UI (settings, history, deep-link enrollment) and `mtu.pmtud_up` remain device-only. diff --git a/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveDownstreamTest.kt b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveDownstreamTest.kt index 548125f..d507d98 100644 --- a/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveDownstreamTest.kt +++ b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveDownstreamTest.kt @@ -44,7 +44,9 @@ class LiveDownstreamTest { for (t in tests) println("${t.type} status=${t.status} metrics=${t.metrics}") for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}") - assertEquals(3, tests.size, "expected pmtud_down, frag_delivery and a downstream train") + // Assert on what is present, not on how many: adding a measurement should not be a + // test edit. (It was, once — hence the note.) + assertTrue(tests.size >= 3, "expected at least the three downstream tests, got ${tests.size}") val byType = tests.associateBy { it.type } val pmtud = assertNotNull(byType[TestType.MTU_PMTUD_DOWN], "no mtu.pmtud_down test") @@ -58,6 +60,17 @@ class LiveDownstreamTest { val frag = assertNotNull(byType[TestType.MTU_FRAG_DELIVERY], "no mtu.frag_delivery test") assertNotNull(frag.metrics?.get("largest_delivered_bytes")) + // Fragment ordering runs only when fragments arrive at all, and only against a server + // that can craft them — so it is checked when present rather than required. + byType[TestType.MTU_FRAG_ORDERING]?.let { fo -> + val m = fo.metrics?.toString() ?: "" + println("fragment ordering: ${fo.status} $m") + if (fo.status != TestStatus.UNSUPPORTED) { + assertTrue(m.contains("in_order"), "no per-ordering result: $m") + assertTrue(m.contains("reversed"), "reversed ordering was never attempted: $m") + } + } + val train = assertNotNull(byType[TestType.TRAIN_UDP_DOWNSTREAM], "no downstream train") assertNotNull(train.evidence, "a train without columnar evidence is not recomputable") val received = train.metrics?.get("received")?.toString()?.toIntOrNull() ?: 0