Files
echolot/docs/build-status.md
T
mrambossekandClaude Fable 5 172afb421d privacy: fix a real leak - global IPv6 addresses were uploaded verbatim
Setting out to build the machine-readable schema, the first step was checking
whether the anonymizer covers the fields the schema declares sensitive. It did
not, and five identifying values were going out at the `balanced` level:

  networks[].link.addresses[].addr   the device's own global IPv6 address
  networks[].link.routes[].gateway   the ISP allocation
  networks[].link.dns.servers[]      the configured resolver
  private_dns_hostname               an internal hostname
  search_domains[]                   the internal domain

The settings screen describes that level as pseudonymizing addresses.

Root cause: classification keyed on field names, and the schema's actual names
were never added to the table. Every existing test passed, because each checked
a field somebody had remembered to write a case for - an unfalsifiable design
for a privacy control.

So beyond adding the names, classification now falls back to the *value* when
the name is unknown: anything shaped like an IPv4/IPv6 address or a MAC is
treated as one. Hostnames deliberately are not inferred by shape, since
train.udp_updown is indistinguishable from a domain and mangling a test type
would corrupt the document to protect nothing.

LeakTest is the guard, and is written to fail for fields nobody thought of: it
plants identifying values wherever one can occur and asserts none survive. It
also pins that RFC1918 addresses stay readable, so it cannot pass by
over-redacting. Route prefixes and :: needed care - 0.0.0.0/0 must stay itself
or a routing table becomes unreadable for no privacy gain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 14:45:52 +02:00

65 KiB
Raw Blame History

Echolot — build status & next steps

Last updated: 2026-07-29.

Decided

  • Name Echolot; domain echo-lot.app; scheme echolot://; namespace app.echo_lot.* (hyphen→underscore; appIds/packages can't contain hyphens). Prober appId app.echo_lot.prober.
  • Stack: native Kotlin + Jetpack Compose, no Flutter.
  • Tiers: app (no root), shizuku in v1 (wireless-ADB pairing), root future.
  • 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.matrix 4/4, peer.ble_advertise: SUPPORTED.
  • trace.errqueue_reachable SUPPORTED — 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.ping6 EAGAIN — 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_battery SUPPORTED, shell(2000): real ip -6 route RA data (lifetimes, per-table), ip monitor NEIGH 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_bind ERROR → missing CHANGE_NETWORK_STATE in the manifest;
  • rtt_ms: "38,1" → device locale leaked into report numbers, now Locale.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_bind SUPPORTED — 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 monitor 2 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.udp4 SUPPORTED on BOTH devices (SDK 35 + 36): 6 real hops via pure-Kotlin Os.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._tcp finds 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.newProcess remote-process API when the bind fails; exec_path in the evidence says which path ran. Whatever the outcome, core-shizuku must not assume UserService works everywhere. (Phone build 3: still 7/7; ip_monitor now 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 via exec_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 monitor times 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_monitor returned 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.android alongside it fails with "extension with name 'kotlin' already registered" — the alias must be dropped (kotlin.compose / kotlin.serialization stay; they are separate compiler plugins).
  • The kotlinOptions { jvmTarget } block came from that plugin and no longer resolves; AGP derives jvmTarget from compileOptions instead.

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 checking link.snapshot's network_count in the same report.
  • The Shizuku permission dialog times out after 30 s (shizuku.command_battery logs Shizuku permission not granted at ~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 dump can report a stale idle state right after a tap — trust the exported JSON's durationMs values as ground truth for whether a run really completed, not the button label.

Next steps

  1. Build locally, run on several physical devices (varied Android versions/vendors), collect the JSON reports — especially the real per-device Shizuku dump formats.
  2. If trace.errqueue_reachable = PARTIAL, add a C-over-JNI errqueue shim. Retired — SUPPORTED on both known devices; traceroute.udp4 reads real hops via Os.recvmsg + StructMsghdr reflection, so no :native module is needed.
  3. Start the Go server skeleton (enrollment + profile + sessions + UDP echo with observation blocks + canary-DNS reference records) per probe-protocol.md.
  4. Fold confirmed capabilities into the production core-probe / core-shizuku modules.

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, 150060), 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) and connect_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}/observationsdns_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/echo reflects the received request head+body (b64) and observed TLS (version/cipher/SNI/ALPN) — captured real SNI fmr-1.echo-lot.app and an injected header over public TLS1.3; GET /v1/tls-reference returns the served DER chain + pin (cross-checked against the openssl-derived pin). Optional cleartext echo listener (default off). Capability http-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. Recommended server/deploy/99-echolot-sysctl.conf applied (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 -c as 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.newProcess reflection API where it never binds (Lenovo). exec_path records 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_monitor Test 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_integrity in the green case).
  • The un-cacheable nonce name 1006ad16.adhoc.c.echo-lot.app resolved 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, and dns.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 from ip 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 → mapped 178.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 reports ipv6.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:

  1. RA source = next-hop of the ::/0 route per network (a fe80:: link-local).
  2. MAC recovered from the modified-EUI-64 link-local (strip ff:fe, flip the U/L bit) — e.g. fe80::7a9a:18ff:fe54:b8f978:9a:18:54:b8:f9. RFC 7217/privacy addresses don't encode a MAC and are reported as such rather than guessed.
  3. Vendor via a curated OUI table (Oui.kt — SOHO/router vendors; unknown OUIs are printed verbatim). Locally-administered (randomized) MACs are flagged.
  4. UPnP/SSDP M-SEARCH → the gateway's SERVER: banner + device-description XML gives manufacturer / model / friendly name. This is what usually names the exact box.
  5. Reverse DNS for both gateways. All SSDP responders are recorded (not just the gateway) so a rogue RA sender that isn't the gateway can still be matched — and the MAC travels with every identity source, which is the hook for the future LLDP / mDNS cross-matching. UI: a "Router / IPv6 advertiser" panel above the network list, leading with the identified vendor/model.

