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:
mrambossek
2026-08-02 09:44:49 +02:00
co-authored by Claude Opus 5
parent 40e76c52ca
commit d65dbbc75a
7 changed files with 264 additions and 1 deletions
@@ -397,6 +397,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
// app happened to be developed against. With no server configured they get blank
// strings and report themselves skipped, which is the honest outcome — the
// alternative measures someone else's infrastructure and calls it your network.
// Before the canary: "can this device resolve at all" has to be answered before
// "are the answers being tampered with" means anything.
app.echo_lot.probe.DnsResolverProbe(entries),
DnsCanaryProbe(canaryZone = settings.canaryZone, sessionPrefix = "adhoc"),
StunProbe(serverHost = settings.serverHost()),
)
@@ -656,6 +659,39 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
)
}
}
if (t.type == TestType.DNS_RESOLVER && t.status == TestStatus.OK) {
// One finding per network: on a phone the wifi resolver can be wedged while
// cellular is fine, and "DNS is broken" would be wrong about half the device.
val ev = t.evidence
if (ev != null) {
for ((_, v) in ev) {
val o = v as? kotlinx.serialization.json.JsonObject ?: continue
fun str(k: String) =
(o[k] as? kotlinx.serialization.json.JsonPrimitive)?.content
if (str("verdict") != "server answers, device resolver does not") continue
val ref = str("network_ref")
val where = networks.firstOrNull { it.id == ref }?.iface
?.takeIf { it.isNotBlank() } ?: "this network"
out.add(
Finding(
id = ids.uuid(),
code = FindingRegistry.DNS_SYSTEM_RESOLVER_BROKEN.code,
category = FindingRegistry.DNS_SYSTEM_RESOLVER_BROKEN.category,
severity = FindingRegistry.DNS_SYSTEM_RESOLVER_BROKEN.severity,
confidence = Confidence.HIGH,
title = "This device cannot resolve names, but the DNS server is fine ($where)",
description = "A DNS query sent straight from this device was " +
"answered by ${str("servers") ?: "the configured server"}, yet " +
"asking Android to resolve the same name fails. The network is " +
"working; this device's resolver is not. Turning wifi off and on " +
"again, or rejoining the network, usually clears it. If it " +
"returns after a restart, look at the network instead.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
}
}
}
if (t.type == TestType.ICMP_PING6) {
// A network with no IPv6 at all is NORMAL — most networks are still IPv4-only, and
// that is not a defect. What IS a defect is IPv6 the network claims to provide (a
@@ -222,6 +222,25 @@ object FindingRegistry {
* behaviour. What is not acceptable is a run that quietly measures nothing and calls the
* result healthy, so this says plainly which networks went unmeasured and why.
*/
/**
* The network's DNS server answers, but this device cannot resolve through it.
*
* Worth separating from every other DNS failure because the remedy is somewhere else entirely.
* A name that will not resolve looks identical to a user whatever the cause, and the two causes
* pull in opposite directions: a server that does not answer means the network is broken and
* the router is the thing to examine, while a server that answers a direct query on a device
* that still cannot resolve means the platform resolver has wedged — fixed by toggling wifi,
* and nothing to do with the network at all.
*
* Proven rather than inferred: the probe sends its own UDP query, bypassing the component under
* suspicion, and compares that against what the platform returns for the same name.
*/
val DNS_SYSTEM_RESOLVER_BROKEN = FindingSpec(
"dns.system_resolver_broken", Category.DNS, Severity.HIGH,
"The network's DNS server answers, but this device cannot resolve names through it.",
rulesOut = "A network fault: the server replied to a query sent from this device.",
)
val MEASUREMENT_VPN_CONSTRAINED = FindingSpec(
"measurement.vpn_constrained", Category.CONNECTIVITY, Severity.INFO,
"A VPN was active, so the networks underneath it could not be measured.",
@@ -261,7 +280,7 @@ object FindingRegistry {
NAT_UDP_REBINDING, NAT_SYMMETRIC,
THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED,
DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE,
MEASUREMENT_VPN_CONSTRAINED, V6_NO_DEFAULT_ROUTE, V6_ROUTE_WITHOUT_ADDRESS, V6_NO_ICMP_REPLY, V6_NOT_OFFERED,
DNS_SYSTEM_RESOLVER_BROKEN, MEASUREMENT_VPN_CONSTRAINED, V6_NO_DEFAULT_ROUTE, V6_ROUTE_WITHOUT_ADDRESS, V6_NO_ICMP_REPLY, V6_NOT_OFFERED,
)
private val byCode: Map<String, FindingSpec> = all.associateBy { it.code }
@@ -18,6 +18,28 @@ data class Network(
val wifi: Wifi? = null,
val cellular: Cellular? = null,
val changes: List<NetworkChange> = emptyList(),
@SerialName("system_verdict") val systemVerdict: SystemVerdict? = null,
)
/**
* What Android itself concluded about a network, as opposed to what we measured.
*
* Recorded because it is the verdict the user can see — the "no internet" warning in the status
* bar — and because it is free: the platform has already done the work by the time a run starts.
*
* Its real value is disagreement. When Android says a network is unusable and our own probes reach
* the internet regardless, the fault is in the device rather than the network, and that distinction
* is the difference between "fix your router" and "toggle your wifi". Neither number alone can say
* that; only the two together.
*/
@Serializable
data class SystemVerdict(
/** Android's own connectivity check passed. Null when the platform did not say. */
val validated: Boolean? = null,
/** Android believes a captive portal is intercepting this network. */
@SerialName("captive_portal") val captivePortal: Boolean? = null,
/** Some traffic works and some does not — Android's own hedge. */
@SerialName("partial_connectivity") val partialConnectivity: Boolean? = null,
)
@Serializable
@@ -89,6 +89,13 @@ object TestType {
// dns
const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory"
const val DNS_CANARY = "dns.canary"
/**
* Does this device's own resolver work, as distinct from the network's DNS.
*
* Registry addition, v1.1. Kept apart from [DNS_CANARY], which asks whether answers are being
* tampered with; this asks whether answers arrive at all, and where the failure sits.
*/
const val DNS_RESOLVER = "dns.resolver"
const val DNS_INTERCEPTION = "dns.interception"
const val DNS_TTL_INTEGRITY = "dns.ttl_integrity"
const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity"
@@ -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),
)
}
}