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 3a39bde..9c6ee02 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 @@ -681,11 +681,15 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { 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.", + "answered by ${str("servers") ?: "the configured server"} with " + + "a valid result, yet asking Android to resolve the same name " + + "fails. Whatever is wrong sits between this device's resolver " + + "and a server that demonstrably works. " + + "Turning wifi off and on, or rejoining the network, clears the " + + "common case. If it survives a restart it is not a stuck " + + "resolver: look for something on this device that filters DNS " + + "— an ad blocker, a private-DNS or VPN app — or a per-device " + + "rule on the router aimed at this client.", evidenceRefs = listOf(EvidenceRef(t.id)), ) ) 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 index e8b2aa6..0ac2032 100644 --- 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 @@ -56,7 +56,15 @@ class DnsResolverProbe( var direct: Boolean? = null var directDetail = "no server answered" for (s in servers) { - val r = queryDirect(s, probeName) + val r = try { + queryDirect(s, probeName) + } catch (e: DnsRefused) { + // Distinguished deliberately: a server that replies with a failure is a + // working server saying no, which points at the network rather than here. + direct = false + directDetail = "$s ${e.why}" + break + } if (r != null) { direct = true directDetail = "$s answered in ${r}ms" @@ -112,7 +120,17 @@ class DnsResolverProbe( * 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 { + private fun queryDirect(server: String, name: String): Long? { + return try { + queryDirectOrThrow(server, name) + } catch (e: DnsRefused) { + throw e + } catch (t: Throwable) { + null + } + } + + private fun queryDirectOrThrow(server: String, name: String): Long? = run { val id = Random().nextInt(0xFFFF) val query = buildQuery(id, name) DatagramSocket().use { sock -> @@ -124,11 +142,36 @@ class DnsResolverProbe( 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. + // A reply is not an answer. Counting any packet as success would let a REFUSED or + // SERVFAIL — both perfectly well-formed responses — be reported as "the server + // answers", and this probe's whole output is the claim that the server is fine and + // the device is not. That would be an accusation pointed at the wrong component, + // stated with confidence. val replyId = ((buf[0].toInt() and 0xFF) shl 8) or (buf[1].toInt() and 0xFF) - if (replyId != id || reply.length < 12) null else ms + val rcode = if (reply.length >= 4) buf[3].toInt() and 0x0F else -1 + val answers = if (reply.length >= 8) { + ((buf[6].toInt() and 0xFF) shl 8) or (buf[7].toInt() and 0xFF) + } else 0 + when { + replyId != id || reply.length < 12 -> null + rcode != 0 -> throw DnsRefused(rcodeName(rcode)) + answers == 0 -> throw DnsRefused("answered with no records") + else -> ms + } } - }.getOrNull() + } + + /** The server replied, but with a failure — which is a network fault, not a device one. */ + private class DnsRefused(val why: String) : Exception(why) + + private fun rcodeName(rcode: Int): String = when (rcode) { + 1 -> "rejected the query as malformed" + 2 -> "reported its own failure (SERVFAIL)" + 3 -> "said the name does not exist (NXDOMAIN)" + 4 -> "does not implement this query" + 5 -> "refused the query (REFUSED)" + else -> "returned rcode $rcode" + } /** A minimal DNS query: one question, class IN, type A, recursion desired. */ private fun buildQuery(id: Int, name: String): ByteArray {