Commit Graph
37 Commits
Author SHA1 Message Date
mrambossekandClaude Fable 5 3c9af04e6f grant: replace the rate check with a token bucket
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 32s
server-release / release (push) Successful in 33s
The live throughput test found it: a 3-second run delivered 104 packets and
stopped after 50 milliseconds.

The 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
— and every sustained send died about fifty milliseconds in.

A token bucket (allowance = burst + rate x elapsed) has no such cliff; it is
smooth from t=0. The burst is 100 ms of the allowed rate, floored at one
ordinary datagram so a single packet is never refused outright. The floor is
deliberately one datagram: at 8 kbps a 64 KB floor would be sixty-four seconds'
worth, which is precisely the instant dump the ceiling exists to prevent. The
existing rate test caught that when I first tried it, and it was right.

Second half of the same bug: callers treated any refusal as terminal. TryAllow
now says why, so a sender can pace through a transient "too fast just now" and
still stop dead on a spent budget or an expired grant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 14:10:30 +02:00
mrambossekandClaude Fable 5 3333788d9e throughput: paced downstream rate, with the qualifier that makes it honest
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 32s
server-release / release (push) Successful in 32s
A throughput number reports the smallest limit on the path, and the sender's own
ceiling is one of the candidates. If the server was asked for 50 Mbps and 50
Mbps arrived, the network was never the constraint and "50 Mbps" says nothing
about it. So the result always carries limited_by and measures_network, and a
finding is raised only when the path is actually implicated.

Loss is computed against the *sender's* count, not the requested rate: the
server reports what it put on the wire, and the gap is the loss. 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
count is stored per action, not per packet — half a million packets of structs
would turn a measurement into memory exhaustion.

Sending is paced rather than flat out. An unpaced burst measures the server's
NIC and the first queue it meets, then collapses into loss that reads as a
network fault. The schedule is absolute rather than sleep-per-packet, which
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
halfway: 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 last behaviour came out of a test that failed honestly: 30 s at 100 Mbps
needs 375 MB against a 256 MB cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 14:05:23 +02:00
mrambossekandClaude Fable 5 35744c609e docs: record frag_send and the current testing state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 13:49:43 +02:00
mrambossekandClaude Fable 5 a7dccf7da2 frag_send: crafted IP fragments, so ordering can be tested and not just delivery
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 32s
server-release / release (push) Successful in 32s
Letting the kernel fragment an oversized datagram answers one question — do
fragments get through. It cannot answer the more interesting one, because the
kernel always emits them 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
drop them. That is invisible to any in-order test and shows up in the field as
"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 now builds the fragments itself (raw socket, IP_HDRINCL) and
controls their order: in_order as a baseline, reversed, and first-fragment-last.
The datagram is assembled and signed whole before being cut up, so what the
client reassembles is indistinguishable from an ordinary packet — otherwise it
would be measuring our sender rather than the path.

Two details that would silently produce wrong answers:
  - The UDP checksum is computed rather than left zero. A zero-checksum datagram
    is 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 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, since a permission model has more ways to say no than a
capability bit has to say yes.

Fragment header arithmetic is unit-tested (reassembly coverage, MF flags, shared
IP ID, 8-byte offsets, checksum verification), cross-compiled and run on Linux
since the code is build-tagged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 13:45:09 +02:00
mrambossekandClaude Fable 5 4ffa6e4ae2 engine: split packet loss by direction using the server's observations
"3 % loss" sends an engineer looking in both directions at once. The server
records every packet it received per sequence number, so the two cases are
distinguishable: sent-but-never-seen is upstream loss, seen-but-no-reply is
downstream. The findings say which, and say what is not implicated.

Downstream loss is measured against what reached the server, not against what
was sent — the other denominator counts every upstream loss twice and
overstates the return path.

Per-direction jitter comes out of the same records without needing synchronised
clocks: (server_rx - client_tx) carries a constant unknown offset, and
differencing successive samples cancels it, so RFC 3393 variation is honestly
attributable to a direction even though absolute latency is not.

