app: fold the prober's validated capabilities into core-probe
traceroute.udp4 lands as TracerouteProbe: ICMP time-exceeded read off the socket error queue via Os.recvmsg(MSG_ERRQUEUE) through the reflection facade the prober validated on both devices - no root, no raw socket, and the C-over-JNI shim stays dead. Emits the schema's TracerouteEvidence with rtt_ns. OsAbi carries the hardcoded sockopt ABI numbers across, including the measured fact that Os.getsockoptInt exists on neither device, so PMTU must come from the errqueue, never getsockopt(IP_MTU). local.mdns_inventory lands as MdnsInventoryProbe with both hardware-bought lessons intact: the meta-query lies (0 results beside live services on both devices), and 4s of listening misses what 10s catches. App version 0.2.2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7d98a43866
commit
f6849f8e6a
@@ -15,7 +15,7 @@ plugins {
|
|||||||
//
|
//
|
||||||
// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing
|
// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing
|
||||||
// digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100.
|
// digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100.
|
||||||
val appVersionName = "0.2.1"
|
val appVersionName = "0.2.2"
|
||||||
|
|
||||||
fun versionCodeOf(semver: String): Int {
|
fun versionCodeOf(semver: String): Int {
|
||||||
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
|
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
|
||||||
|
|||||||
@@ -403,6 +403,10 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
RouterIdentityProbe(entries),
|
RouterIdentityProbe(entries),
|
||||||
IcmpProbe(entries, v6 = false),
|
IcmpProbe(entries, v6 = false),
|
||||||
IcmpProbe(entries, v6 = true),
|
IcmpProbe(entries, v6 = true),
|
||||||
|
// Folded from the prober after hardware validation: errqueue traceroute (no root,
|
||||||
|
// no JNI) and the mDNS service inventory / VLAN-leakage detector.
|
||||||
|
app.echo_lot.probe.TracerouteProbe(),
|
||||||
|
app.echo_lot.probe.MdnsInventoryProbe(),
|
||||||
CaptivePortalProbe(entries),
|
CaptivePortalProbe(entries),
|
||||||
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
|
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
|
||||||
// the reference deployment until profiles/enrollment land in the UI.
|
// the reference deployment until profiles/enrollment land in the UI.
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.nsd.NsdManager
|
||||||
|
import android.net.nsd.NsdServiceInfo
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestStatus
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonObject
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
/**
|
||||||
|
* local.mdns_inventory — what answers mDNS on this network (MulticastLock + NSD discovery).
|
||||||
|
* The service inventory doubles as the VLAN-leakage detector: a chromecast answering on the
|
||||||
|
* guest wifi is a segmentation fault made visible. Folded from the prober, validated on both
|
||||||
|
* known devices (4 services each).
|
||||||
|
*
|
||||||
|
* Two hardware-bought lessons are load-bearing here:
|
||||||
|
* - The `_services._dns-sd._udp.` meta-query returned 0 on BOTH devices while concrete types
|
||||||
|
* found live services — NsdManager's meta-query support is unreliable across builds, so the
|
||||||
|
* concrete types are the measurement and the meta-query result is itself evidence.
|
||||||
|
* - 4 s of listening missed services that 10 s catches; mDNS answers straggle.
|
||||||
|
*/
|
||||||
|
class MdnsInventoryProbe : Probe {
|
||||||
|
override val type = TestType.LOCAL_MDNS_INVENTORY
|
||||||
|
override val tier = Tier.APP
|
||||||
|
override val estimatedMs = 10_500L
|
||||||
|
|
||||||
|
/** Meta-query + common concrete types (HTTP covers HA/printers/NAS; googlecast is ubiquitous). */
|
||||||
|
private val queries = listOf(
|
||||||
|
"meta" to "_services._dns-sd._udp.",
|
||||||
|
"http" to "_http._tcp.",
|
||||||
|
"googlecast" to "_googlecast._tcp.",
|
||||||
|
)
|
||||||
|
|
||||||
|
private class Recorder : NsdManager.DiscoveryListener {
|
||||||
|
val names: MutableList<String> = Collections.synchronizedList(mutableListOf())
|
||||||
|
@Volatile var started = false
|
||||||
|
@Volatile var startFailCode: Int? = null
|
||||||
|
override fun onStartDiscoveryFailed(t: String?, code: Int) { startFailCode = code }
|
||||||
|
override fun onStopDiscoveryFailed(t: String?, code: Int) {}
|
||||||
|
override fun onDiscoveryStarted(t: String?) { started = true }
|
||||||
|
override fun onDiscoveryStopped(t: String?) {}
|
||||||
|
override fun onServiceFound(s: NsdServiceInfo?) { s?.serviceName?.let { names.add(it) } }
|
||||||
|
override fun onServiceLost(s: NsdServiceInfo?) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||||
|
val b = TestBuilder(type, tier, ids)
|
||||||
|
val wifi = ctx.getSystemService(WifiManager::class.java)
|
||||||
|
val lock = wifi?.createMulticastLock("echolot")?.apply {
|
||||||
|
setReferenceCounted(false)
|
||||||
|
runCatching { acquire() }
|
||||||
|
}
|
||||||
|
val nsd = ctx.getSystemService(NsdManager::class.java)
|
||||||
|
?: return@withContext b.build(
|
||||||
|
TestStatus.UNSUPPORTED,
|
||||||
|
evidence = buildJsonObject { put("reason", "NsdManager unavailable") },
|
||||||
|
)
|
||||||
|
|
||||||
|
val recorders = queries.map { (label, type) ->
|
||||||
|
val r = Recorder()
|
||||||
|
runCatching { nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, r) }
|
||||||
|
.onFailure { r.startFailCode = -1 }
|
||||||
|
Triple(label, type, r)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
delay(10_000)
|
||||||
|
var total = 0
|
||||||
|
var anyStarted = false
|
||||||
|
val evidence = buildJsonObject {
|
||||||
|
put("multicast_lock", lock?.isHeld == true)
|
||||||
|
for ((label, type, r) in recorders) {
|
||||||
|
runCatching { nsd.stopServiceDiscovery(r) }
|
||||||
|
anyStarted = anyStarted || r.started
|
||||||
|
val names = r.names.distinct()
|
||||||
|
total += names.size
|
||||||
|
putJsonObject(label) {
|
||||||
|
put("query", type)
|
||||||
|
put("started", r.started)
|
||||||
|
r.startFailCode?.let { put("start_fail_code", it) }
|
||||||
|
put("found", names.size)
|
||||||
|
if (names.isNotEmpty()) put("names", names.joinToString(", ").take(300))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val metrics = buildJsonObject { put("services_found", total) }
|
||||||
|
// Zero services on a started discovery is a legitimate result (an empty or properly
|
||||||
|
// isolated network), not a failure — only discovery refusing to start is one.
|
||||||
|
b.build(if (anyStarted) TestStatus.OK else TestStatus.FAILED,
|
||||||
|
evidence = evidence, metrics = metrics)
|
||||||
|
} finally {
|
||||||
|
recorders.forEach { (_, _, r) -> runCatching { nsd.stopServiceDiscovery(r) } }
|
||||||
|
runCatching { lock?.release() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.system.Os
|
||||||
|
import java.io.FileDescriptor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux socket-option ABI numbers that android.system.OsConstants does NOT reliably expose.
|
||||||
|
* Stable across Android's supported ABIs at the IP/IPv6 protocol levels, which is why they can
|
||||||
|
* be hardcoded: if setsockoptInt with one of these succeeds, the kernel accepted the option; if
|
||||||
|
* it throws ErrnoException, it did not. Either outcome is data. Do not "fix" these to
|
||||||
|
* OsConstants names — they don't exist there (validated in the prober; see its OsAbi.kt).
|
||||||
|
*
|
||||||
|
* Measured fact worth keeping: `Os.getsockoptInt` is absent on both known devices (OnePlus 15
|
||||||
|
* A16, Lenovo TB330FU A15), so path-MTU values must be read from the errqueue (`ee_info`), never
|
||||||
|
* from getsockopt(IP_MTU).
|
||||||
|
*/
|
||||||
|
object OsAbi {
|
||||||
|
// IP level
|
||||||
|
const val IP_TTL = 2
|
||||||
|
const val IP_MTU_DISCOVER = 10
|
||||||
|
const val IP_MTU = 14
|
||||||
|
const val IP_RECVERR = 11
|
||||||
|
const val IP_PMTUDISC_DO = 2 // set DF, honor PMTU
|
||||||
|
const val IP_PMTUDISC_PROBE = 3 // set DF, ignore PMTU (for probing)
|
||||||
|
|
||||||
|
// IPv6 level
|
||||||
|
const val IPV6_MTU_DISCOVER = 23
|
||||||
|
const val IPV6_MTU = 24
|
||||||
|
const val IPV6_RECVERR = 25
|
||||||
|
const val IPV6_UNICAST_HOPS = 16
|
||||||
|
const val IPV6_PMTUDISC_PROBE = 3
|
||||||
|
|
||||||
|
// recv flags — not in OsConstants on any current API level
|
||||||
|
const val MSG_ERRQUEUE = 0x2000
|
||||||
|
const val MSG_DONTWAIT = 0x40
|
||||||
|
|
||||||
|
// struct sock_extended_err (uapi/linux/errqueue.h), fixed layout on all Android ABIs:
|
||||||
|
// u32 ee_errno; u8 ee_origin; u8 ee_type; u8 ee_code; u8 ee_pad; u32 ee_info; u32 ee_data;
|
||||||
|
// followed directly by the offender sockaddr (SO_EE_OFFENDER).
|
||||||
|
const val SOCK_EE_SIZE = 16
|
||||||
|
const val SO_EE_ORIGIN_ICMP = 2
|
||||||
|
const val ICMP_TIME_EXCEEDED = 11
|
||||||
|
const val ICMP_DEST_UNREACH = 3
|
||||||
|
|
||||||
|
/** Try setsockoptInt; return null on success, or the errno name on failure. */
|
||||||
|
fun trySetIntOpt(fd: FileDescriptor, level: Int, opt: Int, value: Int): String? =
|
||||||
|
try {
|
||||||
|
Os.setsockoptInt(fd, level, opt, value)
|
||||||
|
null
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
e.message ?: e.javaClass.simpleName
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getsockoptInt is not part of the stable public Os surface on every API level, so it is
|
||||||
|
* reached via reflection; callers must treat failure as "unreadable", not as an error.
|
||||||
|
*/
|
||||||
|
fun tryGetIntOpt(fd: FileDescriptor, level: Int, opt: Int): Result<Int> = runCatching {
|
||||||
|
val m = Os::class.java.getMethod(
|
||||||
|
"getsockoptInt",
|
||||||
|
FileDescriptor::class.java,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
)
|
||||||
|
m.invoke(null, fd, level, opt) as Int
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,9 +55,10 @@ class TestBuilder(
|
|||||||
evidence: JsonObject? = null,
|
evidence: JsonObject? = null,
|
||||||
metrics: JsonObject? = null,
|
metrics: JsonObject? = null,
|
||||||
error: TestError? = null,
|
error: TestError? = null,
|
||||||
|
params: JsonObject? = null,
|
||||||
): Test = Test(
|
): Test = Test(
|
||||||
id = id, type = type, networkRef = networkRef, sessionRef = sessionRef, tier = tier,
|
id = id, type = type, networkRef = networkRef, sessionRef = sessionRef, tier = tier,
|
||||||
startedMonoNs = startedMonoNs, endedMonoNs = ids.monoNs(),
|
startedMonoNs = startedMonoNs, endedMonoNs = ids.monoNs(),
|
||||||
status = status, error = error, evidence = evidence, metrics = metrics,
|
status = status, error = error, params = params, evidence = evidence, metrics = metrics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.system.Os
|
||||||
|
import android.system.OsConstants
|
||||||
|
import app.echo_lot.measurement.Flow
|
||||||
|
import app.echo_lot.measurement.Hop
|
||||||
|
import app.echo_lot.measurement.HopProbe
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestError
|
||||||
|
import app.echo_lot.measurement.TestStatus
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import app.echo_lot.measurement.TracerouteEvidence
|
||||||
|
import app.echo_lot.measurement.toEvidence
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import java.io.FileDescriptor
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* traceroute.udp4 — UDP traceroute reading ICMP time-exceeded off the socket error queue via
|
||||||
|
* Os.recvmsg(MSG_ERRQUEUE): no root, no raw socket, no native code. Folded from the prober,
|
||||||
|
* which validated real hop addresses on both known devices (6 hops on the OnePlus 15, 5 on the
|
||||||
|
* Lenovo) and thereby retired the planned C-over-JNI errqueue shim.
|
||||||
|
*
|
||||||
|
* StructMsghdr/StructCmsghdr/recvmsg are reached via reflection (repo convention for uncertain
|
||||||
|
* OS paths): present since roughly API 34, absent before, and the probe must run — and report —
|
||||||
|
* on both. An absent API is UNSUPPORTED with the reason, never a crash.
|
||||||
|
*/
|
||||||
|
class TracerouteProbe(
|
||||||
|
private val targetHost: String = "1.1.1.1",
|
||||||
|
private val maxHops: Int = 6,
|
||||||
|
) : Probe {
|
||||||
|
override val type = TestType.TRACEROUTE_UDP4
|
||||||
|
override val tier = Tier.APP
|
||||||
|
// Validated wall clock is ~250 ms on a healthy path; the ceiling is maxHops silent hops at
|
||||||
|
// 900 ms each, which only a blackholing path produces.
|
||||||
|
override val estimatedMs = 1_500L
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val BASE_PORT = 33434
|
||||||
|
/** ICMP errors take one RTT to surface on the errqueue; poll briefly, never block. */
|
||||||
|
const val HOP_DEADLINE_NS = 900_000_000L
|
||||||
|
const val POLL_INTERVAL_MS = 40L
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||||
|
val b = TestBuilder(type, tier, ids)
|
||||||
|
val params = buildJsonObject {
|
||||||
|
put("target", targetHost); put("max_hops", maxHops); put("base_port", BASE_PORT)
|
||||||
|
}
|
||||||
|
|
||||||
|
val api = ErrqueueApi.resolve()
|
||||||
|
?: return@withContext b.build(
|
||||||
|
TestStatus.UNSUPPORTED,
|
||||||
|
params = params,
|
||||||
|
error = TestError(
|
||||||
|
"no_recvmsg",
|
||||||
|
"StructMsghdr/Os.recvmsg not on this API level — errqueue unreadable",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
var fd: FileDescriptor? = null
|
||||||
|
try {
|
||||||
|
fd = Os.socket(OsConstants.AF_INET, OsConstants.SOCK_DGRAM, OsConstants.IPPROTO_UDP)
|
||||||
|
OsAbi.trySetIntOpt(fd, OsConstants.IPPROTO_IP, OsAbi.IP_RECVERR, 1)?.let {
|
||||||
|
return@withContext b.build(
|
||||||
|
TestStatus.UNSUPPORTED,
|
||||||
|
params = params,
|
||||||
|
error = TestError("ip_recverr_rejected", it),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val target = InetAddress.getByName(targetHost)
|
||||||
|
|
||||||
|
val hops = ArrayList<Hop>(maxHops)
|
||||||
|
var hopsSeen = 0
|
||||||
|
var reachedTarget = false
|
||||||
|
var srcPort = 0
|
||||||
|
for (ttl in 1..maxHops) {
|
||||||
|
OsAbi.trySetIntOpt(fd, OsConstants.IPPROTO_IP, OsAbi.IP_TTL, ttl)
|
||||||
|
val t0 = System.nanoTime()
|
||||||
|
val sent = runCatching {
|
||||||
|
Os.sendto(fd, ByteArray(32), 0, 32, 0, target, BASE_PORT + ttl)
|
||||||
|
}
|
||||||
|
if (sent.isFailure) {
|
||||||
|
hops.add(Hop(ttl, listOf(HopProbe(icmp = "sendto failed: " +
|
||||||
|
(sent.exceptionOrNull()?.message ?: "?")))))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (srcPort == 0) {
|
||||||
|
// Only readable after the implicit bind the first send performs.
|
||||||
|
srcPort = runCatching {
|
||||||
|
(Os.getsockname(fd) as? InetSocketAddress)?.port ?: 0
|
||||||
|
}.getOrDefault(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hop: ErrqueueApi.ErrEvent? = null
|
||||||
|
val deadline = System.nanoTime() + HOP_DEADLINE_NS
|
||||||
|
while (hop == null && System.nanoTime() < deadline) {
|
||||||
|
hop = api.pollErrqueue(fd)
|
||||||
|
if (hop == null) delay(POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
val rttNs = System.nanoTime() - t0
|
||||||
|
when {
|
||||||
|
hop == null -> hops.add(Hop(ttl, listOf(HopProbe()))) // silent hop: all null
|
||||||
|
hop.parseError != null ->
|
||||||
|
hops.add(Hop(ttl, listOf(HopProbe(icmp = "unparsed: ${hop.parseError}"))))
|
||||||
|
else -> {
|
||||||
|
hopsSeen++
|
||||||
|
hops.add(Hop(ttl, listOf(HopProbe(
|
||||||
|
replyFrom = hop.offender,
|
||||||
|
rttNs = rttNs,
|
||||||
|
icmp = when (hop.icmpType) {
|
||||||
|
OsAbi.ICMP_TIME_EXCEEDED -> "time_exceeded"
|
||||||
|
OsAbi.ICMP_DEST_UNREACH -> "dest_unreachable"
|
||||||
|
else -> "type_${hop.icmpType}"
|
||||||
|
},
|
||||||
|
))))
|
||||||
|
if (hop.icmpType == OsAbi.ICMP_DEST_UNREACH) reachedTarget = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reachedTarget) break
|
||||||
|
}
|
||||||
|
|
||||||
|
// dst_port varies per TTL (classic traceroute, and what was validated on hardware),
|
||||||
|
// so this flow is explicitly NOT fixed-tuple; base_port is in params.
|
||||||
|
val evidence = TracerouteEvidence(
|
||||||
|
flow = Flow(srcPort = srcPort, dstPort = BASE_PORT, fixedTuple = false),
|
||||||
|
hops = hops,
|
||||||
|
).toEvidence()
|
||||||
|
val metrics = buildJsonObject {
|
||||||
|
put("hops_seen", hopsSeen)
|
||||||
|
put("reached_target", reachedTarget)
|
||||||
|
}
|
||||||
|
val status = when {
|
||||||
|
hopsSeen > 0 -> TestStatus.OK
|
||||||
|
// API present, sends succeeded, nothing surfaced: a fact about this path or
|
||||||
|
// kernel, not proof the mechanism is missing.
|
||||||
|
else -> TestStatus.PARTIAL
|
||||||
|
}
|
||||||
|
b.build(status, params = params, evidence = evidence, metrics = metrics)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
b.build(
|
||||||
|
TestStatus.FAILED,
|
||||||
|
params = params,
|
||||||
|
error = TestError("uncaught", e.message ?: e.javaClass.simpleName),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
fd?.let { runCatching { Os.close(it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reflection facade over android.system.{StructMsghdr, StructCmsghdr, Os.recvmsg}.
|
||||||
|
* Resolved once; null if any piece is missing on this API level.
|
||||||
|
*/
|
||||||
|
internal class ErrqueueApi private constructor(
|
||||||
|
private val msghdrCtor: java.lang.reflect.Constructor<*>,
|
||||||
|
private val recvmsg: java.lang.reflect.Method,
|
||||||
|
private val cmsgLevel: java.lang.reflect.Field,
|
||||||
|
private val cmsgType: java.lang.reflect.Field,
|
||||||
|
private val cmsgData: java.lang.reflect.Field,
|
||||||
|
private val msgControl: java.lang.reflect.Field,
|
||||||
|
) {
|
||||||
|
class ErrEvent(
|
||||||
|
val offender: String?,
|
||||||
|
val icmpType: Int,
|
||||||
|
val origin: Int,
|
||||||
|
val parseError: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One non-blocking MSG_ERRQUEUE read; null when the queue is empty. */
|
||||||
|
fun pollErrqueue(fd: FileDescriptor): ErrEvent? {
|
||||||
|
return try {
|
||||||
|
val iov = arrayOf(ByteBuffer.allocate(512))
|
||||||
|
// (SocketAddress msg_name, ByteBuffer[] msg_iov, StructCmsghdr[] msg_control, flags)
|
||||||
|
val msghdr = msghdrCtor.newInstance(
|
||||||
|
InetSocketAddress(0), iov, null, 0,
|
||||||
|
)
|
||||||
|
recvmsg.invoke(null, fd, msghdr, OsAbi.MSG_ERRQUEUE or OsAbi.MSG_DONTWAIT)
|
||||||
|
val control = msgControl.get(msghdr) as? Array<*>
|
||||||
|
?: return ErrEvent(null, -1, -1, "msg_control empty after recvmsg")
|
||||||
|
for (cmsg in control.filterNotNull()) {
|
||||||
|
val level = cmsgLevel.getInt(cmsg)
|
||||||
|
val type = cmsgType.getInt(cmsg)
|
||||||
|
if (level == OsConstants.IPPROTO_IP && type == OsAbi.IP_RECVERR) {
|
||||||
|
return parseSockExtendedErr(cmsgData.get(cmsg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ErrEvent(null, -1, -1, "no IP_RECVERR cmsg among ${control.size}")
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
// The single most load-bearing line: reflection wraps errno in
|
||||||
|
// InvocationTargetException, and EAGAIN there means "queue empty", not failure.
|
||||||
|
val cause = (e as? java.lang.reflect.InvocationTargetException)?.cause ?: e
|
||||||
|
val msg = cause.message ?: cause.javaClass.simpleName
|
||||||
|
if ("EAGAIN" in msg || "EWOULDBLOCK" in msg) null
|
||||||
|
else ErrEvent(null, -1, -1, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** cmsg_data = struct sock_extended_err + offender sockaddr_in (see OsAbi). */
|
||||||
|
private fun parseSockExtendedErr(data: Any?): ErrEvent {
|
||||||
|
val bytes: ByteArray = when (data) {
|
||||||
|
is ByteArray -> data
|
||||||
|
is ByteBuffer -> ByteArray(data.remaining()).also { data.duplicate().get(it) }
|
||||||
|
else -> return ErrEvent(null, -1, -1, "cmsg_data is ${data?.javaClass?.name}")
|
||||||
|
}
|
||||||
|
if (bytes.size < OsAbi.SOCK_EE_SIZE) {
|
||||||
|
return ErrEvent(null, -1, -1, "cmsg_data too short: ${bytes.size}")
|
||||||
|
}
|
||||||
|
val origin = bytes[4].toInt() and 0xFF
|
||||||
|
val icmpType = bytes[5].toInt() and 0xFF
|
||||||
|
// SO_EE_OFFENDER: sockaddr_in directly after the fixed struct; family is in native
|
||||||
|
// byte order, sin_addr at offset +4 within the sockaddr.
|
||||||
|
val offender = if (bytes.size >= OsAbi.SOCK_EE_SIZE + 8) {
|
||||||
|
val family = ByteBuffer.wrap(bytes, OsAbi.SOCK_EE_SIZE, 2)
|
||||||
|
.order(ByteOrder.nativeOrder()).short.toInt()
|
||||||
|
if (family == OsConstants.AF_INET) {
|
||||||
|
val a = bytes.copyOfRange(OsAbi.SOCK_EE_SIZE + 4, OsAbi.SOCK_EE_SIZE + 8)
|
||||||
|
InetAddress.getByAddress(a).hostAddress
|
||||||
|
} else null
|
||||||
|
} else null
|
||||||
|
return ErrEvent(offender, icmpType, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun resolve(): ErrqueueApi? = runCatching {
|
||||||
|
val msghdrCls = Class.forName("android.system.StructMsghdr")
|
||||||
|
val cmsghdrCls = Class.forName("android.system.StructCmsghdr")
|
||||||
|
ErrqueueApi(
|
||||||
|
// Picked by shape, not by position: the 4-arg form is
|
||||||
|
// (SocketAddress, ByteBuffer[], StructCmsghdr[], int) on every level that has it.
|
||||||
|
msghdrCtor = msghdrCls.constructors.first { it.parameterCount == 4 },
|
||||||
|
recvmsg = Os::class.java.getMethod(
|
||||||
|
"recvmsg", FileDescriptor::class.java, msghdrCls, Int::class.javaPrimitiveType,
|
||||||
|
),
|
||||||
|
cmsgLevel = cmsghdrCls.getField("cmsg_level"),
|
||||||
|
cmsgType = cmsghdrCls.getField("cmsg_type"),
|
||||||
|
cmsgData = cmsghdrCls.getField("cmsg_data"),
|
||||||
|
msgControl = msghdrCls.getField("msg_control"),
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user