From d65dbbc75aeef848efb824a72fe2e4c7387a111f Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sun, 2 Aug 2026 09:44:49 +0200 Subject: [PATCH] app: tell a broken device resolver apart from a broken network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/findings-registry.md | 2 + .../kotlin/app/echo_lot/app/RunViewModel.kt | 36 +++++ .../echo_lot/measurement/FindingRegistry.kt | 21 ++- .../app/echo_lot/measurement/Network.kt | 22 +++ .../kotlin/app/echo_lot/measurement/Test.kt | 7 + .../app/echo_lot/probe/DnsResolverProbe.kt | 150 ++++++++++++++++++ .../app/echo_lot/probe/NetworkInventory.kt | 27 ++++ 7 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DnsResolverProbe.kt diff --git a/docs/findings-registry.md b/docs/findings-registry.md index b38c23e..aa100a6 100644 --- a/docs/findings-registry.md +++ b/docs/findings-registry.md @@ -89,6 +89,8 @@ rolled up under *connectivity* instead — the third occurrence of rule 1 being | code | severity | means | rules out | |---|---|---|---| +| `dns.system_resolver_broken` | high | The network's DNS server answers, but this device cannot resolve names through it. | A network fault: the server replied to a query sent from this device. | +| `measurement.vpn_constrained` | info | A VPN was active, so the networks underneath it could not be measured. | Nothing — this run says little about the underlying network either way. | | `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. | 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 05685ce..3a39bde 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 @@ -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 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 bcd2d18..36dcb0e 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 @@ -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 = all.associateBy { it.code } diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt index a432eb0..75fee57 100644 --- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt @@ -18,6 +18,28 @@ data class Network( val wifi: Wifi? = null, val cellular: Cellular? = null, val changes: List = 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 diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt index c6a6d0e..d8e1d7a 100644 --- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt @@ -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" diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DnsResolverProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DnsResolverProbe.kt new file mode 100644 index 0000000..e8b2aa6 --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DnsResolverProbe.kt @@ -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, + /** 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>() + + 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(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() + } +} diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkInventory.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkInventory.kt index 79a73b2..069ccd8 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkInventory.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkInventory.kt @@ -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), ) } }