Correlation is by wire sequence number, not loop index — the counter is shared
with every packet type on the session. ProbeSession exposes it even for a lost
probe, since that is precisely the packet whose direction is in question.

Live against fmr: 0.08 ms upstream jitter vs 0.85 ms downstream, an asymmetry a
round-trip test cannot see.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 13:09:59 +02:00
mrambossekandClaude Fable 5 3e7e3b8d33 scripts: one command to mint an enrollment link, QR included
Scanning beats pasting a 200-character string onto a phone, and with a device
attached the deep link can be delivered by adb with no typing at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:12:28 +02:00
mrambossekandClaude Fable 5 fe3658e009 chore: ignore the Kotlin compiler's .kotlin scratch directory
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:06:47 +02:00
mrambossekandClaude Fable 5 ad85f3bfcd enrollment: the server mints the §2.1 bootstrap link, the app consumes it
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 31s
POST /admin/enroll-tokens now returns the whole link, not just the token:

  echolot://enroll?v=1&u=<control URL>&p=pin-sha256:<b64>&t=<token>

The server is the only party that knows all three parts at once, and the part
an operator gets wrong by hand is the base64 pin — which does not fail loudly,
it just never matches, surfacing days later as an inscrutable TLS error. The
app takes the link from a paste or from an echolot:// deep link (QR scan), and
writes URL, pin and credential together or not at all.

One trap the tests pin: an unencoded "+" in a query string decodes to a space,
so a hand-assembled link arrives with a pin wrong by one character. Base64 has
no spaces, so they are restored — unambiguous, and it cannot damage a correctly
encoded pin.

Also fixes a spec divergence: §2.1 names the field device_credential and the
first implementation shipped "credential". Both are sent now and the client
prefers the spec's; the alias goes once nothing reads it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:06:22 +02:00
mrambossekandClaude Fable 5 33a6acb0bf compat: stop mangling refusal messages with HTML escapes
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 31s
The server's 426 body reached the user as "needs \u003e= 0.2.0, \u003c 1.0.0":
Go escapes <, > and & by default for JSON destined for a page, which this is
not. Disabled at the encoder. The client now parses the error field rather than
pattern-matching it, so it survives whatever a future encoder decides to escape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:41:00 +02:00
mrambossekandClaude Fable 5 9d6572bc33 compat: fix the too-new message's grammar, add a live gate test
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 30s
The generated refusal read "point at a app within range". Also adds
LiveCompatTest, which checks the half a unit test cannot reach: that two
independently-built artifacts agree on the window, that the profile stays
readable for a version the server refuses, and that both bounds are enforced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:38:45 +02:00
mrambossekandClaude Fable 5 0c5b021b63 compat: SemVer version windows between app and server
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 30s
Both sides now declare what they will talk to, and enforce it. Two axes kept
deliberately separate, because conflating them is the trap:

  protocol_version  — CAN these builds talk. The correctness axis. Below 1.0.0
                      the minor is the breaking axis, per SemVer §4.
  release window    — MAY they, per policy. [min, max), advertised in the
                      profile, overridable by the operator.

The server refuses out-of-window apps with 426 and a body naming both versions
and the accepted range; the app checks the profile in both directions before a
run rather than discovering mid-measurement that it will be refused.

Three rules that shape the rest:

  - 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, which is precisely the confusion this exists to remove.
  - An unparseable or absent version is "unknown", and is allowed. Development
    builds report "dev", and a client too old to send the header cannot be
    identified anyway.
  - Bounds sit at breaking boundaries, not at releases, so shipping a patch
    never requires editing a range. The app's server minimum is 0.4.2 for a
    stated reason: earlier multi-homed servers mis-addressed granted sends and
    the client measured 100% downstream loss that never happened.

