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
@@ -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)
|
||||
|
||||
@@ -413,6 +413,29 @@ private fun EcholotScreen(
|
||||
|
||||
@Composable
|
||||
private fun Results(doc: MeasurementDocument) {
|
||||
// A constrained run is answered before the lights are: the verdict below is INCONCLUSIVE by
|
||||
// §7.3, and without this banner "inconclusive" reads as the app failing rather than the OS
|
||||
// (correctly) refusing to let anything past the VPN be measured.
|
||||
val constraints = doc.run.constraints
|
||||
if (constraints.constrained) {
|
||||
val blocked = constraints.unmeasuredNetworks
|
||||
.mapNotNull { id -> doc.networks.firstOrNull { it.id == id } }
|
||||
.joinToString(", ") { it.iface?.takeIf { s -> s.isNotBlank() } ?: it.transport.name.lowercase() }
|
||||
.ifBlank { "the networks beneath it" }
|
||||
Card(colors = CardDefaults.cardColors(containerColor = Color(0xFF3A2E12))) {
|
||||
Column(Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Text("Measured through a VPN", color = Color(0xFFFFD08A),
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Android does not let apps send on the networks beneath an active VPN, so " +
|
||||
"$blocked could not be measured — these results describe the tunnel. " +
|
||||
"Disconnect the VPN and run again to measure the networks themselves.",
|
||||
fontSize = 12.sp, color = Color(0xFFFFD08A),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val summary = doc.summary
|
||||
if (summary != null) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
|
||||
|
||||
@@ -130,6 +130,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private var runStartWall: String = ""
|
||||
private var runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
|
||||
private var runShizukuOk = false
|
||||
private var runConstraints = Constraints()
|
||||
|
||||
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
|
||||
private class RunIds : ProbeIds {
|
||||
@@ -148,6 +149,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
fun run(devUpload: Boolean = false) {
|
||||
if (state.running) return
|
||||
collected.clear()
|
||||
runConstraints = Constraints()
|
||||
state = state.copy(running = true, currentStep = "starting", document = null,
|
||||
uploadStatus = null, archiveStatus = null)
|
||||
runJob = viewModelScope.launch {
|
||||
@@ -391,6 +393,11 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val entries = NetworkInventory.snapshot(ctx)
|
||||
val networks = entries.map { it.model }.also { runNetworks = it }
|
||||
|
||||
// What will this run be prevented from measuring? Decided up front, from one throwaway
|
||||
// bind per network, so the document can say so instead of leaving it to be inferred from
|
||||
// per-test `attempted: false` breadcrumbs (measurement-schema.md §3 `constraints`).
|
||||
runConstraints = app.echo_lot.probe.ConstraintDetector.detect(entries)
|
||||
|
||||
val probes: List<Probe> = listOf(
|
||||
LinkSnapshotProbe(entries),
|
||||
RouterIdentityProbe(entries),
|
||||
@@ -408,6 +415,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
app.echo_lot.probe.DnsResolverProbe(entries),
|
||||
DnsCanaryProbe(canaryZone = settings.canaryZone, sessionPrefix = "adhoc"),
|
||||
StunProbe(serverHost = settings.serverHost()),
|
||||
// Corroboration for icmp.ping6's silence: a real TCP connection over IPv6. Only its
|
||||
// failure, on a network that advertises IPv6, justifies calling IPv6 broken.
|
||||
app.echo_lot.probe.V6ConnectProbe(entries, serverHost = settings.serverHost()),
|
||||
)
|
||||
|
||||
// Plan the run first: the Shizuku battery is counted alongside the app-tier probes so
|
||||
@@ -470,11 +480,12 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
|
||||
),
|
||||
tiers = Tiers(app = true, shizuku = runShizukuOk),
|
||||
constraints = runConstraints,
|
||||
),
|
||||
networks = runNetworks,
|
||||
tests = tests,
|
||||
findings = findings,
|
||||
summary = Verdicts.derive(tests, findings),
|
||||
summary = Verdicts.derive(tests, findings, runConstraints),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -543,6 +554,31 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val out = ArrayList<Finding>()
|
||||
val ids = RunIds()
|
||||
val linkEvidence = tests.filter { it.type == TestType.LINK_SNAPSHOT }.map { EvidenceRef(it.id) }
|
||||
|
||||
// Said as a finding, not only as run.constraints: the constraints block is for machines
|
||||
// aggregating thousands of runs, this is for the person reading this one. Both must exist —
|
||||
// a constrained run with a quiet findings list still reads as "nothing wrong here".
|
||||
if (runConstraints.constrained) {
|
||||
val blocked = runConstraints.unmeasuredNetworks
|
||||
.joinToString(", ") { id -> ifaceOf(networks, id) }
|
||||
.ifBlank { "the underlying networks" }
|
||||
out.add(
|
||||
Finding(
|
||||
id = ids.uuid(),
|
||||
code = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.code,
|
||||
category = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.category,
|
||||
severity = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.severity,
|
||||
confidence = Confidence.HIGH,
|
||||
title = "A VPN is active — $blocked could not be measured",
|
||||
description = "Android refuses to let apps send on the networks beneath an " +
|
||||
"active VPN (that is how it prevents traffic leaking around the tunnel), " +
|
||||
"so every per-network test here measured the tunnel or nothing. Nothing " +
|
||||
"in this run says anything about $blocked. To measure them, disconnect " +
|
||||
"the VPN and run again.",
|
||||
evidenceRefs = linkEvidence,
|
||||
)
|
||||
)
|
||||
}
|
||||
val shapes = V6Analysis.classify(networks)
|
||||
// Named per interface: on a phone several networks are up at once, and "IPv6 is broken" is
|
||||
// useless when wifi is the broken one and cellular is fine.
|
||||
@@ -754,6 +790,10 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
// wifi and cellular up at once that is how "IPv6 is configured but gets no reply"
|
||||
// ends up describing a network where IPv6 was never configured in the first place.
|
||||
val results = icmpResults(t)
|
||||
// The corroborating witness: did a real TCP connection over IPv6 work on this
|
||||
// network? Same evidence shape as the ICMP probe, so the same parser reads it.
|
||||
val v6ConnTest = tests.firstOrNull { it.type == TestType.V6_BROKENNESS }
|
||||
val v6Conn = v6ConnTest?.let { icmpResults(it) } ?: emptyMap()
|
||||
var anyV6Network = false
|
||||
for (n in networks) {
|
||||
val provisioned = ipv6Provisioned(networks, n.id)
|
||||
@@ -764,23 +804,68 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
// this app's permissions and says nothing whatsoever about the network.
|
||||
if (!provisioned || !r.attempted || r.ok) continue
|
||||
val where = n.iface?.takeIf { it.isNotBlank() } ?: "this network"
|
||||
out.add(
|
||||
Finding(
|
||||
id = ids.uuid(), code = FindingRegistry.V6_NO_ICMP_REPLY.code,
|
||||
category = FindingRegistry.V6_NO_ICMP_REPLY.category,
|
||||
severity = FindingRegistry.V6_NO_ICMP_REPLY.severity,
|
||||
confidence = Confidence.MEDIUM,
|
||||
title = "IPv6 is configured, but ICMPv6 gets no reply ($where)",
|
||||
description = "$where advertises IPv6 (a global address and/or a " +
|
||||
"default route), but ICMPv6 echo got no reply over it. That has " +
|
||||
"two explanations which look identical from here: IPv6 is broken, " +
|
||||
"or ICMPv6 is filtered while IPv6 itself works. Filtering is " +
|
||||
"common and is a fault in its own right — it breaks Path MTU " +
|
||||
"Discovery, so large packets vanish rather than being reported as " +
|
||||
"too big.",
|
||||
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||
)
|
||||
val conn = v6Conn[n.id]
|
||||
val evidence = listOfNotNull(
|
||||
EvidenceRef(t.id), v6ConnTest?.let { EvidenceRef(it.id) },
|
||||
)
|
||||
when {
|
||||
// TCP over IPv6 worked: the silence is filtering, and can be said so.
|
||||
conn?.ok == true -> out.add(
|
||||
Finding(
|
||||
id = ids.uuid(), code = FindingRegistry.V6_NO_ICMP_REPLY.code,
|
||||
category = FindingRegistry.V6_NO_ICMP_REPLY.category,
|
||||
severity = FindingRegistry.V6_NO_ICMP_REPLY.severity,
|
||||
confidence = Confidence.HIGH,
|
||||
title = "ICMPv6 is filtered here — IPv6 itself works ($where)",
|
||||
description = "$where answered a real TCP connection over IPv6, " +
|
||||
"so IPv6 works — but ICMPv6 echo got no reply, so something " +
|
||||
"on this network filters ICMPv6. That is a fault in its own " +
|
||||
"right even though connections succeed: Path MTU Discovery " +
|
||||
"depends on ICMPv6, so large packets can vanish rather than " +
|
||||
"being reported as too big.",
|
||||
evidenceRefs = evidence,
|
||||
)
|
||||
)
|
||||
// Both transports failed on a network that advertises IPv6: broken, and
|
||||
// now with the evidence the original v6.broken never had.
|
||||
conn != null && conn.attempted -> out.add(
|
||||
Finding(
|
||||
id = ids.uuid(), code = FindingRegistry.V6_BROKEN.code,
|
||||
category = FindingRegistry.V6_BROKEN.category,
|
||||
severity = FindingRegistry.V6_BROKEN.severity,
|
||||
confidence = Confidence.HIGH,
|
||||
title = "IPv6 is advertised but does not work ($where)",
|
||||
description = "$where advertises IPv6 (a global address and/or a " +
|
||||
"default route), but neither ICMPv6 echo nor a TCP connection " +
|
||||
"over IPv6 got through — two independent transports, both " +
|
||||
"silent. Applications will try IPv6 first and wait out a " +
|
||||
"timeout on every dual-stack destination before falling back " +
|
||||
"to IPv4, felt as everything being slow with no loss to " +
|
||||
"explain it. The network is announcing a service it does not " +
|
||||
"deliver; the fix belongs on the router or upstream.",
|
||||
evidenceRefs = evidence,
|
||||
)
|
||||
)
|
||||
// No corroboration available (no server configured, or the connect never
|
||||
// got as far as sending): the honest two-explanation reading stands.
|
||||
else -> out.add(
|
||||
Finding(
|
||||
id = ids.uuid(), code = FindingRegistry.V6_NO_ICMP_REPLY.code,
|
||||
category = FindingRegistry.V6_NO_ICMP_REPLY.category,
|
||||
severity = FindingRegistry.V6_NO_ICMP_REPLY.severity,
|
||||
confidence = Confidence.MEDIUM,
|
||||
title = "IPv6 is configured, but ICMPv6 gets no reply ($where)",
|
||||
description = "$where advertises IPv6 (a global address and/or a " +
|
||||
"default route), but ICMPv6 echo got no reply over it. That has " +
|
||||
"two explanations which look identical from here: IPv6 is broken, " +
|
||||
"or ICMPv6 is filtered while IPv6 itself works. Filtering is " +
|
||||
"common and is a fault in its own right — it breaks Path MTU " +
|
||||
"Discovery, so large packets vanish rather than being reported as " +
|
||||
"too big.",
|
||||
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!anyV6Network) {
|
||||
// Said once for the device, not once per interface: "this network is IPv4-only"
|
||||
|
||||
+15
-1
@@ -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<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