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:
co-authored by
Claude Opus 5
parent
987b2ceb47
commit
20cfecf566
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user