app: a DNS reply is not a DNS answer
The probe counted any well-formed packet from the server as "the server answers", checking only the transaction id and a minimum length. REFUSED and SERVFAIL are well-formed packets. So a server actively refusing this client would have been reported as healthy, and the finding — whose whole output is "the network is fine, your device is not" — would have pointed confidently at the wrong component. It now requires rcode 0 and at least one record, and reports a refusal as what it is: a working server saying no, which points back at the network. The rcode is named rather than numbered, because "REFUSED" is a fact an operator can act on and "rcode 5" is a lookup. Caught by decoding what fmr's router actually replied — ab cd 81 80 00 01 00 02, NOERROR with two answers — after realising the earlier check only counted bytes. The reply was genuinely good, so the finding on the tablet stands; the check was wrong regardless. The advice is broader too. That tablet's fault survived a reboot, which makes "toggle wifi and it clears" wrong as a flat claim: it now says what to look at when a restart does not fix it — something on the device filtering DNS, or a per-client rule on the router. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2ed4d1f478
commit
cfa58e8d60
@@ -681,11 +681,15 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
confidence = Confidence.HIGH,
|
confidence = Confidence.HIGH,
|
||||||
title = "This device cannot resolve names, but the DNS server is fine ($where)",
|
title = "This device cannot resolve names, but the DNS server is fine ($where)",
|
||||||
description = "A DNS query sent straight from this device was " +
|
description = "A DNS query sent straight from this device was " +
|
||||||
"answered by ${str("servers") ?: "the configured server"}, yet " +
|
"answered by ${str("servers") ?: "the configured server"} with " +
|
||||||
"asking Android to resolve the same name fails. The network is " +
|
"a valid result, yet asking Android to resolve the same name " +
|
||||||
"working; this device's resolver is not. Turning wifi off and on " +
|
"fails. Whatever is wrong sits between this device's resolver " +
|
||||||
"again, or rejoining the network, usually clears it. If it " +
|
"and a server that demonstrably works. " +
|
||||||
"returns after a restart, look at the network instead.",
|
"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)),
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -56,7 +56,15 @@ class DnsResolverProbe(
|
|||||||
var direct: Boolean? = null
|
var direct: Boolean? = null
|
||||||
var directDetail = "no server answered"
|
var directDetail = "no server answered"
|
||||||
for (s in servers) {
|
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) {
|
if (r != null) {
|
||||||
direct = true
|
direct = true
|
||||||
directDetail = "$s answered in ${r}ms"
|
directDetail = "$s answered in ${r}ms"
|
||||||
@@ -112,7 +120,17 @@ class DnsResolverProbe(
|
|||||||
* component under suspicion. Anything that goes through the platform resolver would inherit
|
* component under suspicion. Anything that goes through the platform resolver would inherit
|
||||||
* exactly the fault this is trying to detect.
|
* 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 id = Random().nextInt(0xFFFF)
|
||||||
val query = buildQuery(id, name)
|
val query = buildQuery(id, name)
|
||||||
DatagramSocket().use { sock ->
|
DatagramSocket().use { sock ->
|
||||||
@@ -124,11 +142,36 @@ class DnsResolverProbe(
|
|||||||
val reply = DatagramPacket(buf, buf.size)
|
val reply = DatagramPacket(buf, buf.size)
|
||||||
sock.receive(reply)
|
sock.receive(reply)
|
||||||
val ms = (System.nanoTime() - t0) / 1_000_000
|
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)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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"
|
||||||
}
|
}
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
/** A minimal DNS query: one question, class IN, type A, recursion desired. */
|
/** A minimal DNS query: one question, class IN, type A, recursion desired. */
|
||||||
private fun buildQuery(id: Int, name: String): ByteArray {
|
private fun buildQuery(id: Int, name: String): ByteArray {
|
||||||
|
|||||||
Reference in New Issue
Block a user