prober: per-network ICMP, real errqueue traceroute, sharper mDNS/ip-monitor

- IcmpProbe: attempt the echo on the default network AND each active
  network (Network.bindSocket + per-network DNS). Run 1's ping6 EAGAIN was
  topology (v6 only on cellular), not capability — now the report shows
  which networks carry which family instead of a bare ERROR.
- New traceroute.udp4 (TracerouteProbe): actual UDP traceroute reading
  ICMP time-exceeded via Os.recvmsg(MSG_ERRQUEUE) with cmsg parsing —
  reflection per repo convention (API surface exists ~34+). If this
  returns hops, the C-over-JNI shim is unnecessary on that device.
  sock_extended_err layout constants documented in OsAbi.
- MulticastProbe: 10 s window; meta-query PLUS concrete types
  (_http._tcp, _googlecast._tcp) — run 1 showed the meta-query alone
  returning 0 on a network with live services; capture names + failure
  codes as evidence.
- ShizukuProbe: ip monitor window 2s -> 5s, gateway ping in background to
  provoke a NEIGH transition instead of hoping for ambient churn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-07-30 12:59:43 +02:00
co-authored by Claude Opus 5
parent 81d13ebb08
commit e695fe7aee
6 changed files with 329 additions and 55 deletions
@@ -4,13 +4,15 @@
package app.echo_lot.prober.probe package app.echo_lot.prober.probe
import android.content.Context import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.system.Os import android.system.Os
import android.system.OsConstants import android.system.OsConstants
import android.system.StructTimeval import android.system.StructTimeval
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.FileDescriptor import java.io.FileDescriptor
import java.net.Inet4Address
import java.net.InetAddress import java.net.InetAddress
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.util.Locale import java.util.Locale
@@ -19,6 +21,11 @@ import java.util.Locale
* Probes the unprivileged ICMP echo path: socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP). * Probes the unprivileged ICMP echo path: socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP).
* Android opens ping_group_range to all UIDs, so this should work without root or CAP_NET_RAW. * Android opens ping_group_range to all UIDs, so this should work without root or CAP_NET_RAW.
* If it does, the production app never needs to shell out to /system/bin/ping. * If it does, the production app never needs to shell out to /system/bin/ping.
*
* The echo is attempted once per active network (socket bound via Network.bindSocket), not just
* on the default network: the first device run showed why — v6 lived only on cellular while wifi
* was the v4-only default, so a default-network ping6 EAGAINed and looked like a capability
* failure. Per-network results turn that into topology evidence.
*/ */
class IcmpProbe( class IcmpProbe(
private val v6: Boolean = false, private val v6: Boolean = false,
@@ -29,23 +36,65 @@ class IcmpProbe(
else "ICMPv4 echo (unprivileged datagram socket)" else "ICMPv4 echo (unprivileged datagram socket)"
override val tier = Tier.APP override val tier = Tier.APP
private data class Attempt(val ok: Boolean, val detail: String)
override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.IO) { override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.IO) {
val start = System.nanoTime() val start = System.nanoTime()
val ev = LinkedHashMap<String, String>() val ev = LinkedHashMap<String, String>()
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
// Default-network attempt first — this is what a naive app gets.
val def = attempt(network = null)
ev["default"] = def.detail
var anyOk = def.ok
var okNet: String? = if (def.ok) "default" else null
@Suppress("DEPRECATION")
for (net in cm.allNetworks) {
val caps = cm.getNetworkCapabilities(net) ?: continue
val label = transportLabel(caps)
val res = attempt(net)
ev["net.$label"] = res.detail
if (res.ok && !anyOk) { anyOk = true; okNet = label }
if (res.ok && okNet == null) okNet = label
}
val durationMs = (System.nanoTime() - start) / 1_000_000
if (anyOk) {
val summary = if (def.ok) "Echo reply on the default network"
else "Echo reply on $okNet only — default network has no ${if (v6) "v6" else "v4"} path"
ProbeResult.of(this@IcmpProbe, Verdict.SUPPORTED, summary, ev, durationMs)
} else {
val permission = ev.values.any { "EACCES" in it || "EPERM" in it }
val verdict = if (permission) Verdict.UNSUPPORTED else Verdict.ERROR
ProbeResult.of(this@IcmpProbe, verdict,
"No echo reply on any of ${ev.size} attempt(s)", ev, durationMs)
}
}
private fun transportLabel(caps: NetworkCapabilities): String = when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
else -> "other"
}
/** One socket → optional bind to [network] → send → timed recv. Never throws. */
private fun attempt(network: Network?): Attempt {
var fd: FileDescriptor? = null var fd: FileDescriptor? = null
try { return try {
val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP
val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET
fd = Os.socket(family, OsConstants.SOCK_DGRAM, proto) fd = Os.socket(family, OsConstants.SOCK_DGRAM, proto)
ev["socket"] = "opened family=$family proto=$proto" network?.bindSocket(fd)
Os.setsockoptTimeval( Os.setsockoptTimeval(
fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO, fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO,
StructTimeval.fromMillis(3000), StructTimeval.fromMillis(3000),
) )
// Resolve via the bound network where there is one — the default resolver may
val addr = InetAddress.getByName(targetHost) // not even have records for the other family.
ev["target"] = addr.hostAddress ?: targetHost val addr = network?.getByName(targetHost) ?: InetAddress.getByName(targetHost)
val ident = (Os.getpid() and 0xFFFF) val ident = (Os.getpid() and 0xFFFF)
val packet = buildEchoRequest(v6, ident.toShort(), seq = 1) val packet = buildEchoRequest(v6, ident.toShort(), seq = 1)
@@ -55,42 +104,21 @@ class IcmpProbe(
val buf = ByteBuffer.allocate(1500) val buf = ByteBuffer.allocate(1500)
val received = Os.recvfrom(fd, buf, 0, null) val received = Os.recvfrom(fd, buf, 0, null)
val rttMs = (System.nanoTime() - t0) / 1_000_000.0 val rttMs = (System.nanoTime() - t0) / 1_000_000.0
ev["bytes_received"] = received.toString()
// Locale.ROOT: the device's locale must not leak into the report ("38,1" broke
// the schema's number format on the first Austrian-locale device).
ev["rtt_ms"] = "%.1f".format(Locale.ROOT, rttMs)
// ICMP datagram (ping) sockets deliver the ICMP message with NO IP header, so the // ICMP datagram (ping) sockets deliver the ICMP message with NO IP header, so the
// type byte is at offset 0 for both families. v4 echo reply = 0, v6 echo reply = 129. // type byte is at offset 0 for both families. v4 echo reply = 0, v6 echo reply = 129.
val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1 val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1
ev["reply_type"] = replyType.toString() val ok = replyType == (if (v6) 129 else 0)
ev["reply_type_meaning"] = when (replyType) { // Locale.ROOT: the device's locale must not leak into the report ("38,1" broke
0 -> "echo reply (v4)"; 129 -> "echo reply (v6)"; else -> "other/$replyType" // the schema's number format on the first Austrian-locale device).
} Attempt(ok, "reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} " +
"bytes=$received target=${addr.hostAddress}")
ProbeResult.of(
this@IcmpProbe, Verdict.SUPPORTED,
"Echo reply from ${ev["target"]} in ${ev["rtt_ms"]} ms",
ev, (System.nanoTime() - start) / 1_000_000,
)
} catch (e: Throwable) { } catch (e: Throwable) {
ev["error"] = e.message ?: e.javaClass.simpleName Attempt(false, "error: ${e.message ?: e.javaClass.simpleName}")
val verdict = if (isPermissionLike(e)) Verdict.UNSUPPORTED else Verdict.ERROR
ProbeResult.of(
this@IcmpProbe, verdict,
"ICMP datagram socket failed: ${ev["error"]}",
ev, (System.nanoTime() - start) / 1_000_000,
)
} finally { } finally {
fd?.let { runCatching { Os.close(it) } } fd?.let { runCatching { Os.close(it) } }
} }
} }
private fun isPermissionLike(e: Throwable): Boolean {
val m = (e.message ?: "").uppercase()
return "EACCES" in m || "EPERM" in m || "EAFNOSUPPORT" in m || "EPROTONOSUPPORT" in m
}
/** Minimal ICMP echo request; kernel fills checksum for ICMPv6, we compute it for ICMPv4. */ /** Minimal ICMP echo request; kernel fills checksum for ICMPv6, we compute it for ICMPv4. */
private fun buildEchoRequest(v6: Boolean, ident: Short, seq: Short): ByteArray { private fun buildEchoRequest(v6: Boolean, ident: Short, seq: Short): ByteArray {
val type = if (v6) 128 else 8 // echo request val type = if (v6) 128 else 8 // echo request
@@ -10,18 +10,43 @@ import android.net.wifi.WifiManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicInteger import java.util.Collections
/** /**
* Probes multicast reception (MulticastLock + NSD/mDNS discovery). Confirms the app can do the * Probes multicast reception (MulticastLock + NSD/mDNS discovery). Confirms the app can do the
* mDNS/SSDP/LLMNR service inventory that doubles as the VLAN-leakage detector. Uses NsdManager * mDNS/SSDP/LLMNR service inventory that doubles as the VLAN-leakage detector. Uses NsdManager
* as the least-privileged path; a raw 224.0.0.251:5353 listener is the fuller implementation. * as the least-privileged path; a raw 224.0.0.251:5353 listener is the fuller implementation.
*
* Runs the `_services._dns-sd._udp.` meta-query AND concrete types in parallel: run 1 on a
* network with live mDNS services returned 0 via the meta-query alone — NsdManager's meta-query
* support is unreliable on many builds, while concrete-type discovery is what it is actually
* built for. A device where concrete types answer but the meta-query stays empty is itself a
* finding the production inventory needs to know about.
*/ */
class MulticastProbe : Probe { class MulticastProbe : Probe {
override val id = "local.mdns_discover" override val id = "local.mdns_discover"
override val title = "Multicast reception (MulticastLock + mDNS/NSD)" override val title = "Multicast reception (MulticastLock + mDNS/NSD)"
override val tier = Tier.APP override val tier = Tier.APP
// 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(context: Context): ProbeResult = withContext(Dispatchers.IO) { override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.IO) {
val start = System.nanoTime() val start = System.nanoTime()
val ev = LinkedHashMap<String, String>() val ev = LinkedHashMap<String, String>()
@@ -33,35 +58,37 @@ class MulticastProbe : Probe {
ev["multicast_lock"] = if (lock?.isHeld == true) "acquired" else "not held" ev["multicast_lock"] = if (lock?.isHeld == true) "acquired" else "not held"
val nsd = context.getSystemService(NsdManager::class.java) val nsd = context.getSystemService(NsdManager::class.java)
val found = AtomicInteger(0) val recorders = queries.map { (label, type) ->
val started = java.util.concurrent.atomic.AtomicBoolean(false) val r = Recorder()
val serviceType = "_services._dns-sd._udp." // meta-query: enumerates service types runCatching { nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, r) }
val listener = object : NsdManager.DiscoveryListener { .onFailure { r.startFailCode = -1 }
override fun onStartDiscoveryFailed(t: String?, code: Int) {} Triple(label, type, r)
override fun onStopDiscoveryFailed(t: String?, code: Int) {}
override fun onDiscoveryStarted(t: String?) { started.set(true) }
override fun onDiscoveryStopped(t: String?) {}
override fun onServiceFound(s: NsdServiceInfo?) { found.incrementAndGet() }
override fun onServiceLost(s: NsdServiceInfo?) {}
} }
try { try {
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener) delay(10_000) // 4 s missed services that a longer listen catches (run-1 lesson)
delay(4000) var total = 0
runCatching { nsd.stopServiceDiscovery(listener) } var anyStarted = false
ev["discovery_started"] = started.get().toString() for ((label, type, r) in recorders) {
ev["services_found"] = found.get().toString() runCatching { nsd.stopServiceDiscovery(r) }
val verdict = when { anyStarted = anyStarted || r.started
started.get() -> Verdict.SUPPORTED total += r.names.size
else -> Verdict.INCONCLUSIVE ev["$label.started"] = r.startFailCode?.let { "failed code=$it" } ?: r.started.toString()
ev["$label.found"] = r.names.size.toString()
if (r.names.isNotEmpty()) {
ev["$label.names"] = r.names.distinct().joinToString(", ").take(300)
}
ev["$label.type"] = type
} }
val verdict = if (anyStarted) Verdict.SUPPORTED else Verdict.INCONCLUSIVE
ProbeResult.of(this@MulticastProbe, verdict, ProbeResult.of(this@MulticastProbe, verdict,
"mDNS discovery ${if (started.get()) "ran" else "did not start"}; ${found.get()} service type(s) seen", "mDNS discovery ${if (anyStarted) "ran" else "did not start"}; $total service(s)/type(s) seen across ${queries.size} queries",
ev, (System.nanoTime() - start) / 1_000_000) ev, (System.nanoTime() - start) / 1_000_000)
} catch (e: Throwable) { } catch (e: Throwable) {
ev["error"] = e.message ?: e.javaClass.simpleName ev["error"] = e.message ?: e.javaClass.simpleName
ProbeResult.of(this@MulticastProbe, Verdict.ERROR, "mDNS probe failed", ev, ProbeResult.of(this@MulticastProbe, Verdict.ERROR, "mDNS probe failed", ev,
(System.nanoTime() - start) / 1_000_000) (System.nanoTime() - start) / 1_000_000)
} finally { } finally {
recorders.forEach { (_, _, r) -> runCatching { nsd.stopServiceDiscovery(r) } }
runCatching { lock?.release() } runCatching { lock?.release() }
} }
} }
@@ -28,6 +28,18 @@ object OsAbi {
const val IPV6_UNICAST_HOPS = 16 const val IPV6_UNICAST_HOPS = 16
const val IPV6_PMTUDISC_PROBE = 3 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. */ /** Try setsockoptInt; return null on success, or the errno name on failure. */
fun trySetIntOpt(fd: FileDescriptor, level: Int, opt: Int, value: Int): String? = fun trySetIntOpt(fd: FileDescriptor, level: Int, opt: Int, value: Int): String? =
try { try {
@@ -11,6 +11,7 @@ object ProbeRegistry {
IcmpProbe(v6 = true), IcmpProbe(v6 = true),
SockOptProbe(), SockOptProbe(),
ErrqueueProbe(), ErrqueueProbe(),
TracerouteProbe(),
MultiNetworkProbe(), MultiNetworkProbe(),
MulticastProbe(), MulticastProbe(),
BleAdvertiseProbe(), BleAdvertiseProbe(),
@@ -26,7 +26,9 @@ class ShizukuProbe : Probe {
"ip_neigh" to "ip neigh show", "ip_neigh" to "ip neigh show",
"ip6_route" to "ip -6 route show table all", "ip6_route" to "ip -6 route show table all",
"ip_addr" to "ip addr show", "ip_addr" to "ip addr show",
"ip_monitor" to "timeout 2 ip monitor all || true", // 5 s window (2 s caught nothing on a quiet net); ping the gateway concurrently so at
// least one NEIGH transition is provoked rather than hoping for ambient churn.
"ip_monitor" to "(ping -c 2 -W 1 \$(ip route show default | head -1 | cut -d' ' -f3) >/dev/null 2>&1 &); timeout 5 ip monitor all || true",
"dhcp_log" to "dumpsys network_stack 2>/dev/null | grep -iA2 -m 20 -e dhcp -e 'IpClient' || true", "dhcp_log" to "dumpsys network_stack 2>/dev/null | grep -iA2 -m 20 -e dhcp -e 'IpClient' || true",
"wifi_dump" to "dumpsys wifi 2>/dev/null | grep -iA1 -m 20 -e 'mDhcpResults' -e 'Gateway' -e 'DNS' || true", "wifi_dump" to "dumpsys wifi 2>/dev/null | grep -iA1 -m 20 -e 'mDhcpResults' -e 'Gateway' -e 'DNS' || true",
) )
@@ -0,0 +1,204 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.prober.probe
import android.content.Context
import android.system.Os
import android.system.OsConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.FileDescriptor
import java.net.InetAddress
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* The real thing that trace.errqueue_reachable only smoke-tests: a UDP traceroute reading ICMP
* time-exceeded off the socket error queue via Os.recvmsg(MSG_ERRQUEUE) — no root, no raw socket,
* no native code. If this returns actual hop addresses, the planned C-over-JNI shim is dead on
* this device; if the Os surface falls short, the evidence says exactly where.
*
* 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.
*/
class TracerouteProbe(
private val targetHost: String = "1.1.1.1",
private val maxHops: Int = 6,
) : Probe {
override val id = "traceroute.udp4"
override val title = "UDP traceroute via MSG_ERRQUEUE (no root, no JNI)"
override val tier = Tier.APP
override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.IO) {
val start = System.nanoTime()
val ev = LinkedHashMap<String, String>()
var fd: FileDescriptor? = null
val api = ErrqueueApi.resolve()
ev["recvmsg_api"] = api?.describe() ?: "unavailable (StructMsghdr/recvmsg not on this API level)"
if (api == null) {
return@withContext ProbeResult.of(this@TracerouteProbe, Verdict.PARTIAL,
"Os.recvmsg surface missing — errqueue traceroute needs the native shim here",
ev, (System.nanoTime() - start) / 1_000_000)
}
try {
fd = Os.socket(OsConstants.AF_INET, OsConstants.SOCK_DGRAM, OsConstants.IPPROTO_UDP)
OsAbi.trySetIntOpt(fd, OsConstants.IPPROTO_IP, OsAbi.IP_RECVERR, 1)?.let {
ev["IP_RECVERR"] = "reject: $it"
return@withContext ProbeResult.of(this@TracerouteProbe, Verdict.UNSUPPORTED,
"IP_RECVERR rejected", ev, (System.nanoTime() - start) / 1_000_000)
}
val target = InetAddress.getByName(targetHost)
ev["target"] = target.hostAddress ?: targetHost
var hopsSeen = 0
var reachedTarget = false
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, 33434 + ttl)
}
if (sent.isFailure) {
ev["hop.$ttl"] = "sendto failed: ${sent.exceptionOrNull()?.message}"
continue
}
// The ICMP error takes one RTT to come back; poll the errqueue briefly.
var hop: ErrqueueApi.ErrEvent? = null
val deadline = System.nanoTime() + 900_000_000L
while (hop == null && System.nanoTime() < deadline) {
hop = api.pollErrqueue(fd)
if (hop == null) delay(40)
}
val rttMs = (System.nanoTime() - t0) / 1_000_000
when {
hop == null -> ev["hop.$ttl"] = "* (no errqueue event in 900 ms)"
hop.parseError != null ->
ev["hop.$ttl"] = "errqueue event, cmsg unparsed: ${hop.parseError}"
else -> {
hopsSeen++
ev["hop.$ttl"] = "${hop.offender ?: "?"} icmp type=${hop.icmpType} " +
"origin=${hop.origin} ~${rttMs} ms"
if (hop.icmpType == OsAbi.ICMP_DEST_UNREACH) reachedTarget = true
}
}
if (reachedTarget) break
}
val verdict = when {
hopsSeen > 0 -> Verdict.SUPPORTED
else -> Verdict.PARTIAL // API present, sent fine, but no parseable events
}
val summary = when {
hopsSeen > 0 && reachedTarget ->
"$hopsSeen hop(s) via errqueue, target reached — no native shim needed"
hopsSeen > 0 -> "$hopsSeen hop(s) read via errqueue — no native shim needed"
else -> "recvmsg present but no errqueue events surfaced — shim still the fallback"
}
ProbeResult.of(this@TracerouteProbe, verdict, summary, ev,
(System.nanoTime() - start) / 1_000_000)
} catch (e: Throwable) {
ev["error"] = e.message ?: e.javaClass.simpleName
ProbeResult.of(this@TracerouteProbe, Verdict.ERROR, "traceroute probe failed", ev,
(System.nanoTime() - start) / 1_000_000)
} 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.
*/
private 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,
)
fun describe() = "StructMsghdr + Os.recvmsg via reflection"
/** 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) {
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 // queue empty — not an error
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; sin_addr at offset +4.
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(
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()
}
}