From f6849f8e6a4ef2f4f334ebf177672e3609f18db3 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sun, 2 Aug 2026 12:39:52 +0200 Subject: [PATCH] app: fold the prober's validated capabilities into core-probe traceroute.udp4 lands as TracerouteProbe: ICMP time-exceeded read off the socket error queue via Os.recvmsg(MSG_ERRQUEUE) through the reflection facade the prober validated on both devices - no root, no raw socket, and the C-over-JNI shim stays dead. Emits the schema's TracerouteEvidence with rtt_ns. OsAbi carries the hardcoded sockopt ABI numbers across, including the measured fact that Os.getsockoptInt exists on neither device, so PMTU must come from the errqueue, never getsockopt(IP_MTU). local.mdns_inventory lands as MdnsInventoryProbe with both hardware-bought lessons intact: the meta-query lies (0 results beside live services on both devices), and 4s of listening misses what 10s catches. App version 0.2.2. Co-Authored-By: Claude Opus 5 --- echolot-app/app/build.gradle.kts | 2 +- .../kotlin/app/echo_lot/app/RunViewModel.kt | 4 + .../app/echo_lot/probe/MdnsInventoryProbe.kt | 107 ++++++++ .../main/kotlin/app/echo_lot/probe/OsAbi.kt | 70 +++++ .../main/kotlin/app/echo_lot/probe/Probe.kt | 3 +- .../app/echo_lot/probe/TracerouteProbe.kt | 255 ++++++++++++++++++ 6 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/MdnsInventoryProbe.kt create mode 100644 echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/OsAbi.kt create mode 100644 echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/TracerouteProbe.kt diff --git a/echolot-app/app/build.gradle.kts b/echolot-app/app/build.gradle.kts index dc6d79d..d0ccfc9 100644 --- a/echolot-app/app/build.gradle.kts +++ b/echolot-app/app/build.gradle.kts @@ -15,7 +15,7 @@ plugins { // // major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing // digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100. -val appVersionName = "0.2.1" +val appVersionName = "0.2.2" fun versionCodeOf(semver: String): Int { val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt) 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 bde06ce..f78caae 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 @@ -403,6 +403,10 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { RouterIdentityProbe(entries), IcmpProbe(entries, v6 = false), IcmpProbe(entries, v6 = true), + // Folded from the prober after hardware validation: errqueue traceroute (no root, + // no JNI) and the mDNS service inventory / VLAN-leakage detector. + app.echo_lot.probe.TracerouteProbe(), + app.echo_lot.probe.MdnsInventoryProbe(), CaptivePortalProbe(entries), // Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to // the reference deployment until profiles/enrollment land in the UI. diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/MdnsInventoryProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/MdnsInventoryProbe.kt new file mode 100644 index 0000000..94c30c7 --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/MdnsInventoryProbe.kt @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.probe + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.net.wifi.WifiManager +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.delay +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import java.util.Collections + +/** + * local.mdns_inventory — what answers mDNS on this network (MulticastLock + NSD discovery). + * The service inventory doubles as the VLAN-leakage detector: a chromecast answering on the + * guest wifi is a segmentation fault made visible. Folded from the prober, validated on both + * known devices (4 services each). + * + * Two hardware-bought lessons are load-bearing here: + * - The `_services._dns-sd._udp.` meta-query returned 0 on BOTH devices while concrete types + * found live services — NsdManager's meta-query support is unreliable across builds, so the + * concrete types are the measurement and the meta-query result is itself evidence. + * - 4 s of listening missed services that 10 s catches; mDNS answers straggle. + */ +class MdnsInventoryProbe : Probe { + override val type = TestType.LOCAL_MDNS_INVENTORY + override val tier = Tier.APP + override val estimatedMs = 10_500L + + /** Meta-query + common concrete types (HTTP covers HA/printers/NAS; googlecast is ubiquitous). */ + private val queries = listOf( + "meta" to "_services._dns-sd._udp.", + "http" to "_http._tcp.", + "googlecast" to "_googlecast._tcp.", + ) + + private class Recorder : NsdManager.DiscoveryListener { + val names: MutableList = Collections.synchronizedList(mutableListOf()) + @Volatile var started = false + @Volatile var startFailCode: Int? = null + override fun onStartDiscoveryFailed(t: String?, code: Int) { startFailCode = code } + override fun onStopDiscoveryFailed(t: String?, code: Int) {} + override fun onDiscoveryStarted(t: String?) { started = true } + override fun onDiscoveryStopped(t: String?) {} + override fun onServiceFound(s: NsdServiceInfo?) { s?.serviceName?.let { names.add(it) } } + override fun onServiceLost(s: NsdServiceInfo?) {} + } + + override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) { + val b = TestBuilder(type, tier, ids) + val wifi = ctx.getSystemService(WifiManager::class.java) + val lock = wifi?.createMulticastLock("echolot")?.apply { + setReferenceCounted(false) + runCatching { acquire() } + } + val nsd = ctx.getSystemService(NsdManager::class.java) + ?: return@withContext b.build( + TestStatus.UNSUPPORTED, + evidence = buildJsonObject { put("reason", "NsdManager unavailable") }, + ) + + val recorders = queries.map { (label, type) -> + val r = Recorder() + runCatching { nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, r) } + .onFailure { r.startFailCode = -1 } + Triple(label, type, r) + } + try { + delay(10_000) + var total = 0 + var anyStarted = false + val evidence = buildJsonObject { + put("multicast_lock", lock?.isHeld == true) + for ((label, type, r) in recorders) { + runCatching { nsd.stopServiceDiscovery(r) } + anyStarted = anyStarted || r.started + val names = r.names.distinct() + total += names.size + putJsonObject(label) { + put("query", type) + put("started", r.started) + r.startFailCode?.let { put("start_fail_code", it) } + put("found", names.size) + if (names.isNotEmpty()) put("names", names.joinToString(", ").take(300)) + } + } + } + val metrics = buildJsonObject { put("services_found", total) } + // Zero services on a started discovery is a legitimate result (an empty or properly + // isolated network), not a failure — only discovery refusing to start is one. + b.build(if (anyStarted) TestStatus.OK else TestStatus.FAILED, + evidence = evidence, metrics = metrics) + } finally { + recorders.forEach { (_, _, r) -> runCatching { nsd.stopServiceDiscovery(r) } } + runCatching { lock?.release() } + } + } +} diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/OsAbi.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/OsAbi.kt new file mode 100644 index 0000000..ed62b37 --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/OsAbi.kt @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.probe + +import android.system.Os +import java.io.FileDescriptor + +/** + * Linux socket-option ABI numbers that android.system.OsConstants does NOT reliably expose. + * Stable across Android's supported ABIs at the IP/IPv6 protocol levels, which is why they can + * be hardcoded: if setsockoptInt with one of these succeeds, the kernel accepted the option; if + * it throws ErrnoException, it did not. Either outcome is data. Do not "fix" these to + * OsConstants names — they don't exist there (validated in the prober; see its OsAbi.kt). + * + * Measured fact worth keeping: `Os.getsockoptInt` is absent on both known devices (OnePlus 15 + * A16, Lenovo TB330FU A15), so path-MTU values must be read from the errqueue (`ee_info`), never + * from getsockopt(IP_MTU). + */ +object OsAbi { + // IP level + const val IP_TTL = 2 + const val IP_MTU_DISCOVER = 10 + const val IP_MTU = 14 + const val IP_RECVERR = 11 + const val IP_PMTUDISC_DO = 2 // set DF, honor PMTU + const val IP_PMTUDISC_PROBE = 3 // set DF, ignore PMTU (for probing) + + // IPv6 level + const val IPV6_MTU_DISCOVER = 23 + const val IPV6_MTU = 24 + const val IPV6_RECVERR = 25 + const val IPV6_UNICAST_HOPS = 16 + const val IPV6_PMTUDISC_PROBE = 3 + + // recv flags — not in OsConstants on any current API level + const val MSG_ERRQUEUE = 0x2000 + const val MSG_DONTWAIT = 0x40 + + // struct sock_extended_err (uapi/linux/errqueue.h), fixed layout on all Android ABIs: + // u32 ee_errno; u8 ee_origin; u8 ee_type; u8 ee_code; u8 ee_pad; u32 ee_info; u32 ee_data; + // followed directly by the offender sockaddr (SO_EE_OFFENDER). + const val SOCK_EE_SIZE = 16 + const val SO_EE_ORIGIN_ICMP = 2 + const val ICMP_TIME_EXCEEDED = 11 + const val ICMP_DEST_UNREACH = 3 + + /** Try setsockoptInt; return null on success, or the errno name on failure. */ + fun trySetIntOpt(fd: FileDescriptor, level: Int, opt: Int, value: Int): String? = + try { + Os.setsockoptInt(fd, level, opt, value) + null + } catch (e: Throwable) { + e.message ?: e.javaClass.simpleName + } + + /** + * getsockoptInt is not part of the stable public Os surface on every API level, so it is + * reached via reflection; callers must treat failure as "unreadable", not as an error. + */ + fun tryGetIntOpt(fd: FileDescriptor, level: Int, opt: Int): Result = runCatching { + val m = Os::class.java.getMethod( + "getsockoptInt", + FileDescriptor::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + ) + m.invoke(null, fd, level, opt) as Int + } +} diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/Probe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/Probe.kt index 4041499..464c90c 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/Probe.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/Probe.kt @@ -55,9 +55,10 @@ class TestBuilder( evidence: JsonObject? = null, metrics: JsonObject? = null, error: TestError? = null, + params: JsonObject? = null, ): Test = Test( id = id, type = type, networkRef = networkRef, sessionRef = sessionRef, tier = tier, startedMonoNs = startedMonoNs, endedMonoNs = ids.monoNs(), - status = status, error = error, evidence = evidence, metrics = metrics, + status = status, error = error, params = params, evidence = evidence, metrics = metrics, ) } diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/TracerouteProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/TracerouteProbe.kt new file mode 100644 index 0000000..9f24585 --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/TracerouteProbe.kt @@ -0,0 +1,255 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.probe + +import android.content.Context +import android.system.Os +import android.system.OsConstants +import app.echo_lot.measurement.Flow +import app.echo_lot.measurement.Hop +import app.echo_lot.measurement.HopProbe +import app.echo_lot.measurement.Test +import app.echo_lot.measurement.TestError +import app.echo_lot.measurement.TestStatus +import app.echo_lot.measurement.TestType +import app.echo_lot.measurement.Tier +import app.echo_lot.measurement.TracerouteEvidence +import app.echo_lot.measurement.toEvidence +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.FileDescriptor +import java.net.InetAddress +import java.net.InetSocketAddress +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * traceroute.udp4 — UDP traceroute reading ICMP time-exceeded off the socket error queue via + * Os.recvmsg(MSG_ERRQUEUE): no root, no raw socket, no native code. Folded from the prober, + * which validated real hop addresses on both known devices (6 hops on the OnePlus 15, 5 on the + * Lenovo) and thereby retired the planned C-over-JNI errqueue shim. + * + * StructMsghdr/StructCmsghdr/recvmsg are reached via reflection (repo convention for uncertain + * OS paths): present since roughly API 34, absent before, and the probe must run — and report — + * on both. An absent API is UNSUPPORTED with the reason, never a crash. + */ +class TracerouteProbe( + private val targetHost: String = "1.1.1.1", + private val maxHops: Int = 6, +) : Probe { + override val type = TestType.TRACEROUTE_UDP4 + override val tier = Tier.APP + // Validated wall clock is ~250 ms on a healthy path; the ceiling is maxHops silent hops at + // 900 ms each, which only a blackholing path produces. + override val estimatedMs = 1_500L + + private companion object { + const val BASE_PORT = 33434 + /** ICMP errors take one RTT to surface on the errqueue; poll briefly, never block. */ + const val HOP_DEADLINE_NS = 900_000_000L + const val POLL_INTERVAL_MS = 40L + } + + override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) { + val b = TestBuilder(type, tier, ids) + val params = buildJsonObject { + put("target", targetHost); put("max_hops", maxHops); put("base_port", BASE_PORT) + } + + val api = ErrqueueApi.resolve() + ?: return@withContext b.build( + TestStatus.UNSUPPORTED, + params = params, + error = TestError( + "no_recvmsg", + "StructMsghdr/Os.recvmsg not on this API level — errqueue unreadable", + ), + ) + + var fd: FileDescriptor? = null + try { + fd = Os.socket(OsConstants.AF_INET, OsConstants.SOCK_DGRAM, OsConstants.IPPROTO_UDP) + OsAbi.trySetIntOpt(fd, OsConstants.IPPROTO_IP, OsAbi.IP_RECVERR, 1)?.let { + return@withContext b.build( + TestStatus.UNSUPPORTED, + params = params, + error = TestError("ip_recverr_rejected", it), + ) + } + val target = InetAddress.getByName(targetHost) + + val hops = ArrayList(maxHops) + var hopsSeen = 0 + var reachedTarget = false + var srcPort = 0 + for (ttl in 1..maxHops) { + OsAbi.trySetIntOpt(fd, OsConstants.IPPROTO_IP, OsAbi.IP_TTL, ttl) + val t0 = System.nanoTime() + val sent = runCatching { + Os.sendto(fd, ByteArray(32), 0, 32, 0, target, BASE_PORT + ttl) + } + if (sent.isFailure) { + hops.add(Hop(ttl, listOf(HopProbe(icmp = "sendto failed: " + + (sent.exceptionOrNull()?.message ?: "?"))))) + continue + } + if (srcPort == 0) { + // Only readable after the implicit bind the first send performs. + srcPort = runCatching { + (Os.getsockname(fd) as? InetSocketAddress)?.port ?: 0 + }.getOrDefault(0) + } + + var hop: ErrqueueApi.ErrEvent? = null + val deadline = System.nanoTime() + HOP_DEADLINE_NS + while (hop == null && System.nanoTime() < deadline) { + hop = api.pollErrqueue(fd) + if (hop == null) delay(POLL_INTERVAL_MS) + } + val rttNs = System.nanoTime() - t0 + when { + hop == null -> hops.add(Hop(ttl, listOf(HopProbe()))) // silent hop: all null + hop.parseError != null -> + hops.add(Hop(ttl, listOf(HopProbe(icmp = "unparsed: ${hop.parseError}")))) + else -> { + hopsSeen++ + hops.add(Hop(ttl, listOf(HopProbe( + replyFrom = hop.offender, + rttNs = rttNs, + icmp = when (hop.icmpType) { + OsAbi.ICMP_TIME_EXCEEDED -> "time_exceeded" + OsAbi.ICMP_DEST_UNREACH -> "dest_unreachable" + else -> "type_${hop.icmpType}" + }, + )))) + if (hop.icmpType == OsAbi.ICMP_DEST_UNREACH) reachedTarget = true + } + } + if (reachedTarget) break + } + + // dst_port varies per TTL (classic traceroute, and what was validated on hardware), + // so this flow is explicitly NOT fixed-tuple; base_port is in params. + val evidence = TracerouteEvidence( + flow = Flow(srcPort = srcPort, dstPort = BASE_PORT, fixedTuple = false), + hops = hops, + ).toEvidence() + val metrics = buildJsonObject { + put("hops_seen", hopsSeen) + put("reached_target", reachedTarget) + } + val status = when { + hopsSeen > 0 -> TestStatus.OK + // API present, sends succeeded, nothing surfaced: a fact about this path or + // kernel, not proof the mechanism is missing. + else -> TestStatus.PARTIAL + } + b.build(status, params = params, evidence = evidence, metrics = metrics) + } catch (e: Throwable) { + b.build( + TestStatus.FAILED, + params = params, + error = TestError("uncaught", e.message ?: e.javaClass.simpleName), + ) + } finally { + fd?.let { runCatching { Os.close(it) } } + } + } +} + +/** + * Reflection facade over android.system.{StructMsghdr, StructCmsghdr, Os.recvmsg}. + * Resolved once; null if any piece is missing on this API level. + */ +internal class ErrqueueApi private constructor( + private val msghdrCtor: java.lang.reflect.Constructor<*>, + private val recvmsg: java.lang.reflect.Method, + private val cmsgLevel: java.lang.reflect.Field, + private val cmsgType: java.lang.reflect.Field, + private val cmsgData: java.lang.reflect.Field, + private val msgControl: java.lang.reflect.Field, +) { + class ErrEvent( + val offender: String?, + val icmpType: Int, + val origin: Int, + val parseError: String? = null, + ) + + /** One non-blocking MSG_ERRQUEUE read; null when the queue is empty. */ + fun pollErrqueue(fd: FileDescriptor): ErrEvent? { + return try { + val iov = arrayOf(ByteBuffer.allocate(512)) + // (SocketAddress msg_name, ByteBuffer[] msg_iov, StructCmsghdr[] msg_control, flags) + val msghdr = msghdrCtor.newInstance( + InetSocketAddress(0), iov, null, 0, + ) + recvmsg.invoke(null, fd, msghdr, OsAbi.MSG_ERRQUEUE or OsAbi.MSG_DONTWAIT) + val control = msgControl.get(msghdr) as? Array<*> + ?: return ErrEvent(null, -1, -1, "msg_control empty after recvmsg") + for (cmsg in control.filterNotNull()) { + val level = cmsgLevel.getInt(cmsg) + val type = cmsgType.getInt(cmsg) + if (level == OsConstants.IPPROTO_IP && type == OsAbi.IP_RECVERR) { + return parseSockExtendedErr(cmsgData.get(cmsg)) + } + } + ErrEvent(null, -1, -1, "no IP_RECVERR cmsg among ${control.size}") + } catch (e: Throwable) { + // The single most load-bearing line: reflection wraps errno in + // InvocationTargetException, and EAGAIN there means "queue empty", not failure. + val cause = (e as? java.lang.reflect.InvocationTargetException)?.cause ?: e + val msg = cause.message ?: cause.javaClass.simpleName + if ("EAGAIN" in msg || "EWOULDBLOCK" in msg) null + else ErrEvent(null, -1, -1, msg) + } + } + + /** cmsg_data = struct sock_extended_err + offender sockaddr_in (see OsAbi). */ + private fun parseSockExtendedErr(data: Any?): ErrEvent { + val bytes: ByteArray = when (data) { + is ByteArray -> data + is ByteBuffer -> ByteArray(data.remaining()).also { data.duplicate().get(it) } + else -> return ErrEvent(null, -1, -1, "cmsg_data is ${data?.javaClass?.name}") + } + if (bytes.size < OsAbi.SOCK_EE_SIZE) { + return ErrEvent(null, -1, -1, "cmsg_data too short: ${bytes.size}") + } + val origin = bytes[4].toInt() and 0xFF + val icmpType = bytes[5].toInt() and 0xFF + // SO_EE_OFFENDER: sockaddr_in directly after the fixed struct; family is in native + // byte order, sin_addr at offset +4 within the sockaddr. + val offender = if (bytes.size >= OsAbi.SOCK_EE_SIZE + 8) { + val family = ByteBuffer.wrap(bytes, OsAbi.SOCK_EE_SIZE, 2) + .order(ByteOrder.nativeOrder()).short.toInt() + if (family == OsConstants.AF_INET) { + val a = bytes.copyOfRange(OsAbi.SOCK_EE_SIZE + 4, OsAbi.SOCK_EE_SIZE + 8) + InetAddress.getByAddress(a).hostAddress + } else null + } else null + return ErrEvent(offender, icmpType, origin) + } + + companion object { + fun resolve(): ErrqueueApi? = runCatching { + val msghdrCls = Class.forName("android.system.StructMsghdr") + val cmsghdrCls = Class.forName("android.system.StructCmsghdr") + ErrqueueApi( + // Picked by shape, not by position: the 4-arg form is + // (SocketAddress, ByteBuffer[], StructCmsghdr[], int) on every level that has it. + msghdrCtor = msghdrCls.constructors.first { it.parameterCount == 4 }, + recvmsg = Os::class.java.getMethod( + "recvmsg", FileDescriptor::class.java, msghdrCls, Int::class.javaPrimitiveType, + ), + cmsgLevel = cmsghdrCls.getField("cmsg_level"), + cmsgType = cmsghdrCls.getField("cmsg_type"), + cmsgData = cmsghdrCls.getField("cmsg_data"), + msgControl = msghdrCls.getField("msg_control"), + ) + }.getOrNull() + } +}