From a17c3fd9e612da810591be43efd6ed8bae2adf65 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sun, 2 Aug 2026 10:29:04 +0200 Subject: [PATCH] app: catch a search domain that swallows DNS queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/findings-registry.md | 1 + .../kotlin/app/echo_lot/app/RunViewModel.kt | 43 ++++++++++++++++++- .../echo_lot/measurement/FindingRegistry.kt | 20 ++++++++- .../app/echo_lot/probe/DnsResolverProbe.kt | 34 +++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/docs/findings-registry.md b/docs/findings-registry.md index aa100a6..cdbba85 100644 --- a/docs/findings-registry.md +++ b/docs/findings-registry.md @@ -89,6 +89,7 @@ rolled up under *connectivity* instead — the third occurrence of rule 1 being | code | severity | means | rules out | |---|---|---|---| +| `dns.search_domain_unanswered` | high | The network advertises a DNS search domain that its own server does not answer for. | A fault on this device: the same server answers ordinary names normally. | | `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. | 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 9c6ee02..a1d905c 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 @@ -668,7 +668,48 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { 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 verdict = str("verdict") + val ref0 = str("network_ref") + val iface0 = networks.firstOrNull { it.id == ref0 }?.iface + ?.takeIf { it.isNotBlank() } ?: "this network" + if (verdict == "search domain swallows queries") { + // Severity follows the harm, not the shape: the same misconfiguration + // is fatal on a resolver that tries the search form and invisible on + // one that does not, and saying "high" for a network that currently + // resolves fine would be crying wolf. + val breaking = str("system_resolves") != "true" + out.add( + Finding( + id = ids.uuid(), + code = FindingRegistry.DNS_SEARCH_DOMAIN_UNANSWERED.code, + category = FindingRegistry.DNS_SEARCH_DOMAIN_UNANSWERED.category, + severity = if (breaking) Severity.HIGH else Severity.MEDIUM, + confidence = Confidence.HIGH, + title = "The network's search domain swallows DNS queries ($iface0)", + description = "This network hands out " + + "${str("search_domains") ?: "a search domain"} as a DNS " + + "search domain, but its server never answers queries under " + + "it — not even to say the name does not exist. Resolvers " + + "append that domain to lookups, so they wait for a reply " + + "that never comes. " + + (if (breaking) { + "That is why names are not resolving on this device." + } else { + "Name resolution still works here, because this " + + "resolver tries the plain name first — another " + + "device on the same network may fail outright." + }) + + " Fix it on the router: either stop advertising the search " + + "domain, or make the server answer for it, including " + + "NXDOMAIN for names it does not have. Note that .local is " + + "reserved for mDNS (RFC 6762) and is widely dropped by " + + "design; home.arpa (RFC 8375) is the name reserved for this.", + evidenceRefs = listOf(EvidenceRef(t.id)), + ) + ) + continue + } + if (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" 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 36dcb0e..e2c1d6e 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 @@ -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 = all.associateBy { it.code } 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 0ac2032..9e48c68 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 @@ -74,6 +74,31 @@ class DnsResolverProbe( directDetail = "$s did not answer" } + // The search domains the network handed out, asked about separately. + // + // A resolver appends these to a lookup, so a search domain the server will not answer + // for stalls every name a client asks about — and it fails as silence, which is + // indistinguishable from packet loss, so clients retry rather than moving on. Asking + // about a name that cannot exist is deliberate: the answer wanted here is NXDOMAIN, + // and what matters is only whether anything comes back at all. + val searchDomains = e.model.link.dns?.searchDomains.orEmpty() + var searchAnswered: Boolean? = null + var searchDetail = "" + for (d in searchDomains) { + val nonce = "echolot-probe-" + java.util.UUID.randomUUID().toString().take(8) + val answered = servers.any { srv -> + runCatching { queryDirect(srv, "$nonce.$d") != null } + .getOrElse { it is DnsRefused } // a refusal is still an answer + } + if (!answered) { + searchAnswered = false + searchDetail = "$d is not answered at all — queries under it vanish" + break + } + searchAnswered = true + searchDetail = "$d answers" + } + // Through the platform: what an app actually gets. val viaSystem = runCatching { e.handle.getAllByName(probeName).isNotEmpty() @@ -84,12 +109,21 @@ class DnsResolverProbe( put("servers", servers.joinToString(",")) direct?.let { put("direct_answer", it) } put("direct_detail", directDetail) + if (searchDomains.isNotEmpty()) { + put("search_domains", searchDomains.joinToString(",")) + searchAnswered?.let { put("search_answered", it) } + put("search_detail", searchDetail) + } 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 { + // Ordered by which component is at fault, most specific first. A search + // domain that swallows queries explains a failure that would otherwise be + // blamed on the device, so it has to be tested before that conclusion. + searchAnswered == false -> "search domain swallows queries" viaSystem -> "resolver working" direct == true -> "server answers, device resolver does not" direct == false -> "server does not answer"