findings: v6.broken claimed a cause it had no evidence for

A phone reported "IPv6 is configured but not working" while loading an IPv6-only
site over TCP perfectly well. The finding fired on one signal - ICMPv6 echo
getting no reply - at HIGH confidence. ICMPv6 echo is widely filtered on
networks where IPv6 works, so the two cases are indistinguishable from where the
app stands, and it was picking one.

Same class of error as the multi-homed downstream-loss bug: a confident
measurement of something that was not happening. Now v6.no_icmp_reply, low
severity, medium confidence, naming both explanations. Still reported, because
filtered ICMPv6 breaks Path MTU Discovery - large packets vanish instead of
being reported as too big - which is a fault in its own right.

Corroborating with a real IPv6 connection would separate the two properly, but
needs a target, which runs into the hardcoded-deployment issue already open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 20:16:37 +02:00
co-authored by Claude Fable 5
parent b5c8dda9a2
commit fec374abf5
4 changed files with 56 additions and 11 deletions
+26
View File
@@ -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 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 a third party's infrastructure is the wrong default. It should prefer the configured server, and be
explicit when there is none. **Open.** 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.**
+8 -1
View File
@@ -89,9 +89,16 @@ rolled up under *connectivity* instead — the third occurrence of rule 1 being
| code | severity | means | rules out | | 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.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 `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 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. people to ignore the light — the one thing a diagnostic must never do.
@@ -479,11 +479,11 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
if (ipv6Provisioned(networks)) { if (ipv6Provisioned(networks)) {
out.add( out.add(
Finding( Finding(
id = ids.uuid(), code = FindingRegistry.V6_BROKEN.code, id = ids.uuid(), code = FindingRegistry.V6_NO_ICMP_REPLY.code,
category = FindingRegistry.V6_BROKEN.category, category = FindingRegistry.V6_NO_ICMP_REPLY.category,
severity = FindingRegistry.V6_BROKEN.severity, confidence = Confidence.HIGH, severity = FindingRegistry.V6_NO_ICMP_REPLY.severity, confidence = Confidence.MEDIUM,
title = "IPv6 is configured but not working", title = "IPv6 is configured, but ICMPv6 gets no reply",
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.", 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)), evidenceRefs = listOf(EvidenceRef(t.id)),
) )
) )
@@ -169,10 +169,22 @@ object FindingRegistry {
// they silently rolled up under connectivity: the third instance of a prefix disagreeing with // 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. // its category and quietly moving a fault to a different verdict light.
val V6_BROKEN = FindingSpec( /**
"v6.broken", Category.IPV6, Severity.MEDIUM, * Renamed from `v6.broken`, which claimed more than the evidence supports.
"IPv6 is configured on this network but does not work.", *
rulesOut = "Absence of IPv6: it is provisioned, it simply fails.", * 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, NAT_UDP_REBINDING, NAT_SYMMETRIC,
THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED, THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED,
DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE, DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE,
V6_BROKEN, V6_NOT_OFFERED, V6_NO_ICMP_REPLY, V6_NOT_OFFERED,
) )
private val byCode: Map<String, FindingSpec> = all.associateBy { it.code } private val byCode: Map<String, FindingSpec> = all.associateBy { it.code }