app: tell a broken device resolver apart from a broken network
Diagnosing a tablet that claimed "no internet" took twenty adb commands to establish something the app should have said in one run: ping to 1.1.1.1 worked, the configured DNS server answered a raw UDP query in 65 bytes, and Android still could not resolve a hostname. The network was fine; netd had wedged. Those two failures look identical to a user and want opposite responses — "look at your router" against "toggle your wifi" — so dns.resolver asks the network's own servers directly and compares the answer against what the platform returns for the same name. The query is hand-rolled over a plain DatagramSocket on purpose: anything routed through a resolver API would inherit the very fault being looked for. dns.system_resolver_broken fires only on the pairing that is otherwise unattributable: server answered, platform did not. Per network, because a phone can have wedged wifi and working cellular at once. Also records Android's own verdict per network — validated, captive portal, partial connectivity — which the app reproduced with its own HTTP probes but never stored. It is free, it is what the user sees in the status bar, and its disagreement with our measurements is exactly what identified the tablet. NET_CAPABILITY_PARTIAL_CONNECTIVITY is @SystemApi so the constant is inlined with its rationale, in the manner of OsAbi.kt, and read defensively enough to report unknown rather than false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
40e76c52ca
commit
d65dbbc75a
@@ -0,0 +1,150 @@
|
||||
// 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 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.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.Random
|
||||
|
||||
/**
|
||||
* Asks the network's own DNS servers directly, then asks Android to resolve the same name, and
|
||||
* compares.
|
||||
*
|
||||
* The comparison is the point. A name that fails to resolve looks the same to a user whatever the
|
||||
* cause, but the causes want opposite responses: if the server does not answer, the network is
|
||||
* broken and the router is the thing to look at; if the server answers a raw query while the
|
||||
* platform still cannot resolve, the device's own resolver has wedged and toggling wifi fixes it in
|
||||
* seconds. Nothing else on a phone will tell you which of those you have.
|
||||
*
|
||||
* This is deliberately not a general DNS test — no recursion checks, no DNSSEC, no rewriting
|
||||
* detection; [DnsCanaryProbe] covers interception. This one answers a single question: is the
|
||||
* resolver on this device doing its job.
|
||||
*/
|
||||
class DnsResolverProbe(
|
||||
private val entries: List<NetworkInventory.Entry>,
|
||||
/** Resolved directly rather than through any cache; any name with a stable answer will do. */
|
||||
private val probeName: String = "one.one.one.one",
|
||||
) : Probe {
|
||||
override val type = TestType.DNS_RESOLVER
|
||||
override val tier = Tier.APP
|
||||
// A query per server with a 3s ceiling, plus one getaddrinfo that may sit out its own timeout.
|
||||
override val estimatedMs = 6_000L
|
||||
|
||||
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||
val b = TestBuilder(type, tier, ids)
|
||||
val perNetwork = LinkedHashMap<String, Pair<String, JsonObject>>()
|
||||
|
||||
for (e in entries) {
|
||||
val servers = e.model.link.dns?.servers.orEmpty()
|
||||
if (servers.isEmpty()) continue
|
||||
val label = "${e.model.transport.name.lowercase()}:${e.model.id}"
|
||||
|
||||
// Directly: does the configured server answer at all?
|
||||
var direct: Boolean? = null
|
||||
var directDetail = "no server answered"
|
||||
for (s in servers) {
|
||||
val r = queryDirect(s, probeName)
|
||||
if (r != null) {
|
||||
direct = true
|
||||
directDetail = "$s answered in ${r}ms"
|
||||
break
|
||||
}
|
||||
direct = false
|
||||
directDetail = "$s did not answer"
|
||||
}
|
||||
|
||||
// Through the platform: what an app actually gets.
|
||||
val viaSystem = runCatching {
|
||||
e.handle.getAllByName(probeName).isNotEmpty()
|
||||
}.getOrElse { false }
|
||||
|
||||
perNetwork[label] = e.model.id to buildJsonObject {
|
||||
put("network_ref", e.model.id)
|
||||
put("servers", servers.joinToString(","))
|
||||
direct?.let { put("direct_answer", it) }
|
||||
put("direct_detail", directDetail)
|
||||
put("system_resolves", viaSystem)
|
||||
// Named here rather than left for a finding to infer, because the pairing is the
|
||||
// whole observation and splitting it across two places invites reading one alone.
|
||||
put(
|
||||
"verdict",
|
||||
when {
|
||||
viaSystem -> "resolver working"
|
||||
direct == true -> "server answers, device resolver does not"
|
||||
direct == false -> "server does not answer"
|
||||
else -> "not determined"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (perNetwork.isEmpty()) {
|
||||
return@withContext b.build(
|
||||
TestStatus.SKIPPED,
|
||||
evidence = buildJsonObject { put("reason", "no network advertised a DNS server") },
|
||||
)
|
||||
}
|
||||
val evidence = buildJsonObject {
|
||||
put("name", probeName)
|
||||
for ((label, v) in perNetwork) put(label, v.second)
|
||||
}
|
||||
// OK means the measurement ran, not that DNS is healthy — the finding says that.
|
||||
b.build(TestStatus.OK, evidence = evidence)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one A query straight to [server] over UDP. Returns the round trip in ms, or null.
|
||||
*
|
||||
* Hand-rolled rather than via any resolver API on purpose: the entire point is to bypass the
|
||||
* component under suspicion. Anything that goes through the platform resolver would inherit
|
||||
* exactly the fault this is trying to detect.
|
||||
*/
|
||||
private fun queryDirect(server: String, name: String): Long? = runCatching {
|
||||
val id = Random().nextInt(0xFFFF)
|
||||
val query = buildQuery(id, name)
|
||||
DatagramSocket().use { sock ->
|
||||
sock.soTimeout = 3000
|
||||
val addr = InetAddress.getByName(server) // a literal from DHCP; no lookup happens
|
||||
val t0 = System.nanoTime()
|
||||
sock.send(DatagramPacket(query, query.size, InetSocketAddress(addr, 53)))
|
||||
val buf = ByteArray(512)
|
||||
val reply = DatagramPacket(buf, buf.size)
|
||||
sock.receive(reply)
|
||||
val ms = (System.nanoTime() - t0) / 1_000_000
|
||||
// Match the transaction id, or a stray packet counts as success.
|
||||
val replyId = ((buf[0].toInt() and 0xFF) shl 8) or (buf[1].toInt() and 0xFF)
|
||||
if (replyId != id || reply.length < 12) null else ms
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
/** A minimal DNS query: one question, class IN, type A, recursion desired. */
|
||||
private fun buildQuery(id: Int, name: String): ByteArray {
|
||||
val labels = name.split('.').filter { it.isNotEmpty() }
|
||||
val out = ArrayList<Byte>(32)
|
||||
out.add((id shr 8).toByte()); out.add(id.toByte())
|
||||
out.add(0x01); out.add(0x00) // recursion desired
|
||||
out.add(0x00); out.add(0x01) // one question
|
||||
repeat(6) { out.add(0x00) } // no answers, authority or additional
|
||||
for (l in labels) {
|
||||
out.add(l.length.toByte())
|
||||
for (c in l.toByteArray(Charsets.US_ASCII)) out.add(c)
|
||||
}
|
||||
out.add(0x00) // root label
|
||||
out.add(0x00); out.add(0x01) // type A
|
||||
out.add(0x00); out.add(0x01) // class IN
|
||||
return out.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,32 @@ object NetworkInventory {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Android's own verdict on the network, read straight from the capabilities it already has.
|
||||
*
|
||||
* PARTIAL_CONNECTIVITY only exists from API 28 and CAPTIVE_PORTAL from 23, so both are read
|
||||
* defensively: an older platform that cannot answer should leave the field null rather than
|
||||
* assert a false.
|
||||
*/
|
||||
private fun systemVerdict(caps: NetworkCapabilities): app.echo_lot.measurement.SystemVerdict =
|
||||
app.echo_lot.measurement.SystemVerdict(
|
||||
validated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
|
||||
captivePortal = runCatching {
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)
|
||||
}.getOrNull(),
|
||||
// NET_CAPABILITY_PARTIAL_CONNECTIVITY is @SystemApi, so the constant is not in the
|
||||
// public SDK even though the platform sets it from API 28. The number is stable —
|
||||
// changing it would break every system app that reads it — but this is a value the
|
||||
// SDK does not promise us, so it is asked for defensively and reported as unknown
|
||||
// rather than as false if anything about it is not as expected.
|
||||
partialConnectivity = runCatching {
|
||||
caps.hasCapability(NET_CAPABILITY_PARTIAL_CONNECTIVITY)
|
||||
}.getOrNull(),
|
||||
)
|
||||
|
||||
/** @SystemApi NetworkCapabilities.NET_CAPABILITY_PARTIAL_CONNECTIVITY, API 28+. */
|
||||
private const val NET_CAPABILITY_PARTIAL_CONNECTIVITY = 24
|
||||
|
||||
private fun toModel(id: String, caps: NetworkCapabilities, lp: LinkProperties): MNetwork {
|
||||
val transport = when {
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Transport.WIFI
|
||||
@@ -72,6 +98,7 @@ object NetworkInventory {
|
||||
return MNetwork(
|
||||
id = id, transport = transport, iface = lp.interfaceName,
|
||||
link = Link(mtu = lp.mtu.takeIf { it > 0 }, addresses = addresses, routes = routes, dns = dns),
|
||||
systemVerdict = systemVerdict(caps),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user