diff --git a/docs/findings-registry.md b/docs/findings-registry.md index b38c23e..aa100a6 100644 --- a/docs/findings-registry.md +++ b/docs/findings-registry.md @@ -89,6 +89,8 @@ rolled up under *connectivity* instead — the third occurrence of rule 1 being | code | severity | means | rules out | |---|---|---|---| +| `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. | | `v6.route_without_address` | medium | The network advertises an IPv6 default route but the device has no global IPv6 address. | A working IPv6 setup: SLAAC did not produce a usable address on this link. | | `v6.no_icmp_reply` | low | IPv6 is configured but ICMPv6 echo gets no reply. | Nothing on its own: IPv6 may work fine with ICMP filtered. | 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 05685ce..3a39bde 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 @@ -397,6 +397,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { // app happened to be developed against. With no server configured they get blank // strings and report themselves skipped, which is the honest outcome — the // alternative measures someone else's infrastructure and calls it your network. + // Before the canary: "can this device resolve at all" has to be answered before + // "are the answers being tampered with" means anything. + app.echo_lot.probe.DnsResolverProbe(entries), DnsCanaryProbe(canaryZone = settings.canaryZone, sessionPrefix = "adhoc"), StunProbe(serverHost = settings.serverHost()), ) @@ -656,6 +659,39 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { ) } } + if (t.type == TestType.DNS_RESOLVER && t.status == TestStatus.OK) { + // One finding per network: on a phone the wifi resolver can be wedged while + // cellular is fine, and "DNS is broken" would be wrong about half the device. + val ev = t.evidence + if (ev != null) { + for ((_, v) in ev) { + 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 ref = str("network_ref") + val where = networks.firstOrNull { it.id == ref }?.iface + ?.takeIf { it.isNotBlank() } ?: "this network" + out.add( + Finding( + id = ids.uuid(), + code = FindingRegistry.DNS_SYSTEM_RESOLVER_BROKEN.code, + category = FindingRegistry.DNS_SYSTEM_RESOLVER_BROKEN.category, + severity = FindingRegistry.DNS_SYSTEM_RESOLVER_BROKEN.severity, + confidence = Confidence.HIGH, + title = "This device cannot resolve names, but the DNS server is fine ($where)", + description = "A DNS query sent straight from this device was " + + "answered by ${str("servers") ?: "the configured server"}, yet " + + "asking Android to resolve the same name fails. The network is " + + "working; this device's resolver is not. Turning wifi off and on " + + "again, or rejoining the network, usually clears it. If it " + + "returns after a restart, look at the network instead.", + evidenceRefs = listOf(EvidenceRef(t.id)), + ) + ) + } + } + } if (t.type == TestType.ICMP_PING6) { // A network with no IPv6 at all is NORMAL — most networks are still IPv4-only, and // that is not a defect. What IS a defect is IPv6 the network claims to provide (a 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 bcd2d18..36dcb0e 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 @@ -222,6 +222,25 @@ object FindingRegistry { * behaviour. What is not acceptable is a run that quietly measures nothing and calls the * result healthy, so this says plainly which networks went unmeasured and why. */ + /** + * The network's DNS server answers, but this device cannot resolve through it. + * + * Worth separating from every other DNS failure because the remedy is somewhere else entirely. + * A name that will not resolve looks identical to a user whatever the cause, and the two causes + * pull in opposite directions: a server that does not answer means the network is broken and + * the router is the thing to examine, while a server that answers a direct query on a device + * that still cannot resolve means the platform resolver has wedged — fixed by toggling wifi, + * and nothing to do with the network at all. + * + * 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. + */ + 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.", + rulesOut = "A network fault: the server replied to a query sent from this device.", + ) + val MEASUREMENT_VPN_CONSTRAINED = FindingSpec( "measurement.vpn_constrained", Category.CONNECTIVITY, Severity.INFO, "A VPN was active, so the networks underneath it could not be measured.", @@ -261,7 +280,7 @@ object FindingRegistry { NAT_UDP_REBINDING, NAT_SYMMETRIC, THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED, DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE, - MEASUREMENT_VPN_CONSTRAINED, V6_NO_DEFAULT_ROUTE, V6_ROUTE_WITHOUT_ADDRESS, V6_NO_ICMP_REPLY, V6_NOT_OFFERED, + 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-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt index a432eb0..75fee57 100644 --- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt @@ -18,6 +18,28 @@ data class Network( val wifi: Wifi? = null, val cellular: Cellular? = null, val changes: List = emptyList(), + @SerialName("system_verdict") val systemVerdict: SystemVerdict? = null, +) + +/** + * What Android itself concluded about a network, as opposed to what we measured. + * + * Recorded because it is the verdict the user can see — the "no internet" warning in the status + * bar — and because it is free: the platform has already done the work by the time a run starts. + * + * Its real value is disagreement. When Android says a network is unusable and our own probes reach + * the internet regardless, the fault is in the device rather than the network, and that distinction + * is the difference between "fix your router" and "toggle your wifi". Neither number alone can say + * that; only the two together. + */ +@Serializable +data class SystemVerdict( + /** Android's own connectivity check passed. Null when the platform did not say. */ + val validated: Boolean? = null, + /** Android believes a captive portal is intercepting this network. */ + @SerialName("captive_portal") val captivePortal: Boolean? = null, + /** Some traffic works and some does not — Android's own hedge. */ + @SerialName("partial_connectivity") val partialConnectivity: Boolean? = null, ) @Serializable diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt index c6a6d0e..d8e1d7a 100644 --- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt @@ -89,6 +89,13 @@ object TestType { // dns const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory" const val DNS_CANARY = "dns.canary" + /** + * Does this device's own resolver work, as distinct from the network's DNS. + * + * Registry addition, v1.1. Kept apart from [DNS_CANARY], which asks whether answers are being + * tampered with; this asks whether answers arrive at all, and where the failure sits. + */ + const val DNS_RESOLVER = "dns.resolver" const val DNS_INTERCEPTION = "dns.interception" const val DNS_TTL_INTEGRITY = "dns.ttl_integrity" const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity" 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 new file mode 100644 index 0000000..e8b2aa6 --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DnsResolverProbe.kt @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.probe + +import android.content.Context +import app.echo_lot.measurement.Test +import app.echo_lot.measurement.TestStatus +import app.echo_lot.measurement.TestType +import app.echo_lot.measurement.Tier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InetSocketAddress +import java.util.Random + +/** + * Asks the network's own DNS servers directly, then asks Android to resolve the same name, and + * compares. + * + * The comparison is the point. A name that fails to resolve looks the same to a user whatever the + * cause, but the causes want opposite responses: if the server does not answer, the network is + * broken and the router is the thing to look at; if the server answers a raw query while the + * platform still cannot resolve, the device's own resolver has wedged and toggling wifi fixes it in + * seconds. Nothing else on a phone will tell you which of those you have. + * + * This is deliberately not a general DNS test — no recursion checks, no DNSSEC, no rewriting + * detection; [DnsCanaryProbe] covers interception. This one answers a single question: is the + * resolver on this device doing its job. + */ +class DnsResolverProbe( + private val entries: List, + /** Resolved directly rather than through any cache; any name with a stable answer will do. */ + private val probeName: String = "one.one.one.one", +) : Probe { + override val type = TestType.DNS_RESOLVER + override val tier = Tier.APP + // A query per server with a 3s ceiling, plus one getaddrinfo that may sit out its own timeout. + override val estimatedMs = 6_000L + + override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) { + val b = TestBuilder(type, tier, ids) + val perNetwork = LinkedHashMap>() + + for (e in entries) { + val servers = e.model.link.dns?.servers.orEmpty() + if (servers.isEmpty()) continue + val label = "${e.model.transport.name.lowercase()}:${e.model.id}" + + // Directly: does the configured server answer at all? + var direct: Boolean? = null + var directDetail = "no server answered" + for (s in servers) { + val r = queryDirect(s, probeName) + if (r != null) { + direct = true + directDetail = "$s answered in ${r}ms" + break + } + direct = false + directDetail = "$s did not answer" + } + + // Through the platform: what an app actually gets. + val viaSystem = runCatching { + e.handle.getAllByName(probeName).isNotEmpty() + }.getOrElse { false } + + perNetwork[label] = e.model.id to buildJsonObject { + put("network_ref", e.model.id) + put("servers", servers.joinToString(",")) + direct?.let { put("direct_answer", it) } + put("direct_detail", directDetail) + 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 { + viaSystem -> "resolver working" + direct == true -> "server answers, device resolver does not" + direct == false -> "server does not answer" + else -> "not determined" + }, + ) + } + } + + if (perNetwork.isEmpty()) { + return@withContext b.build( + TestStatus.SKIPPED, + evidence = buildJsonObject { put("reason", "no network advertised a DNS server") }, + ) + } + val evidence = buildJsonObject { + put("name", probeName) + for ((label, v) in perNetwork) put(label, v.second) + } + // OK means the measurement ran, not that DNS is healthy — the finding says that. + b.build(TestStatus.OK, evidence = evidence) + } + + /** + * Sends one A query straight to [server] over UDP. Returns the round trip in ms, or null. + * + * Hand-rolled rather than via any resolver API on purpose: the entire point is to bypass the + * 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 { + val id = Random().nextInt(0xFFFF) + val query = buildQuery(id, name) + DatagramSocket().use { sock -> + sock.soTimeout = 3000 + val addr = InetAddress.getByName(server) // a literal from DHCP; no lookup happens + val t0 = System.nanoTime() + sock.send(DatagramPacket(query, query.size, InetSocketAddress(addr, 53))) + val buf = ByteArray(512) + 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. + val replyId = ((buf[0].toInt() and 0xFF) shl 8) or (buf[1].toInt() and 0xFF) + if (replyId != id || reply.length < 12) null else ms + } + }.getOrNull() + + /** A minimal DNS query: one question, class IN, type A, recursion desired. */ + private fun buildQuery(id: Int, name: String): ByteArray { + val labels = name.split('.').filter { it.isNotEmpty() } + val out = ArrayList(32) + out.add((id shr 8).toByte()); out.add(id.toByte()) + out.add(0x01); out.add(0x00) // recursion desired + out.add(0x00); out.add(0x01) // one question + repeat(6) { out.add(0x00) } // no answers, authority or additional + for (l in labels) { + out.add(l.length.toByte()) + for (c in l.toByteArray(Charsets.US_ASCII)) out.add(c) + } + out.add(0x00) // root label + out.add(0x00); out.add(0x01) // type A + out.add(0x00); out.add(0x01) // class IN + return out.toByteArray() + } +} diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkInventory.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkInventory.kt index 79a73b2..069ccd8 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkInventory.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkInventory.kt @@ -39,6 +39,32 @@ object NetworkInventory { return out } + /** + * Android's own verdict on the network, read straight from the capabilities it already has. + * + * PARTIAL_CONNECTIVITY only exists from API 28 and CAPTIVE_PORTAL from 23, so both are read + * defensively: an older platform that cannot answer should leave the field null rather than + * assert a false. + */ + private fun systemVerdict(caps: NetworkCapabilities): app.echo_lot.measurement.SystemVerdict = + app.echo_lot.measurement.SystemVerdict( + validated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED), + captivePortal = runCatching { + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) + }.getOrNull(), + // NET_CAPABILITY_PARTIAL_CONNECTIVITY is @SystemApi, so the constant is not in the + // public SDK even though the platform sets it from API 28. The number is stable — + // changing it would break every system app that reads it — but this is a value the + // SDK does not promise us, so it is asked for defensively and reported as unknown + // rather than as false if anything about it is not as expected. + partialConnectivity = runCatching { + caps.hasCapability(NET_CAPABILITY_PARTIAL_CONNECTIVITY) + }.getOrNull(), + ) + + /** @SystemApi NetworkCapabilities.NET_CAPABILITY_PARTIAL_CONNECTIVITY, API 28+. */ + private const val NET_CAPABILITY_PARTIAL_CONNECTIVITY = 24 + private fun toModel(id: String, caps: NetworkCapabilities, lp: LinkProperties): MNetwork { val transport = when { caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Transport.WIFI @@ -72,6 +98,7 @@ object NetworkInventory { return MNetwork( id = id, transport = transport, iface = lp.interfaceName, link = Link(mtu = lp.mtu.takeIf { it > 0 }, addresses = addresses, routes = routes, dns = dns), + systemVerdict = systemVerdict(caps), ) } }