Icons + DEV variant. The branding adaptive icon is now the app icon: icon-adaptive-*.svg converted to Android vector drawables (SVG transform baked in, gradient background, monochrome layer for themed icons) plus PNG mipmaps for legacy launchers. The debug build is a separate app: applicationIdSuffix .dev, label "Echolot DEV", and a DEV-badged icon (layer-list = production foreground + generated amber DEV ribbon) so it is unmistakable next to a real install and both can be installed side by side. NOTE for tooling: the dev package is app.echo_lot.app.dev, activity app.echo_lot.app.MainActivity.

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:b8f9MAC 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 DNS router.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), because pingBinder() 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 both mtu.pmtud_down and mtu.frag_delivery. Sizes above the server's own egress MTU (from the startup self-test) are refused up front and reported as max_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

  1. Granted sends went out from the wrong local address (fixed in server-v0.4.2). fmr binds two IPv4 addresses; connFor returned whichever socket of the right family came first in the bind list. A train for a session established on .150 left from .151 and 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.go pins both that and the family fallback.
  2. A second ProbeSession on 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. ServerMeasurement now 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. enabled is 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; balanced pseudonymizes 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; strict keeps 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 policyoff | 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. account refuses 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=account is 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_versioncan 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:

  1. GET /v1/profile is 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.
  2. An unparseable or absent version is unknown, and is allowed. Dev builds report dev, and a client too old to send the header cannot be identified anyway.
  3. Refusal is 426 with a body naming both versions and the window, surfaced client-side as a distinct VersionRefused rather 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.

Until now a device was configured by hand-typing a control URL, a base64 SPKI pin and a credential. That is the step that goes wrong, and it goes wrong quietly: a pin off by one character does not fail loudly, it just never matches, and surfaces days later as an inscrutable TLS error.

POST /admin/enroll-tokens now returns the whole §2.1 bootstrap link alongside the token, because the server is the only party holding all three parts at once. The app takes it from a paste or an echolot://enroll deep link (so a QR scan configures a server in one action) and writes URL, pin and credential together or not at all — a half-applied server fails later, somewhere else, with an error pointing at the wrong thing.