The app's versionCode is now derived from its SemVer instead of being a second
number someone has to remember to bump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:36:34 +02:00
mrambossekandClaude Fable 5 277e33da75 chore: drop core-measurement/bin from the index too
deploy-site / deploy (push) Failing after 1m31s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:54:48 +02:00
mrambossekandClaude Fable 5 14e5fad1b2 engine: downstream MTU and downstream train in the measurement document
Three facts the client cannot produce alone, kept deliberately separate:
mtu.pmtud_down (largest datagram that arrives unfragmented — meaningful only
because the server sets DF), mtu.frag_delivery (whether larger ones arrive once
fragmentation is allowed), and train.udp_downstream (loss, reordering and
arrival spacing in the download direction, which a round trip cannot separate
from upstream loss).

ServerMeasurement now runs them on the same ProbeSession as the echo train. It
had to: a fresh session restarts client-side sequence numbers and the server's
anti-replay window discards the lot, so the re-primed source is never recorded
and every granted send goes to a socket that has already closed. That produced
four confidently-wrong FAILED tests and a RED verdict on a healthy network.

Live against fmr: path MTU 1500, fragments to 4000, 100/100 downstream, GREEN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:54:38 +02:00
mrambossekandClaude Fable 5 ce1aaa332a server: send granted traffic from the address the session actually used
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 30s
fmr binds two IPv4 addresses. connFor picked whichever socket of the right
family came first in the bind list, so a downtrain for a session established on
.150 went out from .151 — and every packet was dropped by the client's NAT,
which has no mapping for that pair. tcpdump on the server showed all 50 leaving;
the client saw none. Read as "100% downstream loss", which is the worst kind of
wrong: a confident measurement of something that never happened.

Sessions now record which of our own bound addresses received their traffic, and
granted sends (and delayed echo) go back out through that socket. The fallback
to a family match is kept for the case where nothing has been received yet, and
the test pins both paths — a single-homed lab can never reproduce this.

Also: the client-side halves of the same work — anonymizer (core-privacy), local
run archive with retention (core-archive), upload client, and the app's settings
and history screens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:45:43 +02:00
mrambossekandClaude Fable 5 7a94c9a3d7 chore: ignore the VSCodium Java extension's bin/ output
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 28s
server-release / release (push) Successful in 29s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:26:30 +02:00
mrambossekandClaude Fable 5 2521d39989 server: DF-mode big_send + uploaded-run storage with an operator policy
big_send now forces the Don't-Fragment bit for the whole burst by default, so
the largest size that arrives IS the downstream path MTU rather than "fragments
got through" — two different measurements the schema already separates. Sizes
above our own egress MTU (from the startup self-test) are refused up front and
reported as max_df_bytes, because absence caused by our kernel must not be read
as a limit of the client's path.

Uploads: one JSON file per run under the state dir, with the policy the operator
actually cares about — who may upload (off / anonymous / account), how large,
how long to keep, and the least anonymization accepted. The profile advertises
all of it so the app can present the switch honestly instead of discovering the
rules by failing. `account` refuses today rather than falling back to anonymous:
picking the strict setting before OIDC lands must not silently mean the loose one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:26:19 +02:00
mrambossekandClaude Opus 5 c75a9f5eb7 app: accurate Shizuku handoff — name the steps, add developer-options shortcut
Verified against Shizuku 13.6's manifest (pulled APK, aapt2 xmltree): its
wireless-debugging entry points (AdbPairingTutorialActivity,
AdbPairingService, StarterActivity) have no intent filters, so they are
not exported and cannot be launched externally; MainActivity answers only
MAIN/LAUNCHER with no deep link. Starting wireless debugging from another
app is therefore not possible, which is why the handoff lands on the
root-start screen.

Instead the hint now names the exact steps inside Shizuku ("Pairing", then
"Start"), and a second tap opens Developer options — that action IS public
and exported, and Wireless debugging has to be on before Shizuku's
wireless start works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:03:03 +02:00
mrambossekandClaude Opus 5 1d9e2063bf app: make the Shizuku banner actionable (open Shizuku / request permission)
A third-party app cannot start Shizuku — the wireless-debugging pairing
flow is privileged and lives in Shizuku's own app — so the banner
deep-links there when it is installed but stopped, and fires the
permission request directly when it is running but unauthorised. The hint
line says which.

