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>
@@ -452,3 +452,29 @@ gained `POST /report`, `GET /reports`, `GET /report/<name>`), shows the result f
|
|||||||
finishes itself — leaving the device as it was found. On upload failure it stays open so the
|
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
|
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.
|
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`.
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ android {
|
|||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release { isMinifyEnabled = false }
|
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 {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 948 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
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>
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
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:supportsRtl="true"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.Echolot">
|
android:theme="@style/Theme.Echolot">
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ private fun Results(doc: MeasurementDocument) {
|
|||||||
FlowCategories(summary.categories)
|
FlowCategories(summary.categories)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RouterPanel(doc)
|
||||||
|
|
||||||
SectionTitle("Networks (${doc.networks.size})")
|
SectionTitle("Networks (${doc.networks.size})")
|
||||||
for (n in doc.networks) {
|
for (n in doc.networks) {
|
||||||
Text("• ${n.transport.name.lowercase()} ${n.iface ?: ""} — " +
|
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
|
@Composable
|
||||||
private fun FlowCategories(categories: Map<String, CategorySummary>) {
|
private fun FlowCategories(categories: Map<String, CategorySummary>) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
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.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
|
||||||
|
import app.echo_lot.probe.RouterIdentityProbe
|
||||||
import app.echo_lot.shizuku.ShizukuProbe
|
import app.echo_lot.shizuku.ShizukuProbe
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -82,6 +83,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
|
|
||||||
val probes: List<Probe> = listOf(
|
val probes: List<Probe> = listOf(
|
||||||
LinkSnapshotProbe(entries),
|
LinkSnapshotProbe(entries),
|
||||||
|
RouterIdentityProbe(entries),
|
||||||
IcmpProbe(entries, v6 = false),
|
IcmpProbe(entries, v6 = false),
|
||||||
IcmpProbe(entries, v6 = true),
|
IcmpProbe(entries, v6 = true),
|
||||||
CaptivePortalProbe(entries),
|
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>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
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_SNAPSHOT = "link.snapshot"
|
||||||
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
|
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
|
||||||
const val LINK_IP_MONITOR = "link.ip_monitor"
|
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)
|
// net — connectivity validation (reproduces Android's NetworkMonitor generate_204 checks)
|
||||||
const val NET_CAPTIVE_PORTAL = "net.captive_portal"
|
const val NET_CAPTIVE_PORTAL = "net.captive_portal"
|
||||||
// icmp
|
// icmp
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ object TestType {
|
|||||||
const val LINK_SNAPSHOT = "link.snapshot"
|
const val LINK_SNAPSHOT = "link.snapshot"
|
||||||
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
|
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
|
||||||
const val LINK_IP_MONITOR = "link.ip_monitor"
|
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)
|
// net — connectivity validation (reproduces Android's NetworkMonitor generate_204 checks)
|
||||||
const val NET_CAPTIVE_PORTAL = "net.captive_portal"
|
const val NET_CAPTIVE_PORTAL = "net.captive_portal"
|
||||||
// icmp
|
// 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() ?: ""
|
||||||
|
}
|
||||||