From 20cfecf56604027473d84c9c7f0d85f769d64a6c Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sun, 2 Aug 2026 12:31:44 +0200 Subject: [PATCH] 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 --- docs/findings-registry.md | 12 +- docs/measurement-schema.md | 14 ++ echolot-app/app/build.gradle.kts | 2 +- .../kotlin/app/echo_lot/app/MainActivity.kt | 23 +++ .../kotlin/app/echo_lot/app/RunViewModel.kt | 119 +++++++++++++--- .../echo_lot/measurement/FindingRegistry.kt | 16 ++- .../app/echo_lot/probe/ConstraintDetector.kt | 41 ++++++ .../app/echo_lot/probe/V6ConnectProbe.kt | 131 ++++++++++++++++++ 8 files changed, 337 insertions(+), 21 deletions(-) create mode 100644 echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ConstraintDetector.kt create mode 100644 echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/V6ConnectProbe.kt diff --git a/docs/findings-registry.md b/docs/findings-registry.md index cdbba85..d2382c0 100644 --- a/docs/findings-registry.md +++ b/docs/findings-registry.md @@ -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.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.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.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 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 -Discovery. Corroborating it with a real IPv6 connection would let the two cases be separated, and -is the proper fix. +Discovery. + +`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 not a fault; reporting it as a warning lights a yellow verdict on a healthy network, which teaches diff --git a/docs/measurement-schema.md b/docs/measurement-schema.md index 4cbee01..500c8e2 100644 --- a/docs/measurement-schema.md +++ b/docs/measurement-schema.md @@ -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 }, "profiles_used": ["profile-uuid", ...], + "constraints": { + "vpn_active": true, + "per_network_blocked": true, + "unmeasured_networks": ["net-0", "net-1"] + }, "notes": "free-text user annotation" } ``` `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 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. diff --git a/echolot-app/app/build.gradle.kts b/echolot-app/app/build.gradle.kts index e20507b..dc6d79d 100644 --- a/echolot-app/app/build.gradle.kts +++ b/echolot-app/app/build.gradle.kts @@ -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) diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt index 972f692..2fa4b45 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt @@ -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))) { diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt index 2e29888..bde06ce 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt @@ -130,6 +130,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { private var runStartWall: String = "" private var runNetworks: List = 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 = 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() 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" diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt index e2c1d6e..311fea1 100644 --- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt @@ -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 = all.associateBy { it.code } diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ConstraintDetector.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ConstraintDetector.kt new file mode 100644 index 0000000..a62c41b --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ConstraintDetector.kt @@ -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): Constraints { + val vpnActive = entries.any { it.model.transport == Transport.VPN } + val unmeasured = ArrayList() + 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, + ) + } +} diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/V6ConnectProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/V6ConnectProbe.kt new file mode 100644 index 0000000..a00bfcf --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/V6ConnectProbe.kt @@ -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, + 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().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" } +}