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
@@ -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"