diff --git a/docs/build-status.md b/docs/build-status.md index 7081bae..3e43a0b 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -452,3 +452,29 @@ gained `POST /report`, `GET /reports`, `GET /report/`), shows the result f finishes itself — leaving the device as it was found. On upload failure it stays open so the error is visible. Grant permissions once via `adb shell pm grant app.echo_lot.app android.permission.ACCESS_FINE_LOCATION` so nothing blocks on a dialog. + +## App: router identification, brand icons, DEV build variant (2026-08-01) +**`link.ra_source` — who is advertising IPv6 here, and what box is it?** New app-tier probe +(registry addition). Identification chain, each step recorded as evidence so nothing is guessed: +1. RA source = next-hop of the `::/0` route per network (a `fe80::` link-local). +2. **MAC recovered from the modified-EUI-64 link-local** (strip `ff:fe`, flip the U/L bit) — + e.g. `fe80::7a9a:18ff:fe54:b8f9` → `78:9a:18:54:b8:f9`. RFC 7217/privacy addresses don't encode + a MAC and are reported as such rather than guessed. +3. Vendor via a curated OUI table (`Oui.kt` — SOHO/router vendors; unknown OUIs are printed + verbatim). Locally-administered (randomized) MACs are flagged. +4. **UPnP/SSDP M-SEARCH** → the gateway's `SERVER:` banner + device-description XML gives + manufacturer / model / friendly name. This is what usually names the exact box. +5. Reverse DNS for both gateways. +All SSDP responders are recorded (not just the gateway) so a rogue RA sender that isn't the +gateway can still be matched — and the MAC travels with every identity source, which is the hook +for the future LLDP / mDNS cross-matching. +UI: a "Router / IPv6 advertiser" panel above the network list, leading with the identified +vendor/model. + +**Icons + DEV variant.** The branding adaptive icon is now the app icon: `icon-adaptive-*.svg` +converted to Android vector drawables (SVG transform baked in, gradient background, monochrome +layer for themed icons) plus PNG mipmaps for legacy launchers. The **debug build is a separate +app**: `applicationIdSuffix .dev`, label "Echolot DEV", and a DEV-badged icon (layer-list = +production foreground + generated amber DEV ribbon) so it is unmistakable next to a real install +and both can be installed side by side. +NOTE for tooling: the dev package is `app.echo_lot.app.dev`, activity `app.echo_lot.app.MainActivity`. diff --git a/echolot-app/app/build.gradle.kts b/echolot-app/app/build.gradle.kts index f084067..33c25fa 100644 --- a/echolot-app/app/build.gradle.kts +++ b/echolot-app/app/build.gradle.kts @@ -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 diff --git a/echolot-app/app/src/debug/res/drawable-hdpi/ic_dev_badge.png b/echolot-app/app/src/debug/res/drawable-hdpi/ic_dev_badge.png new file mode 100644 index 0000000..f3d75fe Binary files /dev/null and b/echolot-app/app/src/debug/res/drawable-hdpi/ic_dev_badge.png differ diff --git a/echolot-app/app/src/debug/res/drawable-mdpi/ic_dev_badge.png b/echolot-app/app/src/debug/res/drawable-mdpi/ic_dev_badge.png new file mode 100644 index 0000000..63de300 Binary files /dev/null and b/echolot-app/app/src/debug/res/drawable-mdpi/ic_dev_badge.png differ diff --git a/echolot-app/app/src/debug/res/drawable-xhdpi/ic_dev_badge.png b/echolot-app/app/src/debug/res/drawable-xhdpi/ic_dev_badge.png new file mode 100644 index 0000000..2f6b748 Binary files /dev/null and b/echolot-app/app/src/debug/res/drawable-xhdpi/ic_dev_badge.png differ diff --git a/echolot-app/app/src/debug/res/drawable-xxhdpi/ic_dev_badge.png b/echolot-app/app/src/debug/res/drawable-xxhdpi/ic_dev_badge.png new file mode 100644 index 0000000..e18cd26 Binary files /dev/null and b/echolot-app/app/src/debug/res/drawable-xxhdpi/ic_dev_badge.png differ diff --git a/echolot-app/app/src/debug/res/drawable-xxxhdpi/ic_dev_badge.png b/echolot-app/app/src/debug/res/drawable-xxxhdpi/ic_dev_badge.png new file mode 100644 index 0000000..4b74edb Binary files /dev/null and b/echolot-app/app/src/debug/res/drawable-xxxhdpi/ic_dev_badge.png differ diff --git a/echolot-app/app/src/debug/res/drawable/ic_launcher_foreground_dev.xml b/echolot-app/app/src/debug/res/drawable/ic_launcher_foreground_dev.xml new file mode 100644 index 0000000..7b7f2a7 --- /dev/null +++ b/echolot-app/app/src/debug/res/drawable/ic_launcher_foreground_dev.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/echolot-app/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml b/echolot-app/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..be66051 --- /dev/null +++ b/echolot-app/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/echolot-app/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/echolot-app/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..be66051 --- /dev/null +++ b/echolot-app/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/echolot-app/app/src/debug/res/mipmap-hdpi/ic_launcher.png b/echolot-app/app/src/debug/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..d741c14 Binary files /dev/null and b/echolot-app/app/src/debug/res/mipmap-hdpi/ic_launcher.png differ diff --git a/echolot-app/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png b/echolot-app/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..d741c14 Binary files /dev/null and b/echolot-app/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/echolot-app/app/src/debug/res/mipmap-mdpi/ic_launcher.png b/echolot-app/app/src/debug/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..df2fe2b Binary files /dev/null and b/echolot-app/app/src/debug/res/mipmap-mdpi/ic_launcher.png differ diff --git a/echolot-app/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png b/echolot-app/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..df2fe2b Binary files /dev/null and b/echolot-app/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/echolot-app/app/src/debug/res/mipmap-xhdpi/ic_launcher.png b/echolot-app/app/src/debug/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..39c7b82 Binary files /dev/null and b/echolot-app/app/src/debug/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/echolot-app/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png b/echolot-app/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..39c7b82 Binary files /dev/null and b/echolot-app/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/echolot-app/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/echolot-app/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..058e3fd Binary files /dev/null and b/echolot-app/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/echolot-app/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png b/echolot-app/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..058e3fd Binary files /dev/null and b/echolot-app/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/echolot-app/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/echolot-app/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..19f836d Binary files /dev/null and b/echolot-app/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/echolot-app/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png b/echolot-app/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..19f836d Binary files /dev/null and b/echolot-app/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/echolot-app/app/src/debug/res/values/strings.xml b/echolot-app/app/src/debug/res/values/strings.xml new file mode 100644 index 0000000..79c5982 --- /dev/null +++ b/echolot-app/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Echolot DEV + diff --git a/echolot-app/app/src/main/AndroidManifest.xml b/echolot-app/app/src/main/AndroidManifest.xml index 91f394c..f1941bc 100644 --- a/echolot-app/app/src/main/AndroidManifest.xml +++ b/echolot-app/app/src/main/AndroidManifest.xml @@ -13,7 +13,9 @@ diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt index 242826c..becc9f5 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt @@ -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) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt index 4731468..c8ab7d4 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt @@ -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 = listOf( LinkSnapshotProbe(entries), + RouterIdentityProbe(entries), IcmpProbe(entries, v6 = false), IcmpProbe(entries, v6 = true), CaptivePortalProbe(entries), diff --git a/echolot-app/app/src/main/res/drawable/ic_launcher_background.xml b/echolot-app/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..aacffeb --- /dev/null +++ b/echolot-app/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/echolot-app/app/src/main/res/drawable/ic_launcher_foreground.xml b/echolot-app/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..736f111 --- /dev/null +++ b/echolot-app/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + diff --git a/echolot-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/echolot-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..93542a7 --- /dev/null +++ b/echolot-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/echolot-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/echolot-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..93542a7 --- /dev/null +++ b/echolot-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/echolot-app/app/src/main/res/mipmap-hdpi/ic_launcher.png b/echolot-app/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..1a55522 Binary files /dev/null and b/echolot-app/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/echolot-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/echolot-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..1a55522 Binary files /dev/null and b/echolot-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/echolot-app/app/src/main/res/mipmap-mdpi/ic_launcher.png b/echolot-app/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..62a85f6 Binary files /dev/null and b/echolot-app/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/echolot-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/echolot-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..62a85f6 Binary files /dev/null and b/echolot-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/echolot-app/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/echolot-app/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..7fd1f87 Binary files /dev/null and b/echolot-app/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/echolot-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/echolot-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..7fd1f87 Binary files /dev/null and b/echolot-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/echolot-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/echolot-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..bdc0fc4 Binary files /dev/null and b/echolot-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/echolot-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/echolot-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..bdc0fc4 Binary files /dev/null and b/echolot-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/echolot-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/echolot-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..68eafdd Binary files /dev/null and b/echolot-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/echolot-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/echolot-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..68eafdd Binary files /dev/null and b/echolot-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/echolot-app/app/src/main/res/values/strings.xml b/echolot-app/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..4b89fa3 --- /dev/null +++ b/echolot-app/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Echolot + diff --git a/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Test.kt b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Test.kt index eccec5b..c26f202 100644 --- a/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Test.kt +++ b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Test.kt @@ -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 diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt index eccec5b..c26f202 100644 --- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt @@ -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 diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/Oui.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/Oui.kt new file mode 100644 index 0000000..5d52b12 --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/Oui.kt @@ -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 = 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 + } +} diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/RouterIdentityProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/RouterIdentityProbe.kt new file mode 100644 index 0000000..0423281 --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/RouterIdentityProbe.kt @@ -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) : 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 { + val out = LinkedHashMap() + 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>(.*?)", RegexOption.DOT_MATCHES_ALL) + .find(xml)?.groupValues?.get(1)?.trim() ?: "" +}