The control URL comes from ECHOLOT_PUBLIC_URL (set on fmr to https://fmr-1.echo-lot.app:8443), falling back to the first control listen address; a wildcard bind warns rather than emitting a link to 0.0.0.0.

The encoding trap, which is the whole reason this is tested across both languages. The pin is base64, so it contains +, / and = — each of which means something else in a query string. An unencoded + decodes to a space, leaving the pin wrong by exactly one character. Base64 has no spaces, so the parser restores them; that cannot damage a correctly-encoded pin and it rescues every hand-assembled link. LiveEnrollmentTest redeems a link the server produced, which is the only way to catch a disagreement between the Go assembler and the Kotlin parser — a unit test on either side alone cannot see it. It also asserts the token is refused the second time.

Also fixed a spec divergence found while reading §2.1: the spec names the field device_credential, the first implementation shipped credential. The server now sends both and the client prefers the spec's; the alias goes once nothing reads it.

Two process notes from this round:

  • An edit to the admin handler silently failed to apply and the endpoint kept returning just the token. Caught by deploying and looking at the response, not by trusting a green build.
  • The live suite is now six tests (LiveServerTest, LiveMeasurement, LiveGranted, LiveUpload, LiveCompat, LiveEnrollment), all green against fmr from the PC with no device.

Directional loss: which way is the packet loss? (2026-08-01)

A round trip can only report that something was lost somewhere, which is the least useful form of the answer — "3 % loss" sends an engineer looking in both directions at once. The server already records every packet it received per sequence number (§6), so the two cases are actually distinguishable, and train.udp_updown now reports them separately:

  • sent, never seen by the server → upstream loss
  • seen by the server, reply never arrived → downstream loss

Findings name the direction and say what is not implicated, which is half the value: connectivity.loss_upstream ("the return path is not implicated: replies came back for everything that arrived"), connectivity.loss_downstream, nat.udp_unreachable_upstream.

Two things the implementation gets deliberately right:

  • Downstream loss is measured against what reached the server, not against what was sent. Using "sent" as the denominator counts every upstream loss a second time and overstates the return path. Pinned by a test with loss in both directions at once.
  • Per-direction jitter without synchronised clocks. Absolute one-way delay would need clock sync and we deliberately have none (the two-clock rule). But server_rx client_tx carries a constant unknown offset, and differencing successive samples cancels it — so RFC 3393 one-way delay variation is honestly attributable to a direction even though latency is not. A test pins that a 10-second clock offset changes nothing.

Correlation is by wire sequence number, which is not the loop index: the counter is shared with every other packet type on the session, so "the nth echo" is not "sequence n". ProbeSession now exposes lastSeq, including for a probe that was lost — a lost packet still has a sequence number, and that number is exactly what tells you which way it was lost.

Live against fmr: 20/20 both ways, and jitter of 0.08 ms upstream vs 0.85 ms downstream — a tenfold asymmetry that a round-trip measurement cannot see at all.

10 unit tests on the arithmetic (a wrong denominator here does not crash, it produces a plausible number pointing at the wrong half of the network) plus the live correlation check.

frag_send: crafted IP fragments, so ordering is testable (server-v0.6.0, 2026-08-01)

big_send with df=false answers one question — do fragments get through. It cannot answer the more interesting one, because the kernel always emits fragments in order, first one first.

The classic middlebox fault is exactly about that ordering. Only the first fragment carries the UDP header, and therefore the ports; a stateful firewall or NAT that has not seen it has no flow to match the rest against, and many simply drop them. That is invisible to every in-order test, and in the field it looks like "large DNS answers fail on this network" or "the tunnel breaks when the MTU drops" — it works until the network reorders, then fails intermittently, which is the hardest kind of fault to chase.

So the server builds the fragments itself (raw socket, IP_HDRINCL) and controls their order: in_order (baseline), reversed (last fragment first), first_last (first fragment held back 250 ms). The datagram is assembled and signed whole before being cut up, so what the client reassembles is indistinguishable from an ordinary packet — otherwise the test would be measuring our sender rather than the path. New test type mtu.frag_ordering; findings mtu.fragments_blocked and mtu.fragment_reorder_sensitive.

Two details that would otherwise produce confidently wrong answers:

  • The UDP checksum is computed, not left zero. Zero is legal in IPv4 and would be less code, but zero-checksum datagrams are dropped by some middleboxes — and that drop would be recorded as a fragmentation failure, which is the wrong conclusion entirely.
  • Fragment offsets are in 8-byte units, so non-final fragments are rounded down to a multiple of 8. A 100-byte fragment is not an error; it is a datagram no host will ever reassemble.

frag-send is advertised only when a raw socket can actually be opened — checked by opening one, because a permission model has more ways to say no (userns, seccomp, LSM) than a capability bit has to say yes. fmr runs as root with cap_net_raw in its bounding set, so it is available there.

Fragment ordering runs only after mtu.frag_delivery shows fragments arrive at all; otherwise the three orderings would each report "not delivered" and read as three faults instead of one.

The header arithmetic is unit-tested (reassembly coverage with no gaps or double-delivery, MF flags, shared IP ID, 8-byte offsets, checksum verification over odd and even lengths). Because the code is //go:build linux, the tests are cross-compiled and run on fmr — there is no Go toolchain there, so go test -c plus scp is the loop.

Live against fmr: 4 fragments per burst, and all three orderings reassembled — a healthy path, and the baseline against which a mobile network will be interesting.

Testing state (2026-08-01)

Six live tests against fmr, all green, no device involved: LiveServerTest, LiveMeasurement, LiveGranted, LiveDownstream, LiveUpload, LiveCompat, LiveEnrollment. Plus 74 client unit tests and the full Go suite. Everything in the last several entries is verified from the PC; the app's UI (settings, history, deep-link enrollment) and mtu.pmtud_up remain device-only.

throughput: a rate, plus the qualifier that makes it a measurement (server-v0.6.1 … v0.6.2)

A throughput test reports the smallest limit on the path — and the sender's own ceiling is one of the candidates. If the server is asked for 50 Mbps and 50 Mbps arrives, the network was never the constraint and "50 Mbps" says nothing about it. So perf.throughput_udp always carries limited_by (duration | budget | rate | send_error) and measures_network, and a finding is raised only when the path is actually implicated. The live run against fmr reports 20 Mbit/s with measures_network: false, which is the correct and useful answer.

Loss is computed against the sender's own count, fetched from the observations API, not against the requested rate. A receiver alone cannot tell "the network dropped it" from "the sender never sent it", and guessing turns a healthy server-side limit into a phantom network fault. The server keeps one summary per action rather than per-packet records — a ten-second run at 50 Mbps is half a million packets, and a struct each would turn a measurement into memory exhaustion.

Sending is paced, on an absolute schedule. Unpaced would measure the server's NIC and the first queue it meets, then collapse into loss that reads as a network fault; sleep-per-packet would accumulate scheduler error and drift the rate down over a ten-second run.

Throughput gets its own grant budget sized from the request, so every other action stays bounded at 8 MiB. When the byte cap binds before the clock does, the duration is shortened and reported rather than the run being truncated: promising thirty seconds and delivering twenty-one is the same information with a surprise attached, and it keeps "the clock ended the run" as the normal case — the only case where the rate is a clean property of the path. That behaviour came out of a test that failed honestly (30 s at 100 Mbps needs 375 MB against a 256 MB cap).

It is opt-in in the run config, default off. A 5-second run at 50 Mbps moves ~30 MB; on a metered mobile connection that is the user's money, and a tool that spends it without being asked is not one people keep installed.

The bug the live test found

The first live run delivered 104 packets and stopped after 50 ms. The grant's rate check exempted the first 50 ms entirely, meaning to be lenient at startup — the effect was the opposite. A sender could dump an unbounded burst into that free window, and the instant the check switched on it compared those bytes against 50 ms worth of allowance and refused everything until real time caught up. Every short test passed (downtrain sends 50 packets, big_send seven); every sustained send died fifty milliseconds in.

Replaced with a token bucket (allowance = burst + rate × elapsed), which is smooth from t=0. The burst is 100 ms of the allowed rate, floored at one ordinary datagram — deliberately one, since at 8 kbps a 64 KB floor is sixty-four seconds' worth, exactly the instant dump the ceiling exists to prevent. The pre-existing rate test caught that when I first tried the generous floor, and it was right to. Second half of the same bug: callers treated any refusal as terminal, so TryAllow now says why — a sender paces through a transient "too fast just now" and still stops dead on a spent budget or an expired grant. Both halves are pinned by regression tests.

Findings registry (2026-08-01)

Closes open item 1 of measurement-schema.md §9. A finding code is the stable, machine-readable half of a result — what a dashboard groups by and what someone greps a year of archived runs for — and that only holds if a code means exactly one thing forever. Ad-hoc string literals at fifteen call sites cannot promise that, and by the time the registry was written the failure had already happened.

Two emitters had independently produced connectivity.downstream_loss and connectivity.loss_downstream for the same claim, and nothing anywhere objected. Anyone aggregating either one would have silently seen half their data. Merged into connectivity.loss_downstream, paired with loss_upstream so the two directions read as a set.

Two codes were also renamed out of nat.*. nat.udp_unreachable is not about NAT — it means no replies came back — but the prefix determines the category, and the category determines which verdict light the finding rolls up into (§7.3). A nat.* code landing under connectivity is not a naming quibble; it changes which light turns red. Cheap to fix now, a breaking change later.

Codes are now declared as typed FindingSpecs carrying their category and default severity, and emitters reference the spec instead of retyping the string — so a typo is a compile error and two call sites cannot disagree about a finding's category.

docs/findings-registry.md is the contract, and a test reads it: it fails when the document and the registry have codes the other lacks, or when a severity differs. Documentation that drifts from its implementation is worse than none, because it still looks authoritative. The check scopes itself to table rows, so the prose can keep explaining which codes were retired and why.

Six tests: uniqueness, declared-vs-listed, prefix↔category agreement, naming convention, a word-order-anagram check (the shape the duplication actually took), and the document agreement.

A real privacy leak, found by starting on the machine-readable schema (2026-08-01)

The intent was measurement.schema.json (§8's promised companion). The first step — checking whether the anonymizer actually covers the fields the schema declares as sensitive — found that it did not, so that became the work.

At the balanced level, five identifying values were being uploaded verbatim:

value field why it matters
2001:…::150 networks[].link.addresses[].addr the device's own global IPv6 address — a strong, geolocatable device identifier
2a02:…::1 networks[].link.routes[].gateway identifies the ISP allocation
203.0.113.77 networks[].link.dns.servers[] the configured resolver
nas.example.lan private_dns_hostname an internal hostname
example.lan search_domains[] the internal domain

The settings screen describes that level as pseudonymizing addresses. It was not.

Root cause: classification keyed on field names, and the schema's actual names (addr, gateway, dst, servers, search_domains, private_dns_hostname) had never been added to the table. Not a subtle bug — just an unfalsifiable design. The existing tests all passed, because each one checked a field somebody had remembered to write a case for.

Two fixes, one of them structural:

  1. The missing names were added.
  2. More importantly, a shape-based backstop: when a field name is unrecognised, the value is inspected, and anything shaped like an IPv4/IPv6 address or a MAC is treated as one. A name table can only protect fields someone thought of, which is precisely the wrong property for a privacy control. Hostnames are deliberately not inferred by shape — train.udp_updown is indistinguishable from a domain, and mangling a test type would corrupt the document to protect nothing.

LeakTest is the new guard and is written to fail for fields nobody has considered: it plants identifying values wherever one can actually occur and asserts none survive, rather than checking a list of known cases. It also pins that RFC1918 addresses still come through readable, so the test cannot pass by over-redacting everything.

Route prefixes and the unspecified address needed care in the transform: 0.0.0.0/0 and ::/0 must stay themselves, or a routing table becomes unreadable for no privacy gain.

Still outstanding: measurement.schema.json itself. Worth noting what this episode implies for it — much of a document's payload lives in evidence/metrics/params, which are per-test-type JsonObject by design and therefore outside any schema. A schema-driven anonymizer would have less coverage there than the name-plus-shape one now does, so the schema should be built for validation and external tooling, not as a replacement for the classifier.