app: nat.stun_5780 — NAT mapping/filtering discovery, verified vs live server

Hand-rolled RFC 5389/5780 STUN client (stdlib only) that exercises the
server's stun-5780 capability: one socket, three binding requests
(primary, OTHER-ADDRESS alternate IP, CHANGE-REQUEST port) — the
comparison classifies NAT mapping and filtering behavior.

Verified on the OnePlus: local 10.13.102.124 -> mapped
178.191.120.247:53259 (behind_nat true), alternate address answered from
the server's second IP, mapping endpoint-independent, filtering
address/port-dependent. Finding nat.symmetric (medium) for the
P2P-hostile case.

Two real bugs found by running it: port preservation was misread as "no
NAT" (compare addresses, not ports), and an unbound socket reports the
wildcard local address (resolve via a throwaway connected socket).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 09:15:29 +02:00
co-authored by Claude Opus 5
parent dd9ecf5032
commit 483de5ca54
4 changed files with 675 additions and 0 deletions
+16
View File
@@ -414,3 +414,19 @@ Ran the app on the OnePlus with Shizuku running (`echolot-app/reports/CPH2747-ap
Both privilege tiers now work end to end in the production app on real hardware, alongside the Both privilege tiers now work end to end in the production app on real hardware, alongside the
canary-DNS loop against the live server. 6 tests/run: link.snapshot, icmp.ping4, icmp.ping6, canary-DNS loop against the live server. 6 tests/run: link.snapshot, icmp.ping4, icmp.ping6,
net.captive_portal, dns.canary, link.ip_monitor(shizuku). net.captive_portal, dns.canary, link.ip_monitor(shizuku).
## App: nat.stun_5780 — NAT behavior discovery verified against the live server (2026-08-01)
Client-side RFC 5389/5780 STUN (hand-rolled, stdlib only) exercising the server's `stun-5780`
capability. Three binding requests from ONE socket: primary, the server's OTHER-ADDRESS
(alternate IP), and CHANGE-REQUEST(port). Verified on the OnePlus
(`echolot-app/reports/CPH2747-app-run4-stun.json`):
- local `10.13.102.124` → mapped `178.191.120.247:53259`, `behind_nat: true`
- `other_address 89.185.109.151:3479` — the server's second IP answered, so RFC 5780 works
end to end (client ↔ our own STUN implementation)
- **mapping: endpoint-independent** (same external port toward a different destination → P2P
friendly); **filtering: address/port-dependent** (no reply to CHANGE-REQUEST → unsolicited
inbound is dropped). Classic full-cone-mapping + port-restricted-filtering NAT.
Finding wired: `nat.symmetric` (medium) when mapping is address/port-dependent.
Two bugs caught by running it for real: port preservation was misread as "no NAT" (now compares
ADDRESSES), and an unbound socket reports the wildcard as its local address (now resolved via a
throwaway connected socket). 7 tests/run.
@@ -15,6 +15,7 @@ import app.echo_lot.probe.CaptivePortalProbe
import app.echo_lot.probe.DnsCanaryProbe import app.echo_lot.probe.DnsCanaryProbe
import app.echo_lot.probe.IcmpProbe import app.echo_lot.probe.IcmpProbe
import app.echo_lot.probe.LinkSnapshotProbe import app.echo_lot.probe.LinkSnapshotProbe
import app.echo_lot.probe.StunProbe
import app.echo_lot.probe.NetworkInventory import app.echo_lot.probe.NetworkInventory
import app.echo_lot.probe.Probe import app.echo_lot.probe.Probe
import app.echo_lot.probe.ProbeIds import app.echo_lot.probe.ProbeIds
@@ -75,6 +76,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
// 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.
DnsCanaryProbe(canaryZone = "c.echo-lot.app", sessionPrefix = "adhoc"), DnsCanaryProbe(canaryZone = "c.echo-lot.app", sessionPrefix = "adhoc"),
StunProbe(serverHost = "fmr-1.echo-lot.app"),
) )
val tests = ArrayList<Test>() val tests = ArrayList<Test>()
@@ -185,6 +187,20 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
) )
} }
} }
if (t.type == TestType.NAT_STUN_5780 && t.status == TestStatus.OK) {
val ev = t.evidence?.toString() ?: ""
if (ev.contains("address/port-dependent (symmetric NAT")) {
out.add(
Finding(
id = ids.uuid(), code = "nat.symmetric", category = Category.NAT,
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
title = "Symmetric NAT — peer-to-peer connections need a relay",
description = "The NAT assigns a different external port per destination (address/port-dependent mapping). Direct peer-to-peer connections (calls, games, file transfer) will usually fail and fall back to relays.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
}
}
if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) { if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) {
out.add( out.add(
Finding( Finding(
@@ -0,0 +1,208 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import android.content.Context
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.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.SecureRandom
/**
* nat.stun_5780 — discovers this network's NAT behavior with plain RFC 5389/5780 STUN against the
* Echolot server (which advertises `stun-5780` when it has ≥2 same-family addresses).
*
* Three binding requests, and the comparison between them is the measurement:
* 1. **primary address, primary port** → the reflexive (public) address and port.
* 2. **same socket, alternate address** (the server's OTHER-ADDRESS): if the mapped port is
* unchanged, the NAT keeps one mapping regardless of destination → **endpoint-independent
* mapping** (good: peer-to-peer works). A different port → address/port-dependent mapping
* (symmetric NAT: P2P needs relays).
* 3. **CHANGE-REQUEST(change-port)** — asks the server to answer from a different port. A reply
* means the NAT/firewall accepts inbound from an endpoint it never sent to →
* **endpoint-independent filtering**; silence means address/port-dependent filtering.
*
* Also detects being behind NAT at all (mapped address ≠ local address) — the CGNAT/double-NAT
* signal when combined with the local address being private.
*/
class StunProbe(
private val serverHost: String,
private val stunPort: Int = 3478,
) : Probe {
override val type = TestType.NAT_STUN_5780
override val tier = Tier.APP
private companion object {
const val MAGIC_COOKIE = 0x2112A442.toInt()
const val TYPE_BINDING_REQUEST = 0x0001
const val TYPE_BINDING_SUCCESS = 0x0101
const val ATTR_CHANGE_REQUEST = 0x0003
const val ATTR_XOR_MAPPED = 0x0020
const val ATTR_OTHER_ADDRESS = 0x802C
const val CHANGE_PORT = 0x02
}
private data class Mapped(val addr: String, val port: Int)
private data class Reply(val mapped: Mapped?, val other: Mapped?)
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
val b = TestBuilder(type, tier, ids)
DatagramSocket().use { sock ->
sock.soTimeout = 3000
val localPort = sock.localPort
// 1) primary
val r1 = request(sock, serverHost, stunPort, change = 0)
if (r1?.mapped == null) {
return@withContext b.build(
TestStatus.FAILED,
evidence = buildJsonObject {
put("server", "$serverHost:$stunPort")
put("error", "no STUN binding response (blocked or server unreachable)")
},
)
}
// 2) same socket → the server's alternate address (OTHER-ADDRESS)
val alt = r1.other
val r2 = alt?.let { request(sock, it.addr, stunPort, change = 0) }
// 3) CHANGE-REQUEST(port) — tests inbound filtering
val r3 = request(sock, serverHost, stunPort, change = CHANGE_PORT)
val mappingBehavior = when {
alt == null -> "unknown (server has no OTHER-ADDRESS; only stun-basic)"
r2?.mapped == null -> "inconclusive (no reply from alternate address)"
r2.mapped.port == r1.mapped.port && r2.mapped.addr == r1.mapped.addr ->
"endpoint-independent (one mapping for all destinations — P2P friendly)"
else -> "address/port-dependent (symmetric NAT — P2P needs a relay)"
}
val filteringBehavior = when {
r3?.mapped != null -> "endpoint-independent (accepts inbound from an unseen endpoint)"
else -> "address/port-dependent (drops inbound from endpoints not contacted)"
}
// Behind NAT iff the reflexive address differs from this socket's local address.
// Port preservation is common and must NOT be read as "no NAT" — compare addresses.
val localAddr = localAddressOf(sock)
val behindNat = localAddr.isNotEmpty() && localAddr != r1.mapped.addr
val evidence: JsonObject = buildJsonObject {
put("server", "$serverHost:$stunPort")
put("local_addr", localAddr)
put("local_port", localPort)
put("mapped", "${r1.mapped.addr}:${r1.mapped.port}")
put("other_address", alt?.let { "${it.addr}:${it.port}" } ?: "")
put("mapped_via_alt", r2?.mapped?.let { "${it.addr}:${it.port}" } ?: "(no reply)")
put("change_port_reply", if (r3?.mapped != null) "received" else "none")
put("mapping_behavior", mappingBehavior)
put("filtering_behavior", filteringBehavior)
put("behind_nat", behindNat)
}
val metrics = buildJsonObject {
put("mapped_port", r1.mapped.port)
put("local_addr", localAddr)
put("local_port", localPort)
put("port_preserved", r1.mapped.port == localPort)
put("behind_nat", behindNat)
}
b.build(TestStatus.OK, evidence = evidence, metrics = metrics)
}
}
/**
* The address this socket actually sources from. An unbound DatagramSocket reports the
* wildcard ("::"/"0.0.0.0"), which says nothing, so probe the route to the server with a
* throwaway connected socket and read its local address.
*/
private fun localAddressOf(sock: DatagramSocket): String {
val direct = sock.localAddress?.hostAddress ?: ""
if (direct.isNotEmpty() && direct != "::" && direct != "0.0.0.0") return direct
return runCatching {
DatagramSocket().use { s ->
s.connect(InetSocketAddress(serverHost, stunPort))
s.localAddress?.hostAddress ?: ""
}
}.getOrDefault("")
}
/** One binding request; returns the parsed reply or null on timeout. */
private fun request(sock: DatagramSocket, host: String, port: Int, change: Int): Reply? {
val txid = ByteArray(12).also { SecureRandom().nextBytes(it) }
val attrs = if (change != 0) {
ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).apply {
putShort(ATTR_CHANGE_REQUEST.toShort()); putShort(4)
put(0); put(0); put(0); put(change.toByte())
}.array()
} else ByteArray(0)
val msg = ByteBuffer.allocate(20 + attrs.size).order(ByteOrder.BIG_ENDIAN)
msg.putShort(TYPE_BINDING_REQUEST.toShort())
msg.putShort(attrs.size.toShort())
msg.putInt(MAGIC_COOKIE)
msg.put(txid)
msg.put(attrs)
val bytes = msg.array()
return try {
sock.send(DatagramPacket(bytes, bytes.size, InetSocketAddress(host, port)))
val buf = ByteArray(1500)
val dp = DatagramPacket(buf, buf.size)
sock.receive(dp)
parse(buf, dp.length, txid)
} catch (e: Throwable) {
null
}
}
private fun parse(data: ByteArray, len: Int, txid: ByteArray): Reply? {
if (len < 20) return null
val bb = ByteBuffer.wrap(data, 0, len).order(ByteOrder.BIG_ENDIAN)
if ((bb.getShort(0).toInt() and 0xFFFF) != TYPE_BINDING_SUCCESS) return null
val msgLen = bb.getShort(2).toInt() and 0xFFFF
var mapped: Mapped? = null
var other: Mapped? = null
var off = 20
while (off + 4 <= 20 + msgLen && off + 4 <= len) {
val at = bb.getShort(off).toInt() and 0xFFFF
val al = bb.getShort(off + 2).toInt() and 0xFFFF
if (off + 4 + al > len) break
when (at) {
ATTR_XOR_MAPPED -> mapped = parseAddr(data, off + 4, al, txid, xor = true)
ATTR_OTHER_ADDRESS -> other = parseAddr(data, off + 4, al, txid, xor = false)
}
off += 4 + al + ((4 - al % 4) % 4)
}
return Reply(mapped, other)
}
/** RFC 5389 address attribute; XOR-MAPPED needs de-XORing with the cookie + txid. */
private fun parseAddr(data: ByteArray, off: Int, len: Int, txid: ByteArray, xor: Boolean): Mapped? {
if (len < 8) return null
val v = data.copyOfRange(off, off + len)
val family = v[1].toInt() and 0xFF
var port = ((v[2].toInt() and 0xFF) shl 8) or (v[3].toInt() and 0xFF)
if (xor) port = port xor ((MAGIC_COOKIE ushr 16) and 0xFFFF)
val key = ByteBuffer.allocate(16).order(ByteOrder.BIG_ENDIAN)
.putInt(MAGIC_COOKIE).put(txid).array()
return if (family == 0x01) {
val a = ByteArray(4) { i -> if (xor) (v[4 + i].toInt() xor key[i].toInt()).toByte() else v[4 + i] }
Mapped(a.joinToString(".") { (it.toInt() and 0xFF).toString() }, port)
} else {
if (len < 20) return null
val a = ByteArray(16) { i -> if (xor) (v[4 + i].toInt() xor key[i].toInt()).toByte() else v[4 + i] }
Mapped(java.net.InetAddress.getByAddress(a).hostAddress ?: "", port)
}
}
}
@@ -0,0 +1,435 @@
{
"schema": "echolot/measurement",
"schema_version": "1.0.0",
"run": {
"id": "91791890-ff4c-4e30-85a1-e1a2416d4f95",
"trigger": "manual",
"started_at": "2026-08-01T07:14:12.849417Z",
"ended_at": "2026-08-01T07:14:29.245241Z",
"clock": {
"mono_origin_wall": "2026-08-01T07:14:12.849417Z",
"ntp_offset_ms": null,
"ntp_offset_source": null
},
"app": {
"version": "0.1.0",
"build": 1,
"git": null,
"flavor": "app"
},
"device": {
"manufacturer": "OnePlus",
"model": "CPH2747",
"android_sdk": 36,
"android_release": "16",
"security_patch": null
},
"tiers": {
"app": true,
"shizuku": true,
"root": false
},
"profiles_used": [],
"notes": null
},
"networks": [
{
"id": "net-0",
"transport": "cellular",
"interface": "rmnet_data4",
"link": {
"mtu": 1500,
"addresses": [
{
"addr": "2001:4bb8:417:bd78:e4fe:8cff:febe:ca8c",
"prefix_len": 64,
"scope": null,
"flags": [],
"valid_lft_s": null,
"pref_lft_s": null
}
],
"routes": [
{
"dst": "::/0",
"gateway": "fe80::246f:12be:21ef:1b54",
"iface": "rmnet_data4",
"proto": null,
"expires_s": null
},
{
"dst": "2001:4bb8:417:bd78::/64",
"gateway": "::",
"iface": "rmnet_data4",
"proto": null,
"expires_s": null
}
],
"dns": {
"servers": [
"fda1:3fb1:0:8:0:10:0:101",
"fda1:3fb1:0:8:0:10:0:100"
],
"private_dns_mode": "off",
"private_dns_hostname": null,
"search_domains": [],
"nat64_prefix": null
},
"dhcp": null,
"captive_portal": null
},
"wifi": null,
"cellular": null,
"changes": []
},
{
"id": "net-1",
"transport": "wifi",
"interface": "wlan0",
"link": {
"mtu": null,
"addresses": [
{
"addr": "fe80::7a:75ff:fee9:ae9e",
"prefix_len": 64,
"scope": null,
"flags": [],
"valid_lft_s": null,
"pref_lft_s": null
},
{
"addr": "10.13.102.124",
"prefix_len": 24,
"scope": null,
"flags": [],
"valid_lft_s": null,
"pref_lft_s": null
}
],
"routes": [
{
"dst": "fe80::/64",
"gateway": "::",
"iface": "wlan0",
"proto": null,
"expires_s": null
},
{
"dst": "::/0",
"gateway": "fe80::7a9a:18ff:fe54:b8f9",
"iface": "wlan0",
"proto": null,
"expires_s": null
},
{
"dst": "10.13.102.0/24",
"gateway": "0.0.0.0",
"iface": "wlan0",
"proto": null,
"expires_s": null
},
{
"dst": "0.0.0.0/0",
"gateway": "10.13.102.1",
"iface": "wlan0",
"proto": null,
"expires_s": null
}
],
"dns": {
"servers": [
"10.13.102.1"
],
"private_dns_mode": "off",
"private_dns_hostname": null,
"search_domains": [
"hudelist.local"
],
"nat64_prefix": null
},
"dhcp": null,
"captive_portal": null
},
"wifi": null,
"cellular": null,
"changes": []
}
],
"server_sessions": [],
"tests": [
{
"id": "c223247c-7da0-43a1-9431-7e86363eb624",
"type": "link.snapshot",
"network_ref": null,
"session_ref": null,
"tier": "app",
"started_mono_ns": 4322917,
"ended_mono_ns": 6075469,
"status": "ok",
"error": null,
"params": null,
"evidence": {
"network_count": 2,
"networks": [
{
"id": "net-0",
"transport": "cellular",
"interface": "rmnet_data4",
"mtu": 1500,
"addresses": "2001:4bb8:417:bd78:e4fe:8cff:febe:ca8c/64",
"dns": "fda1:3fb1:0:8:0:10:0:101, fda1:3fb1:0:8:0:10:0:100",
"nat64": "none"
},
{
"id": "net-1",
"transport": "wifi",
"interface": "wlan0",
"mtu": 0,
"addresses": "fe80::7a:75ff:fee9:ae9e/64, 10.13.102.124/24",
"dns": "10.13.102.1",
"nat64": "none"
}
]
},
"metrics": null
},
{
"id": "29855a8c-8e8e-47c6-b706-0e96637e9173",
"type": "icmp.ping4",
"network_ref": null,
"session_ref": null,
"tier": "app",
"started_mono_ns": 6237865,
"ended_mono_ns": 96142292,
"status": "ok",
"error": null,
"params": null,
"evidence": {
"target": "1.1.1.1",
"default": "reply type=0 rtt_ms=23.4 bytes=15",
"cellular:net-0": "error: Binding socket to network 137 failed: EPERM (Operation not permitted)",
"wifi:net-1": "reply type=0 rtt_ms=62.9 bytes=15"
},
"metrics": {
"networks_ok": 2,
"rtt_ms_min": 23.4,
"rtt_ms_avg": 43.2,
"rtt_ms_max": 62.9
}
},
{
"id": "1c33bbc9-594c-49b8-8095-9b74b67d3a2a",
"type": "icmp.ping6",
"network_ref": null,
"session_ref": null,
"tier": "app",
"started_mono_ns": 96636354,
"ended_mono_ns": 6187647550,
"status": "failed",
"error": null,
"params": null,
"evidence": {
"target": "2606:4700:4700::1111",
"default": "error: recvfrom failed: EAGAIN (Try again)",
"cellular:net-0": "error: Binding socket to network 137 failed: EPERM (Operation not permitted)",
"wifi:net-1": "error: recvfrom failed: EAGAIN (Try again)"
},
"metrics": {
"networks_ok": 0
}
},
{
"id": "e007d496-1b00-45e6-b89a-277e59112e44",
"type": "net.captive_portal",
"network_ref": null,
"session_ref": null,
"tier": "app",
"started_mono_ns": 6188334425,
"ended_mono_ns": 7307979685,
"status": "ok",
"error": null,
"params": null,
"evidence": {
"https_url": "https://www.google.com/generate_204",
"http_url": "http://connectivitycheck.gstatic.com/generate_204",
"default": {
"https_code": 204,
"http_code": 204,
"verdict": "validated"
},
"cellular:net-0": {
"https_code": -1,
"http_code": -1,
"verdict": "no_internet"
},
"wifi:net-1": {
"https_code": 204,
"http_code": 204,
"verdict": "validated"
}
},
"metrics": null
},
{
"id": "4185c707-e213-43e6-9fc2-787904f518da",
"type": "dns.canary",
"network_ref": null,
"session_ref": null,
"tier": "app",
"started_mono_ns": 7308766248,
"ended_mono_ns": 7580395675,
"status": "ok",
"error": null,
"params": null,
"evidence": {
"zone": "c.echo-lot.app",
"reference_records": {
"ttl-5": {
"fqdn": "ttl-5.c.echo-lot.app",
"expected": "192.0.2.5",
"got": "192.0.2.5",
"verdict": "match"
},
"ttl-60": {
"fqdn": "ttl-60.c.echo-lot.app",
"expected": "192.0.2.60",
"got": "192.0.2.60",
"verdict": "match"
},
"ttl-3600": {
"fqdn": "ttl-3600.c.echo-lot.app",
"expected": "192.0.2.36",
"got": "192.0.2.36",
"verdict": "match"
},
"ttl-86400": {
"fqdn": "ttl-86400.c.echo-lot.app",
"expected": "192.0.2.86",
"got": "192.0.2.86",
"verdict": "match"
}
},
"nonce_query": {
"fqdn": "8e870f49.adhoc.c.echo-lot.app",
"got": "192.0.2.63",
"reached_authoritative": true,
"note": "a non-192.0.2.x answer means something other than the canary server replied"
}
},
"metrics": {
"references_matched": 4,
"references_mismatched": 0,
"references_failed": 0
}
},
{
"id": "fe9a8235-234f-4e55-a86c-aa4475e5b7ab",
"type": "nat.stun_5780",
"network_ref": null,
"session_ref": null,
"tier": "app",
"started_mono_ns": 7581523695,
"ended_mono_ns": 10745066350,
"status": "ok",
"error": null,
"params": null,
"evidence": {
"server": "fmr-1.echo-lot.app:3478",
"local_addr": "10.13.102.124",
"local_port": 53259,
"mapped": "178.191.120.247:53259",
"other_address": "89.185.109.151:3479",
"mapped_via_alt": "178.191.120.247:53259",
"change_port_reply": "none",
"mapping_behavior": "endpoint-independent (one mapping for all destinations — P2P friendly)",
"filtering_behavior": "address/port-dependent (drops inbound from endpoints not contacted)",
"behind_nat": true
},
"metrics": {
"mapped_port": 53259,
"local_addr": "10.13.102.124",
"local_port": 53259,
"port_preserved": true,
"behind_nat": true
}
},
{
"id": "560d2305-2e40-477e-a251-c1a89d696d34",
"type": "link.ip_monitor",
"network_ref": null,
"session_ref": null,
"tier": "shizuku",
"started_mono_ns": 10748133694,
"ended_mono_ns": 16387400775,
"status": "ok",
"error": null,
"params": null,
"evidence": {
"binder_alive": true,
"version": 13,
"runs_as": "shell(2000)",
"exec_path": "UserService",
"id": "uid=2000\nuid=2000(shell) gid=2000(shell) groups=2000(shell),1004(input),1007(log),1011(adb),1015(sdcard_rw),1028(sdcard_r),1078(ext_data_rw),1079(ext_obb_rw),3001(net_bt_admin),3002(net_bt),3003(inet),3006(net_bw_stats),3009(readproc),3011(uhid),3012(readtracefs) context=u:r:shell:s0",
"ip_neigh": "uid=2000\n10.13.102.120 dev wlan0 lladdr 0e:d8:14:58:6c:8b STALE\n10.13.102.5 dev wlan0 lladdr 90:09:d0:1a:83:e4 STALE\n10.13.102.1 dev wlan0 lladdr 78:9a:18:54:b8:f9 REACHABLE\n10.13.102.108 dev wlan0 lladdr 14:49:e0:6d:6c:00 STALE\n10.13.102.107 dev wlan0 lladdr 38:8c:50:08:13:5b STALE\n10.13.102.21 dev wlan0 FAILED\n10.13.102.116 dev wlan0 lladdr 0c:08:b4:03:68:0e STALE\n10.13.102.125 dev wlan0 FAILED\nfe80::7a9a:18ff:fe54:b8f9 dev wlan0 lladdr 78:9a:18:54:b8:f9 router REACHABLE",
"ip6_route": "uid=2000\nfe80::/64 dev wlan0 table 1028 proto kernel metric 256 pref medium\nfe80::/64 dev wlan0 table 1028 proto static metric 1024 pref medium\ndefault via fe80::7a9a:18ff:fe54:b8f9 dev wlan0 table 1028 proto ra metric 1024 expires 1686sec pref medium\nfe80::/64 dev vgate0 table 1031 proto kernel metric 256 pref medium\n2001:4bb8:417:bd78::/64 dev rmnet_data4 table 1032 proto kernel metric 256 pref medium\n2001:4bb8:417:bd78::/64 dev rmnet_data4 table 1032 proto static metric 1024 pref medium\nfe80::/64 dev rmnet_data4 table 1032 proto kernel metric 256 pref medium\ndefault via fe80::246f:12be:21ef:1b54 dev rmnet_data4 table 1032 proto ra metric 1024 expires 63203sec hoplimit 255 pref medium\nfe80::/64 dev wlan0 table 1000000028 proto static metric 1024 pref medium\n2001:4bb8:417:bd78::/64 dev rmnet_data4 table 1000000032 proto static metric 1024 pref medium\nfe80::/64 dev dummy0 table 1002 proto kernel metric 256 pref medium\ndefault dev dummy0 table 1002 proto static metric 1024 pref medium\nfe80::/64 dev ifb0 table 1003 proto kernel metric 256 pref medium\nfe80::/64 dev ifb1 table 1004 proto kernel metric 256 pref medium\nfe80::/64 dev ifb2 table 1019 proto kernel metric 256 pref medium\nfe8",
"ip_addr": "uid=2000\n1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000\n link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00\n inet 127.0.0.1/8 scope host lo\n valid_lft forever preferred_lft forever\n inet6 ::1/128 scope host \n valid_lft forever preferred_lft forever\n2: dummy0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000\n link/ether be:3d:e2:93:78:b9 brd ff:ff:ff:ff:ff:ff\n inet6 fe80::bc3d:e2ff:fe93:78b9/64 scope link \n valid_lft forever preferred_lft forever\n3: ifb0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UNKNOWN group default qlen 1000\n link/ether ba:6e:46:b5:3d:bb brd ff:ff:ff:ff:ff:ff\n inet6 fe80::b86e:46ff:feb5:3dbb/64 scope link \n valid_lft forever preferred_lft forever\n4: ifb1: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc htb state UNKNOWN group default qlen 1000\n link/ether d6:2a:e2:f5:93:8f brd ff:ff:ff:ff:ff:ff\n inet6 fe80::d42a:e2ff:fef5:938f/64 scope link \n valid_lft forever preferred_lft forever\n5: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN group default qlen 1000\n link/ipip 0.0.0.0 brd 0.0.0.0\n6: gre0@NO",
"ip_monitor": "uid=2000",
"dhcp_log": "uid=2000\nRecently active IpClient logs:\nIpClient.wlan0\n IpClient.wlan0 APF dump:\n Capabilities: { apfVersionSupported: 6000, maximumApfProgramSize: 4096 }\n InstallableProgramSizeClamp: 2147483647\n--\n IpClient.wlan0 current ProvisioningConfiguration:\n ProvisioningConfiguration{mUniqueEui64AddressesOnly: false, mEnablePreconnection: false, mUsingMultinetworkPolicyTracker: true, mUsingIpReachabilityMonitor: true, mRequestedPreDhcpActionMs: 18000, mInitialConfig: null, mStaticIpConfig: null, mApfCapabilities: ApfCapabilities{version: 6000, maxSize: 4096, format: 1}, mProvisioningTimeoutMs: 18000, mIPv6AddrGenMode: 0, mNetwork: 201, mDisplayName: \"hudeWLAN-WPA3\", mCreatorUid:1000, mScanResultInfo: SSID: hudeWLAN-WPA3, BSSID: 72:a7:41:a3:0d:d7, Information Elements: {[ID: 0, [104, 117, 100, 101, 87, 76, 65, 78, 45, 87, 80, 65, 51]][ID: 1, [-110, 36, 72, 108]][ID: 3, [44]][ID: 48, [1, 0, 0, 15, -84, 4, 1, 0, 0, 15, -84, 4, 1, 0, 0, 15, -84, 8, -64, 0]][ID: 45, [-17, 9, 3, -1, -1, -1, -1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 24, 4, -121, 25, 0]][ID: 61, [44, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]][ID: 127, [0, 0, 8, -128, 0, 0, 0, 64, 0, 0, 0]][ID: 1",
"wifi_dump": "uid=2000\n rec[1]: time=08-01 08:47:26.310 processed=L2ConnectedState org=L3ConnectedState dest=<null> what=CMD_POST_DHCP_ACTION screen=on v4 v4r v4dns v6r\n rec[2]: time=08-01 08:47:26.315 processed=L2ConnectedState org=L3ConnectedState dest=<null> what=CMD_IPV4_PROVISIONING_SUCCESS screen=on DhcpResultsParcelable{baseConfiguration: IP address 10.13.102.124/24 Gateway 10.13.102.1 DNS servers: [ 10.13.102.1 ] Domains hudelist.local, leaseDuration: 600, mtu: 0, serverAddress: 10.13.102.1, vendorInfo: null, serverHostName: , captivePortalApiUrl: null}\n rec[3]: time=08-01 08:47:26.318 processed=ConnectableState org=L3ConnectedState dest=<null> what=CMD_UPDATE_LINKPROPERTIES screen=on 25 0 v4 v4r v4dns v6r\n rec[4]: time=08-01 08:48:05.444 processed=ConnectableState org=L3ConnectedState dest=<null> what=CMD_INSTALL_PACKET_FILTER screen=on len=1512\n--\n rec[20]: time=08-01 08:52:26.391 processed=L2ConnectedState org=L3ConnectedState dest=<null> what=CMD_POST_DHCP_ACTION screen=on v4 v4r v4dns v6r\n rec[21]: time=08-01 08:52:26.395 processed=L2ConnectedState org=L3ConnectedState dest=<null> what=CMD_IPV4_PROVISIONING_SUCCESS screen=on DhcpResultsParcelable{baseConfiguration: IP address 10.13"
},
"metrics": {
"commands_ok": 7,
"commands_total": 7,
"exec_path": "UserService"
}
}
],
"findings": [
{
"id": "b9c0e1b4-620c-4eab-a610-c5c3ffbbd3fa",
"code": "ipv6.no_icmp_path",
"category": "ipv6",
"severity": "low",
"confidence": "medium",
"network_ref": null,
"title": "No IPv6 ICMP path on any active network",
"description": "ICMPv6 echo got no reply on any active network — this network has no working IPv6 path (or filters ICMPv6).",
"evidence_refs": [
{
"test": "1c33bbc9-594c-49b8-8095-9b74b67d3a2a",
"pointer": null
}
],
"recommendation": null
}
],
"summary": {
"overall": "yellow",
"categories": {
"connectivity": {
"verdict": "green",
"worst_finding": null,
"tests_run": 5,
"tests_failed": 1
},
"dns": {
"verdict": "green",
"worst_finding": null,
"tests_run": 1,
"tests_failed": 0
},
"nat": {
"verdict": "green",
"worst_finding": null,
"tests_run": 1,
"tests_failed": 0
},
"ipv6": {
"verdict": "yellow",
"worst_finding": "b9c0e1b4-620c-4eab-a610-c5c3ffbbd3fa",
"tests_run": 0,
"tests_failed": 0
}
}
}
}