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:
mrambossek
2026-08-01 09:35:54 +02:00
co-authored by Claude Opus 5
parent dc094d1631
commit 38f8036252
43 changed files with 432 additions and 1 deletions
@@ -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() ?: ""
}