app: name what a VPN blocked, and prove v6 broken before saying so

Constraints are detected up front (one throwaway bind per network) and land
in run.constraints, a measurement.vpn_constrained finding, the $7.3 verdict
(INCONCLUSIVE outright) and a banner on the run screen - a VPN'd run looked
exactly like a clean run of a healthy network before this.

v6.broken returns to the registry now that it can be earned: V6ConnectProbe
(v6.brokenness) makes a real TCP connection over IPv6 to the enrolled
server, and only both transports failing on a network that advertises IPv6
justifies the claim. TCP succeeding turns the finding into 'ICMPv6 is
filtered, IPv6 works' at high confidence instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 12:31:44 +02:00
co-authored by Claude Opus 5
parent 987b2ceb47
commit 20cfecf566
8 changed files with 337 additions and 21 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ plugins {
//
// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing
// digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100.
val appVersionName = "0.2.0"
val appVersionName = "0.2.1"
fun versionCodeOf(semver: String): Int {
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
@@ -413,6 +413,29 @@ private fun EcholotScreen(
@Composable
private fun Results(doc: MeasurementDocument) {
// A constrained run is answered before the lights are: the verdict below is INCONCLUSIVE by
// §7.3, and without this banner "inconclusive" reads as the app failing rather than the OS
// (correctly) refusing to let anything past the VPN be measured.
val constraints = doc.run.constraints
if (constraints.constrained) {
val blocked = constraints.unmeasuredNetworks
.mapNotNull { id -> doc.networks.firstOrNull { it.id == id } }
.joinToString(", ") { it.iface?.takeIf { s -> s.isNotBlank() } ?: it.transport.name.lowercase() }
.ifBlank { "the networks beneath it" }
Card(colors = CardDefaults.cardColors(containerColor = Color(0xFF3A2E12))) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Text("Measured through a VPN", color = Color(0xFFFFD08A),
fontWeight = FontWeight.SemiBold)
Text(
"Android does not let apps send on the networks beneath an active VPN, so " +
"$blocked could not be measured — these results describe the tunnel. " +
"Disconnect the VPN and run again to measure the networks themselves.",
fontSize = 12.sp, color = Color(0xFFFFD08A),
)
}
}
}
val summary = doc.summary
if (summary != null) {
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
@@ -130,6 +130,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
private var runStartWall: String = ""
private var runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
private var runShizukuOk = false
private var runConstraints = Constraints()
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
private class RunIds : ProbeIds {
@@ -148,6 +149,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
fun run(devUpload: Boolean = false) {
if (state.running) return
collected.clear()
runConstraints = Constraints()
state = state.copy(running = true, currentStep = "starting", document = null,
uploadStatus = null, archiveStatus = null)
runJob = viewModelScope.launch {
@@ -391,6 +393,11 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
val entries = NetworkInventory.snapshot(ctx)
val networks = entries.map { it.model }.also { runNetworks = it }
// What will this run be prevented from measuring? Decided up front, from one throwaway
// bind per network, so the document can say so instead of leaving it to be inferred from
// per-test `attempted: false` breadcrumbs (measurement-schema.md §3 `constraints`).
runConstraints = app.echo_lot.probe.ConstraintDetector.detect(entries)
val probes: List<Probe> = listOf(
LinkSnapshotProbe(entries),
RouterIdentityProbe(entries),
@@ -408,6 +415,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
app.echo_lot.probe.DnsResolverProbe(entries),
DnsCanaryProbe(canaryZone = settings.canaryZone, sessionPrefix = "adhoc"),
StunProbe(serverHost = settings.serverHost()),
// Corroboration for icmp.ping6's silence: a real TCP connection over IPv6. Only its
// failure, on a network that advertises IPv6, justifies calling IPv6 broken.
app.echo_lot.probe.V6ConnectProbe(entries, serverHost = settings.serverHost()),
)
// Plan the run first: the Shizuku battery is counted alongside the app-tier probes so
@@ -470,11 +480,12 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
),
tiers = Tiers(app = true, shizuku = runShizukuOk),
constraints = runConstraints,
),
networks = runNetworks,
tests = tests,
findings = findings,
summary = Verdicts.derive(tests, findings),
summary = Verdicts.derive(tests, findings, runConstraints),
)
}
@@ -543,6 +554,31 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
val out = ArrayList<Finding>()
val ids = RunIds()
val linkEvidence = tests.filter { it.type == TestType.LINK_SNAPSHOT }.map { EvidenceRef(it.id) }
// Said as a finding, not only as run.constraints: the constraints block is for machines
// aggregating thousands of runs, this is for the person reading this one. Both must exist —
// a constrained run with a quiet findings list still reads as "nothing wrong here".
if (runConstraints.constrained) {
val blocked = runConstraints.unmeasuredNetworks
.joinToString(", ") { id -> ifaceOf(networks, id) }
.ifBlank { "the underlying networks" }
out.add(
Finding(
id = ids.uuid(),
code = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.code,
category = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.category,
severity = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.severity,
confidence = Confidence.HIGH,
title = "A VPN is active — $blocked could not be measured",
description = "Android refuses to let apps send on the networks beneath an " +
"active VPN (that is how it prevents traffic leaking around the tunnel), " +
"so every per-network test here measured the tunnel or nothing. Nothing " +
"in this run says anything about $blocked. To measure them, disconnect " +
"the VPN and run again.",
evidenceRefs = linkEvidence,
)
)
}
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.
@@ -754,6 +790,10 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
// wifi and cellular up at once that is how "IPv6 is configured but gets no reply"
// ends up describing a network where IPv6 was never configured in the first place.
val results = icmpResults(t)
// The corroborating witness: did a real TCP connection over IPv6 work on this
// network? Same evidence shape as the ICMP probe, so the same parser reads it.
val v6ConnTest = tests.firstOrNull { it.type == TestType.V6_BROKENNESS }
val v6Conn = v6ConnTest?.let { icmpResults(it) } ?: emptyMap()
var anyV6Network = false
for (n in networks) {
val provisioned = ipv6Provisioned(networks, n.id)
@@ -764,23 +804,68 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
// 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(
id = ids.uuid(), code = FindingRegistry.V6_NO_ICMP_REPLY.code,
category = FindingRegistry.V6_NO_ICMP_REPLY.category,
severity = FindingRegistry.V6_NO_ICMP_REPLY.severity,
confidence = Confidence.MEDIUM,
title = "IPv6 is configured, but ICMPv6 gets no reply ($where)",
description = "$where advertises IPv6 (a global address and/or a " +
"default route), but ICMPv6 echo got no reply over it. That has " +
"two explanations which look identical from here: IPv6 is broken, " +
"or ICMPv6 is filtered while IPv6 itself works. Filtering is " +
"common and is a fault in its own right — it breaks Path MTU " +
"Discovery, so large packets vanish rather than being reported as " +
"too big.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
val conn = v6Conn[n.id]
val evidence = listOfNotNull(
EvidenceRef(t.id), v6ConnTest?.let { EvidenceRef(it.id) },
)
when {
// TCP over IPv6 worked: the silence is filtering, and can be said so.
conn?.ok == true -> out.add(
Finding(
id = ids.uuid(), code = FindingRegistry.V6_NO_ICMP_REPLY.code,
category = FindingRegistry.V6_NO_ICMP_REPLY.category,
severity = FindingRegistry.V6_NO_ICMP_REPLY.severity,
confidence = Confidence.HIGH,
title = "ICMPv6 is filtered here — IPv6 itself works ($where)",
description = "$where answered a real TCP connection over IPv6, " +
"so IPv6 works — but ICMPv6 echo got no reply, so something " +
"on this network filters ICMPv6. That is a fault in its own " +
"right even though connections succeed: Path MTU Discovery " +
"depends on ICMPv6, so large packets can vanish rather than " +
"being reported as too big.",
evidenceRefs = evidence,
)
)
// Both transports failed on a network that advertises IPv6: broken, and
// now with the evidence the original v6.broken never had.
conn != null && conn.attempted -> out.add(
Finding(
id = ids.uuid(), code = FindingRegistry.V6_BROKEN.code,
category = FindingRegistry.V6_BROKEN.category,
severity = FindingRegistry.V6_BROKEN.severity,
confidence = Confidence.HIGH,
title = "IPv6 is advertised but does not work ($where)",
description = "$where advertises IPv6 (a global address and/or a " +
"default route), but neither ICMPv6 echo nor a TCP connection " +
"over IPv6 got through — two independent transports, both " +
"silent. Applications will try IPv6 first and wait out a " +
"timeout on every dual-stack destination before falling back " +
"to IPv4, felt as everything being slow with no loss to " +
"explain it. The network is announcing a service it does not " +
"deliver; the fix belongs on the router or upstream.",
evidenceRefs = evidence,
)
)
// No corroboration available (no server configured, or the connect never
// got as far as sending): the honest two-explanation reading stands.
else -> out.add(
Finding(
id = ids.uuid(), code = FindingRegistry.V6_NO_ICMP_REPLY.code,
category = FindingRegistry.V6_NO_ICMP_REPLY.category,
severity = FindingRegistry.V6_NO_ICMP_REPLY.severity,
confidence = Confidence.MEDIUM,
title = "IPv6 is configured, but ICMPv6 gets no reply ($where)",
description = "$where advertises IPv6 (a global address and/or a " +
"default route), but ICMPv6 echo got no reply over it. That has " +
"two explanations which look identical from here: IPv6 is broken, " +
"or ICMPv6 is filtered while IPv6 itself works. Filtering is " +
"common and is a fault in its own right — it breaks Path MTU " +
"Discovery, so large packets vanish rather than being reported as " +
"too big.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
}
}
if (!anyV6Network) {
// Said once for the device, not once per interface: "this network is IPv4-only"
@@ -277,6 +277,19 @@ object FindingRegistry {
rulesOut = "Nothing on its own: IPv6 may work fine with ICMP filtered.",
)
/**
* IPv6 is advertised and does not work — the claim `v6.broken` originally made on ICMP
* silence alone, now reinstated because it can finally be backed: it is only emitted when a
* real IPv6 TCP connection (v6.brokenness) failed on the same network whose ICMPv6 went
* unanswered. Two independent transports failing on a network that advertises IPv6 is what
* "broken" actually means; either signal alone still gets [V6_NO_ICMP_REPLY].
*/
val V6_BROKEN = FindingSpec(
"v6.broken", Category.IPV6, Severity.HIGH,
"IPv6 is advertised on this network but carries no traffic.",
rulesOut = "ICMP filtering as the benign explanation: a TCP connection over IPv6 failed too.",
)
/**
* INFO deliberately, and it needs to stay that way.
*
@@ -298,7 +311,8 @@ object FindingRegistry {
NAT_UDP_REBINDING, NAT_SYMMETRIC,
THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED,
DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE,
DNS_SEARCH_DOMAIN_UNANSWERED, DNS_SYSTEM_RESOLVER_BROKEN, MEASUREMENT_VPN_CONSTRAINED, V6_NO_DEFAULT_ROUTE, V6_ROUTE_WITHOUT_ADDRESS, V6_NO_ICMP_REPLY, V6_NOT_OFFERED,
DNS_SEARCH_DOMAIN_UNANSWERED, DNS_SYSTEM_RESOLVER_BROKEN, MEASUREMENT_VPN_CONSTRAINED,
V6_NO_DEFAULT_ROUTE, V6_ROUTE_WITHOUT_ADDRESS, V6_NO_ICMP_REPLY, V6_BROKEN, V6_NOT_OFFERED,
)
private val byCode: Map<String, FindingSpec> = all.associateBy { it.code }
@@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import app.echo_lot.measurement.Constraints
import app.echo_lot.measurement.Transport
import java.net.DatagramSocket
/**
* Detects what will prevent this run from measuring (measurement-schema.md §3 `constraints`),
* before any probe runs and independently of all of them.
*
* The known case: while a VPN holds the default route, Android refuses `Network.bindSocket()` on
* the underlying networks (EPERM) so apps cannot leak around the tunnel. Every per-network test
* then silently measures the tunnel or nothing, and the run comes out shaped exactly like a clean
* run of a healthy network. Detecting that here — one throwaway bind per network — is what lets
* the document say "these networks went unmeasured" instead of leaving the reader to infer it
* from a pattern of `attempted: false` scattered across the tests.
*/
object ConstraintDetector {
fun detect(entries: List<NetworkInventory.Entry>): Constraints {
val vpnActive = entries.any { it.model.transport == Transport.VPN }
val unmeasured = ArrayList<String>()
for (e in entries) {
// The tunnel itself stays bindable — it is the underlying networks the OS walls off.
if (e.model.transport == Transport.VPN) continue
val bindable = runCatching {
DatagramSocket().use { s -> e.handle.bindSocket(s) }
true
}.getOrDefault(false)
if (!bindable) unmeasured.add(e.model.id)
}
return Constraints(
vpnActive = vpnActive,
perNetworkBlocked = unmeasured.isNotEmpty(),
unmeasuredNetworks = unmeasured,
)
}
}
@@ -0,0 +1,131 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import android.content.Context
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
import app.echo_lot.measurement.Network as MNetwork
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.net.Inet6Address
import java.net.InetSocketAddress
import java.util.Locale
/**
* v6.brokenness — does IPv6 actually carry traffic, asked with a real TCP connection.
*
* This exists to corroborate (or refute) the ICMPv6 silence that icmp.ping6 observes. ICMPv6 echo
* is widely filtered on networks where IPv6 works fine, so silence alone cannot distinguish
* "IPv6 is broken" from "ping is filtered" — a phone that reported v6.broken while happily
* loading IPv6-only sites is what proved the point. A TCP connect over IPv6 to the configured
* server settles it: if it succeeds, IPv6 works and the ICMP silence is filtering; if it fails
* too, on a network that advertises IPv6, the brokenness claim finally has evidence behind it.
*
* Only networks that claim to offer IPv6 (a global address or a v6 default route) are attempted:
* connecting over v6 on an IPv4-only network fails by design, and recording that as evidence
* would manufacture the exact false positive this probe exists to kill.
*/
class V6ConnectProbe(
private val entries: List<NetworkInventory.Entry>,
private val serverHost: String,
private val port: Int = 443,
) : Probe {
override val type = TestType.V6_BROKENNESS
override val tier = Tier.APP
// One 3s connect timeout per v6-provisioned network, at most.
override val estimatedMs = 4_000L
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
val b = TestBuilder(type, tier, ids)
// Same rule as the STUN and canary probes: with no server there is no target, and
// borrowing someone else's infrastructure to get one is not this app's call to make.
if (serverHost.isBlank()) {
return@withContext b.build(
TestStatus.SKIPPED,
evidence = buildJsonObject { put("reason", "no server configured to connect to") },
)
}
val candidates = entries.filter { ipv6Provisioned(it.model) }
if (candidates.isEmpty()) {
return@withContext b.build(
TestStatus.SKIPPED,
evidence = buildJsonObject {
put("reason", "no active network claims to offer IPv6")
},
)
}
var okCount = 0
var attemptedCount = 0
val evidence: JsonObject = buildJsonObject {
put("target", "$serverHost:$port")
for (e in candidates) {
val label = "${e.model.transport.name.lowercase()}:${e.model.id}"
val a = attempt(e)
if (a.attempted) attemptedCount++
if (a.ok) okCount++
put(label, buildJsonObject {
put("network_ref", e.model.id)
put("ok", a.ok)
put("attempted", a.attempted)
put("detail", a.detail)
})
}
}
val status = when {
attemptedCount == 0 -> TestStatus.SKIPPED // resolution/binding never got that far
okCount == attemptedCount -> TestStatus.OK
okCount > 0 -> TestStatus.PARTIAL
else -> TestStatus.FAILED
}
b.build(status, evidence = evidence)
}
/** Same attempted/ok separation as IcmpProbe: a connect we never sent proves nothing. */
private data class Attempt(val ok: Boolean, val attempted: Boolean, val detail: String)
private fun attempt(e: NetworkInventory.Entry): Attempt {
// Resolved through this network's own resolver; a v6 address obtained over another
// network would still be connected to over this one, which is what matters.
val addr = runCatching {
e.handle.getAllByName(serverHost).filterIsInstance<Inet6Address>().firstOrNull()
}.getOrNull()
?: return Attempt(false, false, "no AAAA answer for $serverHost via this network")
// createSocket() binds to the network at creation; failing here means the app could not
// use the interface at all (e.g. EPERM under a VPN) — nothing was sent, nothing is known.
val socket = try {
e.handle.socketFactory.createSocket()
} catch (t: Throwable) {
return Attempt(false, false, "socket unavailable: ${t.message ?: t.javaClass.simpleName}")
}
return try {
val t0 = System.nanoTime()
socket.connect(InetSocketAddress(addr, port), 3000)
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
Attempt(true, true, "connected to [${addr.hostAddress}]:$port " +
"rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)}")
} catch (t: Throwable) {
// A refused connection would still prove the path forwards IPv6, but against our own
// server's 443 the realistic failures are timeout and unreachable — both silence.
Attempt(false, true, "error: ${t.message ?: t.javaClass.simpleName}")
} finally {
runCatching { socket.close() }
}
}
/** The network claims IPv6: a global (non-link-local) address or a v6 default route. */
private fun ipv6Provisioned(n: MNetwork): Boolean =
n.link.addresses.any { a ->
a.addr.contains(':') &&
!a.addr.startsWith("fe80", ignoreCase = true) &&
!a.addr.startsWith("::1")
} || n.link.routes.any { it.dst == "::/0" }
}