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
@@ -95,14 +95,22 @@ rolled up under *connectivity* instead — the third occurrence of rule 1 being
|
|||||||
| `v6.no_default_route` | medium | The device has a global IPv6 address but no IPv6 default route. | Guesswork: this is read from the routing table, not inferred from silence. |
|
| `v6.no_default_route` | medium | The device has a global IPv6 address but no IPv6 default route. | Guesswork: this is read from the routing table, not inferred from silence. |
|
||||||
| `v6.route_without_address` | medium | The network advertises an IPv6 default route but the device has no global IPv6 address. | A working IPv6 setup: SLAAC did not produce a usable address on this link. |
|
| `v6.route_without_address` | medium | The network advertises an IPv6 default route but the device has no global IPv6 address. | A working IPv6 setup: SLAAC did not produce a usable address on this link. |
|
||||||
| `v6.no_icmp_reply` | low | IPv6 is configured but ICMPv6 echo gets no reply. | Nothing on its own: IPv6 may work fine with ICMP filtered. |
|
| `v6.no_icmp_reply` | low | IPv6 is configured but ICMPv6 echo gets no reply. | Nothing on its own: IPv6 may work fine with ICMP filtered. |
|
||||||
|
| `v6.broken` | high | IPv6 is advertised on this network but carries no traffic. | ICMP filtering as the benign explanation: a TCP connection over IPv6 failed too. |
|
||||||
| `v6.not_offered` | info | This network does not offer IPv6. | — |
|
| `v6.not_offered` | info | This network does not offer IPv6. | — |
|
||||||
|
|
||||||
`v6.no_icmp_reply` was `v6.broken` until a phone reported it while loading an IPv6-only site over
|
`v6.no_icmp_reply` was `v6.broken` until a phone reported it while loading an IPv6-only site over
|
||||||
TCP perfectly well. The only evidence behind it is ICMPv6 echo, which is widely filtered on
|
TCP perfectly well. The only evidence behind it is ICMPv6 echo, which is widely filtered on
|
||||||
networks where IPv6 works — so the finding now states what was observed and names both
|
networks where IPv6 works — so the finding now states what was observed and names both
|
||||||
explanations instead of choosing one. It is still worth reporting: filtered ICMPv6 breaks Path MTU
|
explanations instead of choosing one. It is still worth reporting: filtered ICMPv6 breaks Path MTU
|
||||||
Discovery. Corroborating it with a real IPv6 connection would let the two cases be separated, and
|
Discovery.
|
||||||
is the proper fix.
|
|
||||||
|
`v6.broken` returned once that corroboration existed: the `v6.brokenness` test attempts a real TCP
|
||||||
|
connection over IPv6 to the configured server, and only when *both* transports fail on a network
|
||||||
|
that advertises IPv6 is the brokenness claim made — at high severity, because every dual-stack
|
||||||
|
destination pays a timeout before falling back to IPv4. When the TCP connect *succeeds*,
|
||||||
|
`v6.no_icmp_reply` is emitted at high confidence instead, now able to say plainly that ICMPv6 is
|
||||||
|
filtered while IPv6 works. With no server configured there is no corroboration target and the
|
||||||
|
two-explanation `v6.no_icmp_reply` stands unchanged.
|
||||||
|
|
||||||
`v6.not_offered` is **info and must stay info**. Most networks still do not offer IPv6 and that is
|
`v6.not_offered` is **info and must stay info**. Most networks still do not offer IPv6 and that is
|
||||||
not a fault; reporting it as a warning lights a yellow verdict on a healthy network, which teaches
|
not a fault; reporting it as a warning lights a yellow verdict on a healthy network, which teaches
|
||||||
|
|||||||
@@ -52,12 +52,26 @@ Export encoding: UTF-8 JSON, gzip for files (`.echolot.json.gz`), share intent u
|
|||||||
},
|
},
|
||||||
"tiers": { "app": true, "shizuku": true, "root": false },
|
"tiers": { "app": true, "shizuku": true, "root": false },
|
||||||
"profiles_used": ["profile-uuid", ...],
|
"profiles_used": ["profile-uuid", ...],
|
||||||
|
"constraints": {
|
||||||
|
"vpn_active": true,
|
||||||
|
"per_network_blocked": true,
|
||||||
|
"unmeasured_networks": ["net-0", "net-1"]
|
||||||
|
},
|
||||||
"notes": "free-text user annotation"
|
"notes": "free-text user annotation"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`tiers` records what was *available*; each test records what it *used*.
|
`tiers` records what was *available*; each test records what it *used*.
|
||||||
|
|
||||||
|
`constraints` records what was *prevented*. A constrained run is neither a failed run nor a normal
|
||||||
|
one, and the distinction has to survive into the data: a run taken through a VPN has the same shape
|
||||||
|
and the same green verdict as a clean run of a healthy network, so without this a reader — or a
|
||||||
|
server aggregating thousands of them — cannot tell that almost nothing was measured. The known case
|
||||||
|
is `per_network_blocked`: Android refuses `Network.bindSocket()` on the underlying networks while a
|
||||||
|
VPN holds the default route, so every per-network test measures the tunnel or nothing at all, and
|
||||||
|
any conclusion about the link underneath is unfounded. Consumers should treat findings from a
|
||||||
|
constrained run as scoped to what was actually reachable, and `unmeasured_networks` names the rest.
|
||||||
|
|
||||||
## 4. `networks[]` — one entry per Android `Network` in play
|
## 4. `networks[]` — one entry per Android `Network` in play
|
||||||
|
|
||||||
A run may exercise several networks simultaneously (Wi-Fi + cellular + USB ethernet). Everything is a snapshot at run start; a `changes[]` list captures mid-run deltas.
|
A run may exercise several networks simultaneously (Wi-Fi + cellular + USB ethernet). Everything is a snapshot at run start; a `changes[]` list captures mid-run deltas.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ plugins {
|
|||||||
//
|
//
|
||||||
// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing
|
// 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.
|
// 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 {
|
fun versionCodeOf(semver: String): Int {
|
||||||
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
|
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
|
||||||
|
|||||||
@@ -413,6 +413,29 @@ private fun EcholotScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Results(doc: MeasurementDocument) {
|
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
|
val summary = doc.summary
|
||||||
if (summary != null) {
|
if (summary != null) {
|
||||||
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
|
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
private var runStartWall: String = ""
|
private var runStartWall: String = ""
|
||||||
private var runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
|
private var runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
|
||||||
private var runShizukuOk = false
|
private var runShizukuOk = false
|
||||||
|
private var runConstraints = Constraints()
|
||||||
|
|
||||||
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
|
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
|
||||||
private class RunIds : ProbeIds {
|
private class RunIds : ProbeIds {
|
||||||
@@ -148,6 +149,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
fun run(devUpload: Boolean = false) {
|
fun run(devUpload: Boolean = false) {
|
||||||
if (state.running) return
|
if (state.running) return
|
||||||
collected.clear()
|
collected.clear()
|
||||||
|
runConstraints = Constraints()
|
||||||
state = state.copy(running = true, currentStep = "starting", document = null,
|
state = state.copy(running = true, currentStep = "starting", document = null,
|
||||||
uploadStatus = null, archiveStatus = null)
|
uploadStatus = null, archiveStatus = null)
|
||||||
runJob = viewModelScope.launch {
|
runJob = viewModelScope.launch {
|
||||||
@@ -391,6 +393,11 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val entries = NetworkInventory.snapshot(ctx)
|
val entries = NetworkInventory.snapshot(ctx)
|
||||||
val networks = entries.map { it.model }.also { runNetworks = it }
|
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(
|
val probes: List<Probe> = listOf(
|
||||||
LinkSnapshotProbe(entries),
|
LinkSnapshotProbe(entries),
|
||||||
RouterIdentityProbe(entries),
|
RouterIdentityProbe(entries),
|
||||||
@@ -408,6 +415,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
app.echo_lot.probe.DnsResolverProbe(entries),
|
app.echo_lot.probe.DnsResolverProbe(entries),
|
||||||
DnsCanaryProbe(canaryZone = settings.canaryZone, sessionPrefix = "adhoc"),
|
DnsCanaryProbe(canaryZone = settings.canaryZone, sessionPrefix = "adhoc"),
|
||||||
StunProbe(serverHost = settings.serverHost()),
|
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
|
// 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,
|
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
|
||||||
),
|
),
|
||||||
tiers = Tiers(app = true, shizuku = runShizukuOk),
|
tiers = Tiers(app = true, shizuku = runShizukuOk),
|
||||||
|
constraints = runConstraints,
|
||||||
),
|
),
|
||||||
networks = runNetworks,
|
networks = runNetworks,
|
||||||
tests = tests,
|
tests = tests,
|
||||||
findings = findings,
|
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 out = ArrayList<Finding>()
|
||||||
val ids = RunIds()
|
val ids = RunIds()
|
||||||
val linkEvidence = tests.filter { it.type == TestType.LINK_SNAPSHOT }.map { EvidenceRef(it.id) }
|
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)
|
val shapes = V6Analysis.classify(networks)
|
||||||
// Named per interface: on a phone several networks are up at once, and "IPv6 is broken" is
|
// 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.
|
// 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"
|
// 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.
|
// ends up describing a network where IPv6 was never configured in the first place.
|
||||||
val results = icmpResults(t)
|
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
|
var anyV6Network = false
|
||||||
for (n in networks) {
|
for (n in networks) {
|
||||||
val provisioned = ipv6Provisioned(networks, n.id)
|
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.
|
// this app's permissions and says nothing whatsoever about the network.
|
||||||
if (!provisioned || !r.attempted || r.ok) continue
|
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(
|
val conn = v6Conn[n.id]
|
||||||
Finding(
|
val evidence = listOfNotNull(
|
||||||
id = ids.uuid(), code = FindingRegistry.V6_NO_ICMP_REPLY.code,
|
EvidenceRef(t.id), v6ConnTest?.let { EvidenceRef(it.id) },
|
||||||
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)),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
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) {
|
if (!anyV6Network) {
|
||||||
// Said once for the device, not once per interface: "this network is IPv4-only"
|
// Said once for the device, not once per interface: "this network is IPv4-only"
|
||||||
|
|||||||
+15
-1
@@ -277,6 +277,19 @@ object FindingRegistry {
|
|||||||
rulesOut = "Nothing on its own: IPv6 may work fine with ICMP filtered.",
|
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.
|
* INFO deliberately, and it needs to stay that way.
|
||||||
*
|
*
|
||||||
@@ -298,7 +311,8 @@ object FindingRegistry {
|
|||||||
NAT_UDP_REBINDING, NAT_SYMMETRIC,
|
NAT_UDP_REBINDING, NAT_SYMMETRIC,
|
||||||
THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED,
|
THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED,
|
||||||
DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE,
|
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 }
|
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" }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user