49 KiB
Echolot — build status & next steps
Last updated: 2026-07-29.
Decided
- Name Echolot; domain echo-lot.app; scheme
echolot://; namespaceapp.echo_lot.*(hyphen→underscore; appIds/packages can't contain hyphens). Prober appIdapp.echo_lot.prober. - Stack: native Kotlin + Jetpack Compose, no Flutter.
- Tiers:
app(no root),shizukuin v1 (wireless-ADB pairing),rootfuture. - License (decided 2026-07-30): all code GPL-3.0-or-later; the specs in
docs/CC-BY-4.0. Rationale: the moat is the no-root platform research, which is trivially liftable into a proprietary repackage — copyleft is the only option that prevents that, and the F-Droid / network-engineer audience reads GPL as a trust signal rather than friction. The specs go permissive on purpose: a wire protocol only becomes a standard if anyone can implement it. Not AGPL for the server — the ASP loophole it closes is speculative here, while blanket corporate AGPL bans would hit exactly the enterprise network teams most likely to self-host. Sole copyright holder, so relicensing the server to AGPL later stays possible. Open: substitute a real legal copyright holder for "Echolot contributors" in the SPDX headers.
Specs (in docs/, alongside this file)
feature-catalog-and-feasibility.md,measurement-schema.md,probe-protocol.md. Considered draft-complete and reviewed by the user.
Capability prober — DELIVERED (as zip, 2026-07-29)
Full Kotlin/Compose project scaffolded: app.echo_lot.prober, minSdk 26 / target+compile 35,
AGP 8.7.3, Kotlin 2.0.21, Compose BOM 2024.10, Shizuku api+provider 13.1.5, kotlinx-serialization.
Probes implemented: link.snapshot, icmp.ping4/6 (unprivileged ICMP datagram), sockopt.matrix
(TTL/TOS/RECVERR/MTU_DISCOVER), trace.errqueue_reachable, multinetwork.request_and_bind,
local.mdns_discover, peer.ble_advertise, shizuku.command_battery (ip neigh / ip -6 route /
ip addr / ip monitor / dumpsys network_stack DHCP+IpClient / dumpsys wifi). JSON export via share
intent; results carry verdict + raw evidence.
Could NOT be compiled in the cloud sandbox: dl.google.com (Google Maven) and services.gradle.org are proxy-blocked, and no device is reachable for on-device runs. Build + iterate locally (Android Studio / Claude Code). Wrapper is pinned to Gradle 8.14.3.
Website — web/ (2026-07-30, LIVE at https://echo-lot.app)
Single-page site for echo-lot.app on Cloudflare Workers: static public/ from the edge, a small
Worker for the stable short URLs (/apk, /apk.sha256, /fdroid, /source) and /api/latest.
Version info is resolved from the Gitea releases API at request time (edge-cached 5 min), so
tagging a release is the only publish step — the homepage shows the latest version/date/size via
progressive-enhancement JS and falls back to pre-release copy otherwise. Auto light/dark via
prefers-color-scheme: dark = sonar display, light = chart-recorder printout, same amber-accent
token system. Deploy: npx wrangler deploy from web/, or CI via
.gitea/workflows/deploy-site.yml (needs CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID
secrets); full story in web/README.md. Branding applied (2026-07-30): real favicon/wordmark
copied into web/public/assets/ (copies — assets/branding/ is the source of truth), palette
switched to abyss/teal/amber with teal-as-instrument semantics, sonar-sweep hero replaced by a
Focus-lattice panel (rogue-DHCP finding vignette), tagline "measure, don't guess".
Branding — assets/branding/ (2026-07-30)
Identity chosen: the Focus mark — a quiet lattice of nodes with exactly one under examination
(amber) inside teal viewfinder brackets. Tone is surgical measurement, not scanning (sonar-sweep
visuals and the "sound out your network" tagline were explicitly rejected); tagline is
"measure, don't guess". Palette: abyss #071522, tile #0E2433, instrument teal #35E0C4,
finding amber #FFB454, foam #E8F4F2 — teal is always the instrument, the single amber point is
the finding. The wordmark is hand-drawn monoline SVG paths (zero font dependency); the second "o"
is the signature teal ring + amber ping. Assets: icon.svg (+ icon.png 512² — Gitea avatars reject SVG), icon-adaptive-{foreground,background}.svg
(108dp adaptive-icon layers, art inside the 66dp safe circle), wordmark-on-{dark,light}.svg,
banner.svg (1200×300, wired into the repo README), social-preview.svg/.png (1280×640, for the
Gitea/GitHub social-preview slot). The website's placeholder favicon/wordmark can now be replaced
from these.
First device report — OnePlus 15, Android 16/SDK 36 (2026-07-30)
Archived at echolot-prober/reports/CPH2747-android16-sdk36.json. Collection workflow that works:
deploy over (wireless) adb, but the user starts the run and exports the JSON manually — probe
runs reliably kill the adb-over-wifi session (multinetwork/multicast churn the link adb rides on).
OS findings:
link.snapshot,icmp.ping4(38 ms),sockopt.matrix4/4,peer.ble_advertise: SUPPORTED.trace.errqueue_reachableSUPPORTED — full Os-API errqueue path exists on Android 16; the C-over-JNI shim (next-steps #2) may be unnecessary on modern devices. Still needs a real errqueue round-trip test, and older devices may differ.icmp.ping6EAGAIN — topology, not capability: wifi was v4-only, v6 only on cellular, socket used the default network. Probe should bind per-network (also covers the ping4 case properly).shizuku.command_batterySUPPORTED, shell(2000): realip -6 routeRA data (lifetimes, per-table),ip monitorNEIGH events, wifi dump with full DhcpResults. Vendor formats look parseable.local.mdns_discover: lock acquired, discovery ran, 0 services in the 4 s window — needs a longer window / known-good reference network before calling it more than formally SUPPORTED.
Prober bugs the run exposed (all fixed same day):
- rotation wiped results + cancelled runs → state moved to a ViewModel;
multinetwork.request_and_bindERROR → missingCHANGE_NETWORK_STATEin the manifest;rtt_ms: "38,1"→ device locale leaked into report numbers, nowLocale.ROOT;- 3/7 Shizuku commands
SHIZUKU_BIND_TIMEOUT→ bind/unbind-per-command raced Shizuku; now one bind per battery (execBatch). Run 2 (same device, fixed APK —…-run2.json) confirmed all three fixes: multinetwork.request_and_bindSUPPORTED — wifi + cellular acquired and bound concurrently (downKbps 39094 / 13231); requesting cellular even brought up a second, dual-stack PDN (rmnet_data2, private v4 /30 + carrier-NAT DNS) next to the IPv6-only default PDN — exactly the per-network behavior the production app needs.- Shizuku battery 7/7 in 2.9 s (was 4/7 in 34 s): full ARP/NDP neighbor table,
ip addr, IpClient ProvisioningConfiguration incl. SSID/BSSID and APF caps (v6000, 4096 B). rtt_ms: "32.0"— locale fix holds. Still open (probe improvements, not blockers): ping6 should bind per-network (still EAGAIN via v4-only default wifi); mDNS window too short to see real services (0 found on a network that has them);ip monitor2 s window caught nothing on a quiet net — consider longer/event-triggered capture; IP_MTU read via getsockoptInt reflection unavailable — reader belongs in the errqueue shim if that lands.
Go server skeleton — server/ (2026-07-30)
Pure-Go stdlib implementation of the spec's core: control plane (enroll with single-use tokens,
profile with SPKI pin, sessions with the §2.4 HKDF key schedule) + UDP data plane (ELT1 header,
HMAC gate, 1024-wide anti-replay, ECHO_RESP with observation block, TIMESYNC, §3.4
anti-amplification). Wire format covered by go test (roundtrip, replay/bad-HMAC/unknown-prefix
silent drops); full enroll→profile→session flow smoke-tested live. Two run modes: container
(autodetected via /.dockerenv etc. or --docker; config from ECHOLOT_* env; distroless image,
network_mode: host required — Docker NAT would falsify the observed sources the protocol
measures) and native (--install-systemd/--uninstall-systemd, opt-in --self-update
against a Gitea releases API). CI: .gitea/workflows/build-server.yml — tests on push;
server-v* tags push the image to the Gitea registry + attach linux amd64/arm64 binaries that
self-update consumes. Not yet: TCP/TLS echo, STUN, canary DNS, actions, observations API,
real admin UI, self-update signature verification.
Prober build numbering: ascending versionCode shown on screen + as proberBuild in the JSON
report; current deployed build = 3. Bump on every deployed change.
Build-2 reports: OnePlus 15 (A16) + Lenovo TB330FU (A15) — 2026-07-30
Archived as …-build2.json in echolot-prober/reports/. The findings:
-
traceroute.udp4SUPPORTED on BOTH devices (SDK 35 + 36): 6 real hops via pure-KotlinOs.recvmsg(MSG_ERRQUEUE)in ~250 ms, identical paths (A1 → Cloudflare). The C-over-JNI errqueue shim is dead for Android 15+; keep it in mind only if pre-15 devices matter. -
Per-network ICMP works as designed: phone ping6 = "cellular only, default has no v6 path" (SUPPORTED with topology evidence instead of the old bare ERROR); per-network RTTs (wifi 20 ms vs cellular 201 ms on v4).
-
Network finding (dogfood!): hudeWLAN advertises a v6 default route via RA but hands out no global v6 address — both devices have only link-local + a ::/0 route on wifi. That's a half-broken IPv6 config on the LAN, exactly the class of issue the product should flag.
-
mDNS: meta-query returns 0 on BOTH devices (NsdManager limitation, now confirmed twice); concrete
_http._tcpfinds 4 real services (3× "Magic 2 LAN", EPSON WF-7840) on both. Production inventory must enumerate concrete types, not trust the meta-query. -
Tablet Shizuku: binder alive, permission granted, but UserService bind timed out (0/7) — first-spawn dex extraction on slow storage suspected; build 3 raises the bind window to 25 s + one retry. Phone stays 7/7 with rich neighbor/RA/DHCP evidence. Build-3 verdict: not timing. Both 25 s attempts timed out (50 s total) — the UserService spawn genuinely fails on the Lenovo/A15. Build 4 adds a reflection fallback to the legacy
Shizuku.newProcessremote-process API when the bind fails;exec_pathin the evidence says which path ran. Whatever the outcome, core-shizuku must not assume UserService works everywhere. (Phone build 3: still 7/7;ip_monitornow catches provoked NEIGH PROBE→REACHABLE transitions, and a mid-path router dropping one TTL round showed the "*" hop path works in traceroute.udp4.)Build-4 verdict (
…-build4.json): the fallback WORKS. Tablet Shizuku now SUPPORTED viaexec_path: newProcess fallback— 6/7 commands with real data (full neighbor table, per-netId v6 route tables, Lenovo's IpClient dump — a distinctly different format from OnePlus's, i.e. the per-vendor parser sample we wanted). core-shizuku design consequence: dual-path executor is mandatory — UserService where it binds, newProcess where it doesn't; both confirmed live on real hardware. Known rough edges:ip monitortimes out under newProcess (subshell trick doesn't survive that path); the 2×25 s bind wait before falling back is worth shortening once a device is known-bad. Tablet UserService root cause still open — multi-user (3 users) is the prime suspect; Shizuku app is current (13.6.0), so the stale-app theory is dead. -
ip_monitorreturned no events this run even with the provoked gateway ping (gateway was already REACHABLE, so no NEIGH transition happened). Evidence-dependent, not a bug.
Pipeline shakeout — 2026-07-30, partially done
Repo pushed to git.rambossek.at/EchoLot/echolot (ssh :2222). Actions runner works:
server-test green on push. server-v0.1.0 release shipped with all assets
(echolot-server_linux_{amd64,arm64} + SHA256SUMS) after two workflow fixes: mixed
paths+tags triggers never fire (split into test-server.yml / build-server.yml), and a greedy
sed grabbed a nested user id instead of the release id → uploads 404ed (first-match grep now;
release creation also falls back to GET-by-tag on re-runs). Binaries verified downloadable →
--self-update has its artifact source. Image job green too on the dedicated
compilesau-echolot runner (label echolot): needed a PAT with package read/write as the
REGISTRY_TOKEN repo secret — the built-in Actions token is rejected by the container
registry. git.rambossek.at/echolot/echolot-server:{v0.1.0,latest} pulls anonymously
(3.9 MB distroless, verified). Full chain proven: tag → tests → binaries → release assets →
registry image → anonymous pull. Note the tag convention: image tags carry the bare version
(v0.1.0), release tags the namespaced one (server-v0.1.0); selfupdate normalizes.
Toolchain upgrade + build-5 tablet re-run (2026-07-30)
Moved to the laptop (USB adb — far more stable than wireless, as expected). Upgraded the build off the JDK-21 ceiling: AGP 8.7.3 → 9.2.0, Gradle 8.14.3 → 9.6.0, Kotlin 2.0.21 → 2.2.10, which lets the toolchain run on JDK 25. Two AGP-9 migration edits were required:
- AGP 9 has built-in Kotlin support, so applying
org.jetbrains.kotlin.androidalongside it fails with "extension with name 'kotlin' already registered" — the alias must be dropped (kotlin.compose/kotlin.serializationstay; they are separate compiler plugins). - The
kotlinOptions { jvmTarget }block came from that plugin and no longer resolves; AGP derives jvmTarget fromcompileOptionsinstead.
compileSdk/targetSdk 35 → 36, because Android Studio only ships API 36.1 by default and
installing 35 was avoidable churn. Build-5 re-run on the TB330FU is verdict-identical to
build-4 across all 10 probes (…-build5.json) — the toolchain jump and targetSdk 36 are
behaviour-neutral on this device, including the newProcess Shizuku fallback (still 7/7,
exec_path: newProcess fallback, UserService still never binds).
Also of note: traceroute.udp4 is SUPPORTED here (6 hops via errqueue, "no native shim needed"),
which is what retires next-step 2 below — the JNI shim is not required on either known device.
OnePlus 15 build-5 re-run (CPH2747-android16-sdk36-build5.json): all 10 SUPPORTED, identical
to build-3. exec_path: UserService on the phone vs newProcess fallback on the tablet, both
7/7 — the dual-path executor requirement is now confirmed on the current toolchain on both
devices. Phone is on Android 16 / SDK 36, i.e. targetSdk = 36 is also exercised natively there.
Two collection-loop gotchas found while driving the phone over USB:
icmp.ping6's verdict is topology-dependent, not build-dependent. A first run with Shizuku stopped and only 2 networks up reported UNSUPPORTED; with cellular+wifi present it is the expected SUPPORTED / "echo reply on cellular only — default network has no v6 path". Do not read a ping6 verdict without checkinglink.snapshot'snetwork_countin the same report.- The Shizuku permission dialog times out after 30 s (
shizuku.command_batterylogsShizuku permission not grantedat ~30027 ms). Screenshot→read→tap round-trips can exceed that. Grant the permission before starting a run, or accept that the first run after a fresh install burns the Shizuku probe; the grant is persistent, so the next run is clean. - The phone auto-rotates; tap coordinates must be recomputed per orientation, and
uiautomator dumpcan report a stale idle state right after a tap — trust the exported JSON'sdurationMsvalues as ground truth for whether a run really completed, not the button label.
Next steps
- Build locally, run on several physical devices (varied Android versions/vendors), collect the JSON reports — especially the real per-device Shizuku dump formats.
IfRetired — SUPPORTED on both known devices;trace.errqueue_reachable= PARTIAL, add a C-over-JNI errqueue shim.traceroute.udp4reads real hops viaOs.recvmsg+StructMsghdrreflection, so no:nativemodule is needed.- Start the Go server skeleton (enrollment + profile + sessions + UDP echo with observation blocks + canary-DNS reference records) per probe-protocol.md.
- Fold confirmed capabilities into the production
core-probe/core-shizukumodules.
Production probe server — LIVE on dedicated VM "fmr" (2026-07-31)
echolot-server v0.2.0 runs natively (systemd, no docker) on a dedicated VM: 2×IPv4 + 2×IPv6
service addresses (fmr-1/fmr-2.echo-lot.app, dual-stack DNS), a third IPv6 (::2) reserved for
SSH only — verified untouched by the daemon (explicit multi-address binds, no wildcard).
Control: fmr-1:8443 (SPKI pin zRV9qkiLnRexAeh4RrSfJzbPWO+U/2Oj2/NVM/KfXlg=, verified
externally over v4+v6). UDP data plane on all four service addresses :8442 — the second IP is
the stun-5780 substrate. Daily randomized self-update timer installed (checksum-verified
against SHA256SUMS; signature verification still TODO before treating the source as untrusted).
Host config in /etc/echolot-server.env. SSH access for sessions: ssh claude-echolot.
Server v0.3.0 — STUN + TCP echo + observations + actions (2026-07-31)
Shipped and deployed to fmr via the server's own --self-update (first real exercise:
checksum-verified download v0.2.0→v0.3.0, atomic replace, restart — worked). Added over v0.2.0:
- STUN (RFC 5389 + 5780): 4 service addrs × primary/alt-port grid. Externally verified on
v4 AND v6 — binding success with XOR-MAPPED, RESPONSE-ORIGIN, OTHER-ADDRESS present, so the
profile now advertises
stun-5780(the second IP earns its keep). - TCP echo (:8441): JSON greeting with observed src + real Linux TCP_INFO — verified
externally
mss:1440(v6, 1500−60), options[sack,wscale], then byte-echo. - Observations API
GET /v1/sessions/{id}/observations(per-packet UDP view, connect-back results, TCP records correlated by source IP). - Actions
POST /v1/sessions/{id}/actions:delayed_echo(DELAYED_ECHO at the observed data-plane source — NAT-lifetime primitive) andconnect_back(dials the control-plane source, records connected/refused/timeout+rtt). - Capabilities computed from what's actually wired:
udp-probe, delayed-echo, connect-back, tcp-echo, stun-5780. Still not implemented: TLS-echo/JA4, HTTP echo, tls-reference, canary DNS (§6.1 reference records), and the train/big-send/frag/throughput actions. Admin UI still token-mint + health only.
Canary DNS live — server v0.3.1 on fmr (2026-07-31)
Zone c.echo-lot.app delegated (NS → fmr-1/fmr-2) and authoritative on all 4 service IPs
udp+tcp/53. Verified through full public recursion: ttl-5 A→192.0.2.5 (Cloudflare), ttl-3600
AAAA→2001:db8::3600 (Google), big-txt TXT returned (TCP fallback, truncated over UDP as
designed). End-to-end session attribution works: a <nonce>.<session-prefix>.c.echo-lot.app
query resolved via a public resolver shows up in GET /v1/sessions/{id}/observations →
dns_canary with the resolver's real egress IP, transport, and EDNS. First real test already
caught a finding: Google applies 0x20 case randomization (mixed-case qname), Cloudflare does
not — captured via case_preserved. Capabilities now: udp-probe, delayed-echo, connect-back,
tcp-echo, stun-5780, canary-dns. Kept the hand-rolled stdlib DNS (no miekg/dns) — validated
against independent clients. Deployed via --self-update (v0.3.0→v0.3.1, checksum-verified).
Server v0.3.2 + v0.3.3 (2026-07-31)
- v0.3.2 — control-plane security (live on fmr, externally verified):
POST /v1/echoreflects the received request head+body (b64) and observed TLS (version/cipher/SNI/ALPN) — captured real SNIfmr-1.echo-lot.appand an injected header over public TLS1.3;GET /v1/tls-referencereturns the served DER chain + pin (cross-checked against the openssl-derived pin). Optional cleartext echo listener (default off). Capabilityhttp-echo. - v0.3.3 — MTU probe (data plane): MTU_PROBE (0x09) → small MTU_ACK (0x0A) carrying the received datagram size; client DF-probes increasing sizes to find path MTU / black holes. ACK is tiny → never amplifies. Tested.
- Note on trains: upstream trains (TRAIN_DATA 0x03) are already observable — every HMAC-valid packet is recorded (seq/t_rx/size/type) with no per-packet response, so loss/reordering/inter- arrival are visible via GET observations. The dedicated data-plane TRAIN_REPORT (0x05) is deferred: §3.4 anti-amplification means it needs an asymmetric grant + columnar multi-datagram encoding — a focused batch, not a corner to rush. Remaining spec: tls-echo (ClientHello+JA4), TRAIN_REPORT, big/frag-send, throughput, downtrain; real admin UI.
Server self-test + host tuning — v0.3.4/v0.3.5, fmr proven good (2026-07-31)
The daemon now proves its own host is a clean measurement target:
- sysctl audit (
GET /admin/selftest, startup warnings): on first run it flagged exactly 4 real issues on fmr — accept_ra=1 on a static-v6 host, accept_redirects=1, send_redirects=1, icmp_ratelimit=1000. Recommendedserver/deploy/99-echolot-sysctl.confapplied (v6 default route/addrs are proto static with 0 RA-derived routes, so disabling accept_ra is safe — verified v6 egress intact after). Now sysctl_ok=true, 0 warnings. - egress-MTU self-proof: DF PMTUD via IP_MTU_DISCOVER + getsockopt IP_MTU (v0.3.4 had a bug — read IP_MTU without connecting → ENOTCONN; v0.3.5 connects first). fmr reports 1500 on both v4 and v6 → mtu_ok=true, so client MTU tests are trustworthy.
- Both signals ride in the profile as
server_selftest{mtu_ok,sysctl_ok}so a client can skip MTU testing when the server can't support it honestly. fmr profile now:{mtu_ok: true, sysctl_ok: true}.
Server v0.3.6 — tls-echo / JA4: spec §4 COMPLETE (2026-07-31)
The elt-echo TLS variant runs on the TCP-echo port (8441), multiplexed by a timed 0x16 peek (plain echo stays server-speaks-first; a TLS ClientHello routes to the TLS path). It captures the full ClientHello, returns it raw (b64) + as a JA4 fingerprint (FoxIO), then TLS byte-echoes — the sec.clienthello_echo evidence. Hand-rolled ClientHello parser (ciphers/exts/ALPN/ supported_versions/sig-algs, GREASE-excluded), unit-tested. Cross-client verified on fmr: openssl → t13d3013eo (30 ciphers), python ssl → t13d1712eo (17) — different stacks, different fingerprints, correct _a structure both. Capability tls-echo.
Spec §4 (TCP/TLS/HTTP/STUN) is now fully implemented. Server capabilities: udp-probe, delayed-echo, connect-back, http-echo, tcp-echo, tls-echo, stun-5780, canary-dns. Remaining spec: §5 heavy actions (downtrain/big_send/frag_send/throughput) + the TRAIN_REPORT retrieval path — all gated on the anti-amplification grant machinery (§3.4) — and a real admin UI.
Production app — echolot-app/, core-protocol proven live (2026-07-31)
Started the Android client, bottom-up from the verifiable spine. echolot-app/ is a multi-module
Gradle build; core-protocol is a pure Kotlin/JVM module (no Android SDK) implementing the
client half of probe-protocol.md: SPKI-pinned control plane (enroll/profile/session via
HttpsURLConnection — Android-API-1 compatible, hostname verification off since trust is the pin),
HKDF-SHA256 session keys, and the ELT1 UDP data plane (HMAC gate, ECHO+observation, MTU probe) —
byte-compatible with the Go server. Unit tests pass incl. the RFC 5869 HKDF vector (so key
derivation provably matches the server). Verified END-TO-END against live fmr via
scripts/test-fmr.sh (mint token over SSH → enroll on public control plane → run LiveServerTest):
profile (8 caps), session, ECHO rtt ~11ms with the observation block round-tripping the client's
observed NAT port, MTU probe 1400→1400, observations 298B. Two client bugs found+fixed doing it:
java.net.http did hostname verification (switched to HttpsURLConnection) and ECHO needed ≥72-byte
requests for the full 40-byte observation to survive §3.4 anti-amplification. Next: core-measurement
(schema types), core-probe (port prober probes), core-shizuku (dual-path), Compose UI.
App: core-measurement + core-engine — full server-facing vertical proven (2026-07-31)
Two more pure-Kotlin/JVM modules, both verifiable without a device:
- core-measurement: the measurement-schema.md document model (two-clock, columnar trains, test-type registry, anonymization types, finding-requires-evidence). The §7.3 deterministic verdict derivation is implemented + unit-tested; document JSON round-trips.
- core-engine: the run engine composing core-protocol probes into core-measurement documents. Injected clock/UUID source (pure, testable). Runs a server ECHO train → RTT distribution, loss, and NAT-rebinding detection (from the server's observed source port) as train.udp_updown. Verified END-TO-END against fmr: 20-packet train, 0% loss, RTT 1.7/2.5/6.9ms, single observed port (no rebinding) → valid MeasurementDocument (2.3kB), overall GREEN. So the whole server-facing stack — protocol client → engine → schema document → verdict — is now proven against the live server, no device needed. Next modules (core-probe device-tier, core-shizuku dual-path, Compose app) are Android + need on-device verification.
App: installable APK — core-probe + Compose UI (2026-07-31)
The production Android app assembles. Android toolchain in echolot-app mirrors the prober (AGP
9.2 built-in Kotlin — do NOT also apply kotlin.android, it double-registers the kotlin
extension; that was the one build gotcha). Modules added:
- core-probe (Android lib): Probe→core-measurement Test abstraction; NetworkInventory (LinkProperties → measurement networks[]), LinkSnapshotProbe (link.snapshot), IcmpProbe (per-network icmp.ping4/6, ported from the prober's validated per-network logic).
- app (Compose): RunViewModel orchestrates probes → assembles a MeasurementDocument with a
§7.3 summary + first-pass findings; Compose UI shows overall/ per-category traffic lights,
networks, tests (status/metrics), findings; JSON export via share intent. Survives rotation
(ViewModel). App-tier only for now; server-facing (core-engine) and Shizuku tier are additive
follow-ups (app degrades gracefully without them, like the prober).
Debug APK: 9.5 MB,
echolot-app/app/build/outputs/apk/debug/app-debug.apk. Not yet run on device (needs the user's phone). core-shizuku (dual-path executor) deferred as additive.
App verified on-device — both phones (2026-07-31)
The production Echolot app runs on real hardware, on BOTH devices, via the beacon-managed adb:
- OnePlus 15 (CPH2747, A16) and Lenovo TB330FU (A15): link.snapshot OK, icmp.ping4 OK (per-network, RTT ~40ms), icmp.ping6 FAILED → finding "No IPv6 ICMP path on any active network" → category ipv6 yellow → Overall YELLOW. The verdict is driven by the real broken-LAN IPv6 (RA default route, no global prefix) we first found with the prober — the product now surfaces it end to end (probe → schema → verdict → traffic-light UI). Wireless-adb beacon (tools/adb-beacon) made this practical: both devices self-report their rotating wireless-debug port to fmr:443; a PC connector keeps adb connected. Debugged live against the restricted LAN (cleartext policy, egress filtering, shared-LAN mDNS crossing, fast port rotation) — all handled.
App: core-shizuku (shell tier) built + wired (2026-07-31)
Ported the prober's validated Shizuku executor into the app as a library module:
- AIDL IUserService + UserService (runs
sh -cas shell/root in the Shizuku-spawned process), Shizuku provider merged into the app manifest. - ShizukuRunner: the build-4 dual-path executor — bind the UserService (25s + retry) where it
works (OnePlus 7/7), fall back to the legacy
Shizuku.newProcessreflection API where it never binds (Lenovo).exec_pathrecords which path ran. - ShizukuProbe: runs the shell battery (ip neigh / ip -6 route / ip addr / ip monitor /
network_stack DHCP / wifi dump) and emits a shizuku-tier
link.ip_monitorTest with the raw per-device dumps as evidence + commands_ok/exec_path metrics. Self-degrades to UNSUPPORTED when Shizuku isn't running. Wired into RunViewModel (runs after app-tier probes; sets tiers.shizuku). App APK assembles clean. On-device test deferred: at build time no device was reachable (tablet wifi/beacon dropped on the churning LAN; phone wireless debugging disabled to stop reconnect notifications). Will verify on a device later — expecting UserService on the OnePlus, newProcess fallback on the Lenovo, per the prober.
App on-device: net.captive_portal verified, Shizuku degrades correctly (2026-07-31)
Installed the app (core-shizuku + net.captive_portal) on the OnePlus 15 and ran it; report archived
at echolot-app/reports/CPH2747-app-run1.json. Results:
- net.captive_portal works — Android's NetworkMonitor logic reproduced: default + wifi both
returned HTTP 204 on the HTTPS and HTTP generate_204 probes →
validated; cellular returned neither (-1/-1) →no_internet. The per-network split immediately surfaces an asymmetry the OS hides (wifi validated, cellular can't reach the checks at all). - Shizuku tier degrades correctly:
binder_alive:false→ test UNSUPPORTED,tiers.shizuku:false(Shizuku isn't running on the phone). The dual-path executing path still needs an on-device test with Shizuku started (expect UserService on this OnePlus). - 5 tests now: link.snapshot ok, icmp.ping4 ok, icmp.ping6 failed, net.captive_portal ok, link.ip_monitor(shizuku) unsupported → overall YELLOW via the IPv6 finding. Also fixed this session: the beacon app itself caused the "wireless debugging connected" notification spam (it re-resolved adbd's own mDNS advertisement, making adbd re-arm each time); now resolves once per service instance and the heartbeat re-POSTs the cached port only.
App: dns.canary verified against the live server (2026-08-01)
Built the client half of the canary-DNS measurement and verified it on the OnePlus against the
deployed fmr zone (echolot-app/reports/CPH2747-app-run2-dns.json):
- All four spec-frozen reference records matched byte-for-byte through the network's own resolver
(ttl-5→192.0.2.5, ttl-60→192.0.2.60, ttl-3600→192.0.2.36, ttl-86400→192.0.2.86) → nothing on
this path rewrites DNS answers (
dns.answer_integrityin the green case). - The un-cacheable nonce name
1006ad16.adhoc.c.echo-lot.appresolved to 192.0.2.21 →reached_authoritative: true, proving the query actually reached the canary server rather than being answered from a cache or an interceptor. Findings wired:dns.answer_rewritten(high) when a reference mismatches, anddns.authoritative_unreachable(medium) when the nonce isn't answered by the canary server. This closes the first full client↔server measurement loop: the Kotlin app measures against the Go server's canary zone on real hardware. 6 tests now run per measurement.
App: Shizuku shell tier VERIFIED on-device — the app is feature-complete for v1 tiers (2026-08-01)
Ran the app on the OnePlus with Shizuku running (echolot-app/reports/CPH2747-app-run3-shizuku.json):
tiers: {app:true, shizuku:true}, shizuku test ok, commands_ok 7/7,exec_path: UserService(the OnePlus binds it — matches the prober; the newProcess fallback stays for the Lenovo).- Evidence is the real privileged material the production parsers need: live ARP/NDP neighbor
table, per-table IPv6 routes,
ip addr, an actual[NEIGH]netlink event fromip monitor, IpClient DHCP logs incl. APF capabilities, and the wifi state dump — all as shell(2000). Both privilege tiers now work end to end in the production app on real hardware, alongside the canary-DNS loop against the live server. 6 tests/run: link.snapshot, icmp.ping4, icmp.ping6, net.captive_portal, dns.canary, link.ip_monitor(shizuku).
App: nat.stun_5780 — NAT behavior discovery verified against the live server (2026-08-01)
Client-side RFC 5389/5780 STUN (hand-rolled, stdlib only) exercising the server's stun-5780
capability. Three binding requests from ONE socket: primary, the server's OTHER-ADDRESS
(alternate IP), and CHANGE-REQUEST(port). Verified on the OnePlus
(echolot-app/reports/CPH2747-app-run4-stun.json):
- local
10.13.102.124→ mapped178.191.120.247:53259,behind_nat: true other_address 89.185.109.151:3479— the server's second IP answered, so RFC 5780 works end to end (client ↔ our own STUN implementation)- mapping: endpoint-independent (same external port toward a different destination → P2P
friendly); filtering: address/port-dependent (no reply to CHANGE-REQUEST → unsolicited
inbound is dropped). Classic full-cone-mapping + port-restricted-filtering NAT.
Finding wired:
nat.symmetric(medium) when mapping is address/port-dependent. Two bugs caught by running it for real: port preservation was misread as "no NAT" (now compares ADDRESSES), and an unbound socket reports the wildcard as its local address (now resolved via a throwaway connected socket). 7 tests/run.
App: autorun mode + IPv6 severity rework (2026-08-01)
IPv6 is no longer treated as a defect just for being absent. The finding now depends on
whether the network actually provisioned IPv6 (a global v6 address or a ::/0 route):
- not provisioned →
ipv6.not_offered, severity INFO → green. Most networks are still IPv4-only and that is not a fault. - provisioned but ICMPv6 fails →
ipv6.broken, severity MEDIUM → yellow. Half-configured IPv6 is worse than none (Happy-Eyeballs stalls). Verified on the OnePlus: our LAN advertises a v6 default route with no working path, so it correctly reportsipv6.broken.
Autorun mode — one adb command runs a full measurement unattended and collects the result without any UI tapping or adb round-trip:
adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
curl http://<fmr>/reports # list
curl http://<fmr>/report/<name> # fetch
The app runs the suite, POSTs the MeasurementDocument to the collection endpoint (receiver.py
gained POST /report, GET /reports, GET /report/<name>), shows the result for 3 s, then
finishes itself — leaving the device as it was found. On upload failure it stays open so the
error is visible. Grant permissions once via adb shell pm grant app.echo_lot.app android.permission.ACCESS_FINE_LOCATION so nothing blocks on a dialog.
App: router identification, brand icons, DEV build variant (2026-08-01)
link.ra_source — who is advertising IPv6 here, and what box is it? New app-tier probe
(registry addition). Identification chain, each step recorded as evidence so nothing is guessed:
- RA source = next-hop of the
::/0route per network (afe80::link-local). - MAC recovered from the modified-EUI-64 link-local (strip
ff:fe, flip the U/L bit) — e.g.fe80::7a9a:18ff:fe54:b8f9→78:9a:18:54:b8:f9. RFC 7217/privacy addresses don't encode a MAC and are reported as such rather than guessed. - Vendor via a curated OUI table (
Oui.kt— SOHO/router vendors; unknown OUIs are printed verbatim). Locally-administered (randomized) MACs are flagged. - UPnP/SSDP M-SEARCH → the gateway's
SERVER:banner + device-description XML gives manufacturer / model / friendly name. This is what usually names the exact box. - Reverse DNS for both gateways. All SSDP responders are recorded (not just the gateway) so a rogue RA sender that isn't the gateway can still be matched — and the MAC travels with every identity source, which is the hook for the future LLDP / mDNS cross-matching. UI: a "Router / IPv6 advertiser" panel above the network list, leading with the identified vendor/model.
Icons + DEV variant. The branding adaptive icon is now the app icon: icon-adaptive-*.svg
converted to Android vector drawables (SVG transform baked in, gradient background, monochrome
layer for themed icons) plus PNG mipmaps for legacy launchers. The debug build is a separate
app: applicationIdSuffix .dev, label "Echolot DEV", and a DEV-badged icon (layer-list =
production foreground + generated amber DEV ribbon) so it is unmistakable next to a real install
and both can be installed side by side.
NOTE for tooling: the dev package is app.echo_lot.app.dev, activity app.echo_lot.app.MainActivity.
link.ra_source verified on-device — it named the actual router (2026-08-01)
Run archived at echolot-app/reports/CPH2747-app-run5-router-id.json. On the wifi network the
probe identified the RA sender completely, from an unprivileged app:
- RA source
fe80::7a9a:18ff:fe54:b8f9→ MAC 78:9A:18:54:B8:F9 recovered via EUI-64 (matches the Shizuku neighbor table exactly) → vendor MikroTik by OUI - IPv4 gateway
10.13.102.1, reverse DNSrouter.hudelist.local - UPnP:
RouterOS/7.23.2 UPnP/1.0 MikroTik→ manufacturer MikroTik, model Router OS, friendly name "MikroTik Router" So the box advertising this LAN's broken IPv6 RA is a MikroTik running RouterOS 7.23.2, named by two independent methods (OUI from the address itself + UPnP device description) that corroborate. On cellular the carrier's RA source is an RFC 7217 privacy address and is correctly reported as "not EUI-64" rather than guessed. The SSDP sweep also inventoried the LAN (Synology DS1522+ DSM 7.3, a Sky ES160 gateway) — the raw material for the planned LLDP/mDNS cross-matching by MAC. Fixes from this run: added the confirmed MikroTik OUI 78:9A:18 (+ other RouterBOARD ranges), and an elvis-operator bug that printed "no UPnP response" even when UPnP data was present.
App UX: progress bar + ETA, cancel, and edge-to-edge insets (2026-08-01)
- Progress + ETA:
Probe.estimatedMs(per-probe, from measured on-device durations — the timeout-bound probes dominate: icmp.ping6 ~7s on a v4-only net, captive-portal ~9s, SSDP ~7s, STUN ~6s) drives a determinate bar plus "test N of M · ~Xs left". The Shizuku battery is counted in the total so the bar covers the whole run. - Cancel: stops an in-flight run and shows what was measured so far, assembled into a normal document (findings + verdict over the partial set). Deliberately does not upload — a partial run is for the person looking at the screen, not for the record.
- Insets/cutout: Android 15 draws edge-to-edge by default, so the title was running under the
status-bar clock and the camera cutout. The root column now uses
safeDrawingPadding(), which covers status bar, navigation bar and display cutout.
Shell-tier readiness shown before a run (2026-08-01)
ShizukuAvailability distinguishes four states and the UI only speaks when it is actionable:
- NOT_INSTALLED → says nothing. Users who don't use Shizuku are never nagged.
- INSTALLED_NOT_RUNNING → amber banner "Shizuku is installed but not running — start it to include shell-tier tests". This is the case worth reminding about: the user has it, but a stopped service silently costs them the whole shell tier.
- NEEDS_PERMISSION → "running but not authorised, it will ask on first use".
- READY → green "shell-tier tests will run".
Detection is listener-based (
addBinderReceivedListenerSticky+ binder-dead), becausepingBinder()is only truthful once ShizukuProvider has delivered the binder — a one-shot poll at launch would show a false "not running". Installed-vs-not needs the<queries>package-visibility entry on Android 11+. Verified on-device: with shizuku_server stopped the banner appears correctly.
Shizuku banner is actionable; progress + cancel verified on-device (2026-08-01)
Tapping the shell-tier banner now does the right thing per state: installed-but-stopped → deep-links into the Shizuku app (a third-party app cannot start Shizuku itself; the wireless- debugging pairing flow is privileged and lives in that app, so taking the user there in one tap is the best available), running-but-unauthorised → fires the Shizuku permission request directly. The hint line states which action the tap performs. Verified on-device in one screenshot: progress bar at "test 4 of 8 · icmp.ping6 · ~33s left", Cancel button beside the disabled Run button, title clear of the status bar/cutout, and the banner having live-switched from "installed but not running" to "running but not authorised" via the binder listener when Shizuku was started mid-session.
Why the Shizuku banner can't start wireless debugging directly (verified, 2026-08-01)
Checked against Shizuku 13.6's own manifest (pulled the APK, aapt2 dump xmltree): the
wireless-debugging entry points — moe.shizuku.manager.adb.AdbPairingTutorialActivity,
moe.shizuku.manager.adb.AdbPairingService, moe.shizuku.manager.starter.StarterActivity —
declare no intent filters, so they are not exported and a third-party app cannot launch them.
MainActivity is the only reachable entry and answers MAIN/LAUNCHER only (no deep link), which is
why the handoff lands on the screen whose primary action is the root start.
Best available behavior, now implemented: the banner still opens Shizuku, but the hint names the
exact steps there ("Pairing", then "Start"), and a second tap target opens Developer options
(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS — public and exported) since Wireless
debugging must be enabled first for Shizuku's wireless start to work at all.
Downstream measurements: asymmetric grants, DF-mode big_send (server-v0.4.0 … v0.4.2, 2026-08-01)
The client can measure a round trip and the largest packet it can send. It cannot measure the largest packet it can receive, or downstream-only loss — those need the server to push, which is exactly what §3.4 gates behind an asymmetric grant. Implemented and verified live from the PC:
session.Grant— created per action, bound at creation to the session's observed data-plane source (no grant without a verified destination), clamped to server limits, with a byte budget, an average-rate ceiling and an expiry. Unit-tested for each of those refusals.downtrain— N packets of size S every I µs; the client derives downstream loss, reordering and inter-arrival spacing.big_send— one datagram per requested size. DF is on by default, so the largest size that arrives is the downstream path MTU. Without DF the kernel fragments and the result only says whether fragments get through — a different fact, and the reason the schema has bothmtu.pmtud_downandmtu.frag_delivery. Sizes above the server's own egress MTU (from the startup self-test) are refused up front and reported asmax_df_bytes, so an absence caused by our kernel is never read as a limit of the client's path.
Live from the PC against fmr: downstream path MTU 1500 (1472 payload, DF), fragmented delivery up to 4000, downstream train 100/100, 0 % loss, 0 reordered, inter-arrival 3.3 ms for a 3000 µs send interval.
Two bugs this shook out, both invisible in a single-homed lab
- Granted sends went out from the wrong local address (fixed in server-v0.4.2). fmr binds two
IPv4 addresses;
connForreturned whichever socket of the right family came first in the bind list. A train for a session established on.150left from.151and every packet was dropped by the client's NAT, which has no mapping for that pair. tcpdump showed all 50 leaving, the client saw none — reported as 100 % downstream loss, a confident measurement of something that never happened. Sessions now record which of our own bound addresses received their traffic and granted sends go back through that socket;connfor_test.gopins both that and the family fallback. - A second
ProbeSessionon one server session is silently dead. Sequence numbers restart at zero client-side while the server's anti-replay window keeps counting, so every packet is discarded as a replay — and because the server then never records the new source, the grant still targets the closed socket.ServerMeasurementnow uses one ProbeSession for the whole run;ProbeSession's doc comment states the constraint.
Run archive, anonymizer and uploads (2026-08-01)
Three pieces, deliberately separate:
core-archive— one JSON file per run plus an index entry, in a plain directory the user can inspect or delete with a file manager. Retention (max runs / max age / max total bytes) is enforced on every save rather than by a sweeper.enabledis a separate flag from the three ceilings because "no limits" and "keep nothing" are opposite intentions; collapsing them onto all-zeros is how a user who turns the caps off ends up with an empty history. 13 tests.core-privacy— the schema §8 anonymizer, three levels.full(your own server) changes nothing;balancedpseudonymizes SSIDs/hostnames, keeps the OUI half of a MAC and the /16 of a public IP, keeps RFC1918 verbatim (it describes topology, not a person), and drops neighbour inventories (SSDP/ARP/scan results) rather than mangling them;strictkeeps only metrics, statuses and finding codes. Pseudonyms are consistent within a document and — by default — not across documents, so an upload endpoint cannot link a device's runs; a stable salt is opt-in for people diffing their own history. Classification is one readable table, not annotations spread across modules. 14 tests, each pinning a property someone's privacy depends on.- Server-side upload policy —
off | anonymous | account, plus max size, retention days, max runs per device, and the least anonymization accepted. The profile advertises all of it so the app presents the choice honestly instead of discovering the rules by being rejected.accountrefuses today rather than falling back to anonymous: picking the strict setting before OIDC lands must not silently mean the loose one.
The archive holds the unredacted document; redaction happens on the way out, per upload. The local archive is the user's own data on their own device, and redacting it would destroy exactly the detail that makes a week-old run worth keeping.
App-side: settings screen (archive limits, privacy level with a plain-language description of what each keeps, auto-upload off by default, server URL/pin/credential), history screen showing whether each run left the device, and a preview of the exact bytes an upload would send — an anonymizer the user cannot inspect is only a promise.
Live round trip against fmr: uploaded a run, listed it, fetched it back and asserted the SSID, the SSDP neighbour name and the free-text note are absent from what the server stores while the finding code and the metrics survive, then deleted it.
Still open
mtu.pmtud_up(DF + errqueue),frag_send,throughput, TRAIN_REPORT retrieval.- Enrollment UI in the app (server URL/pin/credential are typed in by hand today).
- Accounts/OIDC on the server, which is what
uploads=accountis waiting for. - Nothing in this entry has been exercised on a phone yet — all of it was verified from the PC against the live server. On-device verification is the next step.
SemVer compatibility windows between app and server (server-v0.5.0 … v0.5.2, 2026-08-01)
Both artifacts are SemVer, and each now declares — and enforces — which peer versions it will talk
to. Spec: docs/probe-protocol.md §8.
Two axes, deliberately not conflated. Release versions are a proxy for what actually has to match, so the real thing is checked first:
protocol_version— can these builds talk. Advertised in the profile; a peer in a different breaking series is refused whatever its release version says. Below 1.0.0 the minor is the breaking axis (SemVer §4).- release-version window — may they, per policy.
[min, max), min inclusive, max exclusive, because the useful bound is always "the version that broke it".
Bounds sit at breaking boundaries, not at releases, so shipping a patch never requires editing a
range. The app requires server >= 0.4.2 for a stated reason, not caution: earlier multi-homed
servers mis-addressed granted sends and the client measured 100 % downstream loss that never
happened. Operators override the server side with ECHOLOT_MIN_APP_VERSION /
ECHOLOT_MAX_APP_VERSION; a malformed bound is fatal at startup rather than ignored, so a typo
cannot silently disable a restriction.
Three rules that shaped the implementation:
GET /v1/profileis never gated. It is where a refused client learns which version it needs; gating it leaves the user with a network error instead of an answer.- An unparseable or absent version is
unknown, and is allowed. Dev builds reportdev, and a client too old to send the header cannot be identified anyway. - Refusal is 426 with a body naming both versions and the window, surfaced client-side as a
distinct
VersionRefusedrather than folded into "network error".
The app's versionCode is now derived from its SemVer (major*1e6 + minor*1e4 + patch*10) instead
of being a second number to remember.
Verified live against fmr (LiveCompatTest): profile advertises the window and stays readable for a
refused version; 0.1.0 and 99.0.0 are both refused with actionable messages; 0.2.0 and a missing
header are both served.
One user-visible bug caught in the process: Go's JSON encoder HTML-escapes <, > and & by
default, so the refusal reached the client as needs \u003e= 0.2.0. Disabled at the encoder (this
is an API, not a page), and the client now parses the error field instead of pattern-matching it,
so it survives whatever a future encoder decides to escape.