Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ee7d554a6 | ||
|
|
93125a4b1d | ||
|
|
0071e00003 | ||
|
|
ae63bd7c7f | ||
|
|
ab6e278272 | ||
|
|
d420a71a01 | ||
|
|
d621611bdd |
@@ -1367,3 +1367,123 @@ Consequences until resolved:
|
|||||||
- The unpushed v0.10–v0.11 work needs to be found and pushed from whichever machine built it,
|
- The unpushed v0.10–v0.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
|
or the deployed binary's provenance re-established some other way, before the release channel
|
||||||
can be trusted again.
|
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.
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ because it looks authoritative.
|
|||||||
| `connectivity.downstream_reorder` | low | Downstream packets arrive in a different order than they were sent. | — |
|
| `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.captive_portal` | medium | A captive portal is intercepting connectivity checks. | — |
|
||||||
| `connectivity.no_internet` | high | Android's own connectivity checks fail on this network. | — |
|
| `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
|
### mtu
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ Export encoding: UTF-8 JSON, gzip for files (`.echolot.json.gz`), share intent u
|
|||||||
{
|
{
|
||||||
"id": "0198c5f2-...-uuidv7",
|
"id": "0198c5f2-...-uuidv7",
|
||||||
"trigger": "manual | scheduled | monitor | peer",
|
"trigger": "manual | scheduled | monitor | peer",
|
||||||
|
"mode": "short | long",
|
||||||
"started_at": "2026-07-29T14:03:21.114Z",
|
"started_at": "2026-07-29T14:03:21.114Z",
|
||||||
"ended_at": "2026-07-29T14:07:44.902Z",
|
"ended_at": "2026-07-29T14:07:44.902Z",
|
||||||
"clock": {
|
"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*.
|
`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
|
`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
|
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
|
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": [
|
"changes": [
|
||||||
{ "at_mono_ns": 91000000000, "kind": "lost | gained | link_changed",
|
{ "at_mono_ns": 91000000000, "kind": "lost | gained | link_changed",
|
||||||
"detail": { /* new link snapshot or diff */ } }
|
"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".
|
`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[]`
|
## 5. `server_sessions[]`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ plugins {
|
|||||||
//
|
//
|
||||||
// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing
|
// 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.
|
// 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 {
|
fun versionCodeOf(semver: String): Int {
|
||||||
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
|
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_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<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
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
@@ -53,6 +64,15 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
requestRuntimePermissions()
|
requestRuntimePermissions()
|
||||||
|
resumeRelayIfEnabled()
|
||||||
liveIntent.value = intent
|
liveIntent.value = intent
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||||
@@ -149,6 +150,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
screen = Screen.SETTINGS
|
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
|
// 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
|
// 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
|
// 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)
|
lifecycleOwner.lifecycle.addObserver(obs)
|
||||||
onDispose { lifecycleOwner.lifecycle.removeObserver(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) {
|
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
|
// 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
|
// succeeded, show the result briefly, then close so the device is left as it
|
||||||
@@ -215,6 +231,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
onEnroll = vm::enroll,
|
onEnroll = vm::enroll,
|
||||||
serverStatus = vm.state.archiveStatus,
|
serverStatus = vm.state.archiveStatus,
|
||||||
enrollStatus = vm.state.enrollStatus,
|
enrollStatus = vm.state.enrollStatus,
|
||||||
|
onRelayChange = { on ->
|
||||||
|
if (on) AdbRelayService.start(this@MainActivity)
|
||||||
|
else AdbRelayService.stop(this@MainActivity)
|
||||||
|
},
|
||||||
onBack = { screen = Screen.RUN },
|
onBack = { screen = Screen.RUN },
|
||||||
)
|
)
|
||||||
Screen.HISTORY -> HistoryScreen(
|
Screen.HISTORY -> HistoryScreen(
|
||||||
@@ -238,7 +258,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
Screen.RUN -> EcholotScreen(
|
Screen.RUN -> EcholotScreen(
|
||||||
state = vm.state,
|
state = vm.state,
|
||||||
onRun = { vm.run() },
|
longMinutes = vm.settings.longRunMinutes,
|
||||||
|
onRun = { mode -> vm.run(mode) },
|
||||||
onCancel = vm::cancel,
|
onCancel = vm::cancel,
|
||||||
onDeveloperOptions = {
|
onDeveloperOptions = {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -275,11 +296,29 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private fun requestRuntimePermissions() {
|
private fun requestRuntimePermissions() {
|
||||||
val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
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 {
|
val missing = perms.filter {
|
||||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
if (missing.isNotEmpty()) permissionLauncher.launch(missing.toTypedArray())
|
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) {
|
private fun verdictColor(v: Verdict): Color = when (v) {
|
||||||
@@ -299,7 +338,8 @@ private fun statusColor(s: TestStatus): Color = when (s) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun EcholotScreen(
|
private fun EcholotScreen(
|
||||||
state: UiState,
|
state: UiState,
|
||||||
onRun: () -> Unit,
|
longMinutes: Int,
|
||||||
|
onRun: (RunMode) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onShizukuAction: () -> Unit,
|
onShizukuAction: () -> Unit,
|
||||||
onDeveloperOptions: () -> 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) {
|
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")
|
Text(if (state.running) "Running…" else "Run measurement")
|
||||||
}
|
}
|
||||||
if (state.running) {
|
if (state.running) {
|
||||||
@@ -385,25 +453,55 @@ private fun EcholotScreen(
|
|||||||
|
|
||||||
if (state.running) {
|
if (state.running) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
val frac = if (state.stepsTotal > 0)
|
// In a long run the window is the run: once the battery is done, four minutes of
|
||||||
state.stepsDone.toFloat() / state.stepsTotal else 0f
|
// 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(
|
LinearProgressIndicator(
|
||||||
progress = { frac },
|
progress = { frac.coerceIn(0f, 1f) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
if (listening) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
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(
|
Text(
|
||||||
if (state.stepsTotal > 0)
|
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,
|
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
Text(state.currentStep ?: "…", fontSize = 12.sp,
|
Text(state.currentStep ?: "…", fontSize = 12.sp,
|
||||||
fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
||||||
if (state.etaSeconds > 0) {
|
if (!listening && state.etaSeconds > 0) {
|
||||||
Text("~${state.etaSeconds}s left", fontSize = 12.sp,
|
Text("~${state.etaSeconds}s left", fontSize = 12.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
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.")
|
"Disconnect the VPN and run again to measure the networks themselves.")
|
||||||
constraints.perNetworkBlocked ->
|
constraints.perNetworkBlocked ->
|
||||||
"Some networks could not be measured" to
|
"Some networks could not be measured" to
|
||||||
("Android refused sends on $blocked — the restriction a VPN leaves in place " +
|
("Android refused this app permission to send on $blocked, so they went " +
|
||||||
"while it tears down. These networks went unmeasured; wait a few " +
|
"unmeasured. A connected VPN is the usual cause; the refusal can also " +
|
||||||
"seconds and run again.")
|
"outlast one. Everything else in this run is unaffected.")
|
||||||
else ->
|
else ->
|
||||||
"A VPN holds the default route" to
|
"A VPN holds the default route" to
|
||||||
("Default-route results describe the tunnel; per-network measurements " +
|
("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))) {
|
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
|
||||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
Text("Overall: ${summary.overall}", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
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)
|
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
|
@Composable
|
||||||
private fun Dot(color: Color) {
|
private fun Dot(color: Color) {
|
||||||
Surface(color = color, shape = RoundedCornerShape(50), modifier = Modifier.size(12.dp)) {}
|
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.ShizukuAvailability
|
||||||
import app.echo_lot.shizuku.ShizukuProbe
|
import app.echo_lot.shizuku.ShizukuProbe
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -38,6 +40,17 @@ data class UiState(
|
|||||||
val stepsDone: Int = 0,
|
val stepsDone: Int = 0,
|
||||||
val stepsTotal: Int = 0,
|
val stepsTotal: Int = 0,
|
||||||
val etaSeconds: 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). */
|
/** Where the finished run went: archived locally, uploaded, or neither (and why). */
|
||||||
val archiveStatus: String? = null,
|
val archiveStatus: String? = null,
|
||||||
/** History, newest first. Refreshed after every run and whenever the history screen opens. */
|
/** 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() {
|
override fun onCleared() {
|
||||||
stopShizukuObserver()
|
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()
|
super.onCleared()
|
||||||
}
|
}
|
||||||
// Results collected so far. A cancelled run must still be able to show what it measured.
|
// 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 runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
|
||||||
private var runShizukuOk = false
|
private var runShizukuOk = false
|
||||||
private var runConstraints = Constraints()
|
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. */
|
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
|
||||||
private class RunIds : ProbeIds {
|
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
|
* from the user-facing upload so a debugging convenience can never be mistaken for, or
|
||||||
* silently satisfy, the consent-gated one.
|
* silently satisfy, the consent-gated one.
|
||||||
*/
|
*/
|
||||||
fun run(devUpload: Boolean = false) {
|
fun run(mode: RunMode = RunMode.SHORT, devUpload: Boolean = false) {
|
||||||
if (state.running) return
|
if (state.running) return
|
||||||
collected.clear()
|
collected.clear()
|
||||||
runConstraints = Constraints()
|
runConstraints = Constraints()
|
||||||
|
runMode = mode
|
||||||
|
val windowS = if (mode == RunMode.LONG) settings.longRunMinutes * 60 else 0
|
||||||
state = state.copy(running = true, currentStep = "starting", document = null,
|
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 {
|
runJob = viewModelScope.launch {
|
||||||
val doc = withContext(Dispatchers.IO) { measure() }
|
val doc = withContext(Dispatchers.IO) { measure(mode) }
|
||||||
|
|
||||||
step("archiving")
|
step("archiving")
|
||||||
val archived = withContext(Dispatchers.IO) { store.archive(doc) }
|
val archived = withContext(Dispatchers.IO) { store.archive(doc) }
|
||||||
@@ -184,6 +223,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
state = UiState(
|
state = UiState(
|
||||||
running = false, currentStep = null, document = doc,
|
running = false, currentStep = null, document = doc,
|
||||||
uploadStatus = status, archiveStatus = archiveStatus,
|
uploadStatus = status, archiveStatus = archiveStatus,
|
||||||
|
mode = mode,
|
||||||
history = withContext(Dispatchers.IO) { store.list() },
|
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:
|
* 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.
|
* 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() {
|
fun cancel() {
|
||||||
if (!state.running) return
|
if (!state.running || cancelling) return
|
||||||
|
cancelling = true
|
||||||
runJob?.cancel()
|
runJob?.cancel()
|
||||||
|
state = state.copy(currentStep = "stopping listeners")
|
||||||
|
viewModelScope.launch {
|
||||||
|
withContext(Dispatchers.IO) { stopCollectors() }
|
||||||
val doc = buildDocument(collected.toList())
|
val doc = buildDocument(collected.toList())
|
||||||
|
cancelling = false
|
||||||
state = UiState(
|
state = UiState(
|
||||||
running = false, currentStep = null, document = doc,
|
running = false, currentStep = null, document = doc,
|
||||||
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
|
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
|
||||||
archiveStatus = "partial run — not archived",
|
archiveStatus = "partial run — not archived",
|
||||||
|
mode = runMode,
|
||||||
history = state.history,
|
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 ctx = getApplication<Application>()
|
||||||
val ids = RunIds().also { runIds = it }
|
val ids = RunIds().also { runIds = it }
|
||||||
val startWall = Instant.now().toString().also { runStartWall = it }
|
val startWall = Instant.now().toString().also { runStartWall = it }
|
||||||
|
val windowMs = if (mode == RunMode.LONG) settings.longRunMinutes * 60_000L else 0L
|
||||||
|
|
||||||
step("reading networks")
|
step("reading networks")
|
||||||
val entries = NetworkInventory.snapshot(ctx)
|
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`).
|
// per-test `attempted: false` breadcrumbs (measurement-schema.md §3 `constraints`).
|
||||||
runConstraints = app.echo_lot.probe.ConstraintDetector.detect(ctx, entries)
|
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),
|
LinkSnapshotProbe(entries),
|
||||||
RouterIdentityProbe(entries),
|
RouterIdentityProbe(entries),
|
||||||
IcmpProbe(entries, v6 = false),
|
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,
|
// Folded from the prober after hardware validation: errqueue traceroute (no root,
|
||||||
// no JNI) and the mDNS service inventory / VLAN-leakage detector.
|
// no JNI) and the mDNS service inventory / VLAN-leakage detector.
|
||||||
app.echo_lot.probe.TracerouteProbe(),
|
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),
|
CaptivePortalProbe(entries),
|
||||||
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
|
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
|
||||||
// the reference deployment until profiles/enrollment land in the UI.
|
// 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)
|
(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. */
|
/** 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)
|
val findings = deriveFindings(tests, runNetworks)
|
||||||
return MeasurementDocument(
|
return MeasurementDocument(
|
||||||
run = Run(
|
run = Run(
|
||||||
id = runIds.uuid(), trigger = Trigger.MANUAL, startedAt = runStartWall,
|
id = runIds.uuid(), trigger = Trigger.MANUAL, mode = runMode, startedAt = runStartWall,
|
||||||
endedAt = Instant.now().toString(),
|
endedAt = Instant.now().toString(),
|
||||||
clock = Clock(monoOriginWall = runStartWall),
|
clock = Clock(monoOriginWall = runStartWall),
|
||||||
app = AppInfo(
|
app = AppInfo(
|
||||||
@@ -594,10 +745,11 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runConstraints.perNetworkBlocked ->
|
runConstraints.perNetworkBlocked ->
|
||||||
"The OS refused sends on $blocked" to
|
"The OS refused sends on $blocked" to
|
||||||
("Android denied this app permission to send on $blocked (EPERM on " +
|
("Android denied this app permission to send on $blocked (EPERM on " +
|
||||||
"bind). That is the restriction a VPN imposes on the networks " +
|
"bind), so nothing in this run says anything about it. The usual " +
|
||||||
"beneath it — a tunnel disconnected moments ago can still leave it " +
|
"cause is a VPN — Android walls off the networks beneath a tunnel — " +
|
||||||
"in place while it tears down. Nothing in this run says anything " +
|
"but the refusal can also outlast one, and some networks are " +
|
||||||
"about $blocked; wait a few seconds and run again.")
|
"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 ->
|
else ->
|
||||||
"A VPN holds the default route" to
|
"A VPN holds the default route" to
|
||||||
("Everything using the default route in this run describes the tunnel, " +
|
("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)
|
val shapes = V6Analysis.classify(networks)
|
||||||
// Named per interface: on a phone several networks are up at once, and "IPv6 is broken" is
|
// 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.
|
// useless when wifi is the broken one and cellular is fine.
|
||||||
|
|||||||
@@ -50,6 +50,36 @@ class Settings(context: Context) {
|
|||||||
maxTotalBytes = maxTotalMb.toLong() * 1024 * 1024,
|
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 ---------------------------------------------------------
|
// ---- run-duration learning ---------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -245,5 +275,7 @@ class Settings(context: Context) {
|
|||||||
const val ACCOUNT_NAME = "account_name"
|
const val ACCOUNT_NAME = "account_name"
|
||||||
const val ACCOUNT_ID = "account_id"
|
const val ACCOUNT_ID = "account_id"
|
||||||
const val DURATION_PREFIX = "duration_ms."
|
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,
|
onEnroll: (String) -> Unit,
|
||||||
serverStatus: String?,
|
serverStatus: String?,
|
||||||
enrollStatus: String?,
|
enrollStatus: String?,
|
||||||
|
/** Starts or stops the adb relay service; the toggle only records the preference. */
|
||||||
|
onRelayChange: (Boolean) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// SharedPreferences is not observable, so mirror each value into Compose state and write
|
// 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 maxRuns by remember { mutableStateOf(settings.maxRuns.toString()) }
|
||||||
var maxAgeDays by remember { mutableStateOf(settings.maxAgeDays.toString()) }
|
var maxAgeDays by remember { mutableStateOf(settings.maxAgeDays.toString()) }
|
||||||
var maxTotalMb by remember { mutableStateOf(settings.maxTotalMb.toString()) }
|
var maxTotalMb by remember { mutableStateOf(settings.maxTotalMb.toString()) }
|
||||||
|
var longMinutes by remember { mutableStateOf(settings.longRunMinutes) }
|
||||||
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
|
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
|
||||||
var privacy by remember { mutableStateOf(settings.privacyLevel) }
|
var privacy by remember { mutableStateOf(settings.privacyLevel) }
|
||||||
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
|
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
|
||||||
|
var relayOn by remember { mutableStateOf(settings.adbRelayEnabled) }
|
||||||
var enrollLink by remember { mutableStateOf("") }
|
var enrollLink by remember { mutableStateOf("") }
|
||||||
// The public name, which is what the operator handed out and what a person recognises. The
|
// 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
|
// 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)
|
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 ----
|
// ---- archive ----
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
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))
|
Spacer(Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ data class MeasurementDocument(
|
|||||||
data class Run(
|
data class Run(
|
||||||
val id: String, // UUIDv7
|
val id: String, // UUIDv7
|
||||||
val trigger: Trigger,
|
val trigger: Trigger,
|
||||||
|
val mode: RunMode = RunMode.SHORT,
|
||||||
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
|
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
|
||||||
@SerialName("ended_at") val endedAt: String? = null,
|
@SerialName("ended_at") val endedAt: String? = null,
|
||||||
val clock: Clock,
|
val clock: Clock,
|
||||||
@@ -66,6 +67,27 @@ data class Constraints(
|
|||||||
val constrained: Boolean get() = vpnActive || perNetworkBlocked
|
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
|
@Serializable
|
||||||
enum class Trigger {
|
enum class Trigger {
|
||||||
@SerialName("manual") MANUAL,
|
@SerialName("manual") MANUAL,
|
||||||
|
|||||||
+21
-1
@@ -103,6 +103,26 @@ object FindingRegistry {
|
|||||||
"Android's own connectivity checks fail on this network.",
|
"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 -------------------------------------------------------------------------
|
// ---- mtu -------------------------------------------------------------------------
|
||||||
|
|
||||||
val MTU_REDUCED_DOWNSTREAM = FindingSpec(
|
val MTU_REDUCED_DOWNSTREAM = FindingSpec(
|
||||||
@@ -305,7 +325,7 @@ object FindingRegistry {
|
|||||||
/** Every registered finding, in declaration order. */
|
/** Every registered finding, in declaration order. */
|
||||||
val all: List<FindingSpec> = listOf(
|
val all: List<FindingSpec> = listOf(
|
||||||
UDP_UNREACHABLE, UDP_UNREACHABLE_UPSTREAM, UDP_LOSS, LOSS_UPSTREAM, LOSS_DOWNSTREAM,
|
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,
|
MTU_REDUCED_DOWNSTREAM, MTU_DOWNSTREAM_BLACKHOLE, FRAGMENTS_BLOCKED,
|
||||||
FRAGMENT_REORDER_SENSITIVE,
|
FRAGMENT_REORDER_SENSITIVE,
|
||||||
NAT_UDP_REBINDING, NAT_SYMMETRIC,
|
NAT_UDP_REBINDING, NAT_SYMMETRIC,
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ data class Network(
|
|||||||
val cellular: Cellular? = null,
|
val cellular: Cellular? = null,
|
||||||
val changes: List<NetworkChange> = emptyList(),
|
val changes: List<NetworkChange> = emptyList(),
|
||||||
@SerialName("system_verdict") val systemVerdict: SystemVerdict? = null,
|
@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 kind: String, // lost | gained | link_changed
|
||||||
val detail: JsonObject? = null,
|
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_MDNS_INVENTORY = "local.mdns_inventory"
|
||||||
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
|
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
|
||||||
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_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_GATEWAY_SERVICES = "local.gateway_services"
|
||||||
const val LOCAL_NTP = "local.ntp"
|
const val LOCAL_NTP = "local.ntp"
|
||||||
// peer
|
// peer
|
||||||
|
|||||||
+65
@@ -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(
|
listOf(
|
||||||
"addr", "address", "gateway", "dst", "src", "servers", "server", "resolver",
|
"addr", "address", "gateway", "dst", "src", "servers", "server", "resolver",
|
||||||
"next_hop", "via", "public_ip", "observed_ip",
|
"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) }
|
).forEach { put(it, LogicalType.IP6) }
|
||||||
|
|
||||||
listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac")
|
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",
|
"friendly_name", "server_name", "sni", "cname", "search_domain", "device_name",
|
||||||
// Plural and prefixed variants the models actually use.
|
// Plural and prefixed variants the models actually use.
|
||||||
"search_domains", "private_dns_hostname", "domains", "hostnames",
|
"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) }
|
).forEach { put(it, LogicalType.FQDN) }
|
||||||
|
|
||||||
listOf("session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid")
|
listOf(
|
||||||
.forEach { put(it, LogicalType.OPAQUE_ID) }
|
"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")
|
listOf(
|
||||||
.forEach { put(it, LogicalType.FREETEXT) }
|
"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(
|
private val droppedKeys = setOf(
|
||||||
"ssdp_responders", "upnp", "neighbors", "arp_table", "scan_results",
|
"ssdp_responders", "upnp", "neighbors", "arp_table", "scan_results",
|
||||||
"nearby_networks", "peers", "raw_dump", "dumpsys",
|
"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? {
|
fun typeOf(key: String, path: List<String>): LogicalType? {
|
||||||
|
|||||||
@@ -28,4 +28,9 @@ dependencies {
|
|||||||
implementation(project(":core-measurement"))
|
implementation(project(":core-measurement"))
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
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) {
|
for (e in entries) {
|
||||||
// The tunnel itself stays bindable — it is the underlying networks the OS walls off.
|
// The tunnel itself stays bindable — it is the underlying networks the OS walls off.
|
||||||
if (e.model.transport == Transport.VPN) continue
|
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 {
|
val err = try {
|
||||||
DatagramSocket().use { s -> e.handle.bindSocket(s) }
|
DatagramSocket().use { s -> e.handle.bindSocket(s) }
|
||||||
null
|
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.content.Context
|
||||||
import android.net.Network
|
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.Test
|
||||||
import app.echo_lot.measurement.TestStatus
|
import app.echo_lot.measurement.TestStatus
|
||||||
import app.echo_lot.measurement.TestType
|
import app.echo_lot.measurement.TestType
|
||||||
@@ -17,10 +14,6 @@ import kotlinx.coroutines.withContext
|
|||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
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
|
* 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) {
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||||
val b = TestBuilder(type, tier, ids)
|
val b = TestBuilder(type, tier, ids)
|
||||||
val perNetwork = LinkedHashMap<String, Pair<String?, Attempt>>()
|
val perNetwork = LinkedHashMap<String, Pair<String?, IcmpEcho.Result>>()
|
||||||
var anyOk = false
|
var anyOk = false
|
||||||
val rtts = ArrayList<Double>()
|
val rtts = ArrayList<Double>()
|
||||||
|
|
||||||
@@ -82,75 +75,9 @@ class IcmpProbe(
|
|||||||
b.build(status, evidence = evidence, metrics = metrics)
|
b.build(status, evidence = evidence, metrics = metrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The echo exchange itself lives in [IcmpEcho], shared with the long-run ping series. */
|
||||||
* One network's result.
|
private fun attempt(network: Network?): IcmpEcho.Result =
|
||||||
*
|
IcmpEcho.ping(network, target, v6, timeoutMs = 3000)
|
||||||
* [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()
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
|
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
|
* 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.
|
* 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.
|
* - 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 type = TestType.LOCAL_MDNS_INVENTORY
|
||||||
override val tier = Tier.APP
|
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). */
|
/** Meta-query + common concrete types (HTTP covers HA/printers/NAS; googlecast is ubiquitous). */
|
||||||
private val queries = listOf(
|
private val queries = listOf(
|
||||||
@@ -75,7 +80,7 @@ class MdnsInventoryProbe : Probe {
|
|||||||
Triple(label, type, r)
|
Triple(label, type, r)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
delay(10_000)
|
delay(listenMs)
|
||||||
var total = 0
|
var total = 0
|
||||||
var anyStarted = false
|
var anyStarted = false
|
||||||
val evidence = buildJsonObject {
|
val evidence = buildJsonObject {
|
||||||
@@ -95,10 +100,13 @@ class MdnsInventoryProbe : Probe {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val metrics = buildJsonObject { put("services_found", total) }
|
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
|
// 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.
|
// isolated network), not a failure — only discovery refusing to start is one.
|
||||||
b.build(if (anyStarted) TestStatus.OK else TestStatus.FAILED,
|
b.build(if (anyStarted) TestStatus.OK else TestStatus.FAILED,
|
||||||
evidence = evidence, metrics = metrics)
|
evidence = evidence, metrics = metrics, params = params)
|
||||||
} finally {
|
} finally {
|
||||||
recorders.forEach { (_, _, r) -> runCatching { nsd.stopServiceDiscovery(r) } }
|
recorders.forEach { (_, _, r) -> runCatching { nsd.stopServiceDiscovery(r) } }
|
||||||
runCatching { lock?.release() }
|
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+. */
|
/** @SystemApi NetworkCapabilities.NET_CAPABILITY_PARTIAL_CONNECTIVITY, API 28+. */
|
||||||
private const val NET_CAPABILITY_PARTIAL_CONNECTIVITY = 24
|
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 {
|
private fun toModel(id: String, caps: NetworkCapabilities, lp: LinkProperties): MNetwork {
|
||||||
val transport = when {
|
val transport = when {
|
||||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Transport.WIFI
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Transport.WIFI
|
||||||
@@ -99,6 +113,7 @@ object NetworkInventory {
|
|||||||
id = id, transport = transport, iface = lp.interfaceName,
|
id = id, transport = transport, iface = lp.interfaceName,
|
||||||
link = Link(mtu = lp.mtu.takeIf { it > 0 }, addresses = addresses, routes = routes, dns = dns),
|
link = Link(mtu = lp.mtu.takeIf { it > 0 }, addresses = addresses, routes = routes, dns = dns),
|
||||||
systemVerdict = systemVerdict(caps),
|
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. */
|
/** Lists this device's runs stored on the server. */
|
||||||
fun listRuns(credential: String): String {
|
fun listRuns(credential: String): String {
|
||||||
val conn = open("/v1/runs", "GET", credential)
|
val conn = open("/v1/runs", "GET", credential)
|
||||||
|
|||||||
@@ -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`).
|
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
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ func serve(cfg *config.Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("state store: %w", err)
|
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)
|
cert, err := loadOrCreateCert(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("tls: %w", err)
|
return fmt.Errorf("tls: %w", err)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,6 +121,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
// scripts. Authenticates its own way — see apiAdmin — because guard's redirect-to-login is
|
// scripts. Authenticates its own way — see apiAdmin — because guard's redirect-to-login is
|
||||||
// useless to a caller without a browser.
|
// useless to a caller without a browser.
|
||||||
mux.HandleFunc("POST /admin/enroll-tokens", s.enrollTokensAPI)
|
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
|
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)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
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
|
// 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.
|
// is the control that does not depend on the browser being current.
|
||||||
if !s.csrfOK(r, sess) {
|
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 {
|
func (s *Server) session(r *http.Request) *adminauth.Session {
|
||||||
c, err := r.Cookie(sessionCookie)
|
c, err := r.Cookie(sessionCookie)
|
||||||
if err != nil {
|
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
|
// 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).
|
// 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
|
// The cookie path keeps CSRF on anything that changes state, exactly like guard: a cookie is an
|
||||||
// endpoint changes state. Basic auth is exempt — the password is supplied explicitly per
|
// ambient credential. Basic auth is exempt — the password is supplied explicitly per request, so
|
||||||
// request, so there is nothing for a cross-site form to ride on — and a wrong guess pays the
|
// there is nothing for a cross-site form to ride on — and a wrong guess pays the same throttle as
|
||||||
// same throttle as the login form, so this is no better a password oracle than that is.
|
// 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) {
|
func (s *Server) apiAdmin(w http.ResponseWriter, r *http.Request) (subject string, ok bool) {
|
||||||
if sess := s.session(r); sess != nil {
|
if sess := s.session(r); sess != nil {
|
||||||
if !sess.Admin {
|
if !sess.Admin {
|
||||||
http.Error(w, "that action needs an administrator account", http.StatusForbidden)
|
http.Error(w, "that action needs an administrator account", http.StatusForbidden)
|
||||||
return "", false
|
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)
|
http.Error(w, "stale form — reload the page and try again", http.StatusForbidden)
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminau
|
|||||||
if s.SelfTest != nil && sess.Admin {
|
if s.SelfTest != nil && sess.Admin {
|
||||||
selftest = s.SelfTest()
|
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{
|
s.render(w, r, "dashboard", map[string]any{
|
||||||
"Session": sess,
|
"Session": sess,
|
||||||
"CSRF": s.csrfToken(sess),
|
"CSRF": s.csrfToken(sess),
|
||||||
@@ -91,6 +97,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminau
|
|||||||
"Linked": linked,
|
"Linked": linked,
|
||||||
"Runs": s.totalRuns(devices),
|
"Runs": s.totalRuns(devices),
|
||||||
"SelfTest": selftest,
|
"SelfTest": selftest,
|
||||||
|
"ADBEndpoints": adb,
|
||||||
"Version": s.Version,
|
"Version": s.Version,
|
||||||
"Admin": sess.Admin,
|
"Admin": sess.Admin,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +27,20 @@ var tpl = template.Must(template.New("base").Funcs(template.FuncMap{
|
|||||||
return "v-unknown"
|
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;
|
// 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.
|
// "worth a look" is a finding, and the reader is here to act on it.
|
||||||
"verdictLabel": func(v string) string {
|
"verdictLabel": func(v string) string {
|
||||||
@@ -321,6 +336,31 @@ const baseHTML = `<!doctype html>
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{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’s rotating port here for
|
||||||
|
a developer sitting elsewhere. Nothing here is a measurement, and the entries expire —
|
||||||
|
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}}—{{end}}</span></li>
|
||||||
|
</ul>
|
||||||
|
{{with .Note}}<div class="why">{{.}}</div>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{else if eq .Page "devices"}}
|
{{else if eq .Page "devices"}}
|
||||||
<h2>{{if .Admin}}Devices{{else}}Your devices{{end}}</h2>
|
<h2>{{if .Admin}}Devices{{else}}Your devices{{end}}</h2>
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ type Config struct {
|
|||||||
// How long canary DNS query logs are kept, in hours (spec §6; privacy default 24).
|
// 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
|
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
|
// 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.
|
// defaults sit at breaking boundaries, so shipping a patch never requires changing them.
|
||||||
MinAppVersion string // ECHOLOT_MIN_APP_VERSION / --min-app-version
|
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.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.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.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.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.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)")
|
fs.BoolVar(&c.Docker, "docker", envOr("DOCKER", "") == "1", "force container mode (config from env, no systemd/self-update)")
|
||||||
|
|||||||
@@ -192,6 +192,11 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /v1/account/link", gate(s.linkAccount))
|
mux.HandleFunc("POST /v1/account/link", gate(s.linkAccount))
|
||||||
mux.HandleFunc("DELETE /v1/account/link", gate(s.unlinkAccount))
|
mux.HandleFunc("DELETE /v1/account/link", gate(s.unlinkAccount))
|
||||||
mux.HandleFunc("GET /v1/account", gate(s.accountStatus))
|
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
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,9 @@ type Store struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
path string
|
path string
|
||||||
data fileData
|
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 {
|
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
|
// deleting it from the state file invalidates every session at once, which is how an
|
||||||
// operator revokes them.
|
// operator revokes them.
|
||||||
SessionSecret string `json:"session_secret,omitempty"`
|
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) {
|
func Open(stateDir string) (*Store, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user