From d9ee8bc2aea0b9eda1a1cf740f6df345d8e8b0b3 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sat, 1 Aug 2026 21:32:34 +0200 Subject: [PATCH] app: don't report ICMPv6 silence that was never measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-network attribution fix worked — the finding named rmnet_data1 instead of the IPv4-only wifi — and immediately exposed a worse problem underneath. Cellular's result was `ok: false` because binding a socket to it failed with EPERM, so no echo request was ever sent; the finding then reported "IPv6 is configured, but ICMPv6 gets no reply" about a network the app had never pinged. That is an assertion about the user's carrier with nothing behind it. `attempted` now travels beside `ok`, set only once sendto has returned, and the finding requires both. Failing to bind is a fact about this app's permissions on this device; it says nothing about the network, and the two must not share a boolean. Verified on hardware with a VPN active: every network fails to bind with EPERM, nothing is sent, and no ICMPv6 finding is emitted — where the previous build would have blamed the carrier. Recorded in build-status: Android blocks per-network binding entirely while a VPN holds the default route, so per-network measurement is unavailable to anyone with one connected. That needs a deliberate answer rather than a silently green run. Co-Authored-By: Claude Opus 5 --- docs/build-status.md | 19 ++++++++ .../kotlin/app/echo_lot/app/RunViewModel.kt | 25 ++++++++--- .../kotlin/app/echo_lot/probe/IcmpProbe.kt | 45 ++++++++++++++----- 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/docs/build-status.md b/docs/build-status.md index af782a6..632212a 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -1108,3 +1108,22 @@ 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.** + +## Per-network probing is blocked while a VPN is up (2026-08-01) + +`Network.bindSocket()` fails with `EPERM` for every underlying network when a VPN holds the +default route — verified on the OnePlus 15 with Netbird active: `Binding socket to network 101 +failed: EPERM` for both cellular and wifi. This is Android preventing VPN leaks, not a bug to work +around, and it means the whole per-network measurement approach is unavailable to any user with a +VPN connected. Worth deciding deliberately rather than discovering per report: + +- The run currently succeeds and simply measures nothing per network. Honest, but silent — the + document records `attempted: false` and the UI says green. +- A user with a corporate VPN permanently on would get a green run that measured almost nothing. + +Options are to detect the VPN and say so plainly ("this network cannot be measured while a VPN is +active"), to measure the tunnel itself as the network under test, or both. Not yet decided. + +Related: `icmp.ping6` now records `attempted` alongside `ok` per network, because collapsing them +made the app report "IPv6 is configured, but ICMPv6 gets no reply" about an interface it had never +succeeded in sending on — a claim about the user's carrier with no evidence behind it. 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 319ad66..ced690a 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 @@ -420,14 +420,24 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { * that depended on the wording of a human-readable string would break silently the first time * that wording improved. */ - private fun icmpResults(t: Test): Map { - val out = HashMap() + /** What one network's ICMP attempt did: whether it ran at all, and whether it was answered. */ + private data class IcmpOutcome(val attempted: Boolean, val ok: Boolean) + + /** + * Per-network ICMP outcomes, keyed by network id. + * + * Reads the structured evidence the probe records rather than its prose detail — a finding + * that depended on the wording of a human-readable string would break silently the first time + * that wording improved. + */ + private fun icmpResults(t: Test): Map { + val out = HashMap() val ev = t.evidence ?: return out for ((_, v) in ev) { val o = v as? kotlinx.serialization.json.JsonObject ?: continue val ref = (o["network_ref"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: continue - val ok = (o["ok"] as? kotlinx.serialization.json.JsonPrimitive)?.content == "true" - out[ref] = ok + fun flag(k: String) = (o[k] as? kotlinx.serialization.json.JsonPrimitive)?.content == "true" + out[ref] = IcmpOutcome(attempted = flag("attempted"), ok = flag("ok")) } return out } @@ -578,8 +588,11 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { for (n in networks) { val provisioned = ipv6Provisioned(networks, n.id) if (provisioned) anyV6Network = true - val ok = results[n.id] ?: continue - if (!provisioned || ok) continue + val r = results[n.id] ?: continue + // Silence is only evidence if something was actually sent. A bind that failed + // with EPERM says the app could not use the interface, which is a fact about + // this app's permissions and says nothing whatsoever about the network. + if (!provisioned || !r.attempted || r.ok) continue val where = n.iface?.takeIf { it.isNotBlank() } ?: "this network" out.add( Finding( diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpProbe.kt index af9f3dc..f2b87fb 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpProbe.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/IcmpProbe.kt @@ -40,19 +40,20 @@ class IcmpProbe( override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) { val b = TestBuilder(type, tier, ids) - val perNetwork = LinkedHashMap>() + val perNetwork = LinkedHashMap>() var anyOk = false val rtts = ArrayList() // Default network first, then each active network explicitly. - attempt(null).let { (ok, detail, rtt) -> - perNetwork["default"] = Triple(null, ok, detail); if (ok) { anyOk = true; rtt?.let(rtts::add) } + attempt(null).let { a -> + perNetwork["default"] = null to a + if (a.ok) { anyOk = true; a.rttMs?.let(rtts::add) } } for (e in entries) { val label = "${e.model.transport.name.lowercase()}:${e.model.id}" - val (ok, detail, rtt) = attempt(e.handle) - perNetwork[label] = Triple(e.model.id, ok, detail) - if (ok) { anyOk = true; rtt?.let(rtts::add) } + val a = attempt(e.handle) + perNetwork[label] = e.model.id to a + if (a.ok) { anyOk = true; a.rttMs?.let(rtts::add) } } // Per-network results are recorded structurally, not just as prose. The aggregate status @@ -62,11 +63,12 @@ class IcmpProbe( val evidence: JsonObject = buildJsonObject { put("target", target) for ((label, r) in perNetwork) { - val (netId, ok, detail) = r + val (netId, a) = r put(label, buildJsonObject { netId?.let { put("network_ref", it) } - put("ok", ok) - put("detail", detail) + put("ok", a.ok) + put("attempted", a.attempted) + put("detail", a.detail) }) } } @@ -80,14 +82,31 @@ class IcmpProbe( b.build(status, evidence = evidence, metrics = metrics) } - private data class Attempt(val ok: Boolean, val detail: String, val rttMs: Double?) + /** + * One network's result. + * + * [attempted] separates "we sent an echo request and heard nothing" from "we never got as far + * as sending one". Both leave [ok] false, and collapsing them is how a probe ends up asserting + * something about a network it never touched: binding to a non-default network can fail with + * EPERM, and reporting that as ICMPv6 silence blames the carrier for the app's own inability + * to use the interface. + */ + private data class Attempt( + val ok: Boolean, + val attempted: Boolean, + val detail: String, + val rttMs: Double?, + ) private fun attempt(network: Network?): Attempt { var fd: FileDescriptor? = null + var sent = false return try { val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET fd = Os.socket(family, OsConstants.SOCK_DGRAM, proto) + // Everything up to and including sendto is setup. A failure here means the test did + // not run on this network — not that the network stayed silent. network?.bindSocket(fd) Os.setsockoptTimeval(fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO, StructTimeval.fromMillis(3000)) val addr = network?.getByName(target) ?: InetAddress.getByName(target) @@ -96,14 +115,16 @@ class IcmpProbe( val packet = buildEchoRequest(v6, ident.toShort(), 1) val t0 = System.nanoTime() Os.sendto(fd, packet, 0, packet.size, 0, addr, 0) + sent = true val buf = ByteBuffer.allocate(1500) val received = Os.recvfrom(fd, buf, 0, null) val rttMs = (System.nanoTime() - t0) / 1_000_000.0 val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1 val ok = replyType == (if (v6) 129 else 0) - Attempt(ok, "reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} bytes=$received", if (ok) rttMs else null) + Attempt(ok, true, "reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} bytes=$received", if (ok) rttMs else null) } catch (e: Throwable) { - Attempt(false, "error: ${e.message ?: e.javaClass.simpleName}", null) + // A timeout after a successful send is a real "no reply"; anything before it is not. + Attempt(false, sent, "error: ${e.message ?: e.javaClass.simpleName}", null) } finally { fd?.let { runCatching { Os.close(it) } } }