Compare commits

...
Author SHA1 Message Date
mrambossekandClaude Opus 5 9ee7d554a6 app: listen to what the segment says unprompted
server-release / image (push) Successful in 14s
server-release / release (push) Successful in 31s
Four passive collectors join long mode: SSDP (passive NOTIFY plus paced
M-SEARCH from the capture socket, so unicast replies land in the same
capture), LLMNR, NetBIOS-NS and WS-Discovery. All are periodic and sparse
by nature, which is exactly why they belong to a window rather than a
probe - a thirty-second run mostly hears silence and would report an
empty network as confidently as a quiet one.

NetBIOS reports unsupported on the app tier and says why: UDP 137 is
privileged. The decoder and evidence shape are tested and waiting for the
Shizuku tier; a recorded reason beats a missing test.

Silence without a multicast lock or a group join is PARTIAL, never OK -
that case is a fact about this app, not about the network. Hostnames,
banners and device UUIDs are classified in core-privacy so the anonymizer
treats them like every other identifier rather than letting a neighbour's
device model ride out in an upload.

No active WSD Probe and no NBSTAT sweep: the app listens to what a
network broadcasts, it does not announce itself to strangers or
interrogate its neighbours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:58:41 +02:00
mrambossekandClaude Opus 5 93125a4b1d app: 0.3.0 — run modes and the dev relay
server-test / test (push) Successful in 37s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:46:25 +02:00
mrambossekandClaude Opus 5 0071e00003 app: long runs that watch, and a relay for the port that keeps moving
Long mode starts listeners at t=0 and keeps them running past the
battery: a network-change watcher that finally fills networks[].changes[]
(defined since the schema's first draft, never populated), an RSSI log, a
ping series giving loss and jitter over minutes, and mDNS listening for
the whole window. This is the class of fault a short run cannot see - a
link that drops for four seconds between two probes is reported healthy
by both of them. run.mode records which question was asked, because
silence means different things in the two modes.

The adb relay replaces the retired beacon: AdbRelay watches adbd's own
mDNS with the resolve-once discipline the beacon learned the hard way
(resolving re-arms adbd and pops a notification), a foreground service
keeps it alive with the screen off, and the heartbeat re-posts the cached
endpoint rather than re-resolving. It exists because mDNS does not cross
subnets and the wireless-debug port rotates every few minutes.

Also records why LLDP/CDP cannot follow SSDP into long mode: both are raw
L2 frames, so they need CAP_NET_RAW - root tier, not app, and Shizuku's
shell user does not have it either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:43:33 +02:00
mrambossekandClaude Opus 5 ae63bd7c7f server: relay a test device's adb endpoint, because mDNS does not cross subnets
The beacon this replaces was a separate service wildcard-bound to
0.0.0.0:443 - it silently occupied port 443 on the reserved measurement
addresses, voiding the IPv4 interception proof for as long as it ran, and
it accepted a port report from anyone who could reach it. So this lives
where the repo's own post-mortem said it belongs: POST on the control
plane authenticated by the device credential, GET on the admin UI behind
the existing apiAdmin helper. No new listener, no new port, no wildcard.

Entries expire after 24h (ECHOLOT_ADB_ENDPOINT_RETENTION_H) on both write
and read - a LAN address is a breadcrumb for driving a test device, not
measurement data worth keeping.

Also records the BLE peer-comparison design: the case for it is that BLE
is out-of-band, which is what makes client isolation measurable at all -
silence over IP cannot distinguish an isolating AP from an absent peer,
and a peer confirming out-of-band that it was listening turns that
silence into proof.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:31:01 +02:00
mrambossekandClaude Opus 5 ab6e278272 app: an IMS network is not a blocked measurement
rmnet_data1 on the OnePlus is the carrier's IMS/VoLTE network: it carries
IMS&MMTEL but neither INTERNET nor NOT_RESTRICTED, so binding needs
CONNECTIVITY_USE_RESTRICTED_NETWORKS - signature-level, unobtainable.
EPERM there is permanent and says nothing about any network's health, but
the constraint detector counted it, so a phone with VoLTE reported
'measurement blocked' on every run and every verdict came out
INCONCLUSIVE. Capabilities now decide: networks an app may never bind are
recorded as app_usable:false in networks[] and skipped, rather than
reported as something the run failed to do.

Also retires the 'while it tears down' wording, which was a guess that
turned out to be wrong - the refusal outlasts the VPN indefinitely, so
the text now names the VPN as the usual cause without asserting a
timeline it cannot know.

App version 0.2.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:11:08 +02:00
mrambossekandClaude Opus 5 d420a71a01 docs: v0.11.3 live on fmr, trains validated 120/120; two mint bugs logged
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:04:46 +02:00
mrambossekandClaude Opus 5 d621611bdd docs: the v0.11.2 mystery was an unstamped-tag problem, not lost code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:54:42 +02:00
50 changed files with 4638 additions and 134 deletions
+120
View File
@@ -1367,3 +1367,123 @@ Consequences until resolved:
- The unpushed v0.10v0.11 work needs to be found and pushed from whichever machine built it,
or the deployed binary's provenance re-established some other way, before the release channel
can be trusted again.
**Resolved same day.** The binary itself settled it: `go version -m` on the deployed executable
shows `vcs.revision=d5b1bab` — a commit on this repo's master — built 2026-08-02 08:36 UTC with a
hand-stamped `-X main.Version=v0.11.2` and a dirty tree (`vcs.modified=true`, the then-uncommitted
schema doc). An earlier session stamped release numbers ahead of the tag line; no code was ever
missing. Current master is tagged and released as **server-v0.11.3** (signed), restoring a
monotonic, tag-backed lineage above the deployed number. The rule going forward: **the version a
binary is stamped with must be a pushed `server-v*` tag** — an ad-hoc stamp above the tag line
poisons `/releases/latest` for the string-comparing updater the moment anyone tags honestly again.
The stale `server-v0.9.2` release (same code lineage, wrong number, created during the confusion)
remains in Gitea but is harmless now that v0.11.3 outranks it as latest.
## LLDP and CDP are root-tier, and that is a hard boundary (2026-08-02)
Asked for alongside SSDP in long mode; they belong to a different tier and no amount of app-side
cleverness moves them. LLDP is an EtherType `0x88CC` frame to `01:80:C2:00:00:0E`; CDP is an
LLC/SNAP frame to `01:00:0C:CC:CC:CC`. Neither is IP, so neither is ever delivered to a socket an
app can open — receiving them needs `AF_PACKET` with `CAP_NET_RAW`, which is root. Shizuku does
not bridge this either: the ADB shell user (uid 2000) has no `CAP_NET_RAW`, and stock devices do
not ship `tcpdump`. Android's unprivileged ICMP sockets are what make `icmp.ping4` work without
root; there is no equivalent back door for raw L2 receive.
Worth building in the root module when it lands, because the payoff is large: LLDP names the
switch, the port and the VLAN a device is attached to, which is the best available answer to
"where in this building am I actually plugged in", and CDP does the same on Cisco gear. Until
then they are recorded as absent capabilities rather than left to look unimplemented.
What IS reachable at app tier, and what long mode now listens for instead: SSDP (passive NOTIFY
plus periodic M-SEARCH), LLMNR, NetBIOS-NS and WS-Discovery — all IP multicast/broadcast, all
sockets an app may open. The security reading matters as much as the inventory: LLMNR and
NetBIOS-NS being live on a segment is a finding in itself, since both are trivially spoofable.
**One of those four cannot run at app tier either, and says so.** `local.netbios_inventory`
reports `unsupported` with the bind error attached: UDP 137 is below 1024, and Android reserves
privileged ports exactly like any other Linux. The decoder, the evidence shape and the registry
id are built and tested, waiting for the Shizuku tier to supply a socket. Recorded as a result
rather than dropped, so nobody later reads its absence as an oversight.
Two deliberate restraints in that work, both worth keeping: no active WS-Discovery Probe (an
M-SEARCH is traffic every SSDP device expects constantly, whereas a WSD Probe from an unknown
host announces *this* device to the segment), and no NBSTAT sweep (that is host scanning, not
measurement — the app listens to what a network broadcasts, it does not interrogate its
neighbours). The parsers are also hardened against the input they will actually meet: a DNS
compression pointer is refused rather than followed (the classic parser hang), and the WSD
extractor is string-based on purpose, tested against an entity bomb and 20 000-deep nesting.
## Design note: what BLE between two devices is actually for (2026-08-02, not built)
Two or more phones running Echolot, talking over Bluetooth LE. The schema already anticipates
this — `Trigger.PEER` and the whole `peer.*` test family (`peer.reachability`, `peer.isolation`,
`peer.multicast`, `peer.lan_train`, `peer.lease_diff`) are in the registry, unused — and the
prober measured `peer.ble_advertise` **SUPPORTED on both known devices**, so the mechanism is
proven; what has been missing is a reason that beats "use the server".
**The reason is that BLE is out-of-band.** Everything else this app does depends on the network
under test being at least partly functional. A second device reachable over a radio that shares
nothing with the wifi turns several measurements from ambiguous into conclusive:
1. **Client isolation becomes measurable at all.** Today, "I sent a packet to the peer and heard
nothing" cannot distinguish AP client isolation from the peer being asleep, gone, or on a
different VLAN — the failure mode is silence, and silence has too many parents. With BLE the
peer confirms out-of-band that it was listening on address X at time T, so silence over IP
becomes *proof* of isolation rather than a guess. This is the single strongest argument for
the feature, and it mirrors the rule this project keeps rediscovering: a measurement that
cannot separate "nothing happened" from "nothing was tried" is not a measurement.
2. **Differential diagnosis: the network or this phone?** Two devices on the same SSID, one
resolving DNS and one not, settles in seconds what a single device cannot settle at all —
and it is the same distinction `system_verdict` exists to draw, only with a second opinion
instead of Android's. Natural finding: *this device fails where a peer on the same link
succeeds* → look at the device (private DNS, ad blocker, per-client router rule, MAC
randomization), not the router.
3. **Two DHCP servers on one L2**, the classic invisible fault: peers compare lease source,
subnet and gateway (`peer.lease_diff`). Disagreement is conclusive and needs no server.
4. **Coverage and roaming**, later: several devices sampling RSSI in different rooms, exchanging
summaries over BLE, gives a picture no single device standing in one place can produce.
**What crosses the link is a summary, never the document.** A measurement document describes
someone's home network in detail; broadcasting it to whoever is nearby would betray the whole
posture of §8. The peer payload should be: a *hashed* network identity (so two devices can agree
they are on the same L2 without either putting the SSID/BSSID on the air in the clear), the §7.3
category verdicts, the finding codes, and an IP endpoint plus a one-shot nonce for the LAN tests.
Findings and verdicts are already the interpretation layer — exactly the right granularity to
share.
**Privacy constraints, which are not optional here.** A BLE advertiser is a tracking beacon: it
must be user-initiated, time-boxed to the run, carry no identifier that is stable across runs
(the resolvable-private-address default plus a per-session ephemeral id), and pair by a code the
two humans can see. "Discoverable by default" would make this app a worse citizen than the
networks it audits.
**Deliberately not doing:** clock synchronisation over BLE. GATT latency is jitter measured in
tens of milliseconds, which is the same order as the one-way delays worth measuring; peers should
sync against the server's `time.server_offset` and use BLE only to correlate run ids. Nor should
BLE become a transport for uploads — it is a *comparison* channel.
Staging when it happens: `peer.isolation` first (highest value, needs only advertise + connect +
a nonce exchange), then `peer.lease_diff` (pure summary comparison, no extra plumbing), then the
rest. Needs `BLUETOOTH_ADVERTISE/CONNECT/SCAN` in the manifest, which the app does not yet
request.
## v0.11.3 live on fmr; trains validated end to end (2026-08-02)
Deployed via `--self-update` (the pre-signing v0.11.2 updater accepted the first signed release,
as planned; every later update verifies). Startup clean on the real host — the reserved-port
check passed against the OS, self-test green, capabilities unchanged plus the new machinery.
**`train.udp_updown` validated against production**: `LiveUpstreamTrainTest` from this PC sent
120 packets; the server's ledger counted 120, both columnar report parts arrived, loss 0.0 %,
`truncated=false`. The 0x03/0x04/0x05 path works, client and server, over the real internet.
Two operational bugs surfaced doing it:
- **CLI-minted tokens are lost while the daemon runs.** `devices.json` is loaded once at startup
and held in memory; `--mint-enroll-token` writes to disk, the running daemon never re-reads,
answers "unknown token", and clobbers the token on its next write. `enroll-link.sh` has only
ever worked by timing luck. Workaround used: mint, `systemctl restart echolot-server`, then
redeem. Real fix belongs server-side (re-read on miss, or route the CLI mint through the
running daemon).
- **`test-fmr.sh` still mints against `127.0.0.1:8444`**, which no longer exists (the admin API
moved to authenticated :443). Needs the same CLI-mint flow enroll-link.sh uses — plus the
restart caveat above until that bug is fixed.
+7
View File
@@ -50,6 +50,13 @@ because it looks authoritative.
| `connectivity.downstream_reorder` | low | Downstream packets arrive in a different order than they were sent. | — |
| `connectivity.captive_portal` | medium | A captive portal is intercepting connectivity checks. | — |
| `connectivity.no_internet` | high | Android's own connectivity checks fail on this network. | — |
| `connectivity.link_flapping` | medium | A network dropped and came back one or more times during the run. | A momentary probe failure: the drop was watched happening, not inferred from silence. |
`connectivity.link_flapping` is only reachable from a **long run** (`run.mode: "long"`,
measurement-schema §3). It is derived from `networks[].changes[]` rather than from any test's
evidence, because no one-shot probe can produce it: the probes before and after a four-second drop
both succeed. The emitter escalates to *high* from three completed drop-and-return cycles, and
requires the cycle to complete — a network switched off partway through a run is not flapping.
### mtu
+35 -1
View File
@@ -38,6 +38,7 @@ Export encoding: UTF-8 JSON, gzip for files (`.echolot.json.gz`), share intent u
{
"id": "0198c5f2-...-uuidv7",
"trigger": "manual | scheduled | monitor | peer",
"mode": "short | long",
"started_at": "2026-07-29T14:03:21.114Z",
"ended_at": "2026-07-29T14:07:44.902Z",
"clock": {
@@ -63,6 +64,27 @@ Export encoding: UTF-8 JSON, gzip for files (`.echolot.json.gz`), share intent u
`tiers` records what was *available*; each test records what it *used*.
`mode` records how long the run watched, and it exists because **it changes what a reader may
conclude from absence**. A `short` run is a sequence of one-shot probes — each looks at the network
for a second or two and moves on — which characterises the network's *configuration* well and is
structurally blind to anything intermittent. A `long` run starts continuous listeners at t=0, runs
the same battery beside them, and keeps sampling until its window closes; the window's length is
recorded in the `params` of the tests the listeners produce, not here.
The consequence is asymmetric and matters more than the field looks. A finding is worth the same in
either mode: a drop that was observed, was observed. Silence is not. "No link changes were seen" is
evidence of a stable link after five minutes of watching and is evidence of nothing at all after a
thirty-second run, in which a link could drop and return between two consecutive probes without
leaving a mark anywhere in the document. Consumers — a diff between two runs, a dashboard counting
how often a fault occurs, a person reading one report — must therefore not treat the absence of a
time-dependent finding in a `short` run as its refutation, and must not compare the two modes as if
they had asked the same question. `connectivity.link_flapping` is the first finding that only a
`long` run can reach; `networks[].changes[]` (§4) is likewise populated only by a long run's
listener, and an empty `changes[]` in a short run means "not watched", never "nothing happened".
Absent `mode` means `short`: it was added after the first documents were written, and every one of
them was a battery of one-shot probes.
`constraints` records what was *prevented*. A constrained run is neither a failed run nor a normal
one, and the distinction has to survive into the data: a run taken through a VPN has the same shape
and the same green verdict as a clean run of a healthy network, so without this a reader — or a
@@ -111,12 +133,24 @@ A run may exercise several networks simultaneously (Wi-Fi + cellular + USB ether
"changes": [
{ "at_mono_ns": 91000000000, "kind": "lost | gained | link_changed",
"detail": { /* new link snapshot or diff */ } }
]
],
"app_usable": true
}
```
`routes[].proto` and lifetime fields are Shizuku-tier data (`ip route`/`ip addr`); app-tier snapshots leave them absent — absence means "not observed", never "not present".
`app_usable` records whether an ordinary app may send on this network at all. Android lists the
carrier's special-purpose networks — IMS/VoLTE, MMS, XCAP — alongside the real ones, and they
carry neither `INTERNET` nor `NOT_RESTRICTED`; binding to one needs
`CONNECTIVITY_USE_RESTRICTED_NETWORKS`, which is signature-level and unobtainable for a normal
app. Those networks are therefore permanently unmeasurable, and that is a property of Android's
permission model rather than of the link. They stay in `networks[]` because they are genuinely
present — an interface silently missing from the inventory is its own kind of lie — but a
consumer must not read the absence of tests against them as a fault, and they are **not**
`constraints.unmeasured_networks` (§3): nothing was prevented, the run was never entitled to
measure them.
## 5. `server_sessions[]`
```json
+1 -1
View File
@@ -15,7 +15,7 @@ plugins {
//
// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing
// digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100.
val appVersionName = "0.2.3"
val appVersionName = "0.3.0"
fun versionCodeOf(semver: String): Int {
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
@@ -10,6 +10,17 @@
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!--
The adb relay (AdbRelayService) only. It runs in the foreground because it must keep
watching while the tablet sits unattended with its screen off, and dataSync is the type
that describes it: it carries an observation off the LAN, nothing more. Android 15 caps
dataSync at a few hours a day, which is acceptable for a tool that is switched on for a
debugging session rather than left running forever.
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Only so the relay's ongoing status is visible; the service runs either way. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
@@ -53,6 +64,15 @@
</intent-filter>
</activity>
<!--
Not exported: nothing outside this app has any business starting a relay that reports
where this device can be reached.
-->
<service
android:name=".AdbRelayService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -0,0 +1,136 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.net.wifi.WifiManager
import java.net.Inet4Address
/**
* Watches adbd's own mDNS advertisement on this device and reports the endpoint to the server.
*
* This replaces the retired Python adb-beacon, and exists for one reason: **mDNS does not cross
* subnets**. A developer on another network cannot see `_adb-tls-connect._tcp` at all, while the
* wireless-debug port rotates every few minutes — so the port has to be carried out of the LAN by
* something sitting inside it. That is this. A tablet parked on the test network relays; the
* developer reads the endpoint back from the server.
*
* Two hard-won rules from the beacon, both load-bearing:
*
* - **Resolve each service instance exactly ONCE.** Resolving adbd's advertisement makes adbd
* re-arm its connection and post a "wireless debugging connected" notification; re-resolving on
* every heartbeat turns that into a stream of them. The guard is cleared only when the service
* is *lost*, which is also what catches rotation: the new advertisement is a new instance, gets
* resolved once, and is reported within seconds.
* - **Do not run this on the OnePlus.** On network churn that device drops and re-publishes its
* advertisement repeatedly, so lost/found cycles keep clearing the guard and each resolve
* re-arms adbd. Guarding reduces but cannot eliminate the noise; the Lenovo tablet is the
* intended host, which is also why relaying is a mode rather than something always on.
*/
class AdbRelay(
private val ctx: Context,
private val onEvent: (String) -> Unit,
) {
private val nsd = ctx.getSystemService(NsdManager::class.java)
/** Instances already resolved, by service name — the re-arm guard described above. */
private val resolved = HashSet<String>()
/** Last endpoint reported, so the heartbeat re-posts from cache instead of re-resolving. */
@Volatile var lastEndpoint: Endpoint? = null
private set
data class Endpoint(val host: String, val port: Int, val serviceName: String)
private var listener: NsdManager.DiscoveryListener? = null
fun start() {
if (nsd == null) {
onEvent("mDNS unavailable on this device")
return
}
if (listener != null) return
val l = object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(type: String?, code: Int) {
onEvent("discovery failed to start (code $code)")
}
override fun onStopDiscoveryFailed(type: String?, code: Int) {}
override fun onDiscoveryStarted(type: String?) {
onEvent("watching for adbd on this network")
}
override fun onDiscoveryStopped(type: String?) {}
override fun onServiceFound(info: NsdServiceInfo?) {
val name = info?.serviceName ?: return
// The guard: one resolve per instance, ever. adbd re-arms on every resolve.
if (!resolved.add(name)) return
resolve(info)
}
override fun onServiceLost(info: NsdServiceInfo?) {
// Rotation: the old instance is gone, so allow the replacement to be resolved.
info?.serviceName?.let { resolved.remove(it) }
}
}
listener = l
runCatching { nsd.discoverServices(ADB_SERVICE, NsdManager.PROTOCOL_DNS_SD, l) }
.onFailure { onEvent("could not start discovery: ${it.message}") }
}
fun stop() {
listener?.let { l -> runCatching { nsd?.stopServiceDiscovery(l) } }
listener = null
resolved.clear()
}
@Suppress("DEPRECATION") // the callback-based resolve is the one that exists across our minSdk
private fun resolve(info: NsdServiceInfo) {
val cb = object : NsdManager.ResolveListener {
override fun onResolveFailed(i: NsdServiceInfo?, code: Int) {
// Let a failed instance be retried: the guard exists to stop *successful*
// re-resolution, not to give up on a transient failure.
i?.serviceName?.let { resolved.remove(it) }
onEvent("resolve failed (code $code)")
}
override fun onServiceResolved(i: NsdServiceInfo?) {
val host = i?.host?.hostAddress ?: return
// adbd advertises on every address it listens on, including link-local v6. The
// reachable one from a developer's subnet is the routable v4 address, and it is
// also the only one worth relaying — a link-local address means nothing off-link.
if (i.host !is Inet4Address) return
// Only this device's own advertisement: on a shared network several phones may
// have wireless debugging on, and relaying a neighbour's port would send a
// developer to the wrong device.
if (host != localIp()) return
lastEndpoint = Endpoint(host, i.port, i.serviceName ?: "adb")
onEvent("found adbd at $host:${i.port}")
}
}
runCatching { nsd?.resolveService(info, cb) }
.onFailure {
resolved.remove(info.serviceName)
onEvent("resolve threw: ${it.message}")
}
}
/** This device's own IPv4 address on the wifi it is relaying from. */
private fun localIp(): String? = runCatching {
val wifi = ctx.getSystemService(WifiManager::class.java) ?: return null
@Suppress("DEPRECATION")
val ip = wifi.connectionInfo.ipAddress
if (ip == 0) return null
@Suppress("DEPRECATION")
String.format(
"%d.%d.%d.%d",
ip and 0xff, ip shr 8 and 0xff, ip shr 16 and 0xff, ip shr 24 and 0xff,
)
}.getOrNull()
private companion object {
const val ADB_SERVICE = "_adb-tls-connect._tcp"
}
}
@@ -0,0 +1,156 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import app.echo_lot.protocol.ControlClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Keeps [AdbRelay] running and posts what it finds to the enrolled server.
*
* A foreground service because the whole point is to be useful while nobody is looking at the
* tablet: a background process is frozen within minutes of the screen going off, and a relay that
* stops relaying the moment it is left alone would be worse than none — it would be trusted right
* up until the moment it went quiet.
*
* The heartbeat re-posts the CACHED endpoint and never re-resolves. Resolving adbd's advertisement
* makes adbd re-arm its connection and raise a "wireless debugging connected" notification, so a
* heartbeat that re-resolved would turn a background convenience into a stream of notifications on
* a device sitting on a shelf. Rotation is still caught, because losing the old advertisement
* clears the resolve guard in [AdbRelay] and the replacement is resolved once, within seconds.
*/
class AdbRelayService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var relay: AdbRelay? = null
@Volatile private var status: String = "starting"
@Volatile private var lastPosted: String? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
startForeground(NOTIFICATION_ID, notification("starting"))
val settings = Settings(this)
val r = AdbRelay(this) { msg ->
status = msg
notify(msg)
}
relay = r
r.start()
scope.launch {
while (true) {
val ep = r.lastEndpoint
if (ep != null) {
val wire = "${ep.host}:${ep.port}"
// Re-post on a heartbeat even when unchanged: the server stamps a received-at
// time, and a developer needs to tell "this endpoint is current" from "this
// endpoint is what the tablet saw before it went out of range".
val result = post(settings, ep)
status = if (result == null) {
lastPosted = wire
"reported $wire"
} else {
"found $wire, but reporting failed: $result"
}
notify(status)
}
delay(HEARTBEAT_MS)
}
}
}
/** Posts one endpoint; returns null on success or a short reason on failure. */
private suspend fun post(settings: Settings, ep: AdbRelay.Endpoint): String? =
withContext(Dispatchers.IO) {
if (!settings.serverConfigured) return@withContext "no server enrolled"
runCatching {
ControlClient(settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER)
.reportAdbEndpoint(
credential = settings.serverCredential,
host = ep.host,
port = ep.port,
deviceName = Build.MODEL,
note = "echolot relay",
)
null
}.getOrElse { it.message?.take(120) ?: it.javaClass.simpleName }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Restarted by the system if it is killed: a relay that quietly does not come back after
// a low-memory kill is the failure mode this exists to avoid.
return START_STICKY
}
override fun onDestroy() {
relay?.stop()
scope.cancel()
super.onDestroy()
}
private fun notify(text: String) {
val nm = getSystemService(NotificationManager::class.java)
nm?.notify(NOTIFICATION_ID, notification(text))
}
private fun notification(text: String): Notification {
val nm = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// LOW: this is a status line for a tool the user deliberately started, not news.
val ch = NotificationChannel(CHANNEL, "adb relay", NotificationManager.IMPORTANCE_LOW)
ch.description = "Reports this device's wireless-debug endpoint to the Echolot server"
nm?.createNotificationChannel(ch)
}
val open = android.app.PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
android.app.PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL)
.setContentTitle("Echolot adb relay")
.setContentText(text)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setOngoing(true)
.setContentIntent(open)
.build()
}
companion object {
private const val CHANNEL = "adb-relay"
private const val NOTIFICATION_ID = 4711
/**
* Two minutes. The port rotates on roughly that cadence, and the freshness of the answer
* is the whole product — but this only re-posts a cached value, so it costs one small
* HTTPS request and never touches mDNS.
*/
private const val HEARTBEAT_MS = 120_000L
fun start(ctx: Context) {
val i = Intent(ctx, AdbRelayService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i)
else ctx.startService(i)
}
fun stop(ctx: Context) {
ctx.stopService(Intent(ctx, AdbRelayService::class.java))
}
}
}
@@ -63,6 +63,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestRuntimePermissions()
resumeRelayIfEnabled()
liveIntent.value = intent
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
@@ -149,6 +150,16 @@ class MainActivity : ComponentActivity() {
screen = Screen.SETTINGS
}
}
// A long run samples for minutes, and Android starts throttling timers and
// network access within moments of the screen going off — so a run left to
// itself would measure the device's power management rather than the network,
// and would do it silently. Held only while a run is in flight, and released
// on the way out.
val view = androidx.compose.ui.platform.LocalView.current
androidx.compose.runtime.DisposableEffect(vm.state.running) {
view.keepScreenOn = vm.state.running
onDispose { view.keepScreenOn = false }
}
// Shizuku can be started, stopped or authorised in its own app, where nothing
// calls back into this process. Asking again each time this screen comes
// forward is what makes the banner right after the user has been away to fix
@@ -163,8 +174,13 @@ class MainActivity : ComponentActivity() {
lifecycleOwner.lifecycle.addObserver(obs)
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
}
// Autorun stays a quick run: it is an unattended batch job driven over adb, and
// an automation that silently held the device for five minutes would be a
// surprise. `--es mode long` asks for the other one explicitly.
val autorunMode =
if (intent?.getStringExtra("mode") == "long") RunMode.LONG else RunMode.SHORT
androidx.compose.runtime.LaunchedEffect(autorun) {
if (autorun) vm.run(devUpload = true)
if (autorun) vm.run(autorunMode, devUpload = true)
}
// In autorun the app is a batch job: once the run is done AND the upload
// succeeded, show the result briefly, then close so the device is left as it
@@ -215,6 +231,10 @@ class MainActivity : ComponentActivity() {
onEnroll = vm::enroll,
serverStatus = vm.state.archiveStatus,
enrollStatus = vm.state.enrollStatus,
onRelayChange = { on ->
if (on) AdbRelayService.start(this@MainActivity)
else AdbRelayService.stop(this@MainActivity)
},
onBack = { screen = Screen.RUN },
)
Screen.HISTORY -> HistoryScreen(
@@ -238,7 +258,8 @@ class MainActivity : ComponentActivity() {
)
Screen.RUN -> EcholotScreen(
state = vm.state,
onRun = { vm.run() },
longMinutes = vm.settings.longRunMinutes,
onRun = { mode -> vm.run(mode) },
onCancel = vm::cancel,
onDeveloperOptions = {
runCatching {
@@ -275,11 +296,29 @@ class MainActivity : ComponentActivity() {
private fun requestRuntimePermissions() {
val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION)
// Only so the relay's ongoing notification is visible. The service runs either way, but a
// foreground service the user cannot see is worse than one they can dismiss knowingly.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
perms.add(Manifest.permission.POST_NOTIFICATIONS)
}
val missing = perms.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isNotEmpty()) permissionLauncher.launch(missing.toTypedArray())
}
/**
* Restarts the relay if it was left on.
*
* A relay that silently fails to come back after a reboot or a process kill is worse than one
* that was never enabled: it is trusted right up to the moment it goes quiet, and the symptom
* is a stale endpoint that sends a developer to a port nothing is listening on.
*/
private fun resumeRelayIfEnabled() {
if (Settings(this).adbRelayEnabled) {
runCatching { AdbRelayService.start(this) }
}
}
}
private fun verdictColor(v: Verdict): Color = when (v) {
@@ -299,7 +338,8 @@ private fun statusColor(s: TestStatus): Color = when (s) {
@Composable
private fun EcholotScreen(
state: UiState,
onRun: () -> Unit,
longMinutes: Int,
onRun: (RunMode) -> Unit,
onCancel: () -> Unit,
onShizukuAction: () -> Unit,
onDeveloperOptions: () -> Unit,
@@ -363,8 +403,36 @@ private fun EcholotScreen(
}
}
// The choice is made before the run, not after, because it is a choice about how long the
// user is willing to stand still — and because the two modes answer different questions.
var mode by remember { mutableStateOf(RunMode.SHORT) }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
FilterChip(
selected = mode == RunMode.SHORT,
onClick = { mode = RunMode.SHORT },
enabled = !state.running,
label = { Text("Quick") },
)
FilterChip(
selected = mode == RunMode.LONG,
onClick = { mode = RunMode.LONG },
enabled = !state.running,
label = { Text("Long ($longMinutes min)") },
)
}
Text(
if (mode == RunMode.SHORT) {
"About 30 seconds. Describes how the network is configured right now."
} else {
"Listens for $longMinutes minutes while it measures. Finds what a quick run " +
"structurally cannot: links that drop and come back, signal that decays, " +
"loss that arrives in bursts."
},
fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = onRun, enabled = !state.running) {
Button(onClick = { onRun(mode) }, enabled = !state.running) {
Text(if (state.running) "Running…" else "Run measurement")
}
if (state.running) {
@@ -385,25 +453,55 @@ private fun EcholotScreen(
if (state.running) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
val frac = if (state.stepsTotal > 0)
state.stepsDone.toFloat() / state.stepsTotal else 0f
// In a long run the window is the run: once the battery is done, four minutes of
// listening remain, and a bar driven by the step count would sit at 100 % through
// all of it — which reads as an app that has hung, not one that is working.
val listening = state.windowTotalS > 0
val frac = when {
listening -> state.windowElapsedS.toFloat() / state.windowTotalS
state.stepsTotal > 0 -> state.stepsDone.toFloat() / state.stepsTotal
else -> 0f
}
LinearProgressIndicator(
progress = { frac },
progress = { frac.coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
)
if (listening) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"listening ${clock(state.windowElapsedS)} of ${clock(state.windowTotalS)}",
fontSize = 12.sp, fontWeight = FontWeight.Medium,
)
Text(
"${clock(state.windowTotalS - state.windowElapsedS)} left",
fontSize = 12.sp, modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Still shown during a long run's listening phase, where it stops at the last
// test: the battery's progress is real information, it is simply not the whole
// run any more.
Text(
if (state.stepsTotal > 0)
"test ${state.stepsDone + 1} of ${state.stepsTotal}" else "starting",
"test ${(state.stepsDone + 1).coerceAtMost(state.stepsTotal)} of ${state.stepsTotal}"
else "starting",
fontSize = 12.sp, fontWeight = FontWeight.Medium,
)
Text(state.currentStep ?: "", fontSize = 12.sp,
fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
if (state.etaSeconds > 0) {
if (!listening && state.etaSeconds > 0) {
Text("~${state.etaSeconds}s left", fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (listening) {
Text(
"Cancelling keeps what has been collected so far.",
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -432,9 +530,9 @@ private fun Results(doc: MeasurementDocument) {
"Disconnect the VPN and run again to measure the networks themselves.")
constraints.perNetworkBlocked ->
"Some networks could not be measured" to
("Android refused sends on $blocked — the restriction a VPN leaves in place " +
"while it tears down. These networks went unmeasured; wait a few " +
"seconds and run again.")
("Android refused this app permission to send on $blocked, so they went " +
"unmeasured. A connected VPN is the usual cause; the refusal can also " +
"outlast one. Everything else in this run is unaffected.")
else ->
"A VPN holds the default route" to
("Default-route results describe the tunnel; per-network measurements " +
@@ -453,6 +551,17 @@ private fun Results(doc: MeasurementDocument) {
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text("Overall: ${summary.overall}", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp)
// Which question this document answers. A green light from a quick run does not
// mean the same thing as a green light from a long one, and the report should not
// let the two look identical.
Text(
if (doc.run.mode == RunMode.LONG) {
"long run — the network was watched continuously as well as probed"
} else {
"quick run — a snapshot; nothing here rules out an intermittent fault"
},
color = Color.White, fontSize = 12.sp,
)
}
}
FlowCategories(summary.categories)
@@ -558,6 +667,12 @@ private fun FlowCategories(categories: Map<String, CategorySummary>) {
}
}
/** m:ss — minutes are how a five-minute wait is read; "247s left" is a number to convert. */
private fun clock(seconds: Int): String {
val s = seconds.coerceAtLeast(0)
return "${s / 60}:${"%02d".format(s % 60)}"
}
@Composable
private fun Dot(color: Color) {
Surface(color = color, shape = RoundedCornerShape(50), modifier = Modifier.size(12.dp)) {}
@@ -23,6 +23,8 @@ import app.echo_lot.probe.RouterIdentityProbe
import app.echo_lot.shizuku.ShizukuAvailability
import app.echo_lot.shizuku.ShizukuProbe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant
@@ -38,6 +40,17 @@ data class UiState(
val stepsDone: Int = 0,
val stepsTotal: Int = 0,
val etaSeconds: Int = 0,
/** Which mode the run in progress (or the last one) used. */
val mode: RunMode = RunMode.SHORT,
/**
* The listening window, in seconds. Zero for a short run.
*
* A long run's step count stops being the honest progress measure the moment the battery is
* done and four minutes of listening remain: the bar would sit at 100 % while the run carried
* on, which reads as a hung app. Over a window, elapsed-of-total is the truth.
*/
val windowElapsedS: Int = 0,
val windowTotalS: Int = 0,
/** Where the finished run went: archived locally, uploaded, or neither (and why). */
val archiveStatus: String? = null,
/** History, newest first. Refreshed after every run and whenever the history screen opens. */
@@ -122,6 +135,17 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
override fun onCleared() {
stopShizukuObserver()
// A NetworkCallback outlives the object that registered it: the system holds the reference,
// so a ViewModel dying mid-run with listeners still up leaks one for the life of the
// process. viewModelScope is already cancelled by now, hence a detached scope purely to
// hang up — its results are discarded, only the unregistration matters.
val leftovers = activeCollectors
activeCollectors = emptyList()
if (leftovers.isNotEmpty()) {
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
leftovers.forEach { runCatching { it.stop() } }
}
}
super.onCleared()
}
// Results collected so far. A cancelled run must still be able to show what it measured.
@@ -131,6 +155,18 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
private var runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
private var runShizukuOk = false
private var runConstraints = Constraints()
private var runMode: RunMode = RunMode.SHORT
/**
* The listeners of the run in flight.
*
* Held on the ViewModel rather than inside `measure()` because [cancel] has to be able to reach
* them: the run job is dead by then, and what the listeners gathered up to that moment is the
* most valuable part of a long run that was cut short. They own their own coroutine scopes for
* the same reason — cancelling the run must stop the sampling without discarding the samples.
*/
private var activeCollectors: List<app.echo_lot.probe.Collector> = emptyList()
private var changeCollector: app.echo_lot.probe.NetworkChangeCollector? = null
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
private class RunIds : ProbeIds {
@@ -146,14 +182,17 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
* from the user-facing upload so a debugging convenience can never be mistaken for, or
* silently satisfy, the consent-gated one.
*/
fun run(devUpload: Boolean = false) {
fun run(mode: RunMode = RunMode.SHORT, devUpload: Boolean = false) {
if (state.running) return
collected.clear()
runConstraints = Constraints()
runMode = mode
val windowS = if (mode == RunMode.LONG) settings.longRunMinutes * 60 else 0
state = state.copy(running = true, currentStep = "starting", document = null,
uploadStatus = null, archiveStatus = null)
uploadStatus = null, archiveStatus = null,
mode = mode, windowElapsedS = 0, windowTotalS = windowS)
runJob = viewModelScope.launch {
val doc = withContext(Dispatchers.IO) { measure() }
val doc = withContext(Dispatchers.IO) { measure(mode) }
step("archiving")
val archived = withContext(Dispatchers.IO) { store.archive(doc) }
@@ -184,6 +223,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
state = UiState(
running = false, currentStep = null, document = doc,
uploadStatus = status, archiveStatus = archiveStatus,
mode = mode,
history = withContext(Dispatchers.IO) { store.list() },
)
}
@@ -371,23 +411,63 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
/**
* Stops an in-flight run and shows what was measured so far. Deliberately does NOT upload:
* a partial run is for the person looking at the screen, not for the record.
*
* Cancelling a long run must still hand back what the listeners heard — two minutes of watching
* is worth reporting, and throwing it away because the user did not wait for the third would be
* the worst possible answer to "I've seen enough". The harvest runs on [viewModelScope] rather
* than in the (now cancelled) run job, and the listeners' own scopes are what kept their data
* alive long enough to collect.
*/
fun cancel() {
if (!state.running) return
if (!state.running || cancelling) return
cancelling = true
runJob?.cancel()
state = state.copy(currentStep = "stopping listeners")
viewModelScope.launch {
withContext(Dispatchers.IO) { stopCollectors() }
val doc = buildDocument(collected.toList())
cancelling = false
state = UiState(
running = false, currentStep = null, document = doc,
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
archiveStatus = "partial run — not archived",
mode = runMode,
history = state.history,
)
}
}
private suspend fun measure(): MeasurementDocument {
private var cancelling = false
/**
* Stops every listener, folds its Test into the run, and attaches the observed network changes
* to `networks[]`. Idempotent — the normal path and the cancel path both call it, and only the
* first does anything.
*/
private suspend fun stopCollectors(): List<Test> {
val running = activeCollectors
activeCollectors = emptyList()
val out = ArrayList<Test>()
for (c in running) {
val t = runCatching { c.stop() }.getOrNull() ?: continue
out.add(t)
collected.add(t)
}
changeCollector?.let { watcher ->
changeCollector = null
val byNetwork = runCatching { watcher.changesByNetwork() }.getOrDefault(emptyMap())
if (byNetwork.isNotEmpty()) {
runNetworks = runNetworks.map { n -> n.copy(changes = byNetwork[n.id] ?: n.changes) }
}
}
return out
}
private suspend fun measure(mode: RunMode): MeasurementDocument = coroutineScope {
val ctx = getApplication<Application>()
val ids = RunIds().also { runIds = it }
val startWall = Instant.now().toString().also { runStartWall = it }
val windowMs = if (mode == RunMode.LONG) settings.longRunMinutes * 60_000L else 0L
step("reading networks")
val entries = NetworkInventory.snapshot(ctx)
@@ -398,7 +478,59 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
// per-test `attempted: false` breadcrumbs (measurement-schema.md §3 `constraints`).
runConstraints = app.echo_lot.probe.ConstraintDetector.detect(ctx, entries)
val probes: List<Probe> = listOf(
// Listeners first, at t=0, so the battery itself runs *inside* the observed window: a link
// that drops while the DNS probe is timing out is then recorded as a drop rather than
// guessed at from a failure.
var ticker: kotlinx.coroutines.Job? = null
if (mode == RunMode.LONG) {
step("starting listeners")
val watcher = app.echo_lot.probe.NetworkChangeCollector(entries).also { changeCollector = it }
val collectors = listOf<app.echo_lot.probe.Collector>(
watcher,
app.echo_lot.probe.WifiSignalCollector(entries),
app.echo_lot.probe.PingSeriesCollector(),
// mDNS is a listener wearing a probe's clothes, so in long mode it listens for the
// window instead of blocking the battery for it. Two seconds short of the window,
// so it finishes just before everything is stopped rather than just after.
app.echo_lot.probe.ProbeCollector(
app.echo_lot.probe.MdnsInventoryProbe(
listenMs = (windowMs - 2_000).coerceAtLeast(10_000)
)
),
// The rest of what a segment says about itself unprompted. All passive listeners,
// which is exactly why they belong to long mode: the announcements are periodic
// and sparse, so a thirty-second run mostly hears silence and would report an
// empty network as confidently as a quiet one.
//
// Searches are paced across the window rather than fired at the start: a device
// that was asleep for the first minute still gets asked. NetBIOS is included
// knowing it will usually report `unsupported` — UDP 137 is privileged, so the
// app tier cannot bind it — because a recorded reason beats an absent test, and
// the decoder is ready for the Shizuku tier.
app.echo_lot.probe.SsdpCollector(
searchIntervalMs = (windowMs / 5).coerceAtLeast(30_000),
),
app.echo_lot.probe.LlmnrCollector(),
app.echo_lot.probe.NetbiosCollector(),
app.echo_lot.probe.WsdCollector(),
)
activeCollectors = collectors
for (c in collectors) runCatching { c.start(ctx, ids) }
// One ticker for the whole run: the battery does not report progress by the second, and
// without this the elapsed/remaining line would freeze for as long as the slowest probe.
ticker = launch {
while (isActive) {
val elapsedS = (ids.monoNs() / 1_000_000_000L).toInt()
state = state.copy(
windowElapsedS = elapsedS.coerceAtMost((windowMs / 1000).toInt()),
windowTotalS = (windowMs / 1000).toInt(),
)
kotlinx.coroutines.delay(1000)
}
}
}
val probes: List<Probe> = listOfNotNull(
LinkSnapshotProbe(entries),
RouterIdentityProbe(entries),
IcmpProbe(entries, v6 = false),
@@ -406,7 +538,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
// Folded from the prober after hardware validation: errqueue traceroute (no root,
// no JNI) and the mDNS service inventory / VLAN-leakage detector.
app.echo_lot.probe.TracerouteProbe(),
app.echo_lot.probe.MdnsInventoryProbe(),
// Absent from a long run's battery: it runs there as a collector for the whole window
// instead, and running it in both places would query the same services twice.
if (mode == RunMode.LONG) null else app.echo_lot.probe.MdnsInventoryProbe(),
CaptivePortalProbe(entries),
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
// the reference deployment until profiles/enrollment land in the UI.
@@ -479,7 +613,24 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
(it.status == TestStatus.OK || it.status == TestStatus.PARTIAL)
}
return buildDocument(tests)
if (mode == RunMode.LONG) {
// The battery finishing is not the run finishing. Everything the long mode exists for
// happens in the minutes after this point, so the run waits the window out — in one
// second steps, because `delay` is what makes a five-minute wait cancellable.
while (true) {
val elapsedMs = ids.monoNs() / 1_000_000
val remainingS = ((windowMs - elapsedMs + 999) / 1000).toInt()
if (remainingS <= 0) break
step("listening · ${remainingS}s left", done = totalSteps, total = totalSteps, etaMs = windowMs - elapsedMs)
kotlinx.coroutines.delay(minOf(1000L, windowMs - elapsedMs))
}
step("collecting listeners")
tests.addAll(stopCollectors())
}
// Before the enclosing coroutineScope waits for its children, or the run would never end.
ticker?.cancel()
buildDocument(tests)
}
/** Assembles a document from whatever tests are in hand — used for both full and cancelled runs. */
@@ -487,7 +638,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
val findings = deriveFindings(tests, runNetworks)
return MeasurementDocument(
run = Run(
id = runIds.uuid(), trigger = Trigger.MANUAL, startedAt = runStartWall,
id = runIds.uuid(), trigger = Trigger.MANUAL, mode = runMode, startedAt = runStartWall,
endedAt = Instant.now().toString(),
clock = Clock(monoOriginWall = runStartWall),
app = AppInfo(
@@ -594,10 +745,11 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
runConstraints.perNetworkBlocked ->
"The OS refused sends on $blocked" to
("Android denied this app permission to send on $blocked (EPERM on " +
"bind). That is the restriction a VPN imposes on the networks " +
"beneath it — a tunnel disconnected moments ago can still leave it " +
"in place while it tears down. Nothing in this run says anything " +
"about $blocked; wait a few seconds and run again.")
"bind), so nothing in this run says anything about it. The usual " +
"cause is a VPN — Android walls off the networks beneath a tunnel — " +
"but the refusal can also outlast one, and some networks are " +
"reserved for the system entirely. If no VPN is connected, this is " +
"a restriction on the app rather than a fault in the network.")
else ->
"A VPN holds the default route" to
("Everything using the default route in this run describes the tunnel, " +
@@ -617,6 +769,46 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
)
)
}
// The one finding only a long run can reach. Derived from networks[].changes[] rather than
// from the watcher's evidence, because the changes are the schema's own record of what
// happened (§4) and re-parsing the test would be a second, divergent reading of it. A short
// run has an empty changes[] and therefore never gets here — which is correct, not a gap:
// it did not watch, so it has nothing to say either way.
val watchTest = tests.firstOrNull {
it.type == TestType.LINK_IP_MONITOR && it.tier == Tier.APP
}
if (watchTest != null) {
for (n in networks) {
val cycles = NetworkChanges.flapCyclesOf(n.changes)
if (cycles < 1) continue
val where = n.iface?.takeIf { it.isNotBlank() } ?: "this network"
out.add(
Finding(
id = ids.uuid(),
code = FindingRegistry.LINK_FLAPPING.code,
category = FindingRegistry.LINK_FLAPPING.category,
// Escalated on repetition: once is a hiccup worth knowing about, three
// times in one window is the reason someone's calls keep dropping.
severity = if (cycles >= 3) Severity.HIGH else FindingRegistry.LINK_FLAPPING.severity,
confidence = Confidence.HIGH,
title = if (cycles == 1) "$where dropped and came back during the run"
else "$where dropped and came back $cycles times during the run",
description = "A listener watched this link for the whole measurement and " +
"saw it go away and return " +
(if (cycles == 1) "once" else "$cycles times") + ". Every one-shot " +
"test in this run may still have passed — the probes on either side of " +
"a gap succeed — so this is the kind of fault a quick measurement " +
"cannot find. Connections in flight are dropped each time it happens: " +
"calls end, downloads stall, and anything long-lived reconnects. On " +
"wifi the usual causes are a weak or contended channel, band steering, " +
"or an access point restarting; on cellular, handovers at the edge of " +
"coverage. The timeline of every change is in networks[].changes[].",
evidenceRefs = listOf(EvidenceRef(watchTest.id)),
)
)
}
}
val shapes = V6Analysis.classify(networks)
// Named per interface: on a phone several networks are up at once, and "IPv6 is broken" is
// useless when wifi is the broken one and cellular is fine.
@@ -50,6 +50,36 @@ class Settings(context: Context) {
maxTotalBytes = maxTotalMb.toLong() * 1024 * 1024,
)
// ---- measurement -------------------------------------------------------------------
/**
* How long a long run listens, in minutes. Offered as 1 / 5 / 15.
*
* 5 is the default because it is the shortest window in which the things long mode exists to
* catch — a link that flaps, a signal that decays as someone walks around, loss that comes in
* bursts — have a fair chance of happening at least twice. One minute is for checking that the
* mode works at all; fifteen is for chasing something already suspected.
*
* Clamped rather than trusted: a zero-minute long run would produce a document claiming a
* window it never watched, which is the one thing `run.mode` exists to prevent.
*/
var longRunMinutes: Int
get() = prefs.getInt(LONG_RUN_MINUTES, 5).coerceIn(1, 60)
set(v) = prefs.edit().putInt(LONG_RUN_MINUTES, v.coerceIn(1, 60)).apply()
// ---- dev relay -----------------------------------------------------------------------
/**
* Whether this device relays adbd's wireless-debug endpoint to the enrolled server.
*
* Off by default and never implied by anything else: it publishes where this device can be
* reached for debugging, which is a decision rather than a side effect. Intended for a spare
* device parked on a test network — see AdbRelay for why the OnePlus is a poor host for it.
*/
var adbRelayEnabled: Boolean
get() = prefs.getBoolean(ADB_RELAY, false)
set(v) = prefs.edit().putBoolean(ADB_RELAY, v).apply()
// ---- run-duration learning ---------------------------------------------------------
/**
@@ -245,5 +275,7 @@ class Settings(context: Context) {
const val ACCOUNT_NAME = "account_name"
const val ACCOUNT_ID = "account_id"
const val DURATION_PREFIX = "duration_ms."
const val ADB_RELAY = "adb_relay_enabled"
const val LONG_RUN_MINUTES = "long_run_minutes"
}
}
@@ -59,6 +59,8 @@ fun SettingsScreen(
onEnroll: (String) -> Unit,
serverStatus: String?,
enrollStatus: String?,
/** Starts or stops the adb relay service; the toggle only records the preference. */
onRelayChange: (Boolean) -> Unit,
onBack: () -> Unit,
) {
// SharedPreferences is not observable, so mirror each value into Compose state and write
@@ -67,9 +69,11 @@ fun SettingsScreen(
var maxRuns by remember { mutableStateOf(settings.maxRuns.toString()) }
var maxAgeDays by remember { mutableStateOf(settings.maxAgeDays.toString()) }
var maxTotalMb by remember { mutableStateOf(settings.maxTotalMb.toString()) }
var longMinutes by remember { mutableStateOf(settings.longRunMinutes) }
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
var privacy by remember { mutableStateOf(settings.privacyLevel) }
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
var relayOn by remember { mutableStateOf(settings.adbRelayEnabled) }
var enrollLink by remember { mutableStateOf("") }
// The public name, which is what the operator handed out and what a person recognises. The
// endpoint actually dialled is shown beneath it when the two differ, rather than hidden — a
@@ -97,6 +101,39 @@ fun SettingsScreen(
Text("Settings", style = MaterialTheme.typography.titleLarge)
}
// ---- measurement ----
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Long runs", style = MaterialTheme.typography.titleMedium)
Text(
"How long a long run keeps listening. The measurements themselves take about " +
"30 seconds either way; the rest of the window is spent watching for " +
"things that only happen sometimes.",
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (minutes in listOf(1, 5, 15)) {
FilterChip(
selected = longMinutes == minutes,
onClick = { longMinutes = minutes; settings.longRunMinutes = minutes },
label = { Text("$minutes min") },
)
}
}
Text(
when (longMinutes) {
1 -> "Barely longer than a quick run — enough to confirm the listeners " +
"work, rarely enough to catch anything intermittent."
15 -> "For a fault you already suspect and have to prove. Keep the screen " +
"on and the device where the problem happens."
else -> "Long enough for a link that drops every couple of minutes to do " +
"it at least once, short enough to wait out."
},
style = MaterialTheme.typography.bodySmall,
)
}
}
// ---- archive ----
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -348,6 +385,39 @@ fun SettingsScreen(
)
}
}
// ---- dev relay ------------------------------------------------------------------
//
// Last, and deliberately plain: this is scaffolding for driving a test device, not a
// measurement. It publishes where this device can be reached over adb, which is why it
// is off until someone decides otherwise.
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Developer relay", style = MaterialTheme.typography.titleMedium)
Toggle(
label = "Relay this device's adb endpoint",
detail = "Watches adbd's own mDNS announcement and reports host:port to the " +
"enrolled server, so a developer on another network can reach this " +
"device — mDNS does not cross subnets, and the port rotates every few " +
"minutes. Runs in the foreground with a notification while on.",
checked = relayOn,
enabled = settings.serverConfigured,
onChange = { on ->
relayOn = on
settings.adbRelayEnabled = on
onRelayChange(on)
},
)
if (!settings.serverConfigured) {
Text(
"Needs an enrolled server: the report is authenticated with this " +
"device's credential.",
style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(alpha = 0.7f),
)
}
}
}
Spacer(Modifier.height(24.dp))
}
}
@@ -28,6 +28,7 @@ data class MeasurementDocument(
data class Run(
val id: String, // UUIDv7
val trigger: Trigger,
val mode: RunMode = RunMode.SHORT,
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
@SerialName("ended_at") val endedAt: String? = null,
val clock: Clock,
@@ -66,6 +67,27 @@ data class Constraints(
val constrained: Boolean get() = vpnActive || perNetworkBlocked
}
/**
* How long the run watched the network — and therefore what its silence is worth.
*
* A [SHORT] run is a sequence of one-shot probes: each looks at the network for a second or two and
* moves on. That is enough to characterise a network's *configuration*, and it is structurally
* incapable of seeing anything intermittent. A wifi link that drops for four seconds every two
* minutes, a resolver that stalls under load, an AP that roams — none of these leave a trace in
* thirty seconds of probing unless the run happened to coincide with one.
*
* A [LONG] run starts continuous listeners at t=0, runs the same battery beside them, and keeps
* sampling until the window closes. It answers a different question, so a reader must not treat the
* two alike: **the mode is what licenses an argument from absence**. "No drops were observed" means
* something after five minutes of watching and nothing at all after a thirty-second run, and
* without this field the two documents are indistinguishable.
*/
@Serializable
enum class RunMode {
@SerialName("short") SHORT,
@SerialName("long") LONG,
}
@Serializable
enum class Trigger {
@SerialName("manual") MANUAL,
@@ -103,6 +103,26 @@ object FindingRegistry {
"Android's own connectivity checks fail on this network.",
)
/**
* The finding a short run cannot make.
*
* Every one-shot probe describes the network during its own two seconds. A link that drops and
* returns between two of them leaves no trace anywhere in the document — the probes before and
* after both succeed, and the run reports a healthy network. Only a listener that watches the
* whole window sees the gap, which is why this is emitted from `networks[].changes[]` (§4)
* rather than from any test's evidence.
*
* MEDIUM by default and escalated by the emitter on repeat: one drop in five minutes is worth
* knowing about, three is the difference between "the wifi hiccuped" and "this link is why
* calls keep dropping". Deliberately claims a *completed* cycle — lost and then regained — so
* it never fires for a network that was simply turned off partway through the run.
*/
val LINK_FLAPPING = FindingSpec(
"connectivity.link_flapping", Category.CONNECTIVITY, Severity.MEDIUM,
"A network dropped and came back one or more times during the run.",
rulesOut = "A momentary probe failure: the drop was watched happening, not inferred from silence.",
)
// ---- mtu -------------------------------------------------------------------------
val MTU_REDUCED_DOWNSTREAM = FindingSpec(
@@ -305,7 +325,7 @@ object FindingRegistry {
/** Every registered finding, in declaration order. */
val all: List<FindingSpec> = listOf(
UDP_UNREACHABLE, UDP_UNREACHABLE_UPSTREAM, UDP_LOSS, LOSS_UPSTREAM, LOSS_DOWNSTREAM,
DOWNSTREAM_BLOCKED, DOWNSTREAM_REORDER, CAPTIVE_PORTAL, NO_INTERNET,
DOWNSTREAM_BLOCKED, DOWNSTREAM_REORDER, CAPTIVE_PORTAL, NO_INTERNET, LINK_FLAPPING,
MTU_REDUCED_DOWNSTREAM, MTU_DOWNSTREAM_BLACKHOLE, FRAGMENTS_BLOCKED,
FRAGMENT_REORDER_SENSITIVE,
NAT_UDP_REBINDING, NAT_SYMMETRIC,
@@ -19,6 +19,17 @@ data class Network(
val cellular: Cellular? = null,
val changes: List<NetworkChange> = emptyList(),
@SerialName("system_verdict") val systemVerdict: SystemVerdict? = null,
/**
* Whether an ordinary app may send on this network at all.
*
* False for the carrier's special-purpose networks — IMS/VoLTE, MMS, XCAP — which appear
* beside the real ones in Android's list and carry neither `INTERNET` nor `NOT_RESTRICTED`.
* Binding to those needs `CONNECTIVITY_USE_RESTRICTED_NETWORKS`, a privileged permission no
* normal app can hold, so the refusal is permanent and says nothing about the network's
* health. Recorded rather than hidden: a reader seeing an interface with no measurements
* against it deserves to know the OS forbade them, instead of concluding the link is dead.
*/
@SerialName("app_usable") val appUsable: Boolean? = null,
)
/**
@@ -133,3 +144,38 @@ data class NetworkChange(
val kind: String, // lost | gained | link_changed
val detail: JsonObject? = null,
)
/**
* What a network's `changes[]` add up to.
*
* Lives beside the type rather than in the collector that produces it because two independent
* consumers ask the same question — the watcher, computing its metrics, and the run engine,
* deciding whether to emit `connectivity.link_flapping` — and a document whose metric and finding
* disagreed about how many times the link dropped would be worse than one reporting neither.
*/
object NetworkChanges {
const val LOST = "lost"
const val GAINED = "gained"
const val LINK_CHANGED = "link_changed"
/**
* Completed drop-and-return cycles: a `lost` with a later `gained` on the same network.
*
* A cycle has to *complete*. A link that goes away at minute four and is still gone when the
* window closes was not flapping — it was switched off, or the device was carried out of
* range, and calling that the same fault would put a phone in a lift beside a failing access
* point.
*/
fun flapCycles(kinds: List<String>): Int {
var cycles = 0
var down = false
for (k in kinds) {
if (k == LOST) down = true
else if (k == GAINED && down) { cycles++; down = false }
}
return cycles
}
fun flapCyclesOf(changes: List<NetworkChange>): Int = flapCycles(changes.map { it.kind })
}
@@ -133,6 +133,16 @@ object TestType {
const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory"
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory"
/**
* WS-Discovery (UDP 3702) and NetBIOS name service (UDP 137). Registry additions, v1.2.
*
* Both are passive: the traffic is broadcast to the segment whether or not anyone asks, so
* listening is the whole measurement. They earn their own ids rather than folding into
* [LOCAL_SSDP_INVENTORY] because what they imply differs — WS-Discovery inventories printers
* and cameras, while NetBIOS/LLMNR chatter is a security finding in its own right.
*/
const val LOCAL_WSD_INVENTORY = "local.wsd_inventory"
const val LOCAL_NETBIOS_INVENTORY = "local.netbios_inventory"
const val LOCAL_GATEWAY_SERVICES = "local.gateway_services"
const val LOCAL_NTP = "local.ntp"
// peer
@@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Pins the counting behind `connectivity.link_flapping`.
*
* The finding claims a link went away and came back, and its severity escalates on repetition, so
* this is arithmetic a person reading a report will act on. The cases that matter are the ones
* where the naive count is wrong: a link still down when the window closed, and a run that started
* while the link was already gone.
*/
class NetworkChangesTest {
@Test
fun aQuietWindowHasNoCycles() {
assertEquals(0, NetworkChanges.flapCycles(emptyList()))
assertEquals(0, NetworkChanges.flapCycles(listOf("link_changed", "link_changed")))
}
@Test
fun oneDropAndReturnIsOneCycle() {
assertEquals(1, NetworkChanges.flapCycles(listOf("lost", "gained")))
assertEquals(
1,
NetworkChanges.flapCycles(listOf("link_changed", "lost", "link_changed", "gained")),
)
}
@Test
fun repeatedDropsCountSeparately() {
assertEquals(3, NetworkChanges.flapCycles(listOf("lost", "gained", "lost", "gained", "lost", "gained")))
}
// A link that is still down when the run ends was not flapping — it was switched off, or the
// device left its range. Counting that as a cycle would put a phone in a lift beside a failing
// access point.
@Test
fun aDropThatNeverReturnsIsNotACycle() {
assertEquals(0, NetworkChanges.flapCycles(listOf("lost")))
assertEquals(1, NetworkChanges.flapCycles(listOf("lost", "gained", "lost")))
}
// The mirror case: the window opened while the network was already gone, so its return is the
// first thing seen. Nothing was watched dropping, so nothing is claimed.
@Test
fun aReturnWithNoObservedDropIsNotACycle() {
assertEquals(0, NetworkChanges.flapCycles(listOf("gained")))
assertEquals(0, NetworkChanges.flapCycles(listOf("gained", "link_changed")))
}
@Test
fun theChangeOverloadAgreesWithTheKindsOverload() {
val changes = listOf(
NetworkChange(atMonoNs = 1, kind = NetworkChanges.LOST),
NetworkChange(atMonoNs = 2, kind = NetworkChanges.GAINED),
NetworkChange(atMonoNs = 3, kind = NetworkChanges.LINK_CHANGED),
)
assertEquals(1, NetworkChanges.flapCyclesOf(changes))
}
}
@@ -40,6 +40,10 @@ object Classification {
listOf(
"addr", "address", "gateway", "dst", "src", "servers", "server", "resolver",
"next_hop", "via", "public_ip", "observed_ip",
// Who sent each passive-discovery announcement (SSDP / LLMNR / NetBIOS / WS-Discovery).
// Family-agnostic on purpose: the same field carries a dotted quad from a v4 group and
// a link-local from ff02::c, and the v6 transform hands dotted quads to the v4 path.
"source_ip",
).forEach { put(it, LogicalType.IP6) }
listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac")
@@ -52,13 +56,28 @@ object Classification {
"friendly_name", "server_name", "sni", "cname", "search_domain", "device_name",
// Plural and prefixed variants the models actually use.
"search_domains", "private_dns_hostname", "domains", "hostnames",
// Names a device shouts at the whole segment. An LLMNR question and a NetBIOS
// registration are hostnames in every sense that matters here — they name a machine on
// somebody's home network — so they get the same per-label treatment as any other.
"netbios_name",
).forEach { put(it, LogicalType.FQDN) }
listOf("session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid")
.forEach { put(it, LogicalType.OPAQUE_ID) }
listOf(
"session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid",
// Discovery identities. A UPnP USN and a WS-Discovery endpoint UUID are stable,
// globally unique per device and frequently derived from a serial number — exactly the
// thing that lets two uploads be recognised as the same household.
"usn", "device_uuid",
).forEach { put(it, LogicalType.OPAQUE_ID) }
listOf("notes", "detail", "raw", "excerpt", "location", "model_description")
.forEach { put(it, LogicalType.FREETEXT) }
listOf(
"notes", "detail", "raw", "excerpt", "location", "model_description",
// The make/model a device volunteers, and the URLs it points at. `server_banner` is
// deliberately not named `server`, which the family-agnostic address block already
// claims — a SERVER header run through the address transform would be mangled into
// nonsense while protecting nothing.
"server_banner", "product_hint", "wsd_types", "wsd_xaddrs",
).forEach { put(it, LogicalType.FREETEXT) }
}
/**
@@ -79,6 +98,13 @@ object Classification {
private val droppedKeys = setOf(
"ssdp_responders", "upnp", "neighbors", "arp_table", "scan_results",
"nearby_networks", "peers", "raw_dump", "dumpsys",
// The long run's passive-discovery inventories. Same argument as `ssdp_responders`, only
// more so: these are minutes of everything the segment said about itself — device models,
// hostnames, printers, who is looking for whom. Even fully pseudonymized the *shape* of a
// household is a fingerprint, no metric depends on the list (the counts live in `metrics`,
// which survives), and the collectors' own status fields stay behind to say the capture
// worked. Dropping beats mangling.
"ssdp_devices", "llmnr_queries", "netbios_names", "wsd_devices",
)
fun typeOf(key: String, path: List<String>): LogicalType? {
+5
View File
@@ -28,4 +28,9 @@ dependencies {
implementation(project(":core-measurement"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
// JVM unit tests for the pure wire-format decoders (DiscoveryParsers.kt) against realistic and
// deliberately malformed payloads — no device, no Android runtime. Same setup as core-shizuku's
// dump-parser tests: JUnit 4, because that is what AGP's unit-test source set runs.
testImplementation(libs.kotlin.test.junit)
testImplementation(libs.junit4)
}
@@ -0,0 +1,78 @@
// 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.TestError
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.Tier
/**
* A measurement that watches, rather than one that asks — the long-run counterpart to [Probe].
*
* The difference is not duration but what is observable at all. A [Probe] describes the network
* during its own two seconds, so anything intermittent is invisible to the whole battery unless it
* happens to coincide with a probe: a wifi link that drops for four seconds every two minutes is
* reported as perfectly healthy by every one-shot test, both before and after the gap. A collector
* starts at t=0, keeps sampling while the battery runs beside it and after it finishes, and yields
* one [Test] when the window closes.
*
* Contract, and all of it is load-bearing for a run that can be cancelled at any second:
* - [start] must return promptly, having launched whatever it needs on its own scope. The battery
* runs concurrently and must not wait for a listener.
* - [stop] must be callable after a failed [start], must never throw, and must return whatever was
* gathered so far. A cancelled long run still owes the user the two minutes it did watch.
* - Neither may throw to the caller; a collector that could not register its listener reports that
* as an `unsupported` Test, which is a result rather than an absence.
*/
interface Collector {
/** A TestType registry id — collectors do not get their own namespace. */
val type: String
val tier: Tier get() = Tier.APP
suspend fun start(ctx: Context, ids: ProbeIds)
suspend fun stop(): Test
}
/**
* Shared plumbing: the ids and the [TestBuilder] a collector needs in [Collector.stop], captured in
* [Collector.start] before anything that can fail.
*
* Assigned first thing on purpose. A collector whose registration throws still has to produce a
* Test saying so, and it cannot do that without a UUID source and a start timestamp — so acquiring
* them is never allowed to be the step that failed.
*/
abstract class BaseCollector : Collector {
protected var ids: ProbeIds? = null
private set
private var builder: TestBuilder? = null
/** Call at the top of [Collector.start], before any platform call. */
protected fun begin(ids: ProbeIds, networkRef: String? = null) {
this.ids = ids
builder = TestBuilder(type, tier, ids, networkRef = networkRef)
}
/**
* Builds this collector's Test, or — when [begin] never ran, so the collector was stopped
* without ever being started — a `skipped` one saying exactly that. It reports rather than
* throws for the same reason probes do: the caller is assembling a document, and an exception
* there costs every other collector's data too.
*/
protected fun build(
status: TestStatus,
evidence: kotlinx.serialization.json.JsonObject? = null,
metrics: kotlinx.serialization.json.JsonObject? = null,
error: TestError? = null,
params: kotlinx.serialization.json.JsonObject? = null,
): Test = builder?.build(status, evidence, metrics, error, params)
?: Test(
id = "00000000-0000-7000-8000-000000000000", type = type, tier = tier,
startedMonoNs = 0, endedMonoNs = 0, status = TestStatus.SKIPPED,
error = TestError("not_started", "the collector was stopped before it was started"),
)
}
@@ -40,6 +40,12 @@ object ConstraintDetector {
for (e in entries) {
// The tunnel itself stays bindable — it is the underlying networks the OS walls off.
if (e.model.transport == Transport.VPN) continue
// Networks an app may never bind (carrier IMS/VoLTE, MMS, XCAP — no INTERNET, no
// NOT_RESTRICTED) refuse with EPERM permanently, VPN or no VPN. Counting them as a
// constraint is what made a phone with VoLTE report "measurement blocked" forever
// and forced every run INCONCLUSIVE. They are recorded in networks[] as
// app_usable:false; they are not something a run failed to do.
if (e.model.appUsable == false) continue
val err = try {
DatagramSocket().use { s -> e.handle.bindSocket(s) }
null
@@ -0,0 +1,360 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
/**
* Wire-format decoders for the passive discovery collectors (SSDP, LLMNR, NetBIOS-NS, WS-Discovery).
*
* No Android imports on purpose: every byte these see was broadcast by an unidentified device on
* someone else's network, so they are the part of the collectors that most needs to be exercised
* against malformed input — and that is only cheap to do if it runs on a plain JVM. See
* DiscoveryParsersTest.
*
* The universal contract here is **return null, never throw**. A collector that dies on one
* malformed datagram loses the whole window's inventory, and a device that emits garbage is a
* device we still want counted. Every decoder therefore bounds-checks by hand rather than relying
* on an exception, and treats "this is not the protocol I parse" and "this is the protocol but it
* is broken" as the same answer: nothing to record.
*/
// ---- shared -------------------------------------------------------------------------------
/**
* Makes a decoded string safe to embed in a JSON document.
*
* Names arrive as arbitrary bytes. Control characters would survive JSON encoding as escapes and
* turn up in a terminal that interprets them, and an unbounded length is a memory cost decided by
* whoever is shouting on the segment — so both are capped here rather than at each call site.
*/
internal fun sanitizeText(s: String, max: Int = 255): String {
val sb = StringBuilder(minOf(s.length, max))
for (c in s) {
if (sb.length >= max) break
sb.append(if (c.isISOControl() || c == '') '?' else c)
}
return sb.toString()
}
private fun u8(b: ByteArray, i: Int) = b[i].toInt() and 0xFF
private fun u16(b: ByteArray, i: Int) = (u8(b, i) shl 8) or u8(b, i + 1)
// ---- SSDP ---------------------------------------------------------------------------------
enum class SsdpKind { ALIVE, BYEBYE, UPDATE, RESPONSE, SEARCH, OTHER }
/**
* One SSDP message. [target] is NT for announcements and ST for search responses — they name the
* same thing (which service is being talked about) from the two sides of the conversation, so they
* collapse into one field.
*/
data class SsdpMessage(
val kind: SsdpKind,
val target: String?,
val usn: String?,
val serverBanner: String?,
val location: String?,
)
object SsdpParser {
/**
* SSDP is HTTP-shaped but is not HTTP: there is no framing, no content length, and vendors
* disagree about line endings. Parsing it as "a start line plus colon-separated headers, be
* liberal about the rest" is the whole job — running it through an HTTP client would reject
* messages that real devices send and that we want to count.
*
* ISO-8859-1 decoding because the headers are byte-oriented and this mapping is total: no byte
* sequence can fail to decode, so a device with a Latin-1 model name in its SERVER banner is
* recorded rather than replaced by question marks.
*/
fun parse(bytes: ByteArray, len: Int): SsdpMessage? {
if (len <= 0 || len > bytes.size) return null
val text = String(bytes, 0, len, Charsets.ISO_8859_1)
val lines = text.split('\n')
val start = lines.firstOrNull()?.trim().orEmpty()
if (start.isEmpty()) return null
val headers = HashMap<String, String>()
for (i in 1 until lines.size) {
val line = lines[i].trimEnd('\r')
if (line.isBlank()) break
val c = line.indexOf(':')
if (c <= 0) continue
val key = line.substring(0, c).trim().uppercase()
// First occurrence wins: a duplicated header is a device bug, and taking the first
// matches what every SSDP implementation in the wild does.
if (key !in headers) headers[key] = sanitizeText(line.substring(c + 1).trim(), 512)
}
val kind = when {
start.startsWith("NOTIFY", ignoreCase = true) -> when (headers["NTS"]?.lowercase()) {
"ssdp:alive" -> SsdpKind.ALIVE
"ssdp:byebye" -> SsdpKind.BYEBYE
"ssdp:update" -> SsdpKind.UPDATE
else -> SsdpKind.OTHER
}
start.startsWith("M-SEARCH", ignoreCase = true) -> SsdpKind.SEARCH
start.startsWith("HTTP/", ignoreCase = true) -> SsdpKind.RESPONSE
else -> return null // not SSDP at all
}
return SsdpMessage(
kind = kind,
target = headers["NT"] ?: headers["ST"],
usn = headers["USN"],
serverBanner = headers["SERVER"],
location = headers["LOCATION"],
)
}
/**
* The make/model inside a SERVER banner, as a hint.
*
* A banner reads `Linux/4.4 UPnP/1.0 Synology/DSM-7.3` or `FRITZ!Box 7590 UPnP/1.0`: the
* interesting token is whichever one is not the OS and not the protocol version, and there is
* no grammar that says which. Dropping the known-boilerplate tokens and keeping the rest is
* therefore a heuristic and is reported as such — [SsdpMessage.serverBanner] is kept verbatim
* beside it so nobody has to trust this to read the evidence.
*/
fun productHint(serverBanner: String?): String? {
val banner = serverBanner?.trim().orEmpty()
if (banner.isEmpty()) return null
val kept = banner.split(' ', '\t')
.map { it.trim() }
.filter { it.isNotEmpty() && it.substringBefore('/').lowercase() !in BOILERPLATE }
.distinct()
return kept.joinToString(" ").takeIf { it.isNotEmpty() }
}
private val BOILERPLATE = setOf(
"upnp", "http", "dlnadoc", "linux", "unix", "windows", "darwin", "posix", "sdk",
"upnp-device-host", "microsoft-windows", "mono.upnp", "webos", "android",
)
}
// ---- DNS-format questions (LLMNR, and the shape NetBIOS-NS borrows) -------------------------
/** One question from a DNS-format packet. [isQuery] separates "who is asking" from "who answered". */
data class DnsQuestion(
val id: Int,
val name: String,
val qtype: Int,
val qclass: Int,
val isQuery: Boolean,
val opcode: Int,
)
object LlmnrParser {
/**
* Decodes the first question of an LLMNR packet, which is DNS wire format with a different
* transport.
*
* Name compression is rejected rather than followed. RFC 4795 forbids it in LLMNR, so a pointer
* here is either a broken sender or someone hoping the parser will chase it — and a pointer
* loop is the classic way to hang a DNS decoder. Refusing costs nothing real and removes the
* only unbounded loop this decoder could have had.
*/
fun parse(bytes: ByteArray, len: Int): DnsQuestion? {
if (len < HEADER + 5 || len > bytes.size) return null
val flags = u16(bytes, 2)
if (u16(bytes, 4) < 1) return null // no question section
val sb = StringBuilder()
var i = HEADER
var labels = 0
while (true) {
if (i >= len) return null
val l = u8(bytes, i)
if (l == 0) { i++; break }
if (l and 0xC0 != 0) return null // compression pointer / reserved length
i++
if (i + l > len) return null
if (++labels > MAX_LABELS || sb.length + l > MAX_NAME) return null
if (sb.isNotEmpty()) sb.append('.')
sb.append(String(bytes, i, l, Charsets.UTF_8))
i += l
}
if (sb.isEmpty()) return null
if (i + 4 > len) return null
return DnsQuestion(
id = u16(bytes, 0),
name = sanitizeText(sb.toString()),
qtype = u16(bytes, i),
qclass = u16(bytes, i + 2),
isQuery = (flags and 0x8000) == 0,
opcode = (flags shr 11) and 0x0F,
)
}
/** The record types worth naming in evidence; anything else is reported as its number. */
fun qtypeName(qtype: Int): String = when (qtype) {
1 -> "A"
28 -> "AAAA"
12 -> "PTR"
33 -> "SRV"
255 -> "ANY"
else -> qtype.toString()
}
private const val HEADER = 12
private const val MAX_LABELS = 64
private const val MAX_NAME = 255
}
// ---- NetBIOS name service ------------------------------------------------------------------
/**
* A decoded NetBIOS name-service question.
*
* [suffix] is the sixteenth byte of the name and is the part an engineer reads first: it says what
* the announcement is *for* (a workstation, a file server, a browser election) rather than who is
* making it.
*/
data class NetbiosName(
val name: String,
val suffix: Int,
val role: String,
val isResponse: Boolean,
val opcode: Int,
)
object NetbiosParser {
/**
* Decodes the question name from an NBNS packet (RFC 1002 §4.2).
*
* The header is DNS-shaped, but the name is not: NetBIOS first-level encoding splits each of
* the 16 name bytes into two nibbles and adds 'A' to each, so a 16-byte name is always exactly
* 32 characters drawn from A-P. That fixed shape is also the validity check — anything outside
* A-P means this is not an NBNS name, and it is cheaper and safer to reject the packet than to
* guess at what a half-decodable name was supposed to say.
*/
fun parse(bytes: ByteArray, len: Int): NetbiosName? {
if (len < HEADER + 34 || len > bytes.size) return null
if (u16(bytes, 4) < 1) return null // no question section
if (u8(bytes, HEADER) != ENCODED_LEN) return null
val raw = ByteArray(16)
for (j in 0 until 16) {
val hi = u8(bytes, HEADER + 1 + j * 2) - 'A'.code
val lo = u8(bytes, HEADER + 2 + j * 2) - 'A'.code
if (hi !in 0..15 || lo !in 0..15) return null
raw[j] = ((hi shl 4) or lo).toByte()
}
// The first 15 bytes are the name, space-padded; the 16th is the suffix.
val padded = String(raw, 0, 15, Charsets.ISO_8859_1)
val name = sanitizeText(padded.trimEnd { it == ' ' || it.isISOControl() }, 15)
if (name.isEmpty()) return null
val suffix = raw[15].toInt() and 0xFF
val flags = u16(bytes, 2)
return NetbiosName(
name = name,
suffix = suffix,
role = roleOf(suffix),
isResponse = (flags and 0x8000) != 0,
opcode = (flags shr 11) and 0x0F,
)
}
/** RFC 1001 §15 / the Microsoft suffix assignments people actually see on a LAN. */
fun roleOf(suffix: Int): String = when (suffix) {
0x00 -> "workstation"
0x03 -> "messenger"
0x1B -> "domain_master_browser"
0x1C -> "domain_controllers"
0x1D -> "master_browser"
0x1E -> "browser_elections"
0x20 -> "file_server"
else -> "suffix_0x%02X".format(suffix)
}
/** NBNS opcodes: what the sender is doing, not just that it is talking. */
fun opcodeName(opcode: Int): String = when (opcode) {
0 -> "query"
5 -> "registration"
6 -> "release"
7 -> "wack"
8 -> "refresh"
else -> "opcode_$opcode"
}
private const val HEADER = 12
private const val ENCODED_LEN = 32
}
// ---- WS-Discovery --------------------------------------------------------------------------
/** The four things a WS-Discovery datagram is worth reading for. */
data class WsdMessage(
val action: String?,
val deviceUuid: String?,
val types: String?,
val xaddrs: String?,
)
object WsdParser {
/**
* Pulls four leaf values out of SOAP-over-UDP by targeted matching, deliberately **without an
* XML parser**.
*
* The input is unauthenticated, unsolicited, and written by whatever is on the segment. Handing
* that to a real XML parser buys namespace correctness and pays for it with the whole XML
* attack surface — entity expansion (a 700-byte datagram that allocates gigabytes), DTDs that
* fetch external resources, and nesting deep enough to exhaust the stack — all inside a probe
* whose contract is that it never throws. None of that surface is needed to read four leaf
* elements out of a message we are only ever going to count and quote.
*
* So: cap the text, then match `<[prefix:]Tag ...>value<` with a character class that cannot
* backtrack. The worst case is a value we fail to extract, which is recorded as an absence.
*/
fun parse(bytes: ByteArray, len: Int): WsdMessage? {
if (len <= 0 || len > bytes.size) return null
val text = String(bytes, 0, minOf(len, MAX_TEXT), Charsets.UTF_8)
if (!text.contains("Envelope", ignoreCase = true)) return null
// The action tail is the message type — Hello, Bye, Probe, ProbeMatches, ResolveMatches.
val action = leaf(text, "Action")?.substringAfterLast('/')?.takeIf { it.isNotEmpty() }
// The device's stable identity is an EndpointReference/Address holding a urn:uuid. Other
// Address elements exist (wsa:To names the discovery group), so the uuid shape picks the
// right one rather than the first one.
val uuid = leaves(text, "Address").firstOrNull { it.contains("uuid:", ignoreCase = true) }
val msg = WsdMessage(
action = action?.let { sanitizeText(it, 64) },
deviceUuid = uuid?.let { sanitizeText(it, 128) },
types = leaf(text, "Types")?.let { sanitizeText(it, 200) },
xaddrs = leaf(text, "XAddrs")?.let { sanitizeText(it, 300) },
)
val empty = msg.action == null && msg.deviceUuid == null &&
msg.types == null && msg.xaddrs == null
return if (empty) null else msg
}
private fun leaf(xml: String, tag: String): String? = leaves(xml, tag).firstOrNull()
private fun leaves(xml: String, tag: String): List<String> {
val re = TAGS[tag] ?: return emptyList()
return re.findAll(xml).map { it.groupValues[1].trim() }.filter { it.isNotEmpty() }.toList()
}
/**
* `[^<]{0,512}` rather than a lazy `.*?`: it can only ever match forward, so there is no input
* that makes this regex expensive. WS-Discovery leaves hold no markup, so nothing is lost.
*
* Built once, eagerly, because the alternative is a memoizing map touched from the capture
* thread — a data race for the sake of four Regex allocations.
*/
private val TAGS: Map<String, Regex> =
listOf("Action", "Address", "Types", "XAddrs").associateWith { tag ->
Regex(
"""<(?:[A-Za-z0-9_.\-]{1,32}:)?$tag\b[^>]{0,256}>([^<]{0,512})<""",
RegexOption.IGNORE_CASE,
)
}
private const val MAX_TEXT = 16 * 1024
}
@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import android.net.Network
import android.system.Os
import android.system.OsConstants
import android.system.StructTimeval
import java.io.FileDescriptor
import java.net.InetAddress
import java.nio.ByteBuffer
import java.util.Locale
/**
* One echo exchange over the unprivileged ICMP datagram socket.
*
* Extracted from [IcmpProbe] when the long-run [PingSeriesCollector] needed the same exchange a
* few hundred times instead of once. The two differ only in how often they call this; a second
* copy of the checksum and the sent/not-sent bookkeeping would only be a second place for them to
* drift apart.
*
* Works without root because Android ships an open `ping_group_range` — validated on hardware by
* the prober, and the reason this probe family exists at app tier at all.
*/
internal object IcmpEcho {
/**
* One attempt's result.
*
* [attempted] separates "we sent an echo request and heard nothing" from "we never got as far
* as sending one". Both leave [ok] false, and collapsing them is how a probe ends up asserting
* something about a network it never touched: binding to a non-default network can fail with
* EPERM, and reporting that as ICMP silence blames the network for the app's own inability to
* use the interface. For a series it is also the difference between a lost packet and a socket
* that was never usable — one is loss, the other is not.
*/
data class Result(
val ok: Boolean,
val attempted: Boolean,
val detail: String,
val rttMs: Double?,
)
fun ping(
network: Network?,
target: String,
v6: Boolean,
timeoutMs: Int,
seq: Int = 1,
): Result {
var fd: FileDescriptor? = null
var sent = false
return try {
val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP
val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET
fd = Os.socket(family, OsConstants.SOCK_DGRAM, proto)
// Everything up to and including sendto is setup. A failure here means the test did
// not run on this network — not that the network stayed silent.
network?.bindSocket(fd)
Os.setsockoptTimeval(
fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO,
StructTimeval.fromMillis(timeoutMs.toLong()),
)
val addr = network?.getByName(target) ?: InetAddress.getByName(target)
val ident = (Os.getpid() and 0xFFFF)
val packet = buildEchoRequest(v6, ident.toShort(), seq.toShort())
val t0 = System.nanoTime()
Os.sendto(fd, packet, 0, packet.size, 0, addr, 0)
sent = true
val buf = ByteBuffer.allocate(1500)
val received = Os.recvfrom(fd, buf, 0, null)
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1
val ok = replyType == (if (v6) 129 else 0)
Result(
ok, true,
"reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} bytes=$received",
if (ok) rttMs else null,
)
} catch (e: Throwable) {
// A timeout after a successful send is a real "no reply"; anything before it is not.
Result(false, sent, "error: ${e.message ?: e.javaClass.simpleName}", null)
} finally {
fd?.let { runCatching { Os.close(it) } }
}
}
private fun buildEchoRequest(v6: Boolean, ident: Short, seq: Short): ByteArray {
val type = if (v6) 128 else 8
val payload = "echolot".toByteArray()
val pkt = ByteBuffer.allocate(8 + payload.size)
pkt.put(type.toByte()); pkt.put(0); pkt.putShort(0)
pkt.putShort(ident); pkt.putShort(seq); pkt.put(payload)
val bytes = pkt.array()
// The v6 checksum is computed by the kernel over a pseudo-header the socket owns; filling
// it in here would be wrong, not merely redundant.
if (!v6) {
val cs = checksum(bytes)
bytes[2] = (cs.toInt() shr 8).toByte(); bytes[3] = (cs.toInt() and 0xFF).toByte()
}
return bytes
}
private fun checksum(b: ByteArray): Short {
var sum = 0; var i = 0
while (i < b.size - 1) { sum += ((b[i].toInt() and 0xFF) shl 8) or (b[i + 1].toInt() and 0xFF); i += 2 }
if (i < b.size) sum += (b[i].toInt() and 0xFF) shl 8
while (sum shr 16 != 0) sum = (sum and 0xFFFF) + (sum shr 16)
return sum.inv().toShort()
}
}
@@ -5,9 +5,6 @@ package app.echo_lot.probe
import android.content.Context
import android.net.Network
import android.system.Os
import android.system.OsConstants
import android.system.StructTimeval
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
@@ -17,10 +14,6 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.io.FileDescriptor
import java.net.InetAddress
import java.nio.ByteBuffer
import java.util.Locale
/**
* icmp.ping4 / icmp.ping6 via the unprivileged ICMP datagram socket, per active network
@@ -40,7 +33,7 @@ class IcmpProbe(
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
val b = TestBuilder(type, tier, ids)
val perNetwork = LinkedHashMap<String, Pair<String?, Attempt>>()
val perNetwork = LinkedHashMap<String, Pair<String?, IcmpEcho.Result>>()
var anyOk = false
val rtts = ArrayList<Double>()
@@ -82,75 +75,9 @@ class IcmpProbe(
b.build(status, evidence = evidence, metrics = metrics)
}
/**
* One network's result.
*
* [attempted] separates "we sent an echo request and heard nothing" from "we never got as far
* as sending one". Both leave [ok] false, and collapsing them is how a probe ends up asserting
* something about a network it never touched: binding to a non-default network can fail with
* EPERM, and reporting that as ICMPv6 silence blames the carrier for the app's own inability
* to use the interface.
*/
private data class Attempt(
val ok: Boolean,
val attempted: Boolean,
val detail: String,
val rttMs: Double?,
)
private fun attempt(network: Network?): Attempt {
var fd: FileDescriptor? = null
var sent = false
return try {
val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP
val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET
fd = Os.socket(family, OsConstants.SOCK_DGRAM, proto)
// Everything up to and including sendto is setup. A failure here means the test did
// not run on this network — not that the network stayed silent.
network?.bindSocket(fd)
Os.setsockoptTimeval(fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO, StructTimeval.fromMillis(3000))
val addr = network?.getByName(target) ?: InetAddress.getByName(target)
val ident = (Os.getpid() and 0xFFFF)
val packet = buildEchoRequest(v6, ident.toShort(), 1)
val t0 = System.nanoTime()
Os.sendto(fd, packet, 0, packet.size, 0, addr, 0)
sent = true
val buf = ByteBuffer.allocate(1500)
val received = Os.recvfrom(fd, buf, 0, null)
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1
val ok = replyType == (if (v6) 129 else 0)
Attempt(ok, true, "reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} bytes=$received", if (ok) rttMs else null)
} catch (e: Throwable) {
// A timeout after a successful send is a real "no reply"; anything before it is not.
Attempt(false, sent, "error: ${e.message ?: e.javaClass.simpleName}", null)
} finally {
fd?.let { runCatching { Os.close(it) } }
}
}
private fun buildEchoRequest(v6: Boolean, ident: Short, seq: Short): ByteArray {
val type = if (v6) 128 else 8
val payload = "echolot".toByteArray()
val pkt = ByteBuffer.allocate(8 + payload.size)
pkt.put(type.toByte()); pkt.put(0); pkt.putShort(0)
pkt.putShort(ident); pkt.putShort(seq); pkt.put(payload)
val bytes = pkt.array()
if (!v6) {
val cs = checksum(bytes)
bytes[2] = (cs.toInt() shr 8).toByte(); bytes[3] = (cs.toInt() and 0xFF).toByte()
}
return bytes
}
private fun checksum(b: ByteArray): Short {
var sum = 0; var i = 0
while (i < b.size - 1) { sum += ((b[i].toInt() and 0xFF) shl 8) or (b[i + 1].toInt() and 0xFF); i += 2 }
if (i < b.size) sum += (b[i].toInt() and 0xFF) shl 8
while (sum shr 16 != 0) sum = (sum and 0xFFFF) + (sum shr 16)
return sum.inv().toShort()
}
/** The echo exchange itself lives in [IcmpEcho], shared with the long-run ping series. */
private fun attempt(network: Network?): IcmpEcho.Result =
IcmpEcho.ping(network, target, v6, timeoutMs = 3000)
private companion object {
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
@@ -0,0 +1,124 @@
// 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.TestError
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* local.llmnr_inventory — who is resolving names with LLMNR on this segment, and what for.
*
* The measurement is twofold, and the second half is the one people underestimate:
*
* 1. **Which hostnames are being looked up.** An LLMNR query is a device saying out loud, to
* everyone, the name of something it wants to reach. Over a window that is a map of who talks
* to whom — and, when the names are things like `wpad` or a server that no longer exists, a map
* of what is failing to resolve through DNS and falling back.
* 2. **That LLMNR is in use at all.** LLMNR (and its NetBIOS sibling) is a name-resolution
* fallback that trusts whoever answers first, which is the mechanism behind the standard
* credential-relay attack on Windows networks. Its mere presence on a segment is a finding
* independent of any individual query — which is why this earns a test id rather than folding
* into the mDNS inventory.
*
* Purely passive: queries are broadcast to the group, so listening is the entire measurement, and
* answering or querying would make this device a participant in exactly the trust relationship the
* measurement is about.
*/
class LlmnrCollector : BaseCollector() {
override val type = TestType.LOCAL_LLMNR_INVENTORY
override val tier = Tier.APP
private val capture = MulticastCapture(
label = "llmnr",
port = PORT,
group4 = GROUP4,
// ff02::1:3 is the IPv6 LLMNR group. Joined where IPv6 exists; a v4-only network simply
// records an empty joined_v6 rather than an error, because that is not one.
group6 = GROUP6,
)
private var startedAtMonoNs = 0L
override suspend fun start(ctx: Context, ids: ProbeIds) {
begin(ids)
startedAtMonoNs = ids.monoNs()
capture.acquireLock(ctx)
capture.start(ids)
}
override suspend fun stop(): Test {
val packets = capture.stop()
val table = DiscoveryTable()
var queries = 0
var responses = 0
var undecodable = 0
for (p in packets) {
val q = LlmnrParser.parse(p.data, p.data.size)
if (q == null) {
undecodable++
continue
}
if (q.isQuery) queries++ else responses++
// Responses are normally unicast back to the querier, so what lands here is
// overwhelmingly queries — but a response that does reach the group is still a device
// claiming a name, which is worth the same row.
table.observe(p.sourceIp, "${q.name}/${q.qtype}", p.atMonoNs) {
buildJsonObject {
put("query_name", q.name)
put("qtype", LlmnrParser.qtypeName(q.qtype))
put("kind", if (q.isQuery) "query" else "response")
}
}
}
val evidence: JsonObject = buildJsonObject {
put("capture", capture.statusJson())
put("llmnr_queries", table.toJson())
put("evidence_truncated", capture.truncated || table.overflowed)
}
val metrics: JsonObject = buildJsonObject {
put("distinct_sources", table.distinctSources)
put("distinct_names", table.size)
put("packets", capture.packetsSeen)
put("queries", queries)
put("responses", responses)
put("undecodable_packets", undecodable)
// The headline: whether this protocol is live here at all. A boolean rather than an
// inference from a count, so a reader (or a future finding rule) never has to decide
// what "zero packets" meant — the capture's own status says whether zero is trustworthy.
put("llmnr_in_use", table.distinctSources > 0)
put("truncated", capture.truncated || table.overflowed)
}
val saw = !table.isEmpty
return build(
capture.outcome(saw),
evidence = evidence,
metrics = metrics,
params = params(),
error = capture.reason(saw)?.let { TestError("listen_incomplete", it) },
)
}
private fun params(): JsonObject = buildJsonObject {
put("mode", "passive")
put("group_v4", "$GROUP4:$PORT")
put("group_v6", "[$GROUP6]:$PORT")
put("started_mono_ns", startedAtMonoNs)
}
private companion object {
const val PORT = 5355
const val GROUP4 = "224.0.0.252"
const val GROUP6 = "ff02::1:3"
}
}
@@ -30,11 +30,16 @@ import java.util.Collections
* found live services NsdManager's meta-query support is unreliable across builds, so the
* concrete types are the measurement and the meta-query result is itself evidence.
* - 4 s of listening missed services that 10 s catches; mDNS answers straggle.
*
* That last lesson is why [listenMs] is a parameter. 10 s is the short-mode default because it is
* the shortest window that was not demonstrably lossy; a long run hands it the whole measurement
* window, since the curve does not stop at ten seconds devices announce on their own schedule,
* and a printer that is asleep answers when something else wakes it.
*/
class MdnsInventoryProbe : Probe {
class MdnsInventoryProbe(private val listenMs: Long = 10_000) : Probe {
override val type = TestType.LOCAL_MDNS_INVENTORY
override val tier = Tier.APP
override val estimatedMs = 10_500L
override val estimatedMs = listenMs + 500
/** Meta-query + common concrete types (HTTP covers HA/printers/NAS; googlecast is ubiquitous). */
private val queries = listOf(
@@ -75,7 +80,7 @@ class MdnsInventoryProbe : Probe {
Triple(label, type, r)
}
try {
delay(10_000)
delay(listenMs)
var total = 0
var anyStarted = false
val evidence = buildJsonObject {
@@ -95,10 +100,13 @@ class MdnsInventoryProbe : Probe {
}
}
val metrics = buildJsonObject { put("services_found", total) }
// How long it listened belongs in params: "4 services" means something different after
// ten seconds than after five minutes, and the number alone cannot say which it was.
val params = buildJsonObject { put("listen_ms", listenMs) }
// Zero services on a started discovery is a legitimate result (an empty or properly
// isolated network), not a failure — only discovery refusing to start is one.
b.build(if (anyStarted) TestStatus.OK else TestStatus.FAILED,
evidence = evidence, metrics = metrics)
evidence = evidence, metrics = metrics, params = params)
} finally {
recorders.forEach { (_, _, r) -> runCatching { nsd.stopServiceDiscovery(r) } }
runCatching { lock?.release() }
@@ -0,0 +1,420 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import android.content.Context
import android.net.wifi.WifiManager
import app.echo_lot.measurement.TestStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
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.add
import java.net.DatagramPacket
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.MulticastSocket
import java.net.NetworkInterface
import java.net.SocketAddress
import java.net.SocketTimeoutException
/**
* The listening half of every passive-discovery collector: hold a multicast lock, bind a
* well-known UDP port, join a group on every interface that will take it, and retain the datagrams
* that arrive until the window closes.
*
* Four things here are the difference between a working listener and one that silently reports an
* empty network, and each of them has cost somebody a day:
*
* - **The MulticastLock.** Android's wifi stack filters out frames not addressed to this device
* unless an app holds one. Without it every collector built on this returns "nothing on this
* network" on a network full of chatter — the single most common reason a listener like this
* appears to work and measures nothing. Whether it was actually held is therefore reported, not
* assumed: an unheld lock plus silence is not a clean result and [outcome] refuses to call it one.
* - **SO_REUSEADDR before bind.** The well-known discovery ports are shared by construction the
* system's own mDNS/SSDP responders and any other app doing this are already there so binding
* exclusively fails on exactly the networks worth measuring.
* - **Joining per interface.** The group must be joined on the interface the traffic arrives on,
* which is not necessarily the default route: a phone with wifi plus a VPN plus cellular has
* three, and the LAN chatter is on the one that is not carrying the default route.
* - **A bound buffer, bounded per source too.** A chatty network must not decide how much memory
* this uses, and one device announcing every two seconds must not be able to fill the buffer and
* hide the twenty quieter devices behind it. Both caps set [truncated] rather than being silent.
*
* Nothing here throws. Every failure lands in [failure] (the capture is dead) or [degraded] (it is
* running but cannot see everything), which the owning collector turns into a recorded status.
*/
internal class MulticastCapture(
/** Names the MulticastLock, so a `dumpsys wifi` during a run says which listener holds what. */
private val label: String,
private val port: Int,
private val group4: String? = null,
private val group6: String? = null,
private val maxPackets: Int = 400,
private val maxBytesRetained: Int = 128 * 1024,
private val maxPerSource: Int = 24,
/**
* Whether to fall back to an ephemeral port when the well-known one cannot be bound.
*
* Only useful for a protocol with an active half: replies to our own searches come back to
* whatever port we sent from, so an ephemeral socket still collects those but it can never
* see the unsolicited announcements, which is why it is [degraded] and not normal operation.
*/
private val allowEphemeralFallback: Boolean = false,
) {
/** One retained datagram. Not a data class: the payload is a ByteArray, and structural equality
* over it would be both wrong and expensive. */
class Packet(val sourceIp: String, val atMonoNs: Long, val data: ByteArray)
/** Set when the capture could not be brought up at all; the collector reports `unsupported`. */
var failure: String? = null
private set
/** Set when the capture is running but blind to part of what it exists to see. */
var degraded: String? = null
private set
var lockHeld: Boolean = false
private set
var boundPort: Int = 0
private set
var packetsSeen: Int = 0
private set
var truncated: Boolean = false
private set
val joined4 = ArrayList<String>()
val joined6 = ArrayList<String>()
private val joinErrors = ArrayList<String>()
/** Whether this protocol needs a group join at all — NetBIOS is broadcast, not multicast. */
private val expectsGroup = group4 != null || group6 != null
private val packets = ArrayList<Packet>()
private val perSource = HashMap<String, Int>()
private var retainedBytes = 0
@Volatile private var running = false
private var socket: MulticastSocket? = null
private var lock: WifiManager.MulticastLock? = null
private var scope: CoroutineScope? = null
/**
* Brings the listener up and returns whether it is capturing. Setup is a handful of syscalls,
* so it finishes in milliseconds and the [Collector.start] promptness contract holds; only the
* receive loop is handed to a background scope.
*/
suspend fun start(ids: ProbeIds): Boolean = withContext(Dispatchers.IO) {
val s = bind() ?: return@withContext false
socket = s
running = true
// A degraded (ephemeral-port) socket is deliberately not joined to the group: it would be
// joining for a port nothing sends to, and the resulting "joined wlan0" in the evidence
// would claim a capability the capture does not have.
if (expectsGroup && degraded == null) joinGroups(s)
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { it.launch { pump(s, ids) } }
true
}
/** Acquires the multicast lock. Separate from [start] because it needs a Context and the rest
* does not, and because whether it succeeded is itself evidence. */
fun acquireLock(ctx: Context) {
val wifi = runCatching { ctx.applicationContext.getSystemService(WifiManager::class.java) }
.getOrNull()
lock = runCatching {
wifi?.createMulticastLock("echolot-$label")?.apply {
setReferenceCounted(false)
acquire()
}
}.getOrNull()
lockHeld = runCatching { lock?.isHeld == true }.getOrDefault(false)
}
/** Sends from the capture socket, so replies land back in this capture rather than on a second
* socket nobody is reading. Returns whether the datagram left the device. */
fun send(payload: ByteArray, host: String, toPort: Int): Boolean = runCatching {
val s = socket ?: return false
s.send(DatagramPacket(payload, payload.size, InetSocketAddress(host, toPort)))
true
}.getOrDefault(false)
/** Stops listening and hands back what was retained. Safe after a failed [start], and never
* throws a cancelled run still owes the caller the packets it did see. */
fun stop(): List<Packet> {
running = false
// Closed before the coroutine is cancelled: a blocking receive() does not notice
// cancellation, and closing the socket is what makes it return.
runCatching { socket?.close() }
socket = null
scope?.coroutineContext?.get(Job)?.cancel()
scope = null
runCatching { lock?.release() }
lock = null
// lockHeld deliberately survives the release: it records whether the capture *could* hear
// multicast while it ran, which is what [outcome] needs to decide whether silence is a
// fact about the network. Clearing it here would make every quiet network report that the
// lock was missing.
return synchronized(packets) { packets.toList() }
}
// ---- status ------------------------------------------------------------------------------
/**
* The status this capture's Test deserves, given whether anything was decoded.
*
* The interesting case is the last one. Silence on a network is a legitimate and useful
* finding but only when we know the listener could have heard something. Without the
* multicast lock, or without a single successful group join, "nothing was seen" describes this
* app and not the network, and reporting it as `ok` would be the collector lying by omission.
*/
fun outcome(sawAnything: Boolean): TestStatus = when {
failure != null -> TestStatus.UNSUPPORTED
degraded != null -> TestStatus.PARTIAL
sawAnything -> TestStatus.OK
expectsGroup && joined4.isEmpty() && joined6.isEmpty() -> TestStatus.PARTIAL
!lockHeld -> TestStatus.PARTIAL
else -> TestStatus.OK
}
/** Why [outcome] was not OK, in words, or null when it was. */
fun reason(sawAnything: Boolean): String? = when {
failure != null -> failure
degraded != null -> degraded
sawAnything -> null
expectsGroup && joined4.isEmpty() && joined6.isEmpty() ->
"no interface accepted the group join, so silence here says nothing about the network"
!lockHeld ->
"the wifi multicast lock was not held, so silence here says nothing about the network"
else -> null
}
/** The capture's own facts, for the collector's evidence. Every collector reports these
* identically so that "saw nothing" can always be told apart from "could not listen". */
fun statusJson(): JsonObject = buildJsonObject {
put("multicast_lock", lockHeld)
put("bound_port", boundPort)
putJsonArray("joined_v4") { for (n in joined4) add(n) }
putJsonArray("joined_v6") { for (n in joined6) add(n) }
if (joinErrors.isNotEmpty()) {
put("join_errors", joinErrors.take(6).joinToString("; ").take(400))
}
degraded?.let { put("degraded", it) }
failure?.let { put("failure", it) }
put("packets_seen", packetsSeen)
put("packets_retained", synchronized(packets) { packets.size })
put("evidence_truncated", truncated)
}
// ---- internals ---------------------------------------------------------------------------
private fun bind(): MulticastSocket? {
// Unbound first, so SO_REUSEADDR is set *before* bind — setting it afterwards has no
// effect, and these ports are always already in use by something.
runCatching {
val s = MulticastSocket(null as SocketAddress?)
s.reuseAddress = true
s.bind(InetSocketAddress(port))
s.soTimeout = SO_TIMEOUT_MS
boundPort = port
return s
}.onFailure { first ->
val why = describe(first)
if (!allowEphemeralFallback) {
// Ports below 1024 are privileged on Android as on any Linux, so this is the
// expected outcome for NetBIOS and is a finding rather than a bug — see
// NetbiosCollector.
failure = "could not bind UDP $port: $why"
return null
}
runCatching {
val s = MulticastSocket(null as SocketAddress?)
s.reuseAddress = true
s.bind(InetSocketAddress(0))
s.soTimeout = SO_TIMEOUT_MS
boundPort = s.localPort
degraded = "UDP $port could not be bound ($why); listening on an ephemeral port " +
"instead, so only replies to our own searches are visible and unsolicited " +
"announcements are not"
return s
}.onFailure { failure = "could not bind UDP $port ($why) or any ephemeral port" }
}
return null
}
private fun joinGroups(s: MulticastSocket) {
val ifaces = runCatching { NetworkInterface.getNetworkInterfaces()?.toList() }
.getOrNull().orEmpty()
.filter {
runCatching { it.isUp && !it.isLoopback && it.supportsMulticast() }
.getOrDefault(false)
}
val g4 = group4?.let { runCatching { InetAddress.getByName(it) }.getOrNull() }
val g6 = group6?.let { runCatching { InetAddress.getByName(it) }.getOrNull() }
for (ni in ifaces) {
val addrs = runCatching { ni.inetAddresses.toList() }.getOrNull().orEmpty()
if (g4 != null && addrs.any { it is Inet4Address }) {
join(s, g4, ni)?.let { joined4.add(it) }
}
// A network with no IPv6 address is not a failure to report as an error — it is a
// v4-only network, which is most of them. Only interfaces that could have joined are
// asked to.
if (g6 != null && addrs.any { it is Inet6Address }) {
join(s, g6, ni)?.let { joined6.add(it) }
}
}
// Last resort: let the kernel pick the interface. Some vendor builds refuse the explicit
// form on the very interface that carries the traffic, and a default-interface join is
// better than no listener at all.
if (joined4.isEmpty() && joined6.isEmpty()) {
@Suppress("DEPRECATION")
(g4 ?: g6)?.let { g ->
runCatching { s.joinGroup(g) }
.onSuccess { (if (g is Inet4Address) joined4 else joined6).add("(default)") }
.onFailure { joinErrors.add("default: ${describe(it)}") }
}
}
}
private fun join(s: MulticastSocket, group: InetAddress, ni: NetworkInterface): String? =
runCatching {
s.joinGroup(InetSocketAddress(group, boundPort), ni)
ni.name
}.onFailure {
joinErrors.add("${ni.name}/${if (group is Inet4Address) "v4" else "v6"}: ${describe(it)}")
}.getOrNull()
private fun pump(s: MulticastSocket, ids: ProbeIds) {
val buf = ByteArray(READ_BUFFER)
while (running) {
val p = DatagramPacket(buf, buf.size)
try {
s.receive(p)
} catch (t: SocketTimeoutException) {
// The timeout exists only so this loop notices `running` going false if the socket
// close somehow does not wake it. Nothing to record.
continue
} catch (t: Throwable) {
// The normal exit: stop() closed the socket underneath us. It is also what a
// vanishing interface looks like, and neither is worth a status of its own — the
// packets gathered so far are still the measurement.
return
}
packetsSeen++
val ip = p.address?.hostAddress ?: continue
val len = p.length
if (len <= 0) continue
synchronized(packets) {
val fromThis = perSource[ip] ?: 0
if (packets.size >= maxPackets ||
retainedBytes + len > maxBytesRetained ||
fromThis >= maxPerSource
) {
truncated = true
} else {
packets.add(Packet(ip, ids.monoNs(), p.data.copyOf(len)))
perSource[ip] = fromThis + 1
retainedBytes += len
}
}
}
}
private companion object {
const val SO_TIMEOUT_MS = 1_000
/** Larger than any discovery datagram anyone sends; oversized ones are truncated by the
* kernel, which the parsers survive by design. */
const val READ_BUFFER = 4_096
fun describe(t: Throwable): String =
(t.message ?: t.javaClass.simpleName).take(160)
}
}
/**
* The other half of what every discovery collector does: fold a stream of decoded messages into a
* deduplicated inventory of *things*, not packets.
*
* Deduplication is by source **and** identity, never by identity alone. Two devices announcing the
* same UPnP service type are two devices, and collapsing them would turn the one measurement worth
* having (how many things are on this segment) into a count of protocols. Conversely one device
* re-announcing every thirty seconds must not appear ten times that is what [count] is for, and
* the repetition rate is itself readable from count over the window length.
*
* First-seen is kept on the monotonic clock per the two-clock rule: it answers "was this device
* here from the start, or did it appear four minutes in", which is exactly the question a long run
* exists to answer and the one a wall-clock stamp cannot be trusted for.
*/
internal class DiscoveryTable(private val maxEntries: Int = MAX_ENTRIES) {
private class Row(val firstSeenMonoNs: Long, val fields: JsonObject) {
var count = 0
}
private val rows = LinkedHashMap<Pair<String, String>, Row>()
private val sources = HashSet<String>()
/** True when a network was busy enough that entries had to be dropped — reported, never hidden. */
var overflowed = false
private set
/**
* [fields] is a lambda so the JSON for a repeat sighting is never built: on a chatty segment
* the overwhelming majority of packets are the same device saying the same thing again.
*/
fun observe(sourceIp: String, identity: String, atMonoNs: Long, fields: () -> JsonObject) {
sources.add(sourceIp)
val key = sourceIp to identity
val existing = rows[key]
if (existing != null) {
existing.count++
return
}
if (rows.size >= maxEntries) {
overflowed = true
return
}
val built = buildJsonObject {
put("source_ip", sourceIp)
for ((k, v) in fields()) put(k, v)
}
rows[key] = Row(atMonoNs, built).also { it.count = 1 }
}
val distinctSources: Int get() = sources.size
val size: Int get() = rows.size
val isEmpty: Boolean get() = rows.isEmpty()
fun toJson(): kotlinx.serialization.json.JsonArray = kotlinx.serialization.json.JsonArray(
rows.values.map { r ->
JsonObject(
r.fields + mapOf(
"first_seen_mono_ns" to kotlinx.serialization.json.JsonPrimitive(r.firstSeenMonoNs),
"count" to kotlinx.serialization.json.JsonPrimitive(r.count),
)
)
}
)
private companion object {
/** Generous enough for any real segment, small enough that a broadcast storm cannot make
* one test dominate the document. */
const val MAX_ENTRIES = 200
}
}
@@ -0,0 +1,131 @@
// 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.TestError
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* local.netbios_inventory NetBIOS name-service chatter (UDP 137) on this segment.
*
* NetBIOS name registration and query traffic is broadcast, not multicast, so there is no group to
* join: a socket bound to 137 sees it by being on the segment. Every packet carries a
* first-level-encoded name plus a suffix byte saying what the name is *for* a workstation, a file
* server, a master browser which makes a passive window over it a Windows-side inventory of the
* LAN, and, like LLMNR, a security observation in its own right: NBT-NS is the other half of the
* classic name-resolution spoofing surface.
*
* **Expect this to report `unsupported` on the app tier, and read that as a result rather than a
* bug.** 137 is below 1024, and Linux Android included reserves those ports for privileged
* processes; an unprivileged app UID cannot bind one. So the honest measurement here is usually
* "this tier cannot observe NetBIOS on this device", recorded with the exact bind error, rather
* than a silent absence that reads as a quiet network. The observation belongs to the Shizuku tier,
* whose shell UID can bind it; the collector is written now so that the decoder, the evidence shape
* and the registry id are settled and tested by the time that lands.
*
* An active alternative exists send an NBSTAT query from an ephemeral port and read the unicast
* replies and is deliberately not taken: it is a host sweep, which is scanning rather than
* measuring, and it would change what the run does to the network it is observing.
*/
class NetbiosCollector : BaseCollector() {
override val type = TestType.LOCAL_NETBIOS_INVENTORY
override val tier = Tier.APP
private val capture = MulticastCapture(
label = "netbios",
port = PORT,
// No group: NBT-NS is subnet broadcast. The multicast lock is still taken, because
// Android's wifi firmware filters broadcast as well as multicast under power save.
group4 = null,
group6 = null,
// Registration bursts repeat the same name several times a second; the per-source cap is
// what keeps one noisy Windows box from filling the buffer.
maxPerSource = 12,
)
private var startedAtMonoNs = 0L
override suspend fun start(ctx: Context, ids: ProbeIds) {
begin(ids)
startedAtMonoNs = ids.monoNs()
capture.acquireLock(ctx)
capture.start(ids)
}
override suspend fun stop(): Test {
val packets = capture.stop()
val table = DiscoveryTable()
var queries = 0
var registrations = 0
var undecodable = 0
for (p in packets) {
val n = NetbiosParser.parse(p.data, p.data.size)
if (n == null) {
undecodable++
continue
}
when (n.opcode) {
0 -> queries++
5, 8 -> registrations++
}
// Identity is name + suffix, not the name alone: one host registers the same name
// several times with different suffixes, and those are different facts about it.
table.observe(p.sourceIp, n.name + "#" + "%02X".format(n.suffix), p.atMonoNs) {
buildJsonObject {
put("netbios_name", n.name)
put("suffix", "0x%02X".format(n.suffix))
put("role", n.role)
put("operation", NetbiosParser.opcodeName(n.opcode))
put("kind", if (n.isResponse) "response" else "request")
}
}
}
val evidence: JsonObject = buildJsonObject {
put("capture", capture.statusJson())
put("netbios_names", table.toJson())
put("evidence_truncated", capture.truncated || table.overflowed)
}
val metrics: JsonObject = buildJsonObject {
put("distinct_sources", table.distinctSources)
put("distinct_names", table.size)
put("packets", capture.packetsSeen)
put("name_queries", queries)
put("name_registrations", registrations)
put("undecodable_packets", undecodable)
put("netbios_in_use", table.distinctSources > 0)
put("truncated", capture.truncated || table.overflowed)
}
val saw = !table.isEmpty
return build(
capture.outcome(saw),
evidence = evidence,
metrics = metrics,
params = params(),
error = capture.reason(saw)?.let {
TestError(if (capture.failure != null) "port_unavailable" else "listen_incomplete", it)
},
)
}
private fun params(): JsonObject = buildJsonObject {
put("mode", "passive")
put("port", PORT)
put("transport", "udp broadcast")
put("started_mono_ns", startedAtMonoNs)
}
private companion object {
const val PORT = 137
}
}
@@ -0,0 +1,274 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import app.echo_lot.measurement.NetworkChange
import app.echo_lot.measurement.NetworkChanges
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestError
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
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.util.Collections
/**
* Watches every network for the whole run and fills `networks[].changes[]` (measurement-schema §4).
*
* That array has been in the schema since the first draft and has never been populated, because
* nothing in a battery of one-shot probes is in a position to fill it. It is the single most
* valuable thing a long run adds: a wifi link that drops and returns mid-window is invisible to
* every probe the ones before and after the gap both succeed and it is exactly the fault people
* open a network diagnostic to chase.
*
* Reported as [TestType.LINK_IP_MONITOR] at app tier. The registry lists that type as Shizuku's
* (`ip monitor`), and this is deliberately the same observation from a tier that does not need it:
* link state as it changes over time. A document may therefore carry two `link.ip_monitor` tests,
* told apart by `tier` which is what `tier` is for.
*/
class NetworkChangeCollector(
private val entries: List<NetworkInventory.Entry>,
) : BaseCollector() {
override val type = TestType.LINK_IP_MONITOR
override val tier = Tier.APP
private data class Change(
val atMonoNs: Long,
val kind: String,
val event: String,
val iface: String,
val detail: String,
)
private val changes: MutableList<Change> = Collections.synchronizedList(mutableListOf())
private var cm: ConnectivityManager? = null
private var callback: ConnectivityManager.NetworkCallback? = null
private var registerError: String? = null
/**
* Last seen state per network, so only real changes are recorded.
*
* Both capability and link-property callbacks fire constantly on a live device signal
* strength alone re-delivers capabilities every few seconds and a five-minute window of that
* would bury the four events that matter under several hundred that do not. The first callback
* after a network appears is the baseline, not a change.
*/
private val lastCaps = HashMap<String, String>()
private val lastLink = HashMap<String, String>()
/** Interface name per network handle, remembered because `onLost` can no longer look it up. */
private val ifaceOf = HashMap<String, String>()
/**
* The networks that were already up when the window opened.
*
* `registerNetworkCallback` replays `onAvailable` for every matching network the instant it is
* registered, so without this every run would open with three "the wifi appeared" events that
* describe the registration and not the network. A link that drops and returns comes back as a
* new handle, which is not in this set, so real re-appearances are still recorded.
*/
private val seeded = HashSet<String>()
override suspend fun start(ctx: Context, ids: ProbeIds) {
begin(ids)
val manager = ctx.getSystemService(ConnectivityManager::class.java)
if (manager == null) {
registerError = "ConnectivityManager unavailable"
return
}
cm = manager
// Seed the interface names from the snapshot the run already took, so a network that is
// lost without ever having delivered a callback here is still attributable.
for (e in entries) {
e.model.iface?.let { ifaceOf[key(e.handle)] = it }
seeded.add(key(e.handle))
}
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val iface = resolveIface(network)
if (key(network) in seeded) return
record(ids, NetworkChanges.GAINED, "available", iface, "network became available")
}
override fun onLost(network: Network) {
val iface = ifaceOf[key(network)] ?: "(unknown)"
record(ids, NetworkChanges.LOST, "lost", iface, "network went away")
// Dropped so a returning link re-baselines instead of reporting every property it
// ever had as a change the moment it comes back.
lastCaps.remove(key(network)); lastLink.remove(key(network))
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
val iface = resolveIface(network)
val print = capsFingerprint(caps)
val previous = lastCaps.put(key(network), print)
if (previous == null || previous == print) return
record(ids, NetworkChanges.LINK_CHANGED, "capabilities", iface, "$previous$print")
}
override fun onLinkPropertiesChanged(network: Network, lp: LinkProperties) {
lp.interfaceName?.let { ifaceOf[key(network)] = it }
val print = linkFingerprint(lp)
val previous = lastLink.put(key(network), print)
if (previous == null || previous == print) return
record(ids, NetworkChanges.LINK_CHANGED, "link_properties", lp.interfaceName ?: "(unknown)",
describeLinkDelta(previous, print))
}
override fun onLosing(network: Network, maxMsToLive: Int) {
val iface = ifaceOf[key(network)] ?: "(unknown)"
record(ids, NetworkChanges.LINK_CHANGED, "losing", iface, "about to be torn down in ${maxMsToLive} ms")
}
}
callback = cb
// clearCapabilities(), or the default request only matches INTERNET + NOT_RESTRICTED and
// the carrier's IMS/MMS networks — and, more importantly, a network in the middle of
// failing validation — never appear. The transports are named explicitly so this does not
// also follow whatever internal networks a vendor keeps in the list.
val request = NetworkRequest.Builder()
.clearCapabilities()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
.build()
runCatching { manager.registerNetworkCallback(request, cb) }
.onFailure { registerError = it.message ?: it.javaClass.simpleName; callback = null }
}
override suspend fun stop(): Test {
callback?.let { cb -> runCatching { cm?.unregisterNetworkCallback(cb) } }
callback = null
val snapshot = synchronized(changes) { changes.toList() }
if (registerError != null) {
return build(
TestStatus.UNSUPPORTED,
error = TestError("callback_unavailable", registerError),
)
}
val evidence: JsonObject = buildJsonObject {
putJsonArray("changes") {
for (c in snapshot) addJsonObject {
put("at_mono_ns", c.atMonoNs)
put("kind", c.kind)
put("event", c.event)
put("interface", c.iface)
put("detail", c.detail)
}
}
}
val perIface = snapshot.groupBy { it.iface }
val metrics: JsonObject = buildJsonObject {
put("changes_total", snapshot.size)
put("networks_lost", snapshot.count { it.kind == NetworkChanges.LOST })
put("networks_gained", snapshot.count { it.event == "available" })
put("link_changes", snapshot.count { it.kind == NetworkChanges.LINK_CHANGED })
// The number a reader actually wants: how many times a link went away and came back.
// Counted by the same function the flapping finding uses, so the metric and the
// finding can never tell different stories about one window.
put("flap_cycles", perIface.values.sumOf { NetworkChanges.flapCycles(it.map { c -> c.kind }) })
}
// Zero changes over the window is a real, useful result — a stable network — so it is OK
// rather than a failure. The window that produced it is what makes that mean anything, and
// it is recorded in run.mode plus the sibling collectors' params.
return build(TestStatus.OK, evidence = evidence, metrics = metrics)
}
/**
* The changes belonging to each network in `networks[]`, keyed by its model id.
*
* Matched by interface name rather than by Android's `Network` handle, because a link that
* drops and returns comes back as a *different* handle with the same interface and the
* flapping case is precisely the one this must not lose. Changes on an interface that was not
* in the run's initial snapshot stay in the test evidence but have no `networks[]` entry to
* hang from.
*/
fun changesByNetwork(): Map<String, List<NetworkChange>> {
val byIface = entries.mapNotNull { e -> e.model.iface?.let { it to e.model.id } }.toMap()
val out = LinkedHashMap<String, MutableList<NetworkChange>>()
for (c in synchronized(changes) { changes.toList() }) {
val id = byIface[c.iface] ?: continue
out.getOrPut(id) { mutableListOf() }.add(
NetworkChange(
atMonoNs = c.atMonoNs,
kind = c.kind,
detail = buildJsonObject {
put("event", c.event)
put("interface", c.iface)
put("detail", c.detail)
},
)
)
}
return out
}
private fun record(ids: ProbeIds, kind: String, event: String, iface: String, detail: String) {
changes.add(Change(ids.monoNs(), kind, event, iface, detail))
}
private fun resolveIface(network: Network): String {
val known = ifaceOf[key(network)]
if (known != null) return known
val name = runCatching { cm?.getLinkProperties(network)?.interfaceName }.getOrNull()
if (name != null) ifaceOf[key(network)] = name
return name ?: "(unknown)"
}
private companion object {
/** Android's own network id, stable for the life of one Network object. */
private fun key(n: Network): String = n.toString()
/**
* Only the capabilities whose change means something diagnostically.
*
* Bandwidth estimates and signal strength are deliberately excluded: they change every few
* seconds on a moving device, and including them turns a change log into a sampling log.
* VALIDATED and CAPTIVE_PORTAL are the two that matter most they are the moment Android
* decides a network does or does not carry the internet.
*/
private fun capsFingerprint(c: NetworkCapabilities): String = buildString {
fun flag(name: String, cap: Int) {
if (runCatching { c.hasCapability(cap) }.getOrDefault(false)) append(name).append(' ')
}
flag("internet", NetworkCapabilities.NET_CAPABILITY_INTERNET)
flag("validated", NetworkCapabilities.NET_CAPABILITY_VALIDATED)
flag("captive", NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)
flag("not-metered", NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
flag("not-suspended", NET_CAPABILITY_NOT_SUSPENDED)
flag("not-restricted", NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
}.trim().ifEmpty { "(none)" }
/** NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED, API 28+ (@SystemApi constant). */
private const val NET_CAPABILITY_NOT_SUSPENDED = 21
private fun linkFingerprint(lp: LinkProperties): String {
val addrs = lp.linkAddresses.map { it.toString() }.sorted().joinToString(",")
val routes = lp.routes.map { it.toString() }.sorted().joinToString(",")
val dns = lp.dnsServers.mapNotNull { it.hostAddress }.sorted().joinToString(",")
return "mtu=${lp.mtu}|addr=$addrs|route=$routes|dns=$dns"
}
/** Names which part of the link changed, so the detail is readable without a diff tool. */
private fun describeLinkDelta(before: String, after: String): String {
val b = before.split('|'); val a = after.split('|')
val changed = b.indices.filter { it < a.size && b[it] != a[it] }
.map { a[it].substringBefore('=') }
return if (changed.isEmpty()) "changed" else "changed: ${changed.joinToString(", ")}"
}
}
}
@@ -65,6 +65,20 @@ object NetworkInventory {
/** @SystemApi NetworkCapabilities.NET_CAPABILITY_PARTIAL_CONNECTIVITY, API 28+. */
private const val NET_CAPABILITY_PARTIAL_CONNECTIVITY = 24
/**
* Can an ordinary app send on this network?
*
* The carrier's special-purpose networks (IMS/VoLTE, MMS, XCAP) sit in `allNetworks` next to
* the real ones, carrying `IMS`/`MMS` but neither `INTERNET` nor `NOT_RESTRICTED`. Binding
* to them fails with EPERM forever, because it needs `CONNECTIVITY_USE_RESTRICTED_NETWORKS`
* signature-level, unobtainable for this app. Asking the capabilities up front is what
* separates "the OS will never let us measure this" from "something is blocking us", which
* a bind attempt alone cannot distinguish and which the constraint logic must not confuse.
*/
private fun appUsable(caps: NetworkCapabilities): Boolean =
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
private fun toModel(id: String, caps: NetworkCapabilities, lp: LinkProperties): MNetwork {
val transport = when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Transport.WIFI
@@ -99,6 +113,7 @@ object NetworkInventory {
id = id, transport = transport, iface = lp.interfaceName,
link = Link(mtu = lp.mtu.takeIf { it > 0 }, addresses = addresses, routes = routes, dns = dns),
systemVerdict = systemVerdict(caps),
appUsable = appUsable(caps),
)
}
}
@@ -0,0 +1,158 @@
// 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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.add
import java.util.Collections
/**
* icmp.ping4 sampled across the window loss and jitter over minutes instead of one packet.
*
* The battery's [IcmpProbe] answers "does this network reply at all", which one echo can settle.
* It cannot answer "how often does it not", and that is the complaint people actually have:
* 2 % loss is invisible to a single ping and ruins a video call. A series over five minutes also
* catches loss that comes in bursts, which an average taken over ten packets in one second cannot
* distinguish from a clean link.
*
* Emitted as its own `icmp.ping4` test alongside the battery's. `params` carries the window and
* the interval precisely so the two are never mistaken for each other a reader seeing 300 sent
* packets in one and 1 in the other must be able to tell which is which without guessing.
*
* Sustained loss here deliberately emits **no finding**. Every loss code in the registry is about
* the server path `connectivity.udp_loss` and its directional siblings all say "UDP", and they
* mean the probe protocol's traffic, whose direction the server can attest to. ICMP echo to a
* public address is a different measurement with a different set of benign explanations (rate
* limiting at the target is the obvious one), and borrowing a code that claims otherwise would put
* two unrelated things under one dashboard entry the exact failure the registry exists to
* prevent. The metrics say what was seen; a code for it can be added when it has been defined.
*/
class PingSeriesCollector(
private val target: String = "1.1.1.1",
private val intervalMs: Long = 2_000,
/** Deliberately below [intervalMs]: a reply that arrives after the next probe was due is lost
* for any practical purpose, and waiting for it would make the series drift out of cadence. */
private val timeoutMs: Int = 1_500,
) : BaseCollector() {
override val type = TestType.ICMP_PING4
override val tier = Tier.APP
private val txMonoNs: MutableList<Long> = Collections.synchronizedList(mutableListOf())
private val rttMs: MutableList<Double?> = Collections.synchronizedList(mutableListOf())
private var notSent = 0
private var scope: CoroutineScope? = null
private var startedAtMonoNs = 0L
override suspend fun start(ctx: Context, ids: ProbeIds) {
begin(ids)
startedAtMonoNs = ids.monoNs()
// The default network, and only the default network: this measures what the device's own
// traffic experiences over the window. Per-network binding is the battery's job, and doing
// it here would multiply the packet rate by the number of interfaces for no new answer.
val s = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
s.launch {
var seq = 1
while (isActive) {
val t0 = ids.monoNs()
val r = IcmpEcho.ping(null, target, v6 = false, timeoutMs = timeoutMs, seq = seq)
if (r.attempted) {
txMonoNs.add(t0)
rttMs.add(r.rttMs)
} else {
// Never left the device — a socket or bind failure is not packet loss, and
// counting it as loss would blame the network for the app's own trouble.
notSent++
}
seq = (seq + 1) and 0xFFFF
delay(intervalMs)
}
}
}
override suspend fun stop(): Test {
scope?.coroutineContext?.get(Job)?.cancel()
scope = null
val tx = synchronized(txMonoNs) { txMonoNs.toList() }
val rtt = synchronized(rttMs) { rttMs.toList() }
val received = rtt.filterNotNull()
val evidence: JsonObject = buildJsonObject {
putJsonArray("seq") { for (i in tx.indices) add(i) }
putJsonArray("t_tx_ns") { for (v in tx) add(v) }
// null at an index is a lost probe, per the §6.2 columnar convention.
putJsonArray("rtt_ms") {
for (v in rtt) add(v?.let { JsonPrimitive(round1(it)) } ?: JsonNull)
}
}
val metrics: JsonObject = buildJsonObject {
put("sent", tx.size)
put("received", received.size)
put("not_sent", notSent)
if (tx.isNotEmpty()) {
put("loss_pct", round1((tx.size - received.size) * 100.0 / tx.size))
}
if (received.isNotEmpty()) {
put("rtt_ms_min", round1(received.min()))
put("rtt_ms_avg", round1(received.average()))
put("rtt_ms_max", round1(received.max()))
put("jitter_ms", round1(meanDeviation(received)))
}
}
val status = when {
tx.isEmpty() -> TestStatus.UNSUPPORTED
received.isEmpty() -> TestStatus.FAILED
received.size < tx.size -> TestStatus.PARTIAL
else -> TestStatus.OK
}
return build(status, evidence = evidence, metrics = metrics, params = params())
}
private fun params(): JsonObject = buildJsonObject {
// What separates this from the battery's single ping, and what a reader needs to reproduce
// it. Without these two numbers "300 packets, 2 % loss" is a rate nobody can interpret.
put("mode", "series")
put("target", target)
put("interval_ms", intervalMs)
put("timeout_ms", timeoutMs)
put("started_mono_ns", startedAtMonoNs)
put("network", "default")
}
private companion object {
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
/**
* Mean deviation between consecutive round trips jitter as a stream experiences it.
*
* Not the spread around the average: a link that alternates 20 ms / 200 ms and one that
* drifts slowly from 20 ms to 200 ms have the same standard deviation, and only the first
* one breaks a call.
*/
fun meanDeviation(values: List<Double>): Double {
if (values.size < 2) return 0.0
var sum = 0.0
for (i in 1 until values.size) sum += kotlin.math.abs(values[i] - values[i - 1])
return sum / (values.size - 1)
}
}
}
@@ -0,0 +1,69 @@
// 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.TestError
import app.echo_lot.measurement.TestStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.withTimeoutOrNull
/**
* Runs an ordinary [Probe] beside the battery instead of inside it.
*
* Some probes are already listeners with a fixed window [MdnsInventoryProbe] does nothing but
* wait for answers and in a long run their window should be the run's window. Running them in
* the sequential battery would then stall every probe behind them for five minutes, which is a
* scheduling problem and not a measurement one, so the fix is to move them rather than to shorten
* them.
*
* Durations are deliberately *not* fed back into the estimate learning: a probe that listens for
* the whole window would teach the short-mode progress bar that mDNS discovery takes five minutes.
*/
class ProbeCollector(private val probe: Probe) : BaseCollector() {
override val type get() = probe.type
override val tier get() = probe.tier
private var scope: CoroutineScope? = null
private var running: Deferred<Test>? = null
override suspend fun start(ctx: Context, ids: ProbeIds) {
begin(ids)
val s = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
running = s.async { probe.run(ctx, ids) }
}
override suspend fun stop(): Test {
val job = running
running = null
val finished = job?.let {
// A short grace, not a long one. A probe timed to the window has already finished by
// the time this is called, so the normal path returns instantly; the grace only covers
// it being slightly late. It is deliberately kept to a second and a half because the
// other caller is the Cancel button, where every millisecond spent waiting for a
// listener that will not finish is a millisecond the user watches nothing happen.
withTimeoutOrNull(GRACE_MS) { runCatching { it.await() }.getOrNull() }
}
scope?.coroutineContext?.get(Job)?.cancel()
scope = null
return finished ?: build(
TestStatus.PARTIAL,
error = TestError(
"window_closed",
"the run's window ended before this listener finished",
),
)
}
private companion object {
const val GRACE_MS = 1_500L
}
}
@@ -0,0 +1,181 @@
// 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.TestError
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* local.ssdp_inventory what UPnP/SSDP devices are on this segment, gathered over the whole window.
*
* **Both halves are needed, and neither is sufficient.** Passive listening catches ssdp:alive and
* ssdp:byebye announcements, which is the only way to see a device that ignores searches (plenty
* do, deliberately) and the only way to see one leave. But announcements are periodic and sparse
* a device re-announces on its own cache-control interval, commonly 30 minutes so a five-minute
* passive window silently misses most of the segment. An M-SEARCH provokes an immediate reply from
* everything that is listening, which is most things, and catches the quiet ones. Running only the
* active half is what [RouterIdentityProbe] already does in the battery, and it is why that probe
* cannot tell you that a device disappeared halfway through the run.
*
* The searches are paced, not flooded: [maxSearches] of them spread [searchIntervalMs] apart. A
* repeat catches devices that joined the network after the run started or were asleep at t=0, while
* staying orders of magnitude below a rate that would itself perturb the network being measured
* the tool must not become the fault it is looking for. `upnp:rootdevice` rather than `ssdp:all`
* for the same reason: one reply per device instead of one per service.
*
* The searches go out from the capture socket bound to 1900, so unicast replies land in the same
* capture as the multicast announcements rather than needing a second socket nobody is reading.
*/
class SsdpCollector(
private val searchIntervalMs: Long = 60_000,
private val maxSearches: Int = 5,
) : BaseCollector() {
override val type = TestType.LOCAL_SSDP_INVENTORY
override val tier = Tier.APP
private val capture = MulticastCapture(
label = "ssdp",
port = PORT,
group4 = GROUP4,
// The IPv6 link-local SSDP group. Free to join where IPv6 exists and skipped where it does
// not, so a v4-only network costs nothing and a v6-only device is not invisible.
group6 = GROUP6,
// SSDP is the chattiest of the four; a device announcing every service it hosts can emit a
// dozen NOTIFYs per cycle, so the per-source cap does most of the work here.
maxPackets = 600,
// The only one of the four with an active half worth keeping when 1900 is taken.
allowEphemeralFallback = true,
)
private var scope: CoroutineScope? = null
private var searchesSent = 0
private var startedAtMonoNs = 0L
override suspend fun start(ctx: Context, ids: ProbeIds) {
begin(ids)
startedAtMonoNs = ids.monoNs()
capture.acquireLock(ctx)
if (!capture.start(ids)) return
val s = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
s.launch {
while (isActive && searchesSent < maxSearches) {
if (capture.send(MSEARCH, GROUP4, PORT)) searchesSent++ else break
delay(searchIntervalMs)
}
}
}
override suspend fun stop(): Test {
scope?.coroutineContext?.get(Job)?.cancel()
scope = null
val packets = capture.stop()
val table = DiscoveryTable()
var alive = 0
var byebye = 0
var responses = 0
var undecodable = 0
for (p in packets) {
val msg = SsdpParser.parse(p.data, p.data.size)
if (msg == null) {
undecodable++
continue
}
when (msg.kind) {
SsdpKind.ALIVE -> alive++
SsdpKind.BYEBYE -> byebye++
SsdpKind.RESPONSE -> responses++
// Our own M-SEARCH comes back to us through the group; counting it as a device
// would inventory the phone doing the measuring.
SsdpKind.SEARCH -> continue
else -> Unit
}
// USN is the device+service identity SSDP itself uses; NT/ST is the fallback for
// devices that omit it, and the source IP already separates two devices offering the
// same service type.
val identity = msg.usn ?: msg.target ?: "(unidentified)"
table.observe(p.sourceIp, identity, p.atMonoNs) {
buildJsonObject {
put("usn", identity)
msg.target?.let { put("target", it) }
msg.serverBanner?.let { put("server_banner", it) }
SsdpParser.productHint(msg.serverBanner)?.let { put("product_hint", it) }
msg.location?.let { put("location", it) }
put("kind", msg.kind.name.lowercase())
}
}
}
val evidence: JsonObject = buildJsonObject {
put("capture", capture.statusJson())
put("ssdp_devices", table.toJson())
// Two truncation sources, reported apart: the capture dropping datagrams and the
// inventory dropping distinct entries mean different things about the network.
put("evidence_truncated", capture.truncated || table.overflowed)
}
val metrics: JsonObject = buildJsonObject {
put("distinct_sources", table.distinctSources)
put("distinct_advertisements", table.size)
put("packets", capture.packetsSeen)
put("alive", alive)
put("byebye", byebye)
put("search_responses", responses)
put("undecodable_packets", undecodable)
put("searches_sent", searchesSent)
put("truncated", capture.truncated || table.overflowed)
}
val status = capture.outcome(sawAnything = !table.isEmpty)
val reason = capture.reason(sawAnything = !table.isEmpty)
return build(
status,
evidence = evidence,
metrics = metrics,
params = params(),
error = reason?.let { TestError("listen_incomplete", it) },
)
}
private fun params(): JsonObject = buildJsonObject {
put("mode", "passive+msearch")
put("group_v4", "$GROUP4:$PORT")
put("group_v6", "[$GROUP6]:$PORT")
put("search_target", SEARCH_TARGET)
put("search_interval_ms", searchIntervalMs)
put("max_searches", maxSearches)
put("started_mono_ns", startedAtMonoNs)
}
private companion object {
const val PORT = 1900
const val GROUP4 = "239.255.255.250"
const val GROUP6 = "ff02::c"
const val SEARCH_TARGET = "upnp:rootdevice"
/** MX is the maximum random delay a responder waits, in seconds. 3 spreads the replies
* enough that a segment full of devices does not answer in one burst we then drop. */
val MSEARCH: ByteArray = (
"M-SEARCH * HTTP/1.1\r\n" +
"HOST: $GROUP4:$PORT\r\n" +
"MAN: \"ssdp:discover\"\r\n" +
"MX: 3\r\n" +
"ST: $SEARCH_TARGET\r\n\r\n"
).toByteArray(Charsets.ISO_8859_1)
}
}
@@ -0,0 +1,165 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import android.content.Context
import android.net.wifi.WifiManager
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestError
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
import app.echo_lot.measurement.Transport
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.add
import java.util.Collections
/**
* wifi.signal_log RSSI, link speed and frequency sampled across the whole window.
*
* One reading of the signal strength says almost nothing: -67 dBm is fine, and -67 dBm that was
* -45 dBm ninety seconds ago is somebody walking away from the AP, or an AP whose power is being
* managed, or a band steer about to happen. The series is the measurement; the snapshot in
* `networks[].wifi` is only its first sample.
*
* Evidence is columnar (measurement-schema §6.2 conventions): parallel arrays keep a five-minute
* log at 2 s intervals in a few kB.
*/
class WifiSignalCollector(
private val entries: List<NetworkInventory.Entry>,
private val intervalMs: Long = 2_000,
) : BaseCollector() {
override val type = TestType.WIFI_SIGNAL_LOG
override val tier = Tier.APP
private val atMonoNs: MutableList<Long> = Collections.synchronizedList(mutableListOf())
private val rssi: MutableList<Int> = Collections.synchronizedList(mutableListOf())
private val speed: MutableList<Int> = Collections.synchronizedList(mutableListOf())
private val freq: MutableList<Int> = Collections.synchronizedList(mutableListOf())
/** Only the count of distinct BSSIDs leaves this class a roam is the fact worth reporting,
* and the addresses themselves are neighbours' hardware identifiers. */
private val bssids = Collections.synchronizedSet(HashSet<String>())
private var scope: CoroutineScope? = null
private var unsupported: String? = null
private var startedAtMonoNs = 0L
override suspend fun start(ctx: Context, ids: ProbeIds) {
// network_ref up front: this samples the wifi link, and a signal log with nothing to
// attach it to is a series of numbers about an unnamed thing.
val wifiNet = entries.firstOrNull { it.model.transport == Transport.WIFI }
begin(ids, networkRef = wifiNet?.model?.id)
startedAtMonoNs = ids.monoNs()
val wifi = ctx.applicationContext.getSystemService(WifiManager::class.java)
if (wifi == null) {
unsupported = "WifiManager unavailable"
return
}
if (wifiNet == null) {
unsupported = "no wifi network is connected"
return
}
// Own scope, not the caller's: the run job is cancelled the instant the user taps Cancel,
// and the samples taken up to that point are exactly what a cancelled long run still owes
// them. stop() ends this scope.
val s = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
s.launch {
while (isActive) {
sample(ids, wifi)
delay(intervalMs)
}
}
}
@Suppress("DEPRECATION")
private fun sample(ids: ProbeIds, wifi: WifiManager) {
// WifiManager.getConnectionInfo is deprecated in favour of the NetworkCallback's
// TransportInfo, which delivers a WifiInfo only when the capabilities change — i.e. at the
// platform's cadence, not ours, and with no way to ask for a sample. For a fixed-interval
// log the deprecated call is the one that answers the question, and it still works.
val info = runCatching { wifi.connectionInfo }.getOrNull() ?: return
val r = info.rssi
// -127 and 0 are the "no reading" sentinels; recording them would drag every average down
// and invent a signal cliff that never happened.
if (r == 0 || r <= -127) return
atMonoNs.add(ids.monoNs())
rssi.add(r)
speed.add(info.linkSpeed)
freq.add(runCatching { info.frequency }.getOrDefault(0))
runCatching { info.bssid }.getOrNull()
?.takeIf { it.isNotBlank() && it != "02:00:00:00:00:00" }
?.let { bssids.add(it) }
}
override suspend fun stop(): Test {
scope?.coroutineContext?.get(Job)?.cancel()
scope = null
unsupported?.let {
return build(
TestStatus.UNSUPPORTED,
params = params(),
error = TestError("no_wifi", it),
)
}
val t = synchronized(atMonoNs) { atMonoNs.toList() }
val r = synchronized(rssi) { rssi.toList() }
val sp = synchronized(speed) { speed.toList() }
val f = synchronized(freq) { freq.toList() }
val evidence: JsonObject = buildJsonObject {
putJsonArray("at_mono_ns") { for (v in t) add(v) }
putJsonArray("rssi_dbm") { for (v in r) add(v) }
putJsonArray("link_speed_mbps") { for (v in sp) add(v) }
putJsonArray("frequency_mhz") { for (v in f) add(v) }
}
val metrics: JsonObject = buildJsonObject {
put("samples", r.size)
if (r.isNotEmpty()) {
put("rssi_dbm_min", r.min())
put("rssi_dbm_avg", round1(r.average()))
put("rssi_dbm_max", r.max())
put("rssi_dbm_range", r.max() - r.min())
}
sp.filter { it > 0 }.let { valid ->
if (valid.isNotEmpty()) {
put("link_speed_mbps_min", valid.min())
put("link_speed_mbps_avg", round1(valid.average()))
put("link_speed_mbps_max", valid.max())
}
}
// Distinct BSSIDs minus the one we started on: how often the phone changed AP without
// the network ever going down — invisible to any one-shot probe, and a common cause of
// "the call drops when I walk into the kitchen".
put("roams", (bssids.size - 1).coerceAtLeast(0))
}
return build(
if (r.isEmpty()) TestStatus.PARTIAL else TestStatus.OK,
evidence = evidence, metrics = metrics, params = params(),
)
}
private fun params(): JsonObject = buildJsonObject {
put("interval_ms", intervalMs)
put("started_mono_ns", startedAtMonoNs)
put("source", "WifiManager.connectionInfo")
}
private companion object {
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
}
}
@@ -0,0 +1,133 @@
// 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.TestError
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* local.wsd_inventory WS-Discovery (SOAP-over-UDP, 3702) Hello / Bye / Probe / ProbeMatches.
*
* This is the protocol Windows and modern printers use to find each other, and it inventories a
* class of device the other three miss: network printers, scanners and IP cameras announce here and
* frequently nowhere else. A `Hello` is a device arriving, a `Bye` is one leaving, and a `Probe`
* from a workstation names what it is hunting for so a window over 3702 shows both the equipment
* on the segment and which machines are looking for it.
*
* Passive only. WS-Discovery's active half is a Probe multicast, which would make this app a
* participant announcing itself to every device on the segment; SSDP's M-SEARCH is a single small
* request that devices expect constantly, whereas a WSD Probe from an unknown host is the kind of
* thing that shows up in someone's security log. Listening costs the network nothing.
*
* Payloads are decoded by [WsdParser], which does targeted extraction rather than XML parsing
* see its documentation for why that is the right call for unauthenticated broadcast input.
*/
class WsdCollector : BaseCollector() {
override val type = TestType.LOCAL_WSD_INVENTORY
override val tier = Tier.APP
private val capture = MulticastCapture(
label = "wsd",
port = PORT,
group4 = GROUP4,
group6 = GROUP6,
// SOAP envelopes are an order of magnitude larger than the other three protocols'
// datagrams, so the byte ceiling binds before the packet count does. Both are set
// explicitly rather than left to the default, which was chosen for 200-byte packets.
maxPackets = 250,
maxBytesRetained = 192 * 1024,
)
private var startedAtMonoNs = 0L
override suspend fun start(ctx: Context, ids: ProbeIds) {
begin(ids)
startedAtMonoNs = ids.monoNs()
capture.acquireLock(ctx)
capture.start(ids)
}
override suspend fun stop(): Test {
val packets = capture.stop()
val table = DiscoveryTable()
var hello = 0
var bye = 0
var probes = 0
var matches = 0
var undecodable = 0
for (p in packets) {
val m = WsdParser.parse(p.data, p.data.size)
if (m == null) {
undecodable++
continue
}
when (m.action?.lowercase()) {
"hello" -> hello++
"bye" -> bye++
"probe" -> probes++
"probematches", "resolvematches" -> matches++
}
// The device UUID is WS-Discovery's own stable identity and survives address changes,
// so it is the identity where present; a Probe carries none (it names what it wants,
// not who it is), and there the action plus the types is what distinguishes one
// observation from a repeat of it.
val identity = m.deviceUuid ?: (m.action.orEmpty() + "/" + m.types.orEmpty())
table.observe(p.sourceIp, identity.ifEmpty { "(unidentified)" }, p.atMonoNs) {
buildJsonObject {
m.deviceUuid?.let { put("device_uuid", it) }
m.action?.let { put("action", it) }
m.types?.let { put("wsd_types", it) }
m.xaddrs?.let { put("wsd_xaddrs", it) }
}
}
}
val evidence: JsonObject = buildJsonObject {
put("capture", capture.statusJson())
put("wsd_devices", table.toJson())
put("evidence_truncated", capture.truncated || table.overflowed)
}
val metrics: JsonObject = buildJsonObject {
put("distinct_sources", table.distinctSources)
put("distinct_devices", table.size)
put("packets", capture.packetsSeen)
put("hello", hello)
put("bye", bye)
put("probes", probes)
put("probe_matches", matches)
put("undecodable_packets", undecodable)
put("truncated", capture.truncated || table.overflowed)
}
val saw = !table.isEmpty
return build(
capture.outcome(saw),
evidence = evidence,
metrics = metrics,
params = params(),
error = capture.reason(saw)?.let { TestError("listen_incomplete", it) },
)
}
private fun params(): JsonObject = buildJsonObject {
put("mode", "passive")
put("group_v4", "$GROUP4:$PORT")
put("group_v6", "[$GROUP6]:$PORT")
put("started_mono_ns", startedAtMonoNs)
}
private companion object {
const val PORT = 3702
const val GROUP4 = "239.255.255.250"
const val GROUP6 = "ff02::c"
}
}
@@ -0,0 +1,386 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The decoders in DiscoveryParsers.kt, against payloads shaped like the ones real devices emit and
* against the ones a broken or hostile device emits.
*
* The malformed cases are the point. These four decoders are the only place in the app where bytes
* from an unidentified third party on the local segment are interpreted; they run inside a
* collector whose contract is that it never throws, and every one of them is reachable by anyone
* who can put a frame on the wire. So each protocol is fed truncation, junk, and the specific abuse
* its format invites a DNS compression pointer, a NetBIOS name outside the A-P alphabet, an XML
* entity bomb and the assertion is always the same pair: no exception, and no invented data.
*/
class DiscoveryParsersTest {
private fun bytes(s: String) = s.toByteArray(Charsets.ISO_8859_1)
// ---- SSDP --------------------------------------------------------------------------------
private val notifyAlive = bytes(
"NOTIFY * HTTP/1.1\r\n" +
"HOST: 239.255.255.250:1900\r\n" +
"CACHE-CONTROL: max-age=1800\r\n" +
"LOCATION: http://192.168.1.44:8060/\r\n" +
"NT: upnp:rootdevice\r\n" +
"NTS: ssdp:alive\r\n" +
"SERVER: Roku/12.5.5 UPnP/1.0 Roku/12.5.5\r\n" +
"USN: uuid:roku:ecp:YH00E1234567::upnp:rootdevice\r\n\r\n"
)
private val searchResponse = bytes(
"HTTP/1.1 200 OK\r\n" +
"CACHE-CONTROL: max-age=1800\r\n" +
"EXT:\r\n" +
"LOCATION: http://192.168.1.1:49000/rootDesc.xml\r\n" +
"SERVER: FRITZ!Box 7590 UPnP/1.0 AVM FRITZ!Box 7590 154.07.57\r\n" +
"ST: upnp:rootdevice\r\n" +
"USN: uuid:75802409-bccb-40e7-8e6c-c0ff33445566::upnp:rootdevice\r\n\r\n"
)
@Test
fun ssdpAliveAnnouncementYieldsIdentityAndLocation() {
val m = assertNotNull(SsdpParser.parse(notifyAlive, notifyAlive.size))
assertEquals(SsdpKind.ALIVE, m.kind)
assertEquals("upnp:rootdevice", m.target)
assertEquals("uuid:roku:ecp:YH00E1234567::upnp:rootdevice", m.usn)
assertEquals("http://192.168.1.44:8060/", m.location)
assertEquals("Roku/12.5.5 UPnP/1.0 Roku/12.5.5", m.serverBanner)
}
@Test
fun ssdpByebyeIsDistinguishedFromAlive() {
val byebye = bytes(
"NOTIFY * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n" +
"NT: urn:schemas-upnp-org:device:MediaRenderer:1\r\nNTS: ssdp:byebye\r\n" +
"USN: uuid:aabbccdd::urn:schemas-upnp-org:device:MediaRenderer:1\r\n\r\n"
)
val m = assertNotNull(SsdpParser.parse(byebye, byebye.size))
assertEquals(SsdpKind.BYEBYE, m.kind)
assertEquals("urn:schemas-upnp-org:device:MediaRenderer:1", m.target)
assertNull(m.location)
}
@Test
fun ssdpSearchResponseReadsStAsTheTarget() {
val m = assertNotNull(SsdpParser.parse(searchResponse, searchResponse.size))
assertEquals(SsdpKind.RESPONSE, m.kind)
assertEquals("upnp:rootdevice", m.target)
assertEquals("http://192.168.1.1:49000/rootDesc.xml", m.location)
}
/** Our own M-SEARCH comes back through the group; it must be recognisable so the collector
* does not inventory the phone doing the measuring. */
@Test
fun ssdpOwnSearchIsRecognisable() {
val search = bytes(
"M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n" +
"MAN: \"ssdp:discover\"\r\nMX: 3\r\nST: upnp:rootdevice\r\n\r\n"
)
assertEquals(SsdpKind.SEARCH, assertNotNull(SsdpParser.parse(search, search.size)).kind)
}
@Test
fun ssdpProductHintDropsBoilerplateAndKeepsTheModel() {
assertEquals("Roku/12.5.5", SsdpParser.productHint("Roku/12.5.5 UPnP/1.0 Roku/12.5.5"))
assertEquals("Synology/DSM-7.3", SsdpParser.productHint("Linux/4.4 UPnP/1.0 Synology/DSM-7.3"))
val fritz = assertNotNull(SsdpParser.productHint("FRITZ!Box 7590 UPnP/1.0 AVM FRITZ!Box 7590"))
assertTrue(fritz.contains("FRITZ!Box"))
assertFalse(fritz.contains("UPnP"), "protocol boilerplate leaked into the model hint")
assertNull(SsdpParser.productHint(null))
assertNull(SsdpParser.productHint(" "))
// A banner that is nothing but boilerplate reveals no model and must say so, rather than
// returning an empty string that reads as a name nobody could see.
assertNull(SsdpParser.productHint("Linux/4.4 UPnP/1.0"))
}
@Test
fun ssdpMalformedIsRejectedWithoutThrowing() {
// Truncated mid-header: the headers that did arrive are still usable, and the missing NTS
// makes the kind unknown rather than making the packet a lie.
val cut = notifyAlive.copyOf(70)
assertEquals(SsdpKind.OTHER, assertNotNull(SsdpParser.parse(cut, cut.size)).kind)
assertNull(SsdpParser.parse(ByteArray(0), 0))
val blank = bytes("\r\n\r\n")
assertNull(SsdpParser.parse(blank, blank.size))
val http = bytes("GET / HTTP/1.0\r\n\r\n")
assertNull(SsdpParser.parse(http, http.size), "not an SSDP verb")
// Arbitrary binary, including the high bytes ISO-8859-1 must not choke on.
val junk = ByteArray(256) { it.toByte() }
assertNull(SsdpParser.parse(junk, junk.size))
// A declared length longer than the buffer must be refused, not read past.
assertNull(SsdpParser.parse(notifyAlive, notifyAlive.size + 100))
// Header lines with no colon are skipped rather than fatal.
val noColon = bytes("NOTIFY * HTTP/1.1\r\ngarbage line\r\nNTS: ssdp:alive\r\n\r\n")
assertEquals(SsdpKind.ALIVE, assertNotNull(SsdpParser.parse(noColon, noColon.size)).kind)
}
// ---- LLMNR -------------------------------------------------------------------------------
/** Builds a DNS-format packet: header, one question, nothing else. */
private fun dnsQuery(
name: String,
qtype: Int = 1,
id: Int = 0x1234,
flags: Int = 0x0000,
qdcount: Int = 1,
): ByteArray {
val out = ArrayList<Byte>()
fun u16(v: Int) { out.add((v shr 8).toByte()); out.add((v and 0xFF).toByte()) }
u16(id); u16(flags); u16(qdcount); u16(0); u16(0); u16(0)
for (label in name.split('.')) {
val b = label.toByteArray(Charsets.UTF_8)
out.add(b.size.toByte())
b.forEach { out.add(it) }
}
out.add(0)
u16(qtype); u16(1)
return out.toByteArray()
}
@Test
fun llmnrQueryYieldsTheNameAWorkstationIsHuntingFor() {
val p = dnsQuery("wpad")
val q = assertNotNull(LlmnrParser.parse(p, p.size))
assertEquals("wpad", q.name)
assertEquals(1, q.qtype)
assertEquals("A", LlmnrParser.qtypeName(q.qtype))
assertTrue(q.isQuery)
assertEquals(0, q.opcode)
}
@Test
fun llmnrMultiLabelNamesAndAaaaSurviveIntact() {
val p = dnsQuery("nas-backup.local", qtype = 28)
val q = assertNotNull(LlmnrParser.parse(p, p.size))
assertEquals("nas-backup.local", q.name)
assertEquals("AAAA", LlmnrParser.qtypeName(q.qtype))
}
@Test
fun llmnrResponsesAreSeparatedFromQueries() {
val p = dnsQuery("DESKTOP-A1B2C3", flags = 0x8000)
assertFalse(assertNotNull(LlmnrParser.parse(p, p.size)).isQuery)
}
@Test
fun llmnrMalformedIsRejectedWithoutThrowing() {
val good = dnsQuery("printer")
assertNull(LlmnrParser.parse(ByteArray(0), 0))
assertNull(LlmnrParser.parse(good, 8), "a header-length prefix is not a question")
assertNull(LlmnrParser.parse(good, good.size + 50), "declared length past the buffer")
val noQuestion = dnsQuery("x", qdcount = 0)
assertNull(LlmnrParser.parse(noQuestion, noQuestion.size))
// A label length that runs off the end of the datagram — the classic truncation.
val overrun = good.copyOf(good.size - 6)
assertNull(LlmnrParser.parse(overrun, overrun.size))
// A compression pointer: legal DNS, forbidden in LLMNR, and the shape that makes a naive
// decoder loop forever. It must be refused rather than followed.
val pointer = good.copyOf(20)
pointer[12] = 0xC0.toByte()
pointer[13] = 0x0C
assertNull(LlmnrParser.parse(pointer, pointer.size))
// Random bytes behind a plausible header: whatever comes back, it is not an exception.
val junk = ByteArray(64) { (it * 37).toByte() }
junk[4] = 0; junk[5] = 1
LlmnrParser.parse(junk, junk.size)
}
// ---- NetBIOS -----------------------------------------------------------------------------
/** First-level encoding, the same transform the decoder has to undo. */
private fun nbnsPacket(name: String, suffix: Int, flags: Int = 0x2810): ByteArray {
val raw = ByteArray(16) { ' '.code.toByte() }
name.forEachIndexed { i, c -> if (i < 15) raw[i] = c.code.toByte() }
raw[15] = suffix.toByte()
val out = ArrayList<Byte>()
fun u16(v: Int) { out.add((v shr 8).toByte()); out.add((v and 0xFF).toByte()) }
u16(0x8001); u16(flags); u16(1); u16(0); u16(0); u16(1)
out.add(32)
for (b in raw) {
val v = b.toInt() and 0xFF
out.add(('A'.code + (v shr 4)).toByte())
out.add(('A'.code + (v and 0x0F)).toByte())
}
out.add(0)
u16(0x0020); u16(0x0001)
return out.toByteArray()
}
@Test
fun netbiosNameDecodesWithItsSuffixAndRole() {
val p = nbnsPacket("DESKTOP-A1B2C3", 0x20)
val n = assertNotNull(NetbiosParser.parse(p, p.size))
assertEquals("DESKTOP-A1B2C3", n.name)
assertEquals(0x20, n.suffix)
assertEquals("file_server", n.role)
assertFalse(n.isResponse)
assertEquals("registration", NetbiosParser.opcodeName(n.opcode))
}
@Test
fun netbiosPaddingIsStrippedAndSuffixesAreNamed() {
val p = nbnsPacket("WORKGROUP", 0x1E)
val n = assertNotNull(NetbiosParser.parse(p, p.size))
assertEquals("WORKGROUP", n.name, "the 15-byte space padding leaked into the name")
assertEquals("browser_elections", n.role)
assertEquals("workstation", NetbiosParser.roleOf(0x00))
assertEquals("suffix_0xAB", NetbiosParser.roleOf(0xAB))
}
@Test
fun netbiosResponsesAreSeparatedFromRequests() {
val p = nbnsPacket("FILESRV", 0x20, flags = 0x8500)
assertTrue(assertNotNull(NetbiosParser.parse(p, p.size)).isResponse)
}
@Test
fun netbiosMalformedIsRejectedWithoutThrowing() {
val good = nbnsPacket("PRINTER", 0x00)
assertNull(NetbiosParser.parse(ByteArray(0), 0))
assertNull(NetbiosParser.parse(good, 20), "truncated before the encoded name ends")
assertNull(NetbiosParser.parse(good, good.size + 40), "declared length past the buffer")
// A character outside A-P cannot be half of an encoded byte. Guessing at the rest would
// fabricate a hostname, so the whole packet is refused.
val badAlphabet = good.copyOf()
badAlphabet[15] = 'Z'.code.toByte()
assertNull(NetbiosParser.parse(badAlphabet, badAlphabet.size))
// The length byte must be exactly 32; anything else is a different protocol on this port.
val badLen = good.copyOf()
badLen[12] = 16
assertNull(NetbiosParser.parse(badLen, badLen.size))
// A name that decodes to nothing but padding is not a name.
val blank = nbnsPacket("", 0x00)
assertNull(NetbiosParser.parse(blank, blank.size))
val junk = ByteArray(80) { (it * 13).toByte() }
NetbiosParser.parse(junk, junk.size)
}
@Test
fun netbiosControlCharactersInANameAreNeutralised() {
// Legal first-level encoding, illegal content: these bytes decode cleanly and would
// otherwise reach a JSON document, and from there somebody's terminal.
val p = nbnsPacket("A\u0001B\u0002C", 0x00)
assertEquals("A?B?C", assertNotNull(NetbiosParser.parse(p, p.size)).name)
}
// ---- WS-Discovery ------------------------------------------------------------------------
private val wsdHello = bytes(
"""<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
xmlns:wsd="http://schemas.xmlsoap.org/ws/2005/04/discovery"
xmlns:wsdp="http://schemas.xmlsoap.org/ws/2006/02/devprof">
<soap:Header>
<wsa:To>urn:schemas-xmlsoap-org:ws:2005:04:discovery</wsa:To>
<wsa:Action>http://schemas.xmlsoap.org/ws/2005/04/discovery/Hello</wsa:Action>
<wsa:MessageID>urn:uuid:0a7e6d1b-0000-4000-8000-000000000001</wsa:MessageID>
</soap:Header>
<soap:Body>
<wsd:Hello>
<wsa:EndpointReference>
<wsa:Address>urn:uuid:9f8e7d6c-1111-4222-8333-444455556666</wsa:Address>
</wsa:EndpointReference>
<wsd:Types>wsdp:Device pub:Computer</wsd:Types>
<wsd:XAddrs>http://192.168.1.77:5357/8f2c-4b1a/</wsd:XAddrs>
<wsd:MetadataVersion>1</wsd:MetadataVersion>
</wsd:Hello>
</soap:Body>
</soap:Envelope>"""
)
@Test
fun wsdHelloYieldsActionUuidTypesAndXaddrs() {
val m = assertNotNull(WsdParser.parse(wsdHello, wsdHello.size))
assertEquals("Hello", m.action)
assertEquals(
"urn:uuid:9f8e7d6c-1111-4222-8333-444455556666", m.deviceUuid,
"the EndpointReference address is the device identity, not the header MessageID",
)
assertEquals("wsdp:Device pub:Computer", m.types)
assertEquals("http://192.168.1.77:5357/8f2c-4b1a/", m.xaddrs)
}
@Test
fun wsdProbeCarriesNoDeviceIdentityAndIsStillRecorded() {
val probe = bytes(
"""<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
xmlns:wsd="http://schemas.xmlsoap.org/ws/2005/04/discovery">
<soap:Header>
<wsa:Action>http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</wsa:Action>
</soap:Header>
<soap:Body><wsd:Probe><wsd:Types>wsdp:Device</wsd:Types></wsd:Probe></soap:Body>
</soap:Envelope>"""
)
val m = assertNotNull(WsdParser.parse(probe, probe.size))
assertEquals("Probe", m.action)
assertNull(m.deviceUuid)
assertEquals("wsdp:Device", m.types)
}
@Test
fun wsdMalformedIsRejectedWithoutThrowing() {
assertNull(WsdParser.parse(ByteArray(0), 0))
assertNull(WsdParser.parse(wsdHello, wsdHello.size + 100), "declared length past the buffer")
val prose = bytes("hello world")
assertNull(WsdParser.parse(prose, prose.size), "not a SOAP envelope")
// An envelope with no leaf worth reading is an absence, not an empty device.
val bare = bytes("<soap:Envelope></soap:Envelope>")
assertNull(WsdParser.parse(bare, bare.size))
// Cut mid-element: whatever was complete is extracted, the rest is simply absent.
val cut = wsdHello.copyOf(wsdHello.size / 2)
WsdParser.parse(cut, cut.size)?.let { assertNull(it.xaddrs, "an unterminated element was invented") }
val junk = ByteArray(512) { (it * 7).toByte() }
assertNull(WsdParser.parse(junk, junk.size))
}
@Test
fun wsdHostileXmlCostsNothingBecauseNothingParsesItAsXml() {
// A billion-laughs entity bomb. Against a real XML parser this expands to gigabytes; here
// the entities are never resolved, which is the entire reason for not using one.
val bomb = bytes(
"<!DOCTYPE lolz [<!ENTITY lol \"lol\">" +
(0..8).joinToString("") { i ->
val prev = if (i == 0) "" else (i - 1).toString()
"<!ENTITY lol$i \"&lol$prev;&lol$prev;\">"
} +
"]><soap:Envelope><wsa:Action>x/Bye</wsa:Action><body>&lol8;</body></soap:Envelope>"
)
assertEquals("Bye", assertNotNull(WsdParser.parse(bomb, bomb.size)).action)
// Nesting deep enough to blow a recursive-descent parser's stack.
val deep = bytes("<soap:Envelope>" + "<a>".repeat(20_000) + "</soap:Envelope>")
WsdParser.parse(deep, deep.size)
// A payload far larger than any real datagram, pinning that the text cap is applied before
// the matching rather than after it.
val huge = bytes("<soap:Envelope><wsa:Action>x/Hello</wsa:Action>" + "z".repeat(200_000))
assertEquals("Hello", assertNotNull(WsdParser.parse(huge, huge.size)).action)
}
}
@@ -209,6 +209,36 @@ class ControlClient(
}
}
/**
* Reports where adbd's wireless-debug listener can be reached on this device's LAN.
*
* Dev scaffolding, not a measurement: it exists because mDNS does not cross subnets, so a
* developer working from a different network cannot discover the port that rotates every few
* minutes. A device sitting on the test LAN can see it and say so. Deliberately not part of
* the probe protocol's capability set the server documents it as a dev relay.
*/
fun reportAdbEndpoint(
credential: String,
host: String,
port: Int,
deviceName: String? = null,
note: String? = null,
): String {
val conn = open("/v1/devtools/adb-endpoint", "POST", credential)
val fields = buildString {
append("""{"host":${jstr(host)},"port":$port""")
deviceName?.let { append(""","device_name":${jstr(it)}""") }
note?.let { append(""","note":${jstr(it)}""") }
append("}")
}
writeJson(conn, fields)
val body = body(conn)
check(conn.responseCode in 200..299) {
"adb endpoint report failed: ${conn.responseCode} $body"
}
return body
}
/** Lists this device's runs stored on the server. */
fun listRuns(credential: String): String {
val conn = open("/v1/runs", "GET", credential)
+41
View File
@@ -143,6 +143,47 @@ curl -sk https://<host>:8443/v1/profile -H 'Authorization: Bearer <credential>'
The SPKI pin clients must verify is logged at startup (`pin-sha256`).
## Dev relay (adb endpoint)
Development scaffolding, not protocol: it appears in no capability list and in no measurement
document, and `docs/probe-protocol.md` does not describe it.
It exists because **mDNS does not cross subnets**. Android's wireless debugging advertises adbd's
port over mDNS and rotates that port every few minutes, so a developer on another subnet cannot
discover it at all. An Echolot instance running on the test LAN can — and relays it here.
```
POST /v1/devtools/adb-endpoint control plane, device credential (same Bearer as /v1/sessions)
{"host":"10.13.102.128","port":45305,"device_name":"TB330FU","note":"…"}
GET /admin/adb-endpoints admin UI, session cookie or HTTP Basic
```
Read it back from the developer's machine (the admin listener is loopback-only, so over the same
SSH tunnel as everything else):
```sh
curl -s -u admin:<password> -H 'Accept: application/json' \
http://127.0.0.1:8444/admin/adb-endpoints
# → [{"device":"…","host":"10.13.102.128","port":45305,"device_name":"TB330FU",
# "reported_at":"…","source_ip":"…","age_s":37}]
```
The same list is a card on the admin dashboard, so it is usable without curl.
The newest report per submitting device wins — a rotated port makes the previous one wrong, not
historical. The device id comes from the credential and `source_ip` from the connection, so neither
is something a body can claim. `ECHOLOT_ADB_ENDPOINT_RETENTION_H` (default 24) bounds how long an
entry lives; `0` keeps it until the device replaces it. The window exists because the value is a
LAN address that stops being true within minutes: keeping it afterwards discloses the inside of
someone's network in exchange for nothing.
**Why it lives on these two listeners and nowhere else.** Its predecessor was a separate Python
service wildcard-bound to `0.0.0.0:443`. That silently occupied port 443 on the addresses reserved
for measurement — voiding the IPv4 interception proof for as long as it ran — and it accepted a port
report from anyone who could reach it. So: no new listener, no new port, no wildcard bind, and
nothing unauthenticated. The submit side is rate-limited by the existing §2.5 actions bucket
(`ECHOLOT_RATE_ACTIONS_PER_MIN`).
## Development
```sh
+3
View File
@@ -194,6 +194,9 @@ func serve(cfg *config.Config) error {
if err != nil {
return fmt.Errorf("state store: %w", err)
}
// Dev-relay breadcrumbs carry a LAN address, so they expire on a clock like the canary DNS
// log does rather than sitting in the state file until someone notices them.
st.SetADBEndpointRetention(time.Duration(cfg.ADBEndpointRetentionH) * time.Hour)
cert, err := loadOrCreateCert(cfg)
if err != nil {
return fmt.Errorf("tls: %w", err)
+66
View File
@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package adminui
import (
"encoding/json"
"net/http"
"time"
)
// The read side of the dev relay (control/devtools.go submits, this reads back). It is here, in
// the UI that already authenticates, rather than in a listener of its own — that is the lesson of
// the beacon receiver it replaces, which was a separate unauthenticated service on port 443 of the
// addresses reserved for measurement.
// adbRow is one relayed endpoint as the API and the dashboard both see it.
//
// age_s is computed rather than left to the reader: the port it describes rotates every few
// minutes, so how old the report is decides whether it is worth trying at all.
type adbRow struct {
Device string `json:"device"`
Host string `json:"host"`
Port int `json:"port"`
DeviceName string `json:"device_name,omitempty"`
Note string `json:"note,omitempty"`
ReportedAt time.Time `json:"reported_at"`
SourceIP string `json:"source_ip,omitempty"`
AgeS int `json:"age_s"`
}
// adbRows reads the store's live endpoints (already newest first, already aged out).
func (s *Server) adbRows() []adbRow {
now := time.Now().UTC()
eps := s.Store.ADBEndpoints()
rows := make([]adbRow, 0, len(eps))
for _, e := range eps {
age := int(now.Sub(e.ReportedAt).Seconds())
if age < 0 {
age = 0 // a clock that ran backwards should read "just now", not negative
}
rows = append(rows, adbRow{
Device: e.Device, Host: e.Host, Port: e.Port,
DeviceName: e.DeviceName, Note: e.Note,
ReportedAt: e.ReportedAt, SourceIP: e.SourceIP, AgeS: age,
})
}
return rows
}
// adbEndpointsAPI is GET /admin/adb-endpoints — the developer's half of the relay.
//
// Authenticated exactly like the mint endpoint, so `curl -u admin:PASS` works from a script and a
// signed-in browser session works without one. Admin-only: a LAN address and a debug port are the
// operator's business, and a user's uploads are not made safer by handing out either.
func (s *Server) adbEndpointsAPI(w http.ResponseWriter, r *http.Request) {
if _, ok := s.apiAdmin(w, r); !ok {
return
}
w.Header().Set("Content-Type", "application/json")
rows := s.adbRows()
if rows == nil {
rows = []adbRow{} // an empty list, never null: a caller should not have to special-case it
}
_ = json.NewEncoder(w).Encode(rows)
}
@@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package adminui
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"echo-lot.app/server/internal/store"
)
func adbFixture(t *testing.T) *Server {
t.Helper()
s := tokenFixture(t)
s.Store.SetADBEndpointRetention(24 * time.Hour)
now := time.Now().UTC()
for _, e := range []store.ADBEndpoint{
{Device: "dev-a", Host: "10.13.102.128", Port: 45305, DeviceName: "TB330FU",
SourceIP: "10.13.102.128", ReportedAt: now.Add(-10 * time.Minute)},
{Device: "dev-b", Host: "10.13.102.55", Port: 5555,
SourceIP: "10.13.102.55", ReportedAt: now.Add(-time.Minute)},
} {
if err := s.Store.PutADBEndpoint(e); err != nil {
t.Fatal(err)
}
}
return s
}
// A LAN address and a live debug port are exactly what must not be readable by anyone who can
// reach the listener — which is how the beacon receiver this replaces worked.
func TestADBEndpointsReadRequiresAuth(t *testing.T) {
h := adbFixture(t).Handler()
req := httptest.NewRequest("GET", "/admin/adb-endpoints", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized || rec.Header().Get("WWW-Authenticate") == "" {
t.Fatalf("unauthenticated: code=%d, want 401 with a challenge", rec.Code)
}
if rec.Body.Len() > 0 && json.Valid(rec.Body.Bytes()) {
t.Fatalf("a refusal returned a JSON body: %s", rec.Body.String())
}
req = httptest.NewRequest("GET", "/admin/adb-endpoints", nil)
req.SetBasicAuth("admin", "wrong")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("bad password: code=%d, want 401", rec.Code)
}
}
func TestADBEndpointsReadNewestFirst(t *testing.T) {
h := adbFixture(t).Handler()
req := httptest.NewRequest("GET", "/admin/adb-endpoints", nil)
req.SetBasicAuth("admin", "a-long-test-password")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
var rows []adbRow
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("got %d rows, want 2: %s", len(rows), rec.Body.String())
}
if rows[0].Device != "dev-b" {
t.Fatalf("rows are not newest first: %+v", rows)
}
if rows[0].Host != "10.13.102.55" || rows[0].Port != 5555 || rows[0].SourceIP == "" {
t.Fatalf("row is missing what a developer came for: %+v", rows[0])
}
// age_s is the field that says whether the port is worth trying at all.
if rows[0].AgeS < 50 || rows[0].AgeS > 120 {
t.Fatalf("age_s = %d, want roughly 60", rows[0].AgeS)
}
if rows[1].AgeS <= rows[0].AgeS {
t.Fatalf("ages do not follow the ordering: %+v", rows)
}
}
// The card exists so the relay is usable without curl. Rendered here because a template error is
// only found when the page is executed, not when it is parsed.
func TestDashboardShowsTheRelayToAnAdmin(t *testing.T) {
s := adbFixture(t)
h := s.Handler()
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{
Name: sessionCookie, Value: s.Sessions.Issue("local:admin", "admin", true),
})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("dashboard: code=%d", rec.Code)
}
for _, want := range []string{"Dev relay", "10.13.102.55:5555", "min ago"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("the dashboard card does not show %q", want)
}
}
// A plain user gets no rows at all — not an empty card, no card.
req = httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{
Name: sessionCookie, Value: s.Sessions.Issue("oidc#someone", "Someone", false),
})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "10.13.102.55") {
t.Fatal("a non-admin session was shown a LAN address")
}
}
// The read is a GET, so a signed-in browser session must not be asked for a CSRF token it has no
// form to carry — but a session that is not an administrator is still refused.
func TestADBEndpointsReadFromABrowserSession(t *testing.T) {
s := adbFixture(t)
h := s.Handler()
for _, tc := range []struct {
name string
admin bool
wantCode int
}{
{"admin", true, http.StatusOK},
{"plain user", false, http.StatusForbidden},
} {
req := httptest.NewRequest("GET", "/admin/adb-endpoints", nil)
req.AddCookie(&http.Cookie{
Name: sessionCookie,
Value: s.Sessions.Issue("local:admin", "admin", tc.admin),
})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tc.wantCode {
t.Errorf("%s: code=%d, want %d (%s)", tc.name, rec.Code, tc.wantCode, rec.Body.String())
}
}
}
+16 -6
View File
@@ -121,6 +121,9 @@ func (s *Server) Handler() http.Handler {
// scripts. Authenticates its own way — see apiAdmin — because guard's redirect-to-login is
// useless to a caller without a browser.
mux.HandleFunc("POST /admin/enroll-tokens", s.enrollTokensAPI)
// The dev relay's read side (adbendpoints.go), authenticated the same way and for the same
// reason: a developer reads it with curl from another subnet, where a login redirect is no use.
mux.HandleFunc("GET /admin/adb-endpoints", s.adbEndpointsAPI)
return mux
}
@@ -133,7 +136,7 @@ func (s *Server) guard(h func(http.ResponseWriter, *http.Request, *adminauth.Ses
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
if !safeMethod(r) {
// SameSite=Lax already blocks cross-site form posts in current browsers, but this
// is the control that does not depend on the browser being current.
if !s.csrfOK(r, sess) {
@@ -145,6 +148,13 @@ func (s *Server) guard(h func(http.ResponseWriter, *http.Request, *adminauth.Ses
}
}
// safeMethod reports whether a request only reads. CSRF protection applies to the others: there is
// nothing for a cross-site form to ride on when the handler changes nothing, and demanding a token
// on a GET would make a read endpoint unusable from the session that is already signed in.
func safeMethod(r *http.Request) bool {
return r.Method == http.MethodGet || r.Method == http.MethodHead
}
func (s *Server) session(r *http.Request) *adminauth.Session {
c, err := r.Cookie(sessionCookie)
if err != nil {
@@ -242,17 +252,17 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
// apiAdmin authenticates a programmatic admin request: the normal session cookie, or HTTP Basic
// against the break-glass credential for callers without a cookie jar (the README's curl).
//
// The cookie path keeps CSRF, exactly like guard: a cookie is an ambient credential and this
// endpoint changes state. Basic auth is exempt — the password is supplied explicitly per
// request, so there is nothing for a cross-site form to ride on — and a wrong guess pays the
// same throttle as the login form, so this is no better a password oracle than that is.
// The cookie path keeps CSRF on anything that changes state, exactly like guard: a cookie is an
// ambient credential. Basic auth is exempt — the password is supplied explicitly per request, so
// there is nothing for a cross-site form to ride on — and a wrong guess pays the same throttle as
// the login form, so this is no better a password oracle than that is.
func (s *Server) apiAdmin(w http.ResponseWriter, r *http.Request) (subject string, ok bool) {
if sess := s.session(r); sess != nil {
if !sess.Admin {
http.Error(w, "that action needs an administrator account", http.StatusForbidden)
return "", false
}
if !s.csrfOK(r, sess) {
if !safeMethod(r) && !s.csrfOK(r, sess) {
http.Error(w, "stale form — reload the page and try again", http.StatusForbidden)
return "", false
}
+7
View File
@@ -84,6 +84,12 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminau
if s.SelfTest != nil && sess.Admin {
selftest = s.SelfTest()
}
// Same reasoning for the dev relay, and one more: the rows carry a LAN address and a debug
// port, so they go no further than the account that runs the server.
var adb []adbRow
if sess.Admin {
adb = s.adbRows()
}
s.render(w, r, "dashboard", map[string]any{
"Session": sess,
"CSRF": s.csrfToken(sess),
@@ -91,6 +97,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminau
"Linked": linked,
"Runs": s.totalRuns(devices),
"SelfTest": selftest,
"ADBEndpoints": adb,
"Version": s.Version,
"Admin": sess.Admin,
})
+40
View File
@@ -8,6 +8,7 @@ import (
"html/template"
"log/slog"
"net/http"
"strconv"
"strings"
)
@@ -26,6 +27,20 @@ var tpl = template.Must(template.New("base").Funcs(template.FuncMap{
return "v-unknown"
}
},
// ago renders an age the way someone says it out loud. The dev relay reports a port that
// rotates every few minutes, so "4 m ago" is the entire question a reader has about a row.
"ago": func(seconds int) string {
switch {
case seconds < 45:
return "just now"
case seconds < 90*60:
return strconv.Itoa((seconds+30)/60) + " min ago"
case seconds < 48*3600:
return strconv.Itoa((seconds+1800)/3600) + " h ago"
default:
return strconv.Itoa(seconds/86400) + " d ago"
}
},
// verdictLabel says what the light means rather than what it is called. "yellow" is a colour;
// "worth a look" is a finding, and the reader is here to act on it.
"verdictLabel": func(v string) string {
@@ -321,6 +336,31 @@ const baseHTML = `<!doctype html>
</div>
{{end}}
{{end}}
{{with .ADBEndpoints}}
<h2>Dev relay</h2>
<p class="lede">Wireless-debug endpoints reported by Echolot instances on a test network. mDNS
does not cross subnets, so a device on that network relays adbd&rsquo;s rotating port here for
a developer sitting elsewhere. Nothing here is a measurement, and the entries expire &mdash;
a port older than a few minutes has probably already rotated.</p>
<div class="recs">
{{range .}}
<div class="rec">
<div class="rec-head">
<span class="id">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.Device}}{{end}}</span>
<span class="tag v-unknown">{{ago .AgeS}}</span>
</div>
<ul class="readout">
<li><span class="k">adb connect</span><span class="lead"></span>
<span class="v">{{.Host}}:{{.Port}}</span></li>
<li><span class="k">device</span><span class="lead"></span><span class="v">{{.Device}}</span></li>
<li><span class="k">reported from</span><span class="lead"></span>
<span class="v">{{if .SourceIP}}{{.SourceIP}}{{else}}&mdash;{{end}}</span></li>
</ul>
{{with .Note}}<div class="why">{{.}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
{{else if eq .Page "devices"}}
<h2>{{if .Admin}}Devices{{else}}Your devices{{end}}</h2>
+6
View File
@@ -95,6 +95,11 @@ type Config struct {
// How long canary DNS query logs are kept, in hours (spec §6; privacy default 24).
DNSLogRetentionH int // ECHOLOT_DNS_LOG_RETENTION_H / --dns-log-retention-h
// How long relayed adb endpoints are kept, in hours. Same 24-hour default and the same
// reasoning as the DNS log: the value is a LAN address, it stops being true within minutes,
// and there is nothing to gain from remembering it afterwards.
ADBEndpointRetentionH int // ECHOLOT_ADB_ENDPOINT_RETENTION_H / --adb-endpoint-retention-h
// Client compatibility window. Bounds are SemVer; an empty maximum means unbounded. The
// defaults sit at breaking boundaries, so shipping a patch never requires changing them.
MinAppVersion string // ECHOLOT_MIN_APP_VERSION / --min-app-version
@@ -226,6 +231,7 @@ func Load(args []string) (*Config, *Actions, error) {
fs.IntVar(&c.RateUDPPps, "rate-udp-pps", envInt("RATE_UDP_PPS", 25_000), "per-credential and per-IP data-plane packet ceiling, packets/s, silent drop; 0 disables")
fs.IntVar(&c.RateUDPKbps, "rate-udp-kbps", envInt("RATE_UDP_KBPS", 250_000), "per-credential and per-IP data-plane byte ceiling, kbit/s, silent drop; 0 disables")
fs.IntVar(&c.DNSLogRetentionH, "dns-log-retention-h", envInt("DNS_LOG_RETENTION_H", 24), "hours canary DNS query logs are kept (spec §6 privacy default 24); 0 keeps until the ring overwrites")
fs.IntVar(&c.ADBEndpointRetentionH, "adb-endpoint-retention-h", envInt("ADB_ENDPOINT_RETENTION_H", 24), "hours a relayed adb endpoint is kept (dev tooling; see server/README.md); 0 keeps it until the device replaces it")
fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)")
fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded")
fs.BoolVar(&c.Docker, "docker", envOr("DOCKER", "") == "1", "force container mode (config from env, no systemd/self-update)")
+5
View File
@@ -192,6 +192,11 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /v1/account/link", gate(s.linkAccount))
mux.HandleFunc("DELETE /v1/account/link", gate(s.unlinkAccount))
mux.HandleFunc("GET /v1/account", gate(s.accountStatus))
// Dev tooling, not protocol (see devtools.go). It shares the actions bucket rather than
// getting a ceiling of its own: it is a POST from an enrolled device that makes the server
// write, which is exactly what that limit is for, and a knob nobody tunes is a knob that
// eventually disagrees with the one next to it.
mux.HandleFunc("POST /v1/devtools/adb-endpoint", gate(s.rateLimited(s.RateActions, s.submitADBEndpoint)))
return mux
}
+127
View File
@@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package control
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/netip"
"strings"
"time"
"echo-lot.app/server/internal/store"
)
// The dev relay: an Echolot instance on a test LAN reports where adbd's wireless-debug listener
// can be reached, and a developer on another subnet reads it back from the admin UI. Documented in
// server/README.md; deliberately absent from probe-protocol.md and from the advertised capability
// list, because it measures nothing — it is scaffolding for driving a test device.
//
// It sits on the control plane rather than on a listener of its own, and that is the whole point.
// The receiver this replaces was a separate service wildcard-bound to 0.0.0.0:443, which silently
// occupied port 443 on the addresses reserved for measurement and voided the IPv4 interception
// proof for as long as it ran — and it accepted a port report from anyone who could reach it. Here
// there is no new port, no wildcard bind, and the device credential authenticates the submitter.
// maxADBNoteLen and maxADBNameLen bound what a submitter can store. Free text from a device ends
// up in the state file and on an operator's page; a label is a few words, so a few words is what
// is kept.
const (
maxADBNameLen = 64
maxADBNoteLen = 200
)
// submitADBEndpoint records the calling device's wireless-debug endpoint (POST
// /v1/devtools/adb-endpoint). Newest report per device wins; see store.PutADBEndpoint.
func (s *Server) submitADBEndpoint(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
var body struct {
Host string `json:"host"`
Port int `json:"port"`
DeviceName string `json:"device_name"`
Note string `json:"note"`
}
// A few hundred bytes of JSON at most; the limit is here so a stuck or hostile client cannot
// stream a body at a handler that has no reason to read one.
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad body"})
return
}
host := strings.TrimSpace(body.Host)
if !plausibleHost(host) {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "host must be an IP address or a hostname",
})
return
}
if body.Port < 1 || body.Port > 65535 {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "port must be between 1 and 65535",
})
return
}
e := store.ADBEndpoint{
Device: dev.ID,
Host: host,
Port: body.Port,
DeviceName: clip(body.DeviceName, maxADBNameLen),
Note: clip(body.Note, maxADBNoteLen),
ReportedAt: time.Now().UTC(),
SourceIP: remoteIP(r),
}
if err := s.Store.PutADBEndpoint(e); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
slog.Info("adb endpoint reported", "device", dev.ID, "name", e.DeviceName,
"host", e.Host, "port", e.Port, "from", e.SourceIP)
writeJSON(w, http.StatusCreated, map[string]any{
"device": e.Device, "host": e.Host, "port": e.Port,
"reported_at": e.ReportedAt.Format(time.RFC3339),
})
}
// plausibleHost accepts an IP literal or a DNS-shaped name.
//
// A shape check and nothing more: the address is meaningful only on the reporter's own LAN, so
// this server can never confirm it is reachable or even real. What it can do is refuse a value
// that could not be an address at all, which keeps junk out of the state file and out of the
// command an operator is about to paste into a shell.
func plausibleHost(h string) bool {
if h == "" || len(h) > 253 {
return false
}
if _, err := netip.ParseAddr(strings.Trim(h, "[]")); err == nil {
return true
}
for _, label := range strings.Split(strings.TrimSuffix(h, "."), ".") {
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-':
default:
return false
}
}
}
return true
}
// clip trims a caller-supplied label to a bounded length.
func clip(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) > n {
return s[:n]
}
return s
}
+137
View File
@@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package control
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"echo-lot.app/server/internal/store"
)
// devtoolsFixture is a server with one enrolled device, and that device's credential.
func devtoolsFixture(t *testing.T) (*Server, string) {
t.Helper()
st, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
st.SetADBEndpointRetention(24 * time.Hour)
tok, err := st.NewEnrollToken(time.Hour, "test")
if err != nil {
t.Fatal(err)
}
dev, err := st.Redeem(tok, "tablet")
if err != nil {
t.Fatal(err)
}
return &Server{Store: st}, dev.Credential
}
func postEndpoint(t *testing.T, s *Server, cred, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("POST", "/v1/devtools/adb-endpoint", strings.NewReader(body))
if cred != "" {
req.Header.Set("Authorization", "Bearer "+cred)
}
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
return rec
}
// The receiver this replaces took a port report from anyone who could reach it. This one does not.
func TestADBEndpointNeedsADeviceCredential(t *testing.T) {
s, _ := devtoolsFixture(t)
rec := postEndpoint(t, s, "", `{"host":"10.13.102.128","port":45305}`)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("no credential: code=%d, want 401", rec.Code)
}
rec = postEndpoint(t, s, "not-a-credential", `{"host":"10.13.102.128","port":45305}`)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong credential: code=%d, want 401", rec.Code)
}
if got := s.Store.ADBEndpoints(); len(got) != 0 {
t.Fatalf("an unauthenticated report was stored: %+v", got)
}
}
func TestADBEndpointRejectsImplausibleHostAndPort(t *testing.T) {
s, cred := devtoolsFixture(t)
for _, body := range []string{
`{"host":"","port":45305}`,
`{"host":"10.13.102.128 && rm -rf /","port":45305}`,
`{"host":"not a host","port":45305}`,
`{"host":"-bad.example","port":45305}`,
`{"host":"10.13.102.128","port":0}`,
`{"host":"10.13.102.128","port":65536}`,
`{"host":"10.13.102.128","port":-1}`,
`not json at all`,
} {
rec := postEndpoint(t, s, cred, body)
if rec.Code != http.StatusBadRequest {
t.Errorf("%s: code=%d, want 400 (%s)", body, rec.Code, strings.TrimSpace(rec.Body.String()))
}
}
if got := s.Store.ADBEndpoints(); len(got) != 0 {
t.Fatalf("a rejected report was stored anyway: %+v", got)
}
}
func TestADBEndpointStoresTheObservedSourceAndTheCallersDeviceID(t *testing.T) {
s, cred := devtoolsFixture(t)
rec := postEndpoint(t, s, cred,
`{"host":"10.13.102.128","port":45305,"device_name":"TB330FU","note":"wireless debugging"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
got := s.Store.ADBEndpoints()
if len(got) != 1 {
t.Fatalf("got %d rows, want 1", len(got))
}
e := got[0]
if e.Host != "10.13.102.128" || e.Port != 45305 || e.DeviceName != "TB330FU" {
t.Fatalf("report not stored as sent: %+v", e)
}
// The device id comes from the credential and the source IP from the connection, so neither is
// something the body can claim.
if e.Device == "" {
t.Fatal("the submitting device was not recorded")
}
if e.SourceIP != "192.0.2.1" { // httptest's RemoteAddr
t.Fatalf("source IP = %q, want the observed remote address", e.SourceIP)
}
if e.ReportedAt.IsZero() {
t.Fatal("no server-side timestamp was recorded")
}
}
func TestPlausibleHost(t *testing.T) {
for _, tc := range []struct {
host string
want bool
}{
{"10.13.102.128", true},
{"192.168.1.1", true},
{"fe80::1", true},
{"[2001:db8::1]", true},
{"tablet.lan", true},
{"tablet", true},
{"a-b.example.net.", true},
{"", false},
{"not a host", false},
{"10.0.0.1:5555", false}, // the port is its own field; a host must not smuggle one
{"-lead.example", false},
{"trail-.example", false},
{"a..b", false},
{"http://10.0.0.1", false},
{strings.Repeat("x", 254), false},
} {
if got := plausibleHost(tc.host); got != tc.want {
t.Errorf("plausibleHost(%q) = %v, want %v", tc.host, got, tc.want)
}
}
}
+117
View File
@@ -0,0 +1,117 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package store
import (
"sort"
"time"
)
// ADBEndpoint is one device's report of where its wireless-debug listener can be reached.
//
// Dev tooling, not measurement: mDNS does not cross subnets, so an Echolot instance sitting on the
// test LAN discovers adbd's rotating port there and relays it here for a developer on another
// subnet. It lives in the same state file as devices and tokens because it is a handful of rows —
// a second file, or a database, would be more machinery than the data deserves.
type ADBEndpoint struct {
// Device is the id of the *submitting* device, taken from its credential rather than from the
// body: the report is keyed by who sent it, so nobody can overwrite anyone else's row.
Device string `json:"device"`
Host string `json:"host"`
Port int `json:"port"`
DeviceName string `json:"device_name,omitempty"`
Note string `json:"note,omitempty"`
ReportedAt time.Time `json:"reported_at"`
// SourceIP is where the report arrived from, as this server observed it — the one field here
// the submitter does not get to choose, and the one that says which side of the NAT it sent from.
SourceIP string `json:"source_ip,omitempty"`
}
// maxADBEndpoints bounds the table. One row per device under test is the expected scale; the cap
// is what stops a fleet of enrolled devices from turning a debugging aid into unbounded state.
const maxADBEndpoints = 16
// SetADBEndpointRetention sets how long relayed endpoints are kept; <= 0 keeps them until the
// device replaces its own row or the cap evicts it.
//
// Not persisted: it is configuration (ECHOLOT_ADB_ENDPOINT_RETENTION_H), not state, and reading it
// back out of the state file would let a stale copy outlive the operator's decision.
func (s *Store) SetADBEndpointRetention(d time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
s.adbRetention = d
}
// PutADBEndpoint records a device's endpoint, replacing whatever it reported before.
//
// Newest wins per device rather than appending: a rotated port makes the previous one wrong, not
// historical, and an operator reading a list of dead ports would try them.
func (s *Store) PutADBEndpoint(e ADBEndpoint) error {
s.mu.Lock()
defer s.mu.Unlock()
if e.ReportedAt.IsZero() {
e.ReportedAt = time.Now().UTC()
}
s.dropExpiredADBLocked(e.ReportedAt)
replaced := false
for i := range s.data.ADBEndpoints {
if s.data.ADBEndpoints[i].Device == e.Device {
s.data.ADBEndpoints[i] = e
replaced = true
break
}
}
if !replaced {
s.data.ADBEndpoints = append(s.data.ADBEndpoints, e)
}
// Oldest first out when the cap is reached: the row nobody has refreshed is the one least
// likely to still describe a listening port.
if len(s.data.ADBEndpoints) > maxADBEndpoints {
sort.SliceStable(s.data.ADBEndpoints, func(i, j int) bool {
return s.data.ADBEndpoints[i].ReportedAt.Before(s.data.ADBEndpoints[j].ReportedAt)
})
s.data.ADBEndpoints = s.data.ADBEndpoints[len(s.data.ADBEndpoints)-maxADBEndpoints:]
}
return s.save()
}
// ADBEndpoints returns the live reports, newest first.
func (s *Store) ADBEndpoints() []ADBEndpoint {
s.mu.Lock()
defer s.mu.Unlock()
before := len(s.data.ADBEndpoints)
s.dropExpiredADBLocked(time.Now().UTC())
if len(s.data.ADBEndpoints) != before {
// The read path writes too: an address that has expired should stop existing in the file,
// not merely be filtered out of the answer. A failed save leaves the returned view correct
// and the next write to retry, so it is not worth failing a read over.
_ = s.save()
}
out := append([]ADBEndpoint(nil), s.data.ADBEndpoints...)
sort.SliceStable(out, func(i, j int) bool { return out[i].ReportedAt.After(out[j].ReportedAt) })
return out
}
// dropExpiredADBLocked enforces the retention window.
//
// A LAN address earns an expiry that a device id does not: it describes the inside of somebody's
// home or office network — which subnet, which host, which port a debug shell answers on — and it
// stops being true within minutes, because adbd rotates the port. Keeping it after that trades
// every bit of that disclosure for nothing at all. Applied on write and on read, whichever comes
// first, so an idle server still forgets on schedule the moment anyone looks.
func (s *Store) dropExpiredADBLocked(now time.Time) {
if s.adbRetention <= 0 {
return
}
cutoff := now.Add(-s.adbRetention)
kept := s.data.ADBEndpoints[:0]
for _, e := range s.data.ADBEndpoints {
if e.ReportedAt.After(cutoff) {
kept = append(kept, e)
}
}
// Reallocate rather than reslice in place, so the dropped rows are not left addressable in the
// old backing array.
s.data.ADBEndpoints = append([]ADBEndpoint(nil), kept...)
}
+137
View File
@@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package store
import (
"testing"
"time"
)
func adbStore(t *testing.T, retention time.Duration) *Store {
t.Helper()
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
s.SetADBEndpointRetention(retention)
return s
}
// The port rotates, so a device's previous report is wrong rather than historical: keeping both
// would leave an operator trying a dead port.
func TestNewestReportWinsPerDevice(t *testing.T) {
s := adbStore(t, 24*time.Hour)
now := time.Now().UTC()
for _, e := range []ADBEndpoint{
{Device: "dev-a", Host: "10.13.102.128", Port: 37089, ReportedAt: now.Add(-2 * time.Minute)},
{Device: "dev-b", Host: "10.13.102.55", Port: 5555, ReportedAt: now.Add(-time.Minute)},
{Device: "dev-a", Host: "10.13.102.128", Port: 33667, ReportedAt: now},
} {
if err := s.PutADBEndpoint(e); err != nil {
t.Fatal(err)
}
}
got := s.ADBEndpoints()
if len(got) != 2 {
t.Fatalf("got %d rows, want one per device: %+v", len(got), got)
}
// Newest first, so the most recently reported device leads.
if got[0].Device != "dev-a" || got[0].Port != 33667 {
t.Fatalf("newest-first/newest-wins violated: %+v", got)
}
if got[1].Device != "dev-b" || got[1].Port != 5555 {
t.Fatalf("the other device's report was disturbed: %+v", got)
}
}
func TestRetentionForgetsOldEndpoints(t *testing.T) {
s := adbStore(t, 24*time.Hour)
now := time.Now().UTC()
for _, e := range []ADBEndpoint{
{Device: "stale", Host: "10.0.0.9", Port: 5555, ReportedAt: now.Add(-25 * time.Hour)},
{Device: "fresh", Host: "10.0.0.10", Port: 5555, ReportedAt: now},
} {
if err := s.PutADBEndpoint(e); err != nil {
t.Fatal(err)
}
}
got := s.ADBEndpoints()
if len(got) != 1 || got[0].Device != "fresh" {
t.Fatalf("retention not enforced on write: %+v", got)
}
// Reads must age the table too: an idle server still has to forget on schedule, and nobody
// writes to this table between one debugging session and the next.
s.data.ADBEndpoints[0].ReportedAt = now.Add(-25 * time.Hour)
if got := s.ADBEndpoints(); len(got) != 0 {
t.Fatalf("read path did not expire entries: %+v", got)
}
// The drop is real, not just filtered out of the answer on the way past.
if len(s.data.ADBEndpoints) != 0 {
t.Fatalf("expired rows survived the read: %+v", s.data.ADBEndpoints)
}
}
func TestZeroRetentionKeepsUntilReplaced(t *testing.T) {
s := adbStore(t, 0)
if err := s.PutADBEndpoint(ADBEndpoint{
Device: "dev", Host: "10.0.0.1", Port: 5555,
ReportedAt: time.Now().UTC().Add(-1000 * time.Hour),
}); err != nil {
t.Fatal(err)
}
if got := s.ADBEndpoints(); len(got) != 1 {
t.Fatal("retention 0 must mean 'keep until replaced', not 'keep nothing'")
}
}
// Dev telemetry must not become unbounded state just because a lot of devices are enrolled.
func TestEndpointTableIsBounded(t *testing.T) {
s := adbStore(t, 24*time.Hour)
now := time.Now().UTC()
for i := 0; i < maxADBEndpoints+5; i++ {
if err := s.PutADBEndpoint(ADBEndpoint{
Device: string(rune('a'+i)) + "-dev", Host: "10.0.0.1", Port: 5555 + i,
ReportedAt: now.Add(time.Duration(i) * time.Second),
}); err != nil {
t.Fatal(err)
}
}
got := s.ADBEndpoints()
if len(got) != maxADBEndpoints {
t.Fatalf("table holds %d rows, want the cap of %d", len(got), maxADBEndpoints)
}
// The rows evicted are the oldest, so the newest report is still there.
if got[0].Port != 5555+maxADBEndpoints+4 {
t.Fatalf("the newest report was evicted: %+v", got[0])
}
}
// Restarting the server must not resurrect an address the retention window already dropped.
func TestExpiredEndpointsDoNotSurviveReopen(t *testing.T) {
dir := t.TempDir()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
s.SetADBEndpointRetention(time.Hour)
if err := s.PutADBEndpoint(ADBEndpoint{
Device: "dev", Host: "10.0.0.1", Port: 5555, ReportedAt: time.Now().UTC(),
}); err != nil {
t.Fatal(err)
}
// Age it on disk the way wall-clock time would.
s.data.ADBEndpoints[0].ReportedAt = time.Now().UTC().Add(-2 * time.Hour)
if err := s.save(); err != nil {
t.Fatal(err)
}
again, err := Open(dir)
if err != nil {
t.Fatal(err)
}
again.SetADBEndpointRetention(time.Hour)
if got := again.ADBEndpoints(); len(got) != 0 {
t.Fatalf("a stale LAN address came back after a restart: %+v", got)
}
}
+6
View File
@@ -56,6 +56,9 @@ type Store struct {
mu sync.Mutex
path string
data fileData
// How long relayed adb endpoints live. Configuration rather than state, so it is set by the
// caller after Open and never read back from the file (see SetADBEndpointRetention).
adbRetention time.Duration
}
type fileData struct {
@@ -67,6 +70,9 @@ type fileData struct {
// deleting it from the state file invalidates every session at once, which is how an
// operator revokes them.
SessionSecret string `json:"session_secret,omitempty"`
// Dev-relay breadcrumbs (adbendpoint.go). Not measurement data and not part of the protocol;
// they ride along here because this is already where small server state lives.
ADBEndpoints []ADBEndpoint `json:"adb_endpoints,omitempty"`
}
func Open(stateDir string) (*Store, error) {