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"