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 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 12:39:52 +02:00
co-authored by Claude Opus 5
parent 7d98a43866
commit f6849f8e6a
6 changed files with 439 additions and 2 deletions
@@ -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<String> = 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() }
}
}
}
@@ -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<Int> = 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
}
}
@@ -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,
)
}
@@ -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<Hop>(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()
}
}