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:
co-authored by
Claude Opus 5
parent
fec374abf5
commit
7eaf0c4190
@@ -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() ?: ""
|
||||
|
||||
Reference in New Issue
Block a user