Verified on-device together with the earlier UX work: progress bar showing
"test 4 of 8 · icmp.ping6 · ~33s left", Cancel beside the disabled Run
button, cutout-safe title, and the banner live-updating from
not-running to needs-permission via the binder listener.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:58:39 +02:00
mrambossekandClaude Opus 5 d7dda40e4e app: tell the user before the run when Shizuku is installed but not started
Distinguishes not-installed (say nothing — don't nag users who don't use
Shizuku) from installed-but-stopped (amber banner: start it to include
shell-tier tests), plus running-unauthorised and ready. Detection is
listener-based since pingBinder() only becomes truthful once
ShizukuProvider delivers the binder; a launch-time poll would show a false
"not running". Installed-vs-not needs the <queries> entry on Android 11+.

Verified on-device: with shizuku_server stopped, the banner shows before
pressing Run; the title also now clears the status bar/cutout after the
safeDrawingPadding fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:51:01 +02:00
mrambossekandClaude Opus 5 217818f7b3 app: progress bar with ETA, cancel button, and cutout-safe layout
- Probe.estimatedMs (measured per probe; timeout-bound ones dominate)
  drives a determinate progress bar and "test N of M · ~Xs left",
  including the Shizuku battery in the total.
- Cancel stops the run and shows the partial results as a normal document
  (findings + verdict over what was collected) but never uploads them.
- safeDrawingPadding() on the root column: Android 15 is edge-to-edge by
  default and the title was colliding with the status-bar clock and the
  camera cutout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:48:18 +02:00
mrambossekandClaude Opus 5 2e34463c0a app: router identification verified on-device — named a MikroTik RouterOS 7.23.2
The probe identified the LAN's IPv6 RA sender end to end from an
unprivileged app: EUI-64 MAC recovery (78:9A:18:54:B8:F9, matching the
Shizuku neighbor table) -> MikroTik by OUI, corroborated by the UPnP
device description (RouterOS/7.23.2, MikroTik Router) and reverse DNS
(router.hudelist.local). Cellular's RFC 7217 privacy RA source is
correctly reported as not-EUI-64 instead of guessed. The SSDP sweep also
inventoried a Synology DS1522+ and a Sky gateway — the raw material for
future LLDP/mDNS cross-matching.

Fixes found by running it: added the confirmed MikroTik OUI 78:9A:18 (+
other RouterBOARD ranges) and an elvis-operator bug that printed "no UPnP
response" alongside valid UPnP data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:42:06 +02:00
mrambossekandClaude Opus 5 38f8036252 app: link.ra_source router identification + brand icons + DEV build variant
link.ra_source answers "who advertises IPv6 here, and which box is it":
RA source per network, MAC recovered from the modified-EUI-64 link-local
(privacy addresses reported as such, not guessed), vendor via a curated
OUI table, UPnP/SSDP M-SEARCH for the gateway's server banner + device
description (manufacturer/model/friendly name), and reverse DNS. All SSDP
responders are recorded so a rogue RA sender that isn't the gateway can
still be matched; the MAC accompanies every identity source as the hook
for future LLDP/mDNS cross-matching. UI gains a "Router / IPv6 advertiser"
panel.

Icons: branding adaptive icon converted to vector drawables (+ PNG
mipmaps, monochrome layer). The debug build is now a separate app —
applicationIdSuffix .dev, label "Echolot DEV", DEV-badged icon — so it
installs alongside a production build and can't be confused with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:35:54 +02:00
mrambossekandClaude Opus 5 dc094d1631 app: autorun mode (unattended run + upload + auto-exit); IPv6 severity rework
IPv6: absence is no longer a defect. If the network never provisioned v6
(no global address, no ::/0 route) the finding is ipv6.not_offered at INFO
(green) — most networks are still IPv4-only. If v6 IS advertised but
doesn't work, it's ipv6.broken at MEDIUM (yellow), because half-working v6
stalls connections. Verified on-device: our LAN advertises a v6 default
route with no path, and now reports ipv6.broken.

