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:
mrambossek
2026-08-02 10:10:36 +02:00
co-authored by Claude Opus 5
parent 2ed4d1f478
commit cfa58e8d60
2 changed files with 57 additions and 10 deletions
@@ -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 {