app: catch a search domain that swallows DNS queries

A tablet on a healthy network could not resolve anything. The DNS server
answered the bare name correctly — NOERROR, two records, A and AAAA, with
and without EDNS0 — so the earlier finding blamed the device's resolver.
It was wrong. The network advertised hudelist.local as a search domain and
the server silently dropped every query under it: not NXDOMAIN, nothing at
all. Resolvers append search domains, so they waited for a reply that was
never coming.

Silence is the part that makes this vicious. A negative answer moves a
resolver on; no answer looks like packet loss, so it retries, and some
give up on the lookup entirely. It also explains how two devices on one
network can disagree about whether DNS works — the phone tried the plain
name first and never noticed.

The probe now asks about a nonce name under each advertised search domain,
where the wanted answer is NXDOMAIN and only silence is a fault. The
finding is ordered ahead of dns.system_resolver_broken so the two cannot
both fire: without that, this exact network gets told its device is
broken.

Severity follows the harm rather than the shape. HIGH when resolution is
actually failing, MEDIUM when the domain is a black hole but this resolver
happens to try the plain name first — calling that HIGH would be crying
wolf on a network that works. The message names the fix and notes that
.local is reserved for mDNS by RFC 6762 and widely dropped by design,
while home.arpa (RFC 8375) is the name reserved for this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 10:29:04 +02:00
co-authored by Claude Opus 5
parent cfa58e8d60
commit a17c3fd9e6
4 changed files with 96 additions and 2 deletions
@@ -235,6 +235,24 @@ object FindingRegistry {
* 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.
*/
/**
* The network hands out a search domain its DNS server will not answer for.
*
* A resolver appends search domains to lookups, so every name a client asks about can stall on
* a domain the server ignores. The failure mode is silence rather than a negative answer, and
* silence is indistinguishable from packet loss: clients retry instead of moving on, and some
* give up on the lookup entirely. That makes it look like the device is broken when the
* network is.
*
* Whether it bites depends on the resolver — some try the bare name first and never notice —
* which is why two devices on the same network can disagree about whether DNS works.
*/
val DNS_SEARCH_DOMAIN_UNANSWERED = FindingSpec(
"dns.search_domain_unanswered", Category.DNS, Severity.HIGH,
"The network advertises a DNS search domain that its own server does not answer for.",
rulesOut = "A fault on this device: the same server answers ordinary names normally.",
)
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.",
@@ -280,7 +298,7 @@ object FindingRegistry {
NAT_UDP_REBINDING, NAT_SYMMETRIC,
THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED,
DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE,
DNS_SYSTEM_RESOLVER_BROKEN, MEASUREMENT_VPN_CONSTRAINED, V6_NO_DEFAULT_ROUTE, V6_ROUTE_WITHOUT_ADDRESS, V6_NO_ICMP_REPLY, V6_NOT_OFFERED,
DNS_SEARCH_DOMAIN_UNANSWERED, 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 }