Autorun: `am start ... --ez autorun true` runs the suite immediately,
POSTs the report to the collection endpoint, shows the result for 3s and
finishes the activity (stays open if the upload failed). receiver.py gains
POST /report + GET /reports + GET /report/<name>. Verified end to end: one
adb command, report retrieved over HTTP, app closed itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:27:04 +02:00
mrambossekandClaude Opus 5 483de5ca54 app: nat.stun_5780 — NAT mapping/filtering discovery, verified vs live server
Hand-rolled RFC 5389/5780 STUN client (stdlib only) that exercises the
server's stun-5780 capability: one socket, three binding requests
(primary, OTHER-ADDRESS alternate IP, CHANGE-REQUEST port) — the
comparison classifies NAT mapping and filtering behavior.

Verified on the OnePlus: local 10.13.102.124 -> mapped
178.191.120.247:53259 (behind_nat true), alternate address answered from
the server's second IP, mapping endpoint-independent, filtering
address/port-dependent. Finding nat.symmetric (medium) for the
P2P-hostile case.

Two real bugs found by running it: port preservation was misread as "no
NAT" (compare addresses, not ports), and an unbound socket reports the
wildcard local address (resolve via a throwaway connected socket).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:15:29 +02:00
mrambossekandClaude Opus 5 dd9ecf5032 app: Shizuku shell tier verified on-device — 7/7 via UserService
Ran on the OnePlus with Shizuku started: tiers.shizuku=true, test ok,
commands_ok 7/7, exec_path=UserService (dual-path executor picked the
right path for this device). Evidence includes the live neighbor table,
per-table v6 routes, a real [NEIGH] netlink event, IpClient DHCP logs with
APF caps, and the wifi dump — as shell(2000).

Both v1 privilege tiers (app + shizuku) now verified end to end in the
production app on hardware. Report archived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:06:24 +02:00
mrambossekandClaude Opus 5 59ba1c16bc app: dns.canary probe — client half of the canary measurement, verified live
Resolves the server's canary zone through the platform resolver and
compares against the spec-frozen ground truth (probe-protocol §6.1):
reference records detect answers rewritten in flight, and a per-run nonce
name (uncacheable) proves the query reached the authoritative server.
Findings: dns.answer_rewritten (high), dns.authoritative_unreachable
(medium).

Verified on the OnePlus against the deployed fmr zone: 4/4 reference
records matched exactly, nonce name answered 192.0.2.21 with
reached_authoritative=true. First full client<->server measurement loop
on real hardware; report archived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:02:43 +02:00
mrambossekandClaude Opus 5 b751ca771e app: net.captive_portal verified on-device; archive first app run report
OnePlus 15 run: Android's generate_204 logic reproduced correctly —
default+wifi 204/204 -> validated, cellular -1/-1 -> no_internet (a
per-network asymmetry the OS itself hides). Shizuku tier degraded
correctly to UNSUPPORTED with tiers.shizuku=false since Shizuku isn't
running there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 23:17:35 +02:00
mrambossekandClaude Opus 5 4c51bd2aad beacon: stop causing "wireless debugging connected" notification spam
Root cause of the spam the user kept seeing: the service resolved adbd's
own mDNS advertisement repeatedly (every discovery callback, plus a 20s
heartbeat). Resolving that service makes adbd re-arm the connection, and
Android posts a "wireless debugging connected" notification each time —
so the beacon itself was the noise source, independent of the PC-side
connector loops.

