diff --git a/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/IcmpProbe.kt b/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/IcmpProbe.kt index a3486cd..65f86ef 100644 --- a/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/IcmpProbe.kt +++ b/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/IcmpProbe.kt @@ -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() + 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 diff --git a/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/MulticastProbe.kt b/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/MulticastProbe.kt index 2233d73..95b0a95 100644 --- a/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/MulticastProbe.kt +++ b/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/MulticastProbe.kt @@ -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 = 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() @@ -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() } } } diff --git a/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/OsAbi.kt b/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/OsAbi.kt index 5103f76..5fce66b 100644 --- a/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/OsAbi.kt +++ b/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/OsAbi.kt @@ -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 { diff --git a/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/ProbeRegistry.kt b/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/ProbeRegistry.kt index ef829b8..5d6e7bc 100644 --- a/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/ProbeRegistry.kt +++ b/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/ProbeRegistry.kt @@ -11,6 +11,7 @@ object ProbeRegistry { IcmpProbe(v6 = true), SockOptProbe(), ErrqueueProbe(), + TracerouteProbe(), MultiNetworkProbe(), MulticastProbe(), BleAdvertiseProbe(), diff --git a/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/ShizukuProbe.kt b/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/ShizukuProbe.kt index bcf6c11..08e2526 100644 --- a/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/ShizukuProbe.kt +++ b/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/ShizukuProbe.kt @@ -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", ) diff --git a/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/TracerouteProbe.kt b/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/TracerouteProbe.kt new file mode 100644 index 0000000..266edf5 --- /dev/null +++ b/echolot-prober/app/src/main/java/app/echo_lot/prober/probe/TracerouteProbe.kt @@ -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() + 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() + } +}