diff --git a/docs/build-status.md b/docs/build-status.md index 671c248..af782a6 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -1082,3 +1082,29 @@ so a user with no server of their own still sends DNS and STUN traffic to fmr wi For a tool that goes to this much trouble over what leaves the device, an undisclosed dependency on a third party's infrastructure is the wrong default. It should prefer the configured server, and be explicit when there is none. **Open.** + +### v6.broken was a false positive waiting to happen (2026-08-01) +A phone could not open `https://fmr.echo-lot.app` while loading the same server by IP literal +perfectly well. Two things came out of chasing it. + +**The admin UI is IPv6-only, by consequence rather than intent.** `fmr.echo-lot.app` has an AAAA +and no A record — verified identical at Cloudflare, Google and Quad9, so DNS itself is healthy. +That follows from reserving all four measurement addresses for testing, which left only `::2` for +management, and `::2` has no IPv4 counterpart. Any client without working IPv6 sees an unreachable +admin interface — a poor property for the interface you reach *from the networks you are debugging*. + +**And the app's own `v6.broken` finding was unsound.** It fired on exactly one signal — ICMPv6 echo +getting no reply — with `Confidence.HIGH`. ICMPv6 echo is widely filtered on networks where IPv6 +works fine, which is precisely what that phone demonstrated: no ICMPv6 replies, working IPv6 TCP. +The finding asserted a cause it had no evidence for, which is the same class of error as the +multi-homed `100 % downstream loss` earlier: a confident measurement of something that was not +happening. + +Now `v6.no_icmp_reply`, severity low, confidence medium, and the text names *both* explanations +instead of choosing one. It is still worth reporting, because filtered ICMPv6 breaks Path MTU +Discovery — large packets vanish rather than being reported as too big — which is a real fault even +when IPv6 works. + +The proper fix is corroboration: attempt a real IPv6 connection and only call it broken when that +fails too. That needs a target, which runs into the hardcoded-reference-deployment issue already +open above. **Both remain open.** diff --git a/docs/findings-registry.md b/docs/findings-registry.md index 68bc3d1..bb8afa0 100644 --- a/docs/findings-registry.md +++ b/docs/findings-registry.md @@ -89,9 +89,16 @@ rolled up under *connectivity* instead — the third occurrence of rule 1 being | code | severity | means | rules out | |---|---|---|---| -| `v6.broken` | medium | IPv6 is configured on this network but does not work. | Absence of IPv6: it is provisioned, it simply fails. | +| `v6.no_icmp_reply` | low | IPv6 is configured but ICMPv6 echo gets no reply. | Nothing on its own: IPv6 may work fine with ICMP filtered. | | `v6.not_offered` | info | This network does not offer IPv6. | — | +`v6.no_icmp_reply` was `v6.broken` until a phone reported it while loading an IPv6-only site over +TCP perfectly well. The only evidence behind it is ICMPv6 echo, which is widely filtered on +networks where IPv6 works — so the finding now states what was observed and names both +explanations instead of choosing one. It is still worth reporting: filtered ICMPv6 breaks Path MTU +Discovery. Corroborating it with a real IPv6 connection would let the two cases be separated, and +is the proper fix. + `v6.not_offered` is **info and must stay info**. Most networks still do not offer IPv6 and that is not a fault; reporting it as a warning lights a yellow verdict on a healthy network, which teaches people to ignore the light — the one thing a diagnostic must never do. diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt index b60040f..c61e216 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt @@ -479,11 +479,11 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { if (ipv6Provisioned(networks)) { out.add( Finding( - id = ids.uuid(), code = FindingRegistry.V6_BROKEN.code, - category = FindingRegistry.V6_BROKEN.category, - severity = FindingRegistry.V6_BROKEN.severity, confidence = Confidence.HIGH, - title = "IPv6 is configured but not working", - description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 got no reply on any network. Half-configured IPv6 is worse than none: connections try IPv6 first and stall before falling back.", + id = ids.uuid(), code = FindingRegistry.V6_NO_ICMP_REPLY.code, + category = FindingRegistry.V6_NO_ICMP_REPLY.category, + severity = FindingRegistry.V6_NO_ICMP_REPLY.severity, confidence = Confidence.MEDIUM, + title = "IPv6 is configured, but ICMPv6 gets no reply", + description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 echo got no reply on any network. That has two explanations which look identical from here: IPv6 is broken, or ICMPv6 is filtered while IPv6 itself works. Filtering is common and is a fault in its own right — it breaks Path MTU Discovery, so large packets vanish rather than being reported as too big.", evidenceRefs = listOf(EvidenceRef(t.id)), ) ) diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt index 00db4aa..8b55782 100644 --- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt @@ -169,10 +169,22 @@ object FindingRegistry { // they silently rolled up under connectivity: the third instance of a prefix disagreeing with // its category and quietly moving a fault to a different verdict light. - val V6_BROKEN = FindingSpec( - "v6.broken", Category.IPV6, Severity.MEDIUM, - "IPv6 is configured on this network but does not work.", - rulesOut = "Absence of IPv6: it is provisioned, it simply fails.", + /** + * Renamed from `v6.broken`, which claimed more than the evidence supports. + * + * The only signal behind it is ICMPv6 echo getting no reply — and ICMPv6 echo is widely + * filtered on networks where IPv6 otherwise works perfectly. A phone that reported this while + * happily loading an IPv6-only site over TCP is what caught it. From here the two cases look + * identical, so the finding now says what was observed and names both explanations rather than + * picking one. + * + * It is worth reporting either way: filtered ICMPv6 breaks Path MTU Discovery, which is its + * own fault even when IPv6 works. + */ + val V6_NO_ICMP_REPLY = FindingSpec( + "v6.no_icmp_reply", Category.IPV6, Severity.LOW, + "IPv6 is configured but ICMPv6 echo gets no reply.", + rulesOut = "Nothing on its own: IPv6 may work fine with ICMP filtered.", ) /** @@ -196,7 +208,7 @@ object FindingRegistry { NAT_UDP_REBINDING, NAT_SYMMETRIC, THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED, DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE, - V6_BROKEN, V6_NOT_OFFERED, + V6_NO_ICMP_REPLY, V6_NOT_OFFERED, ) private val byCode: Map = all.associateBy { it.code }