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
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user