diff --git a/docs/findings-registry.md b/docs/findings-registry.md index bb8afa0..b38c23e 100644 --- a/docs/findings-registry.md +++ b/docs/findings-registry.md @@ -89,6 +89,8 @@ rolled up under *connectivity* instead — the third occurrence of rule 1 being | 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.not_offered` | info | This network does not offer IPv6. | — | 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 c61e216..f09e834 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 @@ -403,6 +403,62 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { private fun deriveFindings(tests: List, networks: List): List { val out = ArrayList() 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() ?: "" 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 8b55782..f0c74e6 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 @@ -181,6 +181,45 @@ object FindingRegistry { * It is worth reporting either way: filtered ICMPv6 breaks Path MTU Discovery, which is its * 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( "v6.no_icmp_reply", Category.IPV6, Severity.LOW, "IPv6 is configured but ICMPv6 echo gets no reply.", @@ -208,7 +247,7 @@ object FindingRegistry { NAT_UDP_REBINDING, NAT_SYMMETRIC, THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED, 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 = all.associateBy { it.code } diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/V6Analysis.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/V6Analysis.kt new file mode 100644 index 0000000..8b4d28e --- /dev/null +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/V6Analysis.kt @@ -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): List = 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 != "::" + } +} diff --git a/echolot-app/core-measurement/src/test/kotlin/app/echo_lot/measurement/V6AnalysisTest.kt b/echolot-app/core-measurement/src/test/kotlin/app/echo_lot/measurement/V6AnalysisTest.kt new file mode 100644 index 0000000..b28e48e --- /dev/null +++ b/echolot-app/core-measurement/src/test/kotlin/app/echo_lot/measurement/V6AnalysisTest.kt @@ -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, + routes: List>, + ) = 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) + } +}