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
+2
View File
@@ -89,6 +89,8 @@ rolled up under *connectivity* instead — the third occurrence of rule 1 being
| code | severity | means | rules out | | code | severity | means | rules out |
|---|---|---|---| |---|---|---|---|
| `v6.no_default_route` | medium | The device has a global IPv6 address but no IPv6 default route. | Guesswork: this is read from the routing table, not inferred from silence. |
| `v6.route_without_address` | medium | The network advertises an IPv6 default route but the device has no global IPv6 address. | A working IPv6 setup: SLAAC did not produce a usable address on this link. |
| `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.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. | — |
@@ -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> { private fun deriveFindings(tests: List<Test>, networks: List<app.echo_lot.measurement.Network>): List<Finding> {
val out = ArrayList<Finding>() val out = ArrayList<Finding>()
val ids = RunIds() 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) { for (t in tests) {
if (t.type == TestType.NET_CAPTIVE_PORTAL) { if (t.type == TestType.NET_CAPTIVE_PORTAL) {
val ev = t.evidence?.toString() ?: "" val ev = t.evidence?.toString() ?: ""
@@ -181,6 +181,45 @@ object FindingRegistry {
* It is worth reporting either way: filtered ICMPv6 breaks Path MTU Discovery, which is its * It is worth reporting either way: filtered ICMPv6 breaks Path MTU Discovery, which is its
* own fault even when IPv6 works. * own fault even when IPv6 works.
*/ */
/**
* A global IPv6 address with no default route.
*
* This is the structural version of the same complaint, and it is worth far more than the
* ICMP one because it admits no other explanation: the device has an address it cannot route
* with. Nothing is filtered, nothing is inferred — the routing table says so directly, and it
* is already in the link snapshot.
*
* Not always a fault. A VPN that installs host routes to specific destinations produces
* exactly this shape on purpose, and it works. What makes it worth reporting either way is
* that applications cannot tell: having a global address, they will try IPv6 first and stall
* for every destination the routes do not cover.
*/
/**
* An IPv6 default route with no global address to use it from — the mirror of
* [V6_NO_DEFAULT_ROUTE], and the more common misconfiguration of the two.
*
* The router is sending RAs that name it as a default gateway, but SLAAC produced no address:
* no prefix information option, or a prefix without the autonomous flag, or DHCPv6-only
* addressing the device did not complete. The network is announcing IPv6 service it does not
* actually deliver.
*
* This is worth flagging above the ICMP signal because it is both certain and consequential.
* Hosts see router advertisements, believe IPv6 is available, and pay a connection-attempt
* timeout on every dual-stack destination before falling back to IPv4 — the classic "the
* internet feels slow" complaint with no packet loss anywhere to explain it.
*/
val V6_ROUTE_WITHOUT_ADDRESS = FindingSpec(
"v6.route_without_address", Category.IPV6, Severity.MEDIUM,
"The network advertises an IPv6 default route but the device has no global IPv6 address.",
rulesOut = "A working IPv6 setup: SLAAC did not produce a usable address on this link.",
)
val V6_NO_DEFAULT_ROUTE = FindingSpec(
"v6.no_default_route", Category.IPV6, Severity.MEDIUM,
"The device has a global IPv6 address but no IPv6 default route.",
rulesOut = "Guesswork: this is read from the routing table, not inferred from silence.",
)
val V6_NO_ICMP_REPLY = FindingSpec( val V6_NO_ICMP_REPLY = FindingSpec(
"v6.no_icmp_reply", Category.IPV6, Severity.LOW, "v6.no_icmp_reply", Category.IPV6, Severity.LOW,
"IPv6 is configured but ICMPv6 echo gets no reply.", "IPv6 is configured but ICMPv6 echo gets no reply.",
@@ -208,7 +247,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_NO_ICMP_REPLY, V6_NOT_OFFERED, V6_NO_DEFAULT_ROUTE, V6_ROUTE_WITHOUT_ADDRESS, 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 }
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
/**
* The two ways a network can be half-configured for IPv6, read from the link snapshot.
*
* Pure model logic rather than something a ViewModel does, because "is this network's IPv6
* broken, and in which direction" is exactly the kind of judgement that should be checkable
* against a captured routing table without a phone in the loop.
*/
object V6Analysis {
/** Linux tunnel interfaces: WireGuard/Netbird (tun*, wg*), plus the usual VPN names. */
private val TUNNEL_IFACE = Regex("""^(tun|tap|wg|ppp|ipsec|utun)\d*$""")
/** What one network's IPv6 configuration looks like. */
data class Shape(
val iface: String,
/** A global address with no ::/0 route: an address the device cannot route with. */
val addressWithoutRoute: Boolean,
/** A ::/0 route with no global address: a route the device cannot source from. */
val routeWithoutAddress: Boolean,
/** The routes belong to a tunnel, so a partial view of IPv6 is likely deliberate. */
val tunnel: Boolean,
)
/**
* Classifies each network's IPv6 configuration.
*
* Both shapes are read straight from the link snapshot rather than inferred from silence, so
* unlike an ICMP signal there is no competing explanation for what was observed — and both
* matter for the same reason: an application cannot tell in advance, so it tries IPv6 first
* and waits.
*
* They differ in what they mean. An address with no route is what a VPN installing host routes
* to specific destinations produces on purpose, and it works; calling that a fault would be the
* "lack of IPv6 is a yellow condition" mistake in a new costume, so a tunnel downgrades it to
* information. A route with no address is the opposite: the router advertised itself as a
* default gateway but SLAAC produced nothing usable, so the network is announcing IPv6 service
* it does not deliver. That one is a real misconfiguration however it arises.
*/
fun classify(networks: List<Network>): List<Shape> = networks.map { n ->
val globalV6 = n.link.addresses.any { isGlobalV6(it.addr) }
val v6Routes = n.link.routes.filter { it.dst.contains(':') }
val hasDefault = v6Routes.any { it.dst == "::/0" }
Shape(
iface = n.iface ?: v6Routes.firstOrNull()?.iface.orEmpty(),
addressWithoutRoute = globalV6 && !hasDefault,
routeWithoutAddress = hasDefault && !globalV6,
// Android labels the transport itself, which beats guessing from a name; the regex
// stays as a backstop for tunnels Android does not own (a userspace WireGuard, say,
// or anything seen through the shell tier).
tunnel = n.transport == Transport.VPN ||
v6Routes.any { TUNNEL_IFACE.containsMatchIn(it.iface.orEmpty()) },
)
}
/**
* Whether an address is IPv6 and usable as a source for off-link traffic.
*
* ULAs count. A ULA is not globally routable, but it is a global-*scope* address the stack
* will happily select as a source, which is the property that matters here — an overlay
* network handing out fc00::/7 addresses is providing working IPv6 to the destinations it
* carries, and treating that as "no address" would misreport every VPN as broken.
*/
private fun isGlobalV6(addr: String): Boolean {
if (!addr.contains(':')) return false
val a = addr.substringBefore('%').lowercase() // strip any zone index
return !a.startsWith("fe80") && a != "::1" && a != "::"
}
}
@@ -0,0 +1,125 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The fixtures here are a real device's routing table, transcribed from `dumpsys connectivity`
* on a OnePlus 15 with a Netbird tunnel up: wifi advertising a default route it cannot source
* from, cellular working properly, and a VPN carrying host routes to two destinations.
*
* Using a captured table rather than invented ones matters, because the bug this guards against
* is not "the boolean logic is wrong" — it is "the shapes I imagined are not the shapes real
* networks produce".
*/
class V6AnalysisTest {
private fun net(
id: String,
transport: Transport,
iface: String,
addrs: List<String>,
routes: List<Pair<String, String>>,
) = Network(
id = id,
transport = transport,
iface = iface,
link = Link(
addresses = addrs.map { Address(addr = it.substringBefore('/'), prefixLen = 64) },
routes = routes.map { (dst, dev) -> Route(dst = dst, iface = dev) },
),
)
/** wlan0: an IPv6 default route via a link-local gateway, but SLAAC produced no address. */
private val wifi = net(
"w", Transport.WIFI, "wlan0",
addrs = listOf("fe80::bcf6:edff:fe67:b139", "10.13.102.122"),
routes = listOf(
"fe80::/64" to "wlan0",
"::/0" to "wlan0",
"0.0.0.0/0" to "wlan0",
),
)
/** rmnet_data1: a properly configured cellular link — global address and a default route. */
private val cellular = net(
"c", Transport.CELLULAR, "rmnet_data1",
addrs = listOf("2001:4bb8:46a:e724:289d:87ff:feb6:ebd3"),
routes = listOf("::/0" to "rmnet_data1", "2001:4bb8:46a:e724::/64" to "rmnet_data1"),
)
/** tun1: Netbird, with a ULA and host routes to exactly two destinations. */
private val vpn = net(
"v", Transport.VPN, "tun1",
addrs = listOf("100.64.158.131", "fdfd:c4fe:c4fe:c4fe:1f3c:98a0:dd66:ac7"),
routes = listOf(
"2001:1ad0:c4fe:6767::2/128" to "tun1",
"2001:1ad0:c4fe:a::136/128" to "tun1",
"fdfd:c4fe:c4fe:c4fe::/64" to "tun1",
),
)
@Test
fun `wifi advertising a route it cannot source from is reported`() {
val s = V6Analysis.classify(listOf(wifi)).single()
assertTrue(s.routeWithoutAddress, "::/0 with only a link-local address is the RA-without-SLAAC case")
assertFalse(s.addressWithoutRoute)
assertFalse(s.tunnel, "wifi is not a tunnel")
assertEquals("wlan0", s.iface)
}
@Test
fun `a properly configured link produces no finding`() {
val s = V6Analysis.classify(listOf(cellular)).single()
assertFalse(s.routeWithoutAddress)
assertFalse(s.addressWithoutRoute)
}
@Test
fun `a tunnel with host routes is deliberate, not broken`() {
val s = V6Analysis.classify(listOf(vpn)).single()
assertTrue(s.addressWithoutRoute, "a ULA and no ::/0 is an address with nothing to route it")
assertTrue(s.tunnel, "so it must be reported as information, not as a fault")
assertFalse(s.routeWithoutAddress)
}
@Test
fun `each network is judged on its own`() {
// The whole point of per-network classification: "IPv6 is broken" is useless advice when
// wifi is the broken one and cellular is fine.
val shapes = V6Analysis.classify(listOf(wifi, cellular, vpn)).associateBy { it.iface }
assertTrue(shapes.getValue("wlan0").routeWithoutAddress)
assertFalse(shapes.getValue("rmnet_data1").routeWithoutAddress)
assertFalse(shapes.getValue("rmnet_data1").addressWithoutRoute)
assertTrue(shapes.getValue("tun1").addressWithoutRoute)
}
@Test
fun `a link-local-only network with no v6 route says nothing either way`() {
// Plain IPv4-only wifi: no IPv6 offered at all. That is v6.not_offered's business, and
// reporting it here as well would double up on a network that is merely legacy, not broken.
val v4only = net(
"4", Transport.WIFI, "wlan0",
addrs = listOf("fe80::1", "192.168.1.5"),
routes = listOf("0.0.0.0/0" to "wlan0"),
)
val s = V6Analysis.classify(listOf(v4only)).single()
assertFalse(s.routeWithoutAddress)
assertFalse(s.addressWithoutRoute)
}
@Test
fun `a zone index does not hide a link-local address`() {
val zoned = net(
"z", Transport.WIFI, "wlan0",
addrs = listOf("fe80::1%wlan0"),
routes = listOf("::/0" to "wlan0"),
)
assertTrue(V6Analysis.classify(listOf(zoned)).single().routeWithoutAddress)
}
}