app: name the two half-configured IPv6 shapes, per network

v6.no_icmp_reply infers trouble from silence, which is ambiguous by
construction: a firewall dropping echo requests looks the same as a
network that cannot carry IPv6 at all. Two much stronger signals were
already sitting unread in the link snapshot, and a test device on a
Netbird tunnel surfaced both at once.

v6.route_without_address — a ::/0 route with no global address. The
router advertises itself as an IPv6 gateway while SLAAC produces nothing
usable. Hosts believe IPv6 is available and pay a connection timeout on
every dual-stack destination before falling back, which is felt as
general slowness with no packet loss to explain it.

v6.no_default_route — the mirror: a global address with nothing to route
it. A VPN installing host routes to specific destinations produces this
deliberately and it works, so a VPN transport reports it as INFO rather
than as a fault; without one it means the network handed out an address
it does not carry traffic for.

Both are read from the routing table, so neither is inferred from
silence, and both are reported per interface — "IPv6 is broken" is
useless advice when wifi is the broken one and cellular is fine.

Classification lives in core-measurement rather than the ViewModel so it
can be tested without a device; the fixtures are a real dumpsys table
(wifi advertising a route it cannot source from, working cellular, a
tunnel with two host routes) because the risk here is not bad boolean
logic but imagining shapes real networks do not produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 20:42:03 +02:00
co-authored by Claude Opus 5
parent fec374abf5
commit 7eaf0c4190
5 changed files with 296 additions and 1 deletions
@@ -403,6 +403,62 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
private fun deriveFindings(tests: List<Test>, networks: List<app.echo_lot.measurement.Network>): List<Finding> {
val out = ArrayList<Finding>()
val ids = RunIds()
val linkEvidence = tests.filter { it.type == TestType.LINK_SNAPSHOT }.map { EvidenceRef(it.id) }
val shapes = V6Analysis.classify(networks)
// Named per interface: on a phone several networks are up at once, and "IPv6 is broken" is
// useless when wifi is the broken one and cellular is fine.
for (sh in shapes.filter { it.addressWithoutRoute }) {
val where = if (sh.iface.isBlank()) "This device" else sh.iface
out.add(
Finding(
id = ids.uuid(),
code = FindingRegistry.V6_NO_DEFAULT_ROUTE.code,
category = FindingRegistry.V6_NO_DEFAULT_ROUTE.category,
severity = if (sh.tunnel) Severity.INFO else Severity.MEDIUM,
confidence = Confidence.HIGH,
title = if (sh.tunnel) {
"IPv6 reaches only the destinations a tunnel routes ($where)"
} else {
"IPv6 address with no default route ($where)"
},
description = "$where has a global IPv6 address but no IPv6 default route, so " +
"IPv6 reaches only destinations covered by a specific route. " +
if (sh.tunnel) {
"A tunnel interface holds those routes, so this looks deliberate. " +
"Worth knowing rather than fixing: applications holding a global " +
"address will still try IPv6 first and stall for anything outside " +
"the tunnel's routes."
} else {
"Nothing is routing the rest, so the network handed out an address it " +
"does not carry traffic for — applications will try IPv6 first " +
"and wait for it to fail."
},
evidenceRefs = linkEvidence,
)
)
}
for (sh in shapes.filter { it.routeWithoutAddress }) {
val where = if (sh.iface.isBlank()) "This network" else sh.iface
out.add(
Finding(
id = ids.uuid(),
code = FindingRegistry.V6_ROUTE_WITHOUT_ADDRESS.code,
category = FindingRegistry.V6_ROUTE_WITHOUT_ADDRESS.category,
severity = Severity.MEDIUM,
confidence = Confidence.HIGH,
title = "IPv6 router advertised, but no address was configured ($where)",
description = "$where has an IPv6 default route but no global IPv6 address. " +
"The router is advertising itself as an IPv6 gateway while SLAAC produced " +
"no usable address — a missing prefix option, a prefix without the " +
"autonomous flag, or DHCPv6-only addressing that did not complete. Hosts " +
"believe IPv6 is available and pay a connection timeout on every " +
"dual-stack destination before falling back to IPv4, which is felt as " +
"general slowness with no packet loss to explain it.",
evidenceRefs = linkEvidence,
)
)
}
for (t in tests) {
if (t.type == TestType.NET_CAPTIVE_PORTAL) {
val ev = t.evidence?.toString() ?: ""