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
+6
View File
@@ -25,6 +25,12 @@ android {
}
buildTypes {
release { isMinifyEnabled = false }
debug {
// The dev build is a separate app: own package (installs alongside a real
// Echolot), own label and a DEV-badged icon (src/debug/res).
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later
Debug foreground: the production mark with a DEV ribbon so the dev build is
unmistakable on the launcher next to a real install. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_launcher_foreground"/>
<item android:drawable="@drawable/ic_dev_badge"/>
</layer-list>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground_dev"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground_dev"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Echolot DEV</string>
</resources>
+3 -1
View File
@@ -13,7 +13,9 @@
<application
android:allowBackup="false"
android:label="Echolot"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Echolot">
@@ -138,6 +138,8 @@ private fun Results(doc: MeasurementDocument) {
FlowCategories(summary.categories)
}
RouterPanel(doc)
SectionTitle("Networks (${doc.networks.size})")
for (n in doc.networks) {
Text("${n.transport.name.lowercase()} ${n.iface ?: ""}" +
@@ -175,6 +177,53 @@ private fun Results(doc: MeasurementDocument) {
}
}
/**
* Who advertises IPv6 here, and what box is it? Pulled from the link.ra_source evidence and shown
* up front — a rogue/misconfigured RA sender is a top cause of broken IPv6, and "some router" is
* not actionable without an identity.
*/
@Composable
private fun RouterPanel(doc: MeasurementDocument) {
val test = doc.tests.firstOrNull { it.type == TestType.LINK_RA_SOURCE } ?: return
val nets = (test.evidence?.get("networks") as? kotlinx.serialization.json.JsonArray) ?: return
if (nets.isEmpty()) return
SectionTitle("Router / IPv6 advertiser")
for (el in nets) {
val o = el as? kotlinx.serialization.json.JsonObject ?: continue
fun f(k: String): String? =
(o[k] as? kotlinx.serialization.json.JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
val ra = f("ra_source")
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(f("network") ?: "network", fontWeight = FontWeight.Medium)
// The identity line: vendor/model if we could pin it down.
val identity = listOfNotNull(
f("upnp_manufacturer"), f("upnp_model"), f("ra_source_vendor"),
).distinct().joinToString(" · ").ifBlank { null }
identity?.let {
Text(it, fontWeight = FontWeight.SemiBold, color = Color(0xFF35E0C4), fontSize = 15.sp)
}
f("upnp_friendly_name")?.let { Row0("name", it) }
ra?.let { Row0("RA source", it) }
f("ra_source_mac")?.let { Row0("RA source MAC", it) }
f("ra_source_reverse_dns")?.takeIf { it != "(none)" }?.let { Row0("reverse DNS", it) }
f("v4_gateway")?.let { Row0("IPv4 gateway", it) }
f("v4_gateway_reverse_dns")?.takeIf { it != "(none)" }?.let { Row0("gateway rDNS", it) }
f("upnp_server")?.let { Row0("UPnP server", it) }
}
}
}
}
@Composable
private fun Row0(label: String, value: String) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("$label:", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, fontSize = 12.sp, fontFamily = FontFamily.Monospace)
}
}
@Composable
private fun FlowCategories(categories: Map<String, CategorySummary>) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -19,6 +19,7 @@ import app.echo_lot.probe.StunProbe
import app.echo_lot.probe.NetworkInventory
import app.echo_lot.probe.Probe
import app.echo_lot.probe.ProbeIds
import app.echo_lot.probe.RouterIdentityProbe
import app.echo_lot.shizuku.ShizukuProbe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -82,6 +83,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
val probes: List<Probe> = listOf(
LinkSnapshotProbe(entries),
RouterIdentityProbe(entries),
IcmpProbe(entries, v6 = false),
IcmpProbe(entries, v6 = true),
CaptivePortalProbe(entries),
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later
Adaptive-icon background: the brand "tile" gradient (assets/branding). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:pathData="M0,0h108v108h-108z">
<aapt:attr name="android:fillColor">
<gradient android:startX="54" android:startY="0" android:endX="54" android:endY="108"
android:type="linear">
<item android:offset="0" android:color="#FF0E2433"/>
<item android:offset="1" android:color="#FF071522"/>
</gradient>
</aapt:attr>
</path>
</vector>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later
Adaptive-icon foreground: the Focus mark (assets/branding/icon-adaptive-foreground.svg),
SVG transform baked in so the art sits inside the 66dp safe circle. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M51.92,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,54.0 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,54.0 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M51.92,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:strokeColor="#FFB454" android:strokeAlpha="0.3" android:strokeWidth="1.6" android:fillColor="#00000000" android:pathData="M47.6,54.0 a6.4,6.4 0 1,0 12.8,0 a6.4,6.4 0 1,0 -12.8,0"/>
<path android:fillColor="#FFFFB454" android:pathData="M50.4,54.0 a3.6,3.6 0 1,0 7.2,0 a3.6,3.6 0 1,0 -7.2,0"/>
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M42.0,48.4 V42.0 H48.4"/>
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M59.6,42.0 H66.0 V48.4"/>
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M66.0,59.6 V66.0 H59.6"/>
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M48.4,66.0 H42.0 V59.6"/>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Echolot</string>
</resources>
@@ -55,6 +55,8 @@ object TestType {
const val LINK_SNAPSHOT = "link.snapshot"
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
const val LINK_IP_MONITOR = "link.ip_monitor"
/** Who advertises IPv6 on this link (+ gateway identity). Registry addition, v1.1. */
const val LINK_RA_SOURCE = "link.ra_source"
// net — connectivity validation (reproduces Android's NetworkMonitor generate_204 checks)
const val NET_CAPTIVE_PORTAL = "net.captive_portal"
// icmp
@@ -55,6 +55,8 @@ object TestType {
const val LINK_SNAPSHOT = "link.snapshot"
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
const val LINK_IP_MONITOR = "link.ip_monitor"
/** Who advertises IPv6 on this link (+ gateway identity). Registry addition, v1.1. */
const val LINK_RA_SOURCE = "link.ra_source"
// net — connectivity validation (reproduces Android's NetworkMonitor generate_204 checks)
const val NET_CAPTIVE_PORTAL = "net.captive_portal"
// icmp
@@ -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() ?: ""
}