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:
co-authored by
Claude Opus 5
parent
81d13ebb08
commit
e695fe7aee
@@ -4,13 +4,15 @@
|
||||
package app.echo_lot.prober.probe
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import android.system.StructTimeval
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.FileDescriptor
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.nio.ByteBuffer
|
||||
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).
|
||||
* 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.
|
||||
*
|
||||
* 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(
|
||||
private val v6: Boolean = false,
|
||||
@@ -29,23 +36,65 @@ class IcmpProbe(
|
||||
else "ICMPv4 echo (unprivileged datagram socket)"
|
||||
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) {
|
||||
val start = System.nanoTime()
|
||||
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
|
||||
try {
|
||||
return try {
|
||||
val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP
|
||||
val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET
|
||||
fd = Os.socket(family, OsConstants.SOCK_DGRAM, proto)
|
||||
ev["socket"] = "opened family=$family proto=$proto"
|
||||
|
||||
network?.bindSocket(fd)
|
||||
Os.setsockoptTimeval(
|
||||
fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO,
|
||||
StructTimeval.fromMillis(3000),
|
||||
)
|
||||
|
||||
val addr = InetAddress.getByName(targetHost)
|
||||
ev["target"] = addr.hostAddress ?: targetHost
|
||||
// Resolve via the bound network where there is one — the default resolver may
|
||||
// not even have records for the other family.
|
||||
val addr = network?.getByName(targetHost) ?: InetAddress.getByName(targetHost)
|
||||
|
||||
val ident = (Os.getpid() and 0xFFFF)
|
||||
val packet = buildEchoRequest(v6, ident.toShort(), seq = 1)
|
||||
@@ -55,42 +104,21 @@ class IcmpProbe(
|
||||
val buf = ByteBuffer.allocate(1500)
|
||||
val received = Os.recvfrom(fd, buf, 0, null)
|
||||
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
|
||||
// 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
|
||||
ev["reply_type"] = replyType.toString()
|
||||
ev["reply_type_meaning"] = when (replyType) {
|
||||
0 -> "echo reply (v4)"; 129 -> "echo reply (v6)"; else -> "other/$replyType"
|
||||
}
|
||||
|
||||
ProbeResult.of(
|
||||
this@IcmpProbe, Verdict.SUPPORTED,
|
||||
"Echo reply from ${ev["target"]} in ${ev["rtt_ms"]} ms",
|
||||
ev, (System.nanoTime() - start) / 1_000_000,
|
||||
)
|
||||
val ok = replyType == (if (v6) 129 else 0)
|
||||
// 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).
|
||||
Attempt(ok, "reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} " +
|
||||
"bytes=$received target=${addr.hostAddress}")
|
||||
} catch (e: Throwable) {
|
||||
ev["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,
|
||||
)
|
||||
Attempt(false, "error: ${e.message ?: e.javaClass.simpleName}")
|
||||
} finally {
|
||||
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. */
|
||||
private fun buildEchoRequest(v6: Boolean, ident: Short, seq: Short): ByteArray {
|
||||
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.delay
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* 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 {
|
||||
override val id = "local.mdns_discover"
|
||||
override val title = "Multicast reception (MulticastLock + mDNS/NSD)"
|
||||
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) {
|
||||
val start = System.nanoTime()
|
||||
val ev = LinkedHashMap<String, String>()
|
||||
@@ -33,35 +58,37 @@ class MulticastProbe : Probe {
|
||||
ev["multicast_lock"] = if (lock?.isHeld == true) "acquired" else "not held"
|
||||
|
||||
val nsd = context.getSystemService(NsdManager::class.java)
|
||||
val found = AtomicInteger(0)
|
||||
val started = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
val serviceType = "_services._dns-sd._udp." // meta-query: enumerates service types
|
||||
val listener = object : NsdManager.DiscoveryListener {
|
||||
override fun onStartDiscoveryFailed(t: String?, code: Int) {}
|
||||
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?) {}
|
||||
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 {
|
||||
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener)
|
||||
delay(4000)
|
||||
runCatching { nsd.stopServiceDiscovery(listener) }
|
||||
ev["discovery_started"] = started.get().toString()
|
||||
ev["services_found"] = found.get().toString()
|
||||
val verdict = when {
|
||||
started.get() -> Verdict.SUPPORTED
|
||||
else -> Verdict.INCONCLUSIVE
|
||||
delay(10_000) // 4 s missed services that a longer listen catches (run-1 lesson)
|
||||
var total = 0
|
||||
var anyStarted = false
|
||||
for ((label, type, r) in recorders) {
|
||||
runCatching { nsd.stopServiceDiscovery(r) }
|
||||
anyStarted = anyStarted || r.started
|
||||
total += r.names.size
|
||||
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,
|
||||
"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)
|
||||
} catch (e: Throwable) {
|
||||
ev["error"] = e.message ?: e.javaClass.simpleName
|
||||
ProbeResult.of(this@MulticastProbe, Verdict.ERROR, "mDNS probe failed", ev,
|
||||
(System.nanoTime() - start) / 1_000_000)
|
||||
} finally {
|
||||
recorders.forEach { (_, _, r) -> runCatching { nsd.stopServiceDiscovery(r) } }
|
||||
runCatching { lock?.release() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,18 @@ object OsAbi {
|
||||
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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ object ProbeRegistry {
|
||||
IcmpProbe(v6 = true),
|
||||
SockOptProbe(),
|
||||
ErrqueueProbe(),
|
||||
TracerouteProbe(),
|
||||
MultiNetworkProbe(),
|
||||
MulticastProbe(),
|
||||
BleAdvertiseProbe(),
|
||||
|
||||
@@ -26,7 +26,9 @@ class ShizukuProbe : Probe {
|
||||
"ip_neigh" to "ip neigh show",
|
||||
"ip6_route" to "ip -6 route show table all",
|
||||
"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",
|
||||
"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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user