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