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
+10 -2
View File
@@ -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
+14
View File
@@ -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.
+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 // 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"
@@ -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" }
}