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>
Three facts the client cannot produce alone, kept deliberately separate:
mtu.pmtud_down (largest datagram that arrives unfragmented — meaningful only
because the server sets DF), mtu.frag_delivery (whether larger ones arrive once
fragmentation is allowed), and train.udp_downstream (loss, reordering and
arrival spacing in the download direction, which a round trip cannot separate
from upstream loss).
ServerMeasurement now runs them on the same ProbeSession as the echo train. It
had to: a fresh session restarts client-side sequence numbers and the server's
anti-replay window discards the lot, so the re-primed source is never recorded
and every granted send goes to a socket that has already closed. That produced
four confidently-wrong FAILED tests and a RED verdict on a healthy network.
Live against fmr: path MTU 1500, fragments to 4000, 100/100 downstream, GREEN.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
link.ra_source answers "who advertises IPv6 here, and which box is it":
RA source per network, MAC recovered from the modified-EUI-64 link-local
(privacy addresses reported as such, not guessed), vendor via a curated
OUI table, UPnP/SSDP M-SEARCH for the gateway's server banner + device
description (manufacturer/model/friendly name), and reverse DNS. All SSDP
responders are recorded so a rogue RA sender that isn't the gateway can
still be matched; the MAC accompanies every identity source as the hook
for future LLDP/mDNS cross-matching. UI gains a "Router / IPv6 advertiser"
panel.
Icons: branding adaptive icon converted to vector drawables (+ PNG
mipmaps, monochrome layer). The debug build is now a separate app —
applicationIdSuffix .dev, label "Echolot DEV", DEV-badged icon — so it
installs alongside a production build and can't be confused with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves the server's canary zone through the platform resolver and
compares against the spec-frozen ground truth (probe-protocol §6.1):
reference records detect answers rewritten in flight, and a per-run nonce
name (uncacheable) proves the query reached the authoritative server.
Findings: dns.answer_rewritten (high), dns.authoritative_unreachable
(medium).
Verified on the OnePlus against the deployed fmr zone: 4/4 reference
records matched exactly, nonce name answered 192.0.2.21 with
reached_authoritative=true. First full client<->server measurement loop
on real hardware; report archived.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mirrors NetworkMonitor: per active network, fetch the AOSP default
generate_204 endpoints and check for HTTP 204 No Content.
- HTTPS https://www.google.com/generate_204 == 204 -> validated internet
- HTTP http://connectivitycheck.gstatic.com/generate_204: 204 -> clean;
an unfollowed 3xx or a 200-with-body -> captive portal (Location captured)
- both fail -> no_internet
Per-network verdicts (bound via Network.openConnection), redirects not
followed (the 3xx IS the evidence). Findings: captive_portal (medium) and
no_internet (high). New test type net.captive_portal (net family ->
connectivity category). App gains usesCleartextTraffic (a network
diagnostic that intentionally probes plain HTTP).
Builds; measurement verdict tests still green. On-device verification
deferred with the rest (flaky test devices).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure Kotlin/JVM, faithful to the schema contract: two-clock (wall RFC3339 +
*_mono_ns), units in field names, observation/interpretation split
(tests[] vs findings[]), columnar train evidence (nulls preserved per
index), the full v1 test-type registry, the anonymization logical types as
field notes, and a finding-requires-evidence invariant.
The one piece with real logic — §7.3 deterministic verdict derivation
(category = worst finding light; >50% failed/unsupported → inconclusive;
overall = worst category, inconclusive only if all are) — is implemented
in Verdicts and fully unit-tested. Document JSON round-trips (snake_case
wire names, null-in-columns), typed builders for train/traceroute/resolver
evidence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>