Now: resolve each discovered service instance exactly ONCE (guard set,
cleared on onServiceLost so a genuine rotation re-resolves once), and the
heartbeat only re-POSTs the cached port (60s, no mDNS traffic).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 23:12:14 +02:00
mrambossekandClaude Opus 5 6e269d424b app: net.captive_portal probe — reproduce Android's internet/portal checks
Mirrors NetworkMonitor: per active network, fetch the AOSP default
generate_204 endpoints and check for HTTP 204 No Content.
- HTTPS https://www.google.com/generate_204 == 204 -> validated internet
- HTTP http://connectivitycheck.gstatic.com/generate_204: 204 -> clean;
  an unfollowed 3xx or a 200-with-body -> captive portal (Location captured)
- both fail -> no_internet
Per-network verdicts (bound via Network.openConnection), redirects not
followed (the 3xx IS the evidence). Findings: captive_portal (medium) and
no_internet (high). New test type net.captive_portal (net family ->
connectivity category). App gains usesCleartextTraffic (a network
diagnostic that intentionally probes plain HTTP).

Builds; measurement verdict tests still green. On-device verification
deferred with the rest (flaky test devices).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 23:01:22 +02:00
mrambossekandClaude Opus 5 aaed22dd3f app: core-shizuku — dual-path shell-tier executor + probe, wired into the app
Ports the prober's validated Shizuku tier: AIDL UserService, the build-4
dual-path ShizukuRunner (UserService bind where it works, legacy
newProcess reflection fallback where it doesn't — exec_path records
which), and ShizukuProbe running the shell command battery, emitting a
shizuku-tier link.ip_monitor Test with per-device dumps as evidence.
Self-degrades to UNSUPPORTED without Shizuku.

Wired into RunViewModel (sets tiers.shizuku); app APK assembles. On-device
verification deferred — no device reachable at build time (flaky LAN
dropped the tablet, phone debugging off). Expect UserService on OnePlus,
newProcess on Lenovo per the prober.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:37:48 +02:00
mrambossekandClaude Opus 5 7b9b312dcd beacon: own-IP filter (shared-LAN fix) + wireless-debugging-off warning
Live multi-device test exposed two things:
- On a shared LAN, NsdManager discovers EVERY device's
  _adb-tls-connect._tcp advertisement, so a phone reported the tablet's
  port for its own IP (crossed). Now only accept the resolved service
  whose host matches this device's own wlan0 IP.
