app: link.ra_source router identification + brand icons + DEV build variant
link.ra_source answers "who advertises IPv6 here, and which box is it": RA source per network, MAC recovered from the modified-EUI-64 link-local (privacy addresses reported as such, not guessed), vendor via a curated OUI table, UPnP/SSDP M-SEARCH for the gateway's server banner + device description (manufacturer/model/friendly name), and reverse DNS. All SSDP responders are recorded so a rogue RA sender that isn't the gateway can still be matched; the MAC accompanies every identity source as the hook for future LLDP/mDNS cross-matching. UI gains a "Router / IPv6 advertiser" panel. Icons: branding adaptive icon converted to vector drawables (+ PNG mipmaps, monochrome layer). The debug build is now a separate app — applicationIdSuffix .dev, label "Echolot DEV", DEV-badged icon — so it installs alongside a production build and can't be confused with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dc094d1631
commit
38f8036252
@@ -0,0 +1,62 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.probe
|
||||
|
||||
/**
|
||||
* Minimal OUI → vendor lookup for identifying gateways/routers from a MAC address.
|
||||
*
|
||||
* Deliberately a small curated table rather than the full IEEE registry (~35k entries, ~1.5 MB):
|
||||
* the goal is naming the box that routes a home/office LAN, and consumer/SOHO gear concentrates
|
||||
* in a handful of vendors. An unknown OUI is reported verbatim so it is never silently wrong —
|
||||
* and the SSDP/UPnP identity in [RouterIdentityProbe] usually names the exact model anyway.
|
||||
*/
|
||||
object Oui {
|
||||
|
||||
private val table: Map<String, String> = mapOf(
|
||||
// AVM (FRITZ!Box) — dominant in DE/AT
|
||||
"00:04:0E" to "AVM", "38:10:D5" to "AVM", "5C:49:79" to "AVM", "C8:0E:14" to "AVM",
|
||||
"3C:A6:2F" to "AVM", "9C:C7:A6" to "AVM", "E0:28:6D" to "AVM", "24:65:11" to "AVM",
|
||||
// Ubiquiti
|
||||
"00:15:6D" to "Ubiquiti", "04:18:D6" to "Ubiquiti", "24:5A:4C" to "Ubiquiti",
|
||||
"44:D9:E7" to "Ubiquiti", "68:72:51" to "Ubiquiti", "78:8A:20" to "Ubiquiti",
|
||||
"74:AC:B9" to "Ubiquiti", "F0:9F:C2" to "Ubiquiti", "B4:FB:E4" to "Ubiquiti",
|
||||
"E0:63:DA" to "Ubiquiti", "78:45:58" to "Ubiquiti", "AC:8B:A9" to "Ubiquiti",
|
||||
// MikroTik
|
||||
"00:0C:42" to "MikroTik", "4C:5E:0C" to "MikroTik", "6C:3B:6B" to "MikroTik",
|
||||
"48:8F:5A" to "MikroTik", "2C:C8:1B" to "MikroTik", "DC:2C:6E" to "MikroTik",
|
||||
// TP-Link
|
||||
"00:1D:0F" to "TP-Link", "14:CC:20" to "TP-Link", "50:C7:BF" to "TP-Link",
|
||||
"A4:2B:B0" to "TP-Link", "C0:06:C3" to "TP-Link", "EC:08:6B" to "TP-Link",
|
||||
// Netgear
|
||||
"00:09:5B" to "Netgear", "20:4E:7F" to "Netgear", "A0:40:A0" to "Netgear",
|
||||
"C4:04:15" to "Netgear", "9C:3D:CF" to "Netgear",
|
||||
// ASUS
|
||||
"00:1B:FC" to "ASUS", "2C:56:DC" to "ASUS", "50:46:5D" to "ASUS", "AC:9E:17" to "ASUS",
|
||||
"04:D9:F5" to "ASUS", "1C:B7:2C" to "ASUS",
|
||||
// Cisco / Meraki
|
||||
"00:1A:2F" to "Cisco", "00:26:99" to "Cisco", "E0:CB:BC" to "Cisco",
|
||||
"00:18:0A" to "Cisco Meraki", "88:15:44" to "Cisco Meraki", "E0:55:3D" to "Cisco Meraki",
|
||||
// Zyxel / Draytek / Huawei / ZTE
|
||||
"00:13:49" to "Zyxel", "5C:F4:AB" to "Zyxel", "00:1D:AA" to "DrayTek",
|
||||
"00:E0:FC" to "Huawei", "48:46:FB" to "Huawei", "00:1E:73" to "ZTE",
|
||||
// AVM-adjacent ISP CPE / others common on consumer LANs
|
||||
"00:17:3F" to "Belkin", "B8:27:EB" to "Raspberry Pi", "DC:A6:32" to "Raspberry Pi",
|
||||
"E4:5F:01" to "Raspberry Pi", "00:50:56" to "VMware", "52:54:00" to "QEMU/KVM",
|
||||
"18:E8:29" to "Ubiquiti", "70:A7:41" to "Ubiquiti",
|
||||
)
|
||||
|
||||
/** Vendor for a MAC, or null when the OUI isn't in the curated table. */
|
||||
fun vendor(mac: String): String? {
|
||||
val norm = mac.uppercase().replace('-', ':').trim()
|
||||
if (norm.length < 8) return null
|
||||
return table[norm.substring(0, 8)]
|
||||
}
|
||||
|
||||
/** True for a locally-administered (often randomized) MAC — not a real vendor identity. */
|
||||
fun isLocallyAdministered(mac: String): Boolean {
|
||||
val first = mac.replace('-', ':').split(':').firstOrNull() ?: return false
|
||||
val b = first.toIntOrNull(16) ?: return false
|
||||
return (b and 0x02) != 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// 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 kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* link.ra_source — identifies **who is advertising IPv6 on this network** (and the IPv4 gateway),
|
||||
* with as much attribution as an unprivileged app can gather.
|
||||
*
|
||||
* Why it matters: a rogue or misconfigured RA sender is one of the most common causes of broken
|
||||
* IPv6, and "some router advertises a default route" is useless without knowing *which box*.
|
||||
*
|
||||
* Identification chain, best-effort and each step recorded as evidence:
|
||||
* 1. **RA source** — the next-hop of the `::/0` route (a link-local `fe80::` address) per network.
|
||||
* 2. **MAC from EUI-64** — a link-local formed the classic way encodes the sender's MAC
|
||||
* (`fe80::7a9a:18ff:fe54:b8f9` → `78:9a:18:54:b8:f9`): strip `ff:fe` from the middle and flip
|
||||
* the U/L bit. Privacy/stable-private addresses (RFC 7217) don't encode it — reported as such
|
||||
* rather than guessed.
|
||||
* 3. **Vendor** — OUI lookup on that MAC ([Oui]).
|
||||
* 4. **UPnP/SSDP** — an M-SEARCH usually gets the router itself to answer with a `SERVER:` banner
|
||||
* and a device-description URL; fetching it yields manufacturer / model / friendly name. This
|
||||
* is what usually pins down the exact box.
|
||||
* 5. **Reverse DNS** for the gateway addresses.
|
||||
*
|
||||
* Future cross-matching (same MAC seen via LLDP or in an SSDP/mDNS inventory) is why the MAC is
|
||||
* always reported alongside every identity source.
|
||||
*/
|
||||
class RouterIdentityProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
|
||||
override val type = TestType.LINK_RA_SOURCE
|
||||
override val tier = Tier.APP
|
||||
|
||||
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||
val b = TestBuilder(type, tier, ids)
|
||||
val ssdp = ssdpDiscover() // ip -> (server banner, description url)
|
||||
var raSenders = 0
|
||||
|
||||
val evidence: JsonObject = buildJsonObject {
|
||||
putJsonArray("networks") {
|
||||
for (e in entries) {
|
||||
val n = e.model
|
||||
val v6Gw = n.link.routes.firstOrNull { it.dst == "::/0" }?.gateway
|
||||
val v4Gw = n.link.routes.firstOrNull { it.dst == "0.0.0.0/0" }?.gateway
|
||||
addJsonObject {
|
||||
put("network", "${n.transport.name.lowercase()}:${n.id}")
|
||||
put("interface", n.iface ?: "")
|
||||
|
||||
// --- IPv6 RA sender ---
|
||||
put("ra_source", v6Gw ?: "(none — no IPv6 default route)")
|
||||
if (v6Gw != null) {
|
||||
raSenders++
|
||||
val mac = macFromEui64LinkLocal(v6Gw)
|
||||
put("ra_source_mac", mac ?: "(not EUI-64 — privacy/RFC 7217 address)")
|
||||
if (mac != null) {
|
||||
put("ra_source_vendor", Oui.vendor(mac) ?: "unknown OUI ${mac.take(8)}")
|
||||
put("ra_source_mac_locally_administered", Oui.isLocallyAdministered(mac))
|
||||
}
|
||||
put("ra_source_reverse_dns", reverseDns(v6Gw))
|
||||
}
|
||||
|
||||
// --- IPv4 gateway (usually the same box) ---
|
||||
put("v4_gateway", v4Gw ?: "(none)")
|
||||
if (v4Gw != null) {
|
||||
put("v4_gateway_reverse_dns", reverseDns(v4Gw))
|
||||
ssdp[v4Gw]?.let { s ->
|
||||
put("upnp_server", s.server)
|
||||
put("upnp_location", s.location)
|
||||
s.details?.let { d ->
|
||||
put("upnp_manufacturer", d.manufacturer)
|
||||
put("upnp_model", d.model)
|
||||
put("upnp_friendly_name", d.friendlyName)
|
||||
}
|
||||
} ?: put("upnp", "no UPnP/SSDP response from the gateway")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every SSDP responder, so a rogue RA sender that is not the gateway can still be
|
||||
// matched later (by IP now, by MAC once LLDP/mDNS inventories land).
|
||||
putJsonArray("ssdp_responders") {
|
||||
for ((ip, s) in ssdp) addJsonObject {
|
||||
put("ip", ip); put("server", s.server); put("location", s.location)
|
||||
s.details?.let {
|
||||
put("manufacturer", it.manufacturer); put("model", it.model)
|
||||
put("friendly_name", it.friendlyName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val metrics = buildJsonObject {
|
||||
put("ra_senders", raSenders)
|
||||
put("ssdp_responders", ssdp.size)
|
||||
}
|
||||
val status = if (entries.isEmpty()) TestStatus.FAILED else TestStatus.OK
|
||||
b.build(status, evidence = evidence, metrics = metrics)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovers the sender MAC from a modified-EUI-64 link-local address. The middle `ff:fe` marker
|
||||
* must be present, and bit 1 of the first byte (U/L) is inverted back.
|
||||
*/
|
||||
private fun macFromEui64LinkLocal(addr: String): String? {
|
||||
val bytes = runCatching { InetAddress.getByName(addr.substringBefore('%')).address }.getOrNull()
|
||||
?: return null
|
||||
if (bytes.size != 16) return null
|
||||
// fe80::/10 with EUI-64: bytes 11,12 are 0xFF,0xFE
|
||||
if ((bytes[11].toInt() and 0xFF) != 0xFF || (bytes[12].toInt() and 0xFF) != 0xFE) return null
|
||||
val mac = byteArrayOf(
|
||||
(bytes[8].toInt() xor 0x02).toByte(), bytes[9], bytes[10],
|
||||
bytes[13], bytes[14], bytes[15],
|
||||
)
|
||||
return mac.joinToString(":") { "%02X".format(it) }
|
||||
}
|
||||
|
||||
private fun reverseDns(ip: String): String = runCatching {
|
||||
val clean = ip.substringBefore('%')
|
||||
val host = InetAddress.getByName(clean).canonicalHostName
|
||||
if (host == clean) "(none)" else host
|
||||
}.getOrDefault("(none)")
|
||||
|
||||
private data class Ssdp(val server: String, val location: String, val details: Upnp?)
|
||||
private data class Upnp(val manufacturer: String, val model: String, val friendlyName: String)
|
||||
|
||||
/** SSDP M-SEARCH for the InternetGatewayDevice + root devices; returns responder IP -> identity. */
|
||||
private fun ssdpDiscover(): Map<String, Ssdp> {
|
||||
val out = LinkedHashMap<String, Ssdp>()
|
||||
val targets = listOf("urn:schemas-upnp-org:device:InternetGatewayDevice:1", "upnp:rootdevice")
|
||||
runCatching {
|
||||
DatagramSocket().use { sock ->
|
||||
sock.soTimeout = 2500
|
||||
sock.broadcast = true
|
||||
for (st in targets) {
|
||||
val msg = ("M-SEARCH * HTTP/1.1\r\n" +
|
||||
"HOST: 239.255.255.250:1900\r\n" +
|
||||
"MAN: \"ssdp:discover\"\r\n" +
|
||||
"MX: 2\r\nST: $st\r\n\r\n").toByteArray()
|
||||
sock.send(
|
||||
DatagramPacket(msg, msg.size, InetSocketAddress("239.255.255.250", 1900))
|
||||
)
|
||||
}
|
||||
val deadline = System.currentTimeMillis() + 3000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val buf = ByteArray(2048)
|
||||
val dp = DatagramPacket(buf, buf.size)
|
||||
try {
|
||||
sock.receive(dp)
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
break
|
||||
}
|
||||
val ip = dp.address?.hostAddress ?: continue
|
||||
if (out.containsKey(ip)) continue
|
||||
val text = String(buf, 0, dp.length)
|
||||
val server = header(text, "SERVER") ?: ""
|
||||
val location = header(text, "LOCATION") ?: ""
|
||||
out[ip] = Ssdp(server, location, location.takeIf { it.isNotBlank() }?.let(::fetchUpnp))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun header(msg: String, name: String): String? =
|
||||
msg.lineSequence().firstOrNull { it.startsWith("$name:", ignoreCase = true) }
|
||||
?.substringAfter(':')?.trim()
|
||||
|
||||
/** Fetches the UPnP device description and pulls the identifying fields. */
|
||||
private fun fetchUpnp(location: String): Upnp? = runCatching {
|
||||
val conn = (URL(location).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 2500; readTimeout = 2500; requestMethod = "GET"
|
||||
}
|
||||
val xml = conn.inputStream.bufferedReader().use { it.readText().take(20_000) }
|
||||
conn.disconnect()
|
||||
Upnp(
|
||||
manufacturer = tag(xml, "manufacturer"),
|
||||
model = listOf(tag(xml, "modelName"), tag(xml, "modelNumber"))
|
||||
.filter { it.isNotBlank() }.joinToString(" "),
|
||||
friendlyName = tag(xml, "friendlyName"),
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
private fun tag(xml: String, name: String): String =
|
||||
Regex("<$name>(.*?)</$name>", RegexOption.DOT_MATCHES_ALL)
|
||||
.find(xml)?.groupValues?.get(1)?.trim() ?: ""
|
||||
}
|
||||
Reference in New Issue
Block a user