app: don't report ICMPv6 silence that was never measured
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7a5004f293
commit
d9ee8bc2ae
@@ -1108,3 +1108,22 @@ when IPv6 works.
|
|||||||
The proper fix is corroboration: attempt a real IPv6 connection and only call it broken when that
|
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
|
fails too. That needs a target, which runs into the hardcoded-reference-deployment issue already
|
||||||
open above. **Both remain open.**
|
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.
|
||||||
|
|||||||
@@ -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 depended on the wording of a human-readable string would break silently the first time
|
||||||
* that wording improved.
|
* that wording improved.
|
||||||
*/
|
*/
|
||||||
private fun icmpResults(t: Test): Map<String, Boolean> {
|
/** What one network's ICMP attempt did: whether it ran at all, and whether it was answered. */
|
||||||
val out = HashMap<String, Boolean>()
|
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<String, IcmpOutcome> {
|
||||||
|
val out = HashMap<String, IcmpOutcome>()
|
||||||
val ev = t.evidence ?: return out
|
val ev = t.evidence ?: return out
|
||||||
for ((_, v) in ev) {
|
for ((_, v) in ev) {
|
||||||
val o = v as? kotlinx.serialization.json.JsonObject ?: continue
|
val o = v as? kotlinx.serialization.json.JsonObject ?: continue
|
||||||
val ref = (o["network_ref"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: continue
|
val ref = (o["network_ref"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: continue
|
||||||
val ok = (o["ok"] as? kotlinx.serialization.json.JsonPrimitive)?.content == "true"
|
fun flag(k: String) = (o[k] as? kotlinx.serialization.json.JsonPrimitive)?.content == "true"
|
||||||
out[ref] = ok
|
out[ref] = IcmpOutcome(attempted = flag("attempted"), ok = flag("ok"))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -578,8 +588,11 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
for (n in networks) {
|
for (n in networks) {
|
||||||
val provisioned = ipv6Provisioned(networks, n.id)
|
val provisioned = ipv6Provisioned(networks, n.id)
|
||||||
if (provisioned) anyV6Network = true
|
if (provisioned) anyV6Network = true
|
||||||
val ok = results[n.id] ?: continue
|
val r = results[n.id] ?: continue
|
||||||
if (!provisioned || ok) 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"
|
val where = n.iface?.takeIf { it.isNotBlank() } ?: "this network"
|
||||||
out.add(
|
out.add(
|
||||||
Finding(
|
Finding(
|
||||||
|
|||||||
@@ -40,19 +40,20 @@ class IcmpProbe(
|
|||||||
|
|
||||||
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||||
val b = TestBuilder(type, tier, ids)
|
val b = TestBuilder(type, tier, ids)
|
||||||
val perNetwork = LinkedHashMap<String, Triple<String?, Boolean, String>>()
|
val perNetwork = LinkedHashMap<String, Pair<String?, Attempt>>()
|
||||||
var anyOk = false
|
var anyOk = false
|
||||||
val rtts = ArrayList<Double>()
|
val rtts = ArrayList<Double>()
|
||||||
|
|
||||||
// Default network first, then each active network explicitly.
|
// Default network first, then each active network explicitly.
|
||||||
attempt(null).let { (ok, detail, rtt) ->
|
attempt(null).let { a ->
|
||||||
perNetwork["default"] = Triple(null, ok, detail); if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
perNetwork["default"] = null to a
|
||||||
|
if (a.ok) { anyOk = true; a.rttMs?.let(rtts::add) }
|
||||||
}
|
}
|
||||||
for (e in entries) {
|
for (e in entries) {
|
||||||
val label = "${e.model.transport.name.lowercase()}:${e.model.id}"
|
val label = "${e.model.transport.name.lowercase()}:${e.model.id}"
|
||||||
val (ok, detail, rtt) = attempt(e.handle)
|
val a = attempt(e.handle)
|
||||||
perNetwork[label] = Triple(e.model.id, ok, detail)
|
perNetwork[label] = e.model.id to a
|
||||||
if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
if (a.ok) { anyOk = true; a.rttMs?.let(rtts::add) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-network results are recorded structurally, not just as prose. The aggregate status
|
// Per-network results are recorded structurally, not just as prose. The aggregate status
|
||||||
@@ -62,11 +63,12 @@ class IcmpProbe(
|
|||||||
val evidence: JsonObject = buildJsonObject {
|
val evidence: JsonObject = buildJsonObject {
|
||||||
put("target", target)
|
put("target", target)
|
||||||
for ((label, r) in perNetwork) {
|
for ((label, r) in perNetwork) {
|
||||||
val (netId, ok, detail) = r
|
val (netId, a) = r
|
||||||
put(label, buildJsonObject {
|
put(label, buildJsonObject {
|
||||||
netId?.let { put("network_ref", it) }
|
netId?.let { put("network_ref", it) }
|
||||||
put("ok", ok)
|
put("ok", a.ok)
|
||||||
put("detail", detail)
|
put("attempted", a.attempted)
|
||||||
|
put("detail", a.detail)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,14 +82,31 @@ class IcmpProbe(
|
|||||||
b.build(status, evidence = evidence, metrics = metrics)
|
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 {
|
private fun attempt(network: Network?): Attempt {
|
||||||
var fd: FileDescriptor? = null
|
var fd: FileDescriptor? = null
|
||||||
|
var sent = false
|
||||||
return try {
|
return try {
|
||||||
val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP
|
val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP
|
||||||
val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET
|
val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET
|
||||||
fd = Os.socket(family, OsConstants.SOCK_DGRAM, proto)
|
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)
|
network?.bindSocket(fd)
|
||||||
Os.setsockoptTimeval(fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO, StructTimeval.fromMillis(3000))
|
Os.setsockoptTimeval(fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO, StructTimeval.fromMillis(3000))
|
||||||
val addr = network?.getByName(target) ?: InetAddress.getByName(target)
|
val addr = network?.getByName(target) ?: InetAddress.getByName(target)
|
||||||
@@ -96,14 +115,16 @@ class IcmpProbe(
|
|||||||
val packet = buildEchoRequest(v6, ident.toShort(), 1)
|
val packet = buildEchoRequest(v6, ident.toShort(), 1)
|
||||||
val t0 = System.nanoTime()
|
val t0 = System.nanoTime()
|
||||||
Os.sendto(fd, packet, 0, packet.size, 0, addr, 0)
|
Os.sendto(fd, packet, 0, packet.size, 0, addr, 0)
|
||||||
|
sent = true
|
||||||
val buf = ByteBuffer.allocate(1500)
|
val buf = ByteBuffer.allocate(1500)
|
||||||
val received = Os.recvfrom(fd, buf, 0, null)
|
val received = Os.recvfrom(fd, buf, 0, null)
|
||||||
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
|
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
|
||||||
val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1
|
val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1
|
||||||
val ok = replyType == (if (v6) 129 else 0)
|
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) {
|
} 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 {
|
} finally {
|
||||||
fd?.let { runCatching { Os.close(it) } }
|
fd?.let { runCatching { Os.close(it) } }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user