- When Wireless debugging is turned off, adbd drops its mDNS
  advertisement (onServiceLost) — the app now says so plainly in the
  status line and the ongoing notification ("Wireless debugging appears
  OFF — re-enable it"), instead of a vague "waiting".

Verified with phone + tablet on the same LAN: correct per-device ports,
both auto-connected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:13:13 +02:00
mrambossekandClaude Opus 5 38d4440412 tools: beacon fixes — cleartext, multi-device, validated-net POST, status URL
Debugging against a real restricted LAN surfaced three fixes:
- Android blocks app cleartext HTTP by default (targetSdk 36) — the port
  discovery + reachability were fine (phone curl reached fmr), only the
  app POST was denied. Added usesCleartextTraffic for this dev tool.
- Multi-device: report + receiver are keyed by device (Build.MODEL) so a
  phone and tablet don't clobber each other; connector connects each.
- POST over a VALIDATED internet network (prefer cellular) since the
  wireless-debug wifi is often a restricted LAN.
- Status/notification now show the target beacon URL + which network, per
  the request to surface what it's connecting to.
Verified live: beacon tracks the (frequently rotating) port via mDNS and
self-reports the current endpoint within seconds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:07:35 +02:00
mrambossekandClaude Opus 5 52748ac853 tools: wireless-adb beacon — self-healing bridge across drops + port rotation
The phone's wireless-debug port rotates and the bridge drops; this makes
adb reconnect automatically. Three pieces:
- adb-beacon (Android dev app): reads adbd's own mDNS advertisement
  (_adb-tls-connect._tcp) via NsdManager for the live connect port — no
  root, no Shizuku — plus the wlan0 IPv4, and POSTs {ip,port} to fmr every
  time it changes (continuous NSD discovery catches rotation in seconds).
  Foreground service (specialUse) so it survives backgrounding.
- tools/adb-beacon/receiver.py: ~30-line rendezvous on fmr:9099 (secret-
  gated POST stores the latest endpoint; GET returns it). Deployed as
  echolot-adb-beacon.service.
- PC connector polls the endpoint and keeps `adb connect` current.

Dev tooling, separate from the product. Bootstrap: sideload the beacon
APK once (no adb needed); thereafter adb self-heals for everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:50:33 +02:00
mrambossekandClaude Opus 5 bc220f950e app: installable APK — core-probe (device-tier) + Compose UI
First assembling build of the production app. Android toolchain mirrors
the prober (AGP 9 built-in Kotlin; applying kotlin.android too
double-registers the kotlin extension — the one gotcha).

core-probe (Android lib): Probe→core-measurement Test abstraction;
NetworkInventory (LinkProperties→networks[]), LinkSnapshotProbe,
per-network IcmpProbe (ported from the prober's validated logic).

app (Compose): RunViewModel orchestrates probes into a MeasurementDocument
with a §7.3 summary + first-pass findings; UI renders traffic lights,
networks, tests, findings; JSON export. Rotation-safe (ViewModel).
App-tier only; server-facing (core-engine) + Shizuku are additive
follow-ups. Debug APK 9.5 MB, assembles clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:40:42 +02:00
mrambossekandClaude Opus 5 49c6197aff app: core-engine — run engine; full server-facing vertical proven vs fmr
Composes core-protocol probes into core-measurement documents. Injected
clock/UUID source keeps it pure and unit-testable. Runs a server ECHO
train and derives RTT distribution, loss, and NAT-rebinding detection
(from the server's observed source port) as train.udp_updown, then
findings + a §7.3 summary.

Verified end-to-end against fmr: 20-packet train, 0% loss, RTT
1.7/2.5/6.9ms, no rebinding → valid MeasurementDocument (2.3kB), overall
GREEN. The whole server-facing stack (protocol → engine → schema →
verdict) now produces the real product artifact against the live server,
no device required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:28:09 +02:00
mrambossekandClaude Opus 5 1f8860f7f8 app: core-measurement — the measurement-schema.md document model
Pure Kotlin/JVM, faithful to the schema contract: two-clock (wall RFC3339 +
*_mono_ns), units in field names, observation/interpretation split
(tests[] vs findings[]), columnar train evidence (nulls preserved per
index), the full v1 test-type registry, the anonymization logical types as
field notes, and a finding-requires-evidence invariant.

The one piece with real logic — §7.3 deterministic verdict derivation
(category = worst finding light; >50% failed/unsupported → inconclusive;
overall = worst category, inconclusive only if all are) — is implemented
in Verdicts and fully unit-tested. Document JSON round-trips (snake_case
wire names, null-in-columns), typed builders for train/traceroute/resolver
evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:24:20 +02:00
mrambossekandClaude Opus 5 3520eabd21 app: scaffold echolot-app + core-protocol — client spine verified live vs fmr
Multi-module Android app, built bottom-up from a verifiable core.
core-protocol is pure Kotlin/JVM (no Android SDK): SPKI-pinned control
plane (enroll/profile/session over HttpsURLConnection — API-1 compatible,
hostname verification off, trust is the pin), HKDF-SHA256 session keys,
ELT1 UDP data plane (HMAC gate, ECHO+observation, MTU probe) —
byte-compatible with the Go server.

Unit tests incl. the RFC 5869 HKDF vector (key derivation provably matches
the server). LiveServerTest + scripts/test-fmr.sh prove the client
end-to-end against the deployed fmr server: profile (8 caps), session,
ECHO rtt~11ms with the observation block returning our observed NAT port,
MTU 1400->1400, observations. Live test self-skips without ECHOLOT_LIVE_*.

Two client bugs caught live: java.net.http hostname verification (→
HttpsURLConnection, also the Android-minSdk-26 choice) and ECHO padding
needed for the observation to survive anti-amplification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:16:41 +02:00