"upload failed: HTTP 400 client sent an HTTP request to an HTTPS server"
is a TLS listener rejecting cleartext, and the cleartext was ours:
REPORT_UPLOAD_URL was http://89.185.109.150:443/report, which used to be
the adb-beacon receiver holding 0.0.0.0:443 in plaintext. Disabling that
receiver and giving 443 to echolot-server left this posting plain HTTP at
a TLS port.
Blanked rather than repointed. The endpoint existed so an unattended run
could be collected without adb, and autorun reports are now read straight
off the device with `run-as cat` — so it was buying nothing and emitting
an alarming error for a debugging convenience. Deliberately not aimed at
/v1/runs either: that is the consent-gated upload, and a debugging
shortcut must not be able to satisfy it by accident.
The message says what happened instead of implying something broke.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tablet on a healthy network could not resolve anything. The DNS server
answered the bare name correctly — NOERROR, two records, A and AAAA, with
and without EDNS0 — so the earlier finding blamed the device's resolver.
It was wrong. The network advertised hudelist.local as a search domain and
the server silently dropped every query under it: not NXDOMAIN, nothing at
all. Resolvers append search domains, so they waited for a reply that was
never coming.
Silence is the part that makes this vicious. A negative answer moves a
resolver on; no answer looks like packet loss, so it retries, and some
give up on the lookup entirely. It also explains how two devices on one
network can disagree about whether DNS works — the phone tried the plain
name first and never noticed.
The probe now asks about a nonce name under each advertised search domain,
where the wanted answer is NXDOMAIN and only silence is a fault. The
finding is ordered ahead of dns.system_resolver_broken so the two cannot
both fire: without that, this exact network gets told its device is
broken.
Severity follows the harm rather than the shape. HIGH when resolution is
actually failing, MEDIUM when the domain is a black hole but this resolver
happens to try the plain name first — calling that HIGH would be crying
wolf on a network that works. The message names the fix and notes that
.local is reserved for mDNS by RFC 6762 and widely dropped by design,
while home.arpa (RFC 8375) is the name reserved for this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The probe counted any well-formed packet from the server as "the server
answers", checking only the transaction id and a minimum length. REFUSED
and SERVFAIL are well-formed packets. So a server actively refusing this
client would have been reported as healthy, and the finding — whose whole
output is "the network is fine, your device is not" — would have pointed
confidently at the wrong component.
It now requires rcode 0 and at least one record, and reports a refusal as
what it is: a working server saying no, which points back at the network.
The rcode is named rather than numbered, because "REFUSED" is a fact an
operator can act on and "rcode 5" is a lookup.
Caught by decoding what fmr's router actually replied — ab cd 81 80 00 01
00 02, NOERROR with two answers — after realising the earlier check only
counted bytes. The reply was genuinely good, so the finding on the tablet
stands; the check was wrong regardless.
The advice is broader too. That tablet's fault survived a reboot, which
makes "toggle wifi and it clears" wrong as a flat claim: it now says what
to look at when a restart does not fix it — something on the device
filtering DNS, or a per-client rule on the router.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A measurement tool that cannot report from a broken network is useless
exactly when it matters, and a wedged resolver is one of the faults this
app is built to find — it should not also be the thing that stops the
finding being delivered. The profile already carries the server's
addresses; they are now kept and used when the name fails.
Safe because the pin is the trust and the name is not part of it: the
server presents the same certificate however it was reached, and a wrong
address fails the pin like anything else. Only the primaries are cached —
the alternate pair exists for NAT behaviour discovery and does not carry
the control plane, so falling back to one would fail for a second,
unrelated reason.
Substituted only when the name genuinely does not resolve, and only after
checking the candidate answers on the port: on a v4-only network a v6
address would otherwise be chosen and fail slowly, which is the wrong
answer delivered late.
The server had to meet it halfway. Sharing 443 by SNI meant a client
arriving by IP sent no server name and got the Let's Encrypt certificate,
failing the pin. A numeric host — or no SNI at all — now selects the
pinned certificate and routes to the control plane. That is sound because
the admin UI is only ever reached by name: browsers always send SNI, and
nobody bookmarks an IP for a site with a CA-issued certificate.
Verified against fmr: by IP on both families the served pin is the
control one and /v1/profile answers 401, while fmr.echo-lot.app still
serves the Let's Encrypt certificate and the admin UI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diagnosing a tablet that claimed "no internet" took twenty adb commands
to establish something the app should have said in one run: ping to
1.1.1.1 worked, the configured DNS server answered a raw UDP query in
65 bytes, and Android still could not resolve a hostname. The network was
fine; netd had wedged.
Those two failures look identical to a user and want opposite responses —
"look at your router" against "toggle your wifi" — so dns.resolver asks
the network's own servers directly and compares the answer against what
the platform returns for the same name. The query is hand-rolled over a
plain DatagramSocket on purpose: anything routed through a resolver API
would inherit the very fault being looked for.
dns.system_resolver_broken fires only on the pairing that is otherwise
unattributable: server answered, platform did not. Per network, because a
phone can have wedged wifi and working cellular at once.
Also records Android's own verdict per network — validated, captive
portal, partial connectivity — which the app reproduced with its own HTTP
probes but never stored. It is free, it is what the user sees in the
status bar, and its disagreement with our measurements is exactly what
identified the tablet. NET_CAPABILITY_PARTIAL_CONNECTIVITY is @SystemApi
so the constant is inlined with its rationale, in the manner of OsAbi.kt,
and read defensively enough to report unknown rather than false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Enrolling a device that cannot reach the admin UI meant transcribing a
200-character link with a base64 pin in it — the step the link format
exists to avoid, and the one where a pin wrong by one character fails
later as an inscrutable TLS error.
Rendered as inline SVG rather than a PNG data: URI, because the page's
CSP is default-src 'none' and means it: a data: image would need img-src
opened, markup needs nothing. One path rather than a rect per module,
since a link this long encodes to about 60x60 and two thousand elements
is a lot of DOM for a picture of a square. It is generated from the same
validated value as the href, so a rejected link produces neither.
This relaxes the stdlib-only rule, deliberately and recorded in CLAUDE.md.
The rule bought one self-contained binary with no supply chain to audit,
which one small pure-Go package barely dents; F-Droid never applied to
the server, only the app ships there. A correct QR encoder is ~500 lines
of Reed-Solomon that nobody should be hand-writing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The banner watched only for the binder arriving or dying, and granting
permission does neither. So after the user tapped the banner, answered
the dialog and came back, it still read "running but not authorised" —
wrong at precisely the moment they were looking for confirmation that it
had worked.
Two listeners were missing, because there are two ways this changes.
Answering our own request now fires OnRequestPermissionResultListener.
That is not enough on its own: permission can equally be granted inside
Shizuku's own app, and Shizuku started or stopped there, none of which
calls back into this process — so the state is re-read whenever the
screen comes forward, which is the only thing that covers every route.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The block was rendered as one padded string, which only lines up in a
monospaced font — and the monospace never took, so the values sat at
ragged offsets and the point of the list was lost. Padding text to fake a
table makes the layout depend on a typeface decision made elsewhere.
It is a label column of fixed width and a value column that takes the
rest, so the addresses align whatever the font does and a long value wraps
inside its own column instead of under the labels. The capability list
goes back to plain comma-separated text: hand-wrapping it at three per
line was working around the same missing alignment.
Placement too. The facts now sit under the "Check server" button that
fetches them, rather than among the input fields, and the sentence
explaining the endpoint sits directly under the URL field it describes —
it had ended up orphaned between the two, reading as a comment on nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The settings card offered three editable boxes and said nothing about the
server itself — which addresses a test will actually use, on which ports,
what it can measure. That is the part a person checks before trusting a
result, and "which address did this come from" is precisely the question
a report leaves open.
The server now publishes it. The profile's targets carried one IPv4 and a
TODO; it reports both families and both alternates, derived from the UDP
listen spec rather than configured separately, so the list cannot drift
from what is actually bound. No reservation means no alternate is
claimed: announcing a second address as the RFC 5780 alternate when none
was set aside would promise a redirect the server will not send.
The app renders them read-only, in a panel visibly distinct from the
fields above. An editable box that changes nothing is worse than no box,
and these are facts to read rather than settings to apply.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Server URL field showed the endpoint the app dials, which after
discovery is not the name anyone was given — enrolling against
fmr.echo-lot.app left the settings reading fmr-1.echo-lot.app, with no
explanation of where the -1 came from.
It shows the public name now. The endpoint is not hidden, just demoted to
a line beneath that says where the connection actually goes and why the
two differ: a network engineer debugging a failed connection wants that,
and burying it would trade one confusion for another.
Typing a URL by hand sets both, since there is no discovery to consult in
that case — setting only the public one would leave the app still dialling
the previous server, which is the kind of half-applied change that fails
much later and somewhere else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
html/template rewrites an href whose scheme it does not recognise to
"#ZgotmplZ". echolot:// is not on its list, so "Open in the Echolot app"
was not a link at all — tapping it did nothing, and nothing showed it:
the markup reads correctly, the app resolves the scheme, and only the
sanitised attribute in the served HTML gives it away.
Marking the value template.URL opts out of that sanitising, which is only
safe because the shape is now checked first. The link arrives in a query
parameter, so without the check a crafted /devices?link=javascript:… would
put a script URL into the page for an admin to click.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An entire module was missing from the APK. The app died with
ClassNotFoundException for app.echo_lot.protocol.EnrollmentLink while the
build was green, the module's jar was correct, and :app:dependencies
listed it on debugRuntimeClasspath — its code simply never reached AGP's
intermediates.
The cause was a poisoned Gradle build cache entry, which is why nothing
obvious fixed it: clean, rm -rf */build and --rerun-tasks all leave the
build cache alone. Only --no-build-cache did. Every app build made in this
session shipped without core-protocol, so enrolment, sign-in and upload
would all have crashed identically; several hours of "the tap does
nothing" were this, misread as a UI problem.
CLAUDE.md now carries the symptom, the fix, and the verification —
grepping the dex for a string literal only that module defines, because
grepping for a class *name* proves nothing: callers carry the name as a
reference whether or not the class is packaged. That false check is what
let me believe an earlier rebuild had fixed it.
Also: the enrolment dialog compared the stored endpoint against the link's
public URL, so re-enrolling with the same server announced itself as a
move to a different one. Those are deliberately different strings now that
discovery exists; the comparison uses the public name on both sides.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The enrollment result was written to the same status line as everything
else, which renders at the far end of the server card below three text
fields — and on a fresh install renders nowhere at all, because that line
only appears once a run exists. So enrolling looked identical whether it
worked or not.
It has its own line now, directly under the Enroll button that caused it,
and its own state rather than sharing one with "Check server": two
actions, two results.
The server fields were also re-read the instant the button was pressed,
before the enrollment coroutine had done anything, so they showed the
previous server's values. They now refresh when the result lands, which
is the point at which there is something new to show.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sharing port 443 between the admin UI and the control plane forces two
hostnames — one port and one name is one certificate, and the two need
different ones. That difference had been leaking into every enrollment
link, so an operator handed out fmr-1.echo-lot.app when the thing they
and their users know is fmr.echo-lot.app.
The link now carries the public name and the app asks GET /v1/discover
where to actually connect. The endpoint is plumbing: it exists to select
a certificate, and nobody needs to see it.
Discovery hands out an address and never a pin. The pin stays in the
link. Fetching it over an ordinary TLS connection would make pinning
worth exactly what the certificate authorities are worth, and pinning is
there to survive one the operator does not control — a root injected by
corporate device management, say, which is unremarkable on the networks
this tool gets pointed at. With the pin pre-shared, an intercepted
discovery can only send a device somewhere the pin will not match: an
outage, not a compromise.
Optional on both sides. A server that does not answer, or a link that
already names the control endpoint, works unchanged — enrollment must not
start failing because a lookup did.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MainActivity had no launchMode, so every echolot:// link stacked a fresh
activity with its own ViewModel. The enrolment then ran in a throwaway
copy and pressing back returned to the original screen showing none of
it — silent, and indistinguishable from the link not working at all.
singleTask plus onNewIntent means the link reaches the screen already in
front of the user.
The confirmation dialog also read as nonsense when re-enrolling with the
server already configured: "already enrolled with X ... enrolling with X
replaces that". Naming one URL twice looks like a bug and buries the
consequence that does apply — the credential is replaced, the old one
stops working at once, and the device appears on the server as a second
entry next to the first, which is worth revoking afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scheme was already registered and the deep link already worked — it
enrolled on arrival, with no confirmation. Now that the web UI offers the
link as something to follow, that is one tap between a working enrollment
and a replaced one, from a page that might be showing a link minted for a
different device entirely.
Enrolling is not additive: the new credential replaces the old, and on
the previous server this device simply stops reporting. So the link is
held and the user is asked, with both server URLs named — the question is
"which server", and it cannot be answered without seeing both.
The dialog says what survives, because that is the part someone hesitates
over: uploads already on the old server stay there, runs stored on the
phone are untouched, and the device reappears on the new server as a new
device rather than carrying its history across.
Enrolling also clears the cached canary zone. It describes the old
server's deployment, and querying it against the new one would measure
somebody else's zone and file the answer under this network.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The link was printed as text next to an adb command, so enrolling a phone
meant getting a 200-character string from one device onto another —
which is the step that goes wrong, and it does not have to happen at all.
The app registers the echolot:// scheme, so on the phone being enrolled
following the link is the entire procedure.
The raw string and the adb form stay for every other case: reading it on
a laptop, or enrolling a device that is not the one holding the browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Captive portals, hotel wifi and corporate firewalls routinely permit only
80 and 443 — exactly the networks this tool exists to diagnose. A control
plane on 8443 is unreachable precisely when it matters most, and it fails
as "cannot reach server", which tells the user nothing about why.
The two cannot share a certificate, so sharing the port needs two names.
The control plane is trusted by SPKI pin and uses a long-lived
self-signed certificate; a browser needs one a CA vouches for. One name
on one port is one certificate. Pinning the Let's Encrypt key instead was
considered and rejected: it survives renewal only while key reuse holds,
so a routine key rotation would brick the fleet.
One listener now picks the certificate by SNI and the handler by Host.
Both have to agree, or a client gets the pinned certificate with the
admin UI behind it.
8443 stays open. Devices enrolled before this carry that URL in their
settings, and closing it for the sake of a port number would strand every
one of them; it can go once nothing points at it.
Verified per SNI on 443: fmr.echo-lot.app serves the Let's Encrypt cert,
fmr-1.echo-lot.app serves the self-signed one whose pin is unchanged, and
/v1/profile answers 401 on the control name against 303 to the login page
on the UI name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The canary-DNS zone and the STUN host were compiled in as c.echo-lot.app
and fmr-1.echo-lot.app, so every copy of the app measured against this
particular deployment whatever server its owner had enrolled with. On
someone else's install those two tests describe our infrastructure and
report the result as a fact about their network.
The zone comes from the server's own profile, which has advertised
canary_zone all along — the app simply never read it. It is cached in
settings because the canary probe runs at device tier, before anything
has contacted the control plane, and a probe that had to make a call
first would fail on exactly the networks worth measuring. The STUN host
is derived from the configured server URL rather than stored, since a
second copy of the server's name goes stale the moment someone
re-enrolls elsewhere.
With no server configured both now report SKIPPED. StunProbe previously
would have reported FAILED on a blank host, which reads as a finding
about the network when the truth is that no packet was ever sent — the
same conflation between "measured nothing" and "measured a fault" that
the ICMPv6 finding had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The control plane now advertises the same name the web UI answers on.
That is safe because the client authenticates by SPKI pin and explicitly
does not verify the hostname — "pin is the trust, not the name" — so no
certificate covers or needs to cover either name.
The per-host name still means something, though, and the rule it encodes
has to survive: pinning binds a client to one server's key, so fmr may be
a CNAME to exactly one host and never a multi-address service record. A
second server gets enrolled as fmr-2 explicitly, because a client that
reaches a different key does not fail over, it fails.
Minting a link was broken and had been since the authenticated admin UI
replaced the old admin API: enroll-link.sh still posted to
127.0.0.1:8444/admin/enroll-tokens, an endpoint that no longer exists on
a listener that no longer binds loopback. Rather than add a second
unauthenticated door — which is how the old one ended up briefly reachable
from the network — the binary mints its own link. Whoever can run it
against the state directory already holds every privilege the server has,
so authenticating them to themselves would be theatre.
EnrollmentURI is shared with the running server's EnrollmentLink rather
than reimplemented. Two copies of that encoding would eventually disagree,
and the failure mode is a pin that looks right and surfaces as an
inscrutable TLS error rather than as a bad pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The box under "Self-test" was `%+v` of a Go struct on one line, read
through a horizontal scrollbar — on a phone you could see about six words
of it, from the middle. The design pass had polished the frame around it
and left the contents a debug dump.
The report was structured the whole time: each sysctl check carries the
name, what was found, what was wanted, a severity, and a sentence
explaining why the setting matters to measurement. All of that was being
flattened into one string. It now renders as records like everything
else, with the explanation set as prose across the full row, because it
is a sentence and not a fourth column.
Two faults the render caught: the desktop row grid applied to every
readout, so the standalone summary panel was chopped into four narrow
columns and "full 1500" broke into "ful/l/150/0"; and a fixed first
column wrapped `net.ipv6.conf.all.accept_ra` mid-word. The grid is now
scoped to readouts inside a row, and the label column may grow to 18rem
before it wraps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Echolot is the German word for an echo sounder — an instrument that emits
a ping and reads what comes back — and the UI now looks like one instead
of like a dashboard. The three big-number stat cards went first: that
layout is the stock answer for any admin page, and it told a network
engineer nothing they could act on.
Palette is a water column rather than a neutral near-black, with a
desaturated sea-green return for the accent. Verdict colours come from
the domain, so they carry meaning rather than decorate. No web fonts —
the CSP forbids loading anything and shipping font files would trade
what makes this a single pleasant binary for a typeface — so the
character comes from treatment: machine-set headings, tracked small
caps, hairlines.
The one ornament is a trace of returns across time on the runs page, one
bar per run coloured by verdict, oldest to newest. It is real data, pure
CSS, and it is what an echo sounder actually draws.
The mobile fix is the same idea rather than a fallback. Tables become
label-and-value records with dotted leaders, which is how a sounding log
prints and is easier to read on a phone than any table that scrolls
sideways. Above 46rem every row shares one grid so the columns agree by
construction; the first attempt used table-cell and each row wrapped
independently, which produces a table that does not align — a list
paying for borders.
Found by rendering it rather than reading the CSS: the trace stranded
itself against the right edge when runs were few, "Open run" broke across
two lines, equal columns wrapped device names while a one-digit count
kept a quarter of the row, and the sign-in page carried no wordmark at
all, so you arrived somewhere that never said what it was.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The header was a rigid flex row, so on a narrow screen the account name
and the sign-out button were pushed off the side of the viewport where
they could not be reached at all — not merely ugly, unusable. It wraps
now, and below 40rem the account block takes its own full-width row so a
long display name cannot crowd out the navigation.
Wide content scrolls inside its own box rather than dragging the page
sideways with it. Tables sit in an overflow-x container and <pre> is
capped at the viewport width; without that, one long self-test line or
one device table makes every other column of text unreadable, and on a
phone it is not obvious that the page has moved at all. Long opaque
strings — device ids, enrolment links — wrap anywhere rather than
insisting on a width nothing has.
Also: box-sizing on everything, stat cards that share a row instead of
each claiming the full width, and larger touch targets on buttons, where
.4rem is comfortable with a mouse and fiddly with a thumb.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Listeners came off ::2 before the address did — the other order fails to
bind on restart — and the address stayed up until the CNAME to fmr-1 had
landed, since dropping it earlier would have broken ACME renewal for the
name the certificate is issued to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its Python service wildcard-bound 0.0.0.0:443, which is what silently
compromised the reserved measurement address. Two routes on the admin UI
would inherit the TLS and certificate already in place, need no extra
port, and pick up authentication the standalone receiver never had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fmr keeps .151/::151 for measurement. Their diagnostic value is entirely
in their listening state being known: a TLS handshake that completes on a
port nothing listens on proves interception, with no competing
explanation. One stray bind turns that proof into a shrug, and nothing
about the failure is visible — the run still says the network is clean.
CheckReserved refuses to start when a listener would take one. Wildcards
are refused outright, because that is how this actually happens: every
listener defaults to ":port" and the next one added gets copied from an
existing default, claiming every address without anyone deciding to.
Reserved is not silent, though. The first version of the guard would have
refused the live config's UDP and canary-DNS binds on .151, which are
deliberate — as is STUN's RFC 5780 alternate. Reserving an address and
then forbidding the measurements that need it defeats the purpose. The
rule is narrower: no services, and never ports 80 or 443.
The adb-beacon receiver was wildcard-bound to 0.0.0.0:443, holding port
443 on every IPv4 address including the reserved one, so the IPv4
interception test had been compromised for as long as it had run. It is
disabled; restore with systemctl enable --now echolot-adb-beacon. This
also marks the guard's limit: it governs this server's listeners, and a
process outside its config can still pollute a reserved address.
The admin UI and ACME responder were single-address, which is why the UI
could only live on ::2 and why the server was reachable over IPv6 alone —
the thing that made it look nonexistent from a phone without working
IPv6. Both now take address lists like every other listener.
Verified from outside: .150/::150/::2 answer on 443 with a valid cert for
fmr.echo-lot.app, .151/::151 are closed on 80 and 443, and canary DNS is
still up on .151.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-network attribution fix worked — the finding named rmnet_data1
instead of the IPv4-only wifi — and immediately exposed a worse problem
underneath. Cellular's result was `ok: false` because binding a socket to
it failed with EPERM, so no echo request was ever sent; the finding then
reported "IPv6 is configured, but ICMPv6 gets no reply" about a network
the app had never pinged. That is an assertion about the user's carrier
with nothing behind it.
`attempted` now travels beside `ok`, set only once sendto has returned,
and the finding requires both. Failing to bind is a fact about this app's
permissions on this device; it says nothing about the network, and the
two must not share a boolean.
Verified on hardware with a VPN active: every network fails to bind with
EPERM, nothing is sent, and no ICMPv6 finding is emitted — where the
previous build would have blamed the carrier. Recorded in build-status:
Android blocks per-network binding entirely while a VPN holds the default
route, so per-network measurement is unavailable to anyone with one
connected. That needs a deliberate answer rather than a silently green run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signing in and being allowed to administer the server were the same
question: the OIDC callback refused a session outright to anyone outside
the admin group. A legitimate user could authenticate, be told what they
could not do, and be left with no way to see or delete the data their own
devices had uploaded.
They are separate questions now. Everyone who authenticates gets a
session; the admin flag rides inside the MAC'd payload, so promoting
yourself means forging a signature rather than editing a cookie, and a
role that does not parse fails closed to "user".
Pages scope themselves through visibleDevices/mayTouchRun rather than
filtering individually — per-page scoping is what the next page added
will be missing, and that failure is silent, since a listing that leaks
other people's uploads looks exactly like one that does not. Someone
else's run answers 404, not 403: a distinguishable refusal would confirm
the run exists. Revoking devices and minting enrolment tokens affect the
whole server and stay behind adminOnly at the route table, where someone
looking for who-may-do-what will actually find it.
Ownership is re-read per request instead of captured at sign-in, so
unlinking an account takes effect immediately rather than at session
expiry. Tests cover that, plus the degenerate case of an empty subject,
which must own nothing rather than everything with an empty account id.
Also: attribute the ICMPv6 finding per network. It compared "is IPv6
configured anywhere on this device" against "did any network answer",
which on a phone reports IPv6-is-broken about a network where IPv6 was
never configured. network_ref is null on every test, so the probe now
records per-network outcomes structurally rather than as prose a finding
would have to parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v6.no_icmp_reply infers trouble from silence, which is ambiguous by
construction: a firewall dropping echo requests looks the same as a
network that cannot carry IPv6 at all. Two much stronger signals were
already sitting unread in the link snapshot, and a test device on a
Netbird tunnel surfaced both at once.
v6.route_without_address — a ::/0 route with no global address. The
router advertises itself as an IPv6 gateway while SLAAC produces nothing
usable. Hosts believe IPv6 is available and pay a connection timeout on
every dual-stack destination before falling back, which is felt as
general slowness with no packet loss to explain it.
v6.no_default_route — the mirror: a global address with nothing to route
it. A VPN installing host routes to specific destinations produces this
deliberately and it works, so a VPN transport reports it as INFO rather
than as a fault; without one it means the network handed out an address
it does not carry traffic for.
Both are read from the routing table, so neither is inferred from
silence, and both are reported per interface — "IPv6 is broken" is
useless advice when wifi is the broken one and cellular is fine.
Classification lives in core-measurement rather than the ViewModel so it
can be tested without a device; the fixtures are a real dumpsys table
(wifi advertising a route it cannot source from, working cellular, a
tunnel with two host routes) because the risk here is not bad boolean
logic but imagining shapes real networks do not produce.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A phone reported "IPv6 is configured but not working" while loading an IPv6-only
site over TCP perfectly well. The finding fired on one signal - ICMPv6 echo
getting no reply - at HIGH confidence. ICMPv6 echo is widely filtered on
networks where IPv6 works, so the two cases are indistinguishable from where the
app stands, and it was picking one.
Same class of error as the multi-homed downstream-loss bug: a confident
measurement of something that was not happening. Now v6.no_icmp_reply, low
severity, medium confidence, naming both explanations. Still reported, because
filtered ICMPv6 breaks Path MTU Discovery - large packets vanish instead of
being reported as too big - which is a fault in its own right.
Corroborating with a real IPv6 connection would separate the two properly, but
needs a target, which runs into the hardcoded-deployment issue already open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Authorization code with PKCE, a Sign in card in settings, and the echolot://auth
redirect handled next to the enrolment one - told apart by host, because one
spends a token and the other completes an authorization, and confusing them
would fail obscurely.
The detail that decides whether this survives a real phone: the PKCE verifier is
written to storage before the browser opens rather than held in memory. Handing
control to a browser backgrounds the process and Android may kill it, so the
callback arrives at a fresh one. An in-memory verifier works on a developer's
device and fails under memory pressure.
Pending state is cleared before the exchange is attempted, whatever the outcome:
it is single-use, and leaving it behind would let a later callback complete a
flow nobody started.
Also records an open issue the question about server requirements surfaced: two
probes hardcode the reference deployment, so a user with no server still sends
DNS and STUN traffic to fmr without being told. For a tool this careful about
what leaves the device, that is the wrong default.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three phones on one account now produce one history, which is the main reason to
have accounts beyond upload permission. GET /v1/runs returns the account's runs
and says how many devices contributed; fetching and deleting resolve a run id
against the caller's own devices, so an id from another account is not found
rather than fetched from wherever it happens to live.
The rule that needed stating: the empty account is never a group. Devices nobody
has signed in on are unrelated devices that share the absence of an owner, and
matching on "" would let any anonymous device read every other one's runs.
Tested, along with sibling-device access working and cross-account access not.
App side: authorization code with PKCE. The app is a public client - anything
compiled into an APK can be read out with unzip and strings - and the redirect
returns through a custom URI scheme that any app on the device may register, so
an intercepted code is a real risk. PKCE makes a stolen code worthless: it can
only be exchanged by presenting a verifier that never left the process.
A callback whose state does not match is refused before the code is spent and
before any network call, since that is exactly how someone gets a victim to
complete the attacker's sign-in.
Nothing from the IdP is retained. The ID token is used once to prove who is
signing in and then discarded; the device credential authenticates everything
afterwards. No access tokens to store, no refresh tokens to rotate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the unauthenticated admin mux. Everything but /healthz requires a
session, and that is the point: the previous arrangement relied on binding to
loopback, which worked exactly until the address changed and then failed
silently and publicly. A binding address is a deployment detail, not an access
control, and this package does not treat it as one.
Two ways in. OIDC through the confidential client, with state and PKCE - PKCE
even here, because it costs one hash and closes code interception independently
of the secret. And the break-glass password, throttled, for when the IdP is the
thing that is broken. Signing in without the admin group is refused with the
group named, because "you are not an admin" is a different problem from "your
password is wrong" and the remedy is elsewhere.
Sessions are MAC-checked cookies: HttpOnly, SameSite=Lax, Secure when TLS is on.
CSRF tokens are derived from the session rather than stored, so there is no
server-side table to keep in sync, and they are required on every state-changing
POST - SameSite already blocks cross-site posts in current browsers, but this is
the control that does not depend on the browser being current.
Server-rendered with html/template and no JavaScript: the pages are lists and
forms, and a framework would add a build step, a dependency tree and an update
treadmill to a program that has none of those. The CSP is default-src 'none'
accordingly.
Pages: overview, devices (with revocation and enrolment-link minting), uploaded
runs and a run viewer. Revocations and deletions are logged with who did them.
Runs are shown exactly as uploaded, at the privacy level their uploader chose -
nothing in the UI can un-redact one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The incident is written down with its cause rather than just its fix: the admin
listener was built localhost-only, and that assumption travelled with it when I
changed the address. The compounding error is the one worth remembering -
checkAdminExposure verifies encryption and says nothing about authentication, so
it passed and gave false confidence. A green light on an adjacent property is
worse than no check.
Also records the encrypted-upload idea while the reasoning is fresh, including
the four consequences that decide whether it is worth building: what metadata
must stay readable (and what the UI loses if it does not), that losing the
passphrase loses the data by design, that metadata is not hidden regardless, and
that it makes a server-side anonymization floor unenforceable - which is fine,
since encryption serves the same purpose better.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Authentik derives the issuer from the application slug, so two applications mean
two issuers - and a token's `iss` must match whoever signed it. A single pinned
issuer could therefore only ever serve one of the two clients.
So there is a verifier per issuer, and each accepts only the client belonging to
it. That is tighter than the previous arrangement as well as more general: a
token minted for the phone cannot be replayed at the admin login, and vice
versa, because they arrive at different verifiers with different audiences.
ECHOLOT_OIDC_APP_ISSUER is optional - empty means both clients share
ECHOLOT_OIDC_ISSUER, which is what IdPs with one global issuer do.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HTTP-01 always arrives on port 80 - the CA chooses the port, not the operator -
so it never collides with an admin UI on 443. The conflict only exists for
TLS-ALPN-01, which is the challenge type that does use 443.
Given that, the server keeps a permanent listener on 80 that answers challenges
from a webroot and redirects everything else to the admin UI. Same arrangement
as the webroot plugins for Apache and nginx, and better than letting the ACME
client bind 80 per renewal: nothing binds and unbinds, so a renewal cannot fail
because the port was briefly busy, and the client needs only write access to a
directory instead of the privilege to bind a low port. Port 80 also gets a use
it would want anyway.
The ACME client stays an external program. lego is also a Go library, but
importing it would put a large dependency tree into a server that deliberately
has none, and the CLI does the same job from a timer.
Tokens are validated by *shape* before any filesystem call, so traversal never
reaches the disk - a stronger guarantee than sanitising a path and trusting the
sanitiser.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Direct rather than behind Caddy or nginx. This binary already serves TLS for the
control plane, so it is reuse rather than new machinery; one process with one
config file is most of what makes this thing pleasant to run; and a proxy on the
box would invite someone to eventually front the control plane too, which would
break SPKI pinning because clients pin that certificate's key.
The hard part of TLS is not termination, it is renewal - so the certificate is
re-read when the files change. No reload hook to write, and none to quietly stop
working months later and be noticed only after the certificate has expired. A
torn write (renewal tools write cert and key separately) keeps the previous
certificate rather than taking the listener down.
Not applied to the control plane, on purpose: clients pin that key, so replacing
it should cost an operator a moment's thought and a restart, not happen because
a file changed. Two listeners, two different right answers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two gaps found while answering where configuration lives.
The confidential admin client needs a secret and there was nowhere to put one -
I had added the issuer and both client ids but not the secret the admin login
actually needs. It now reads from ECHOLOT_OIDC_CLIENT_SECRET, and preferably
from ECHOLOT_OIDC_CLIENT_SECRET_FILE: a secret in the environment is readable by
anything that can see /proc/<pid>/environ and lands in every dump of the unit's
config, whereas a path is one file whose permissions an operator can reason
about. (/etc/echolot-server.env was also 0644; now 0600 on fmr.)
And the server now refuses to serve the admin UI in plaintext on a non-loopback
address. The session cookie is a bearer credential for everything the server can
do, and the OIDC authorization code arrives in a URL; in the clear, both belong
to anyone on the path - and on a globally routable address that is the internet.
A hard stop rather than a warning, because a warning in a log is not read by the
person who most needs it, and because the safe answers are cheap: bind to
loopback and tunnel, or supply a certificate. ECHOLOT_ADMIN_INSECURE=1 overrides
it, so the decision is made rather than stumbled into.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying v0.8.0 broke fmr, and the reason is a flaw I should have seen:
self-update is executed by the OLD binary, so the unit repair I put in the new
binary's updater cannot fix the very update that installs it. The unit kept its
argument-less ExecStart, the new binary answered that with usage and exit 2, and
the service went into a restart loop.
Fixed on fmr by hand, but that is not a fix for anyone else - and the whole
premise of an unattended self-update is that nobody is watching when it happens.
So: when started with no verb *and* systemd started us, the server repairs the
unit and serves anyway, loudly. systemd sets INVOCATION_ID for every service
invocation and nothing else does, so a person at a terminal still gets usage and
a non-zero exit. Marked as a one-release shim to remove once no deployment
predates --serve.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Running an unfamiliar binary by name should tell you what it does, not bind a
dozen ports and start answering the internet. --serve (or --daemon) now does
that, and a bare invocation prints usage and exits 2 - non-zero on purpose, so a
service manager sees a failure rather than concluding the server ran and
finished cleanly.
The hazard this creates is worth spelling out, because it bites once and
silently: three places started the binary with no arguments - the systemd unit,
the unit template, and the Dockerfile - and --self-update replaces the binary
but never the unit. A routine update would therefore leave a service that cannot
start, discovered whenever the host next rebooted.
So the updater repairs it: after replacing the binary it appends --serve to an
ExecStart that has no flags, but only in a unit this program wrote (identified
by its description). Editing an operator's hand-written unit would be overreach;
leaving ours broken would be negligence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Explaining public vs confidential clients surfaced a gap in my own design: I had
assumed a single client id, but there are two clients here with genuinely
different properties.
the Android app public + PKCE, because an APK cannot keep a secret
the admin UI confidential, because the server can keep one in
/etc/echolot-server.env and weakening it to public buys
nothing
So the audience check now accepts either registered client id - and only those
two. "Any client of this issuer" would let every other application registered
with the same IdP authenticate here, which is the entire reason the check
exists. Either id alone is enough to enable sign-in, since an operator may
register only the app or only the admin UI.
The profile advertises the *app's* client id, since that is what a phone should
authorize as.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
If the IdP is misconfigured, unreachable, or the admin group is a typo, the
operator is locked out of their own server with no way back short of editing
JSON on disk. A fallback that only matters when everything else is broken is
exactly the thing you cannot add later - by then you cannot get in to add it.
Stored as PBKDF2-HMAC-SHA256 from the standard library (Go 1.24+ has it, so no
dependency), 600k iterations, per-credential salt. A password rather than a
bearer token on purpose: a break-glass credential is the one most likely to end
up in a backup or a config-management repo, and a hash survives that where a
token does not. There is no email reset flow and should not be -
--set-admin-password on the host is the reset, and whoever can run it already
has the machine.
The password is read from stdin, never a flag, so it stays out of shell history
and the process list; piping still works for automation.
Details the tests pin, each for a reason:
- the username is compared in constant time too, or a fast rejection is a
timing oracle for which usernames exist;
- the *stored* iteration count is used, so raising the constant later does not
lock out existing passwords;
- the throttle grows with consecutive failures but stays bounded and forgives
after a quiet minute - a break-glass credential an attacker can lock out is
a denial of service against the one person who needs it;
- sessions are MAC-checked before anything in them is read, and rotating the
secret invalidates every one at once, which is how they are revoked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Echolot delegates identity to whatever IdP the operator already runs and stores
no passwords - no hashing, no reset flow, no lockout policy, and no credential
database to lose. For a tool people self-host next to other services, that is
the difference between one more service and one more thing that can leak
someone's password.
Verification is stdlib-only, matching the server's no-dependency rule. Longer
than jwt.Parse, and auditable in one sitting. The part that matters is the
algorithm allow-list: taking `alg` from the token is the classic forgery, so it
is fixed in code. Tests cover the real attacks against a genuine signer - a
self-contained IdP with real keys, because a mock that returns success proves
nothing about a verifier:
alg=none, HS256/RS256 confusion, a payload swapped under a valid signature,
a token addressed to another client, a token from another issuer, expired
and future-dated tokens, and discovery that renames the issuer (which would
otherwise have us fetch a stranger's keys believing they were the provider's).
With no admin group configured nobody is an admin. An operator who has not said
who may administer the server has not thereby said "anyone who can log in".
Device and account stay separate concepts: enrollment admits a device (operator's
token), signing in attributes it to a person (POST /v1/account/link, device
credential plus ID token - both required, neither substitutes). uploads=account
now means what it says instead of refusing everyone, and signing in does not
override uploads=off.
The profile advertises the sign-in configuration so the app can offer the button
only when there is something behind it, and drive PKCE without anyone typing an
issuer URL. A discovery failure is reported rather than hidden, so "configured
but the provider is not answering" is distinguishable from "not configured".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
At `full` the anonymizer returns the document unchanged, so the salt has nothing
to act on - but the switch was enabled and looked like it did something. A
control that silently does nothing is the same class of fault as the preview
button and the archived-level label: the screen implying more than is true.
Shown disabled with the reason rather than hidden. The setting is still stored
and applies the moment the level changes, so making it vanish would hide state
that is still there; and a settings screen whose controls appear and disappear
as you touch other controls is harder to trust, not easier. The label dims with
the switch so "not active right now" reads at a glance.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Running the Shizuku tier for the first time uploaded every MAC address on the
local network to the server at the balanced level - fourteen of them, router and
all. The probes embed raw command output verbatim (ip neigh, ip route), which is
good evidence and also a complete household device inventory, and the anonymizer
could not see it: classification is by field name and whole-value shape, and
ip_neigh is one long string that is itself neither a MAC nor an address.
measurement-schema.md flagged raw dumps as hard to anonymize and proposed
dropping them from exports. Scrubbing is better: identifiers inside unclassified
strings are replaced in place with the same pseudonyms used elsewhere, so a MAC
appearing in both a parsed field and a raw dump still reads as one device, and
the dump stays readable - neighbour-table shape, host count, RFC1918 addresses
and vendor prefixes all survive. Dropping it would have protected the same data
by destroying the reason for collecting it.
One pass, not three: sequential passes re-process their own output. Once a MAC
became 78:9a:18:xx:yy:zz the IPv6 pattern matched it - six hex groups separated
by colons is an address - and destroyed the vendor prefix the MAC rule had just
preserved. Ordered alternation resolves each position once, MAC first.
RealDocumentTest runs the anonymizer over a captured run when ECHOLOT_REAL_RUN
points at one and fails on any surviving MAC; it self-skips otherwise so no
one's network lands in the repo. Against the document that leaked: 14 in, 0 out.
Also: the Settings preview button did nothing, reading UiState.history which is
empty until the History screen has been opened - same root cause as the "0
run(s)" count. It reads the archive now, and says when there is nothing to show.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3125 sent, 3125 counted by the server, 0% loss. The assertion that earns its
keep is received <= sent: that is what catches a counter that was never reset
between runs, which would otherwise look like a suspiciously good result.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The client generates the traffic and the server counts it. No grant is involved
- the client is sending its own packets, so there is nothing to amplify - but it
does need the server's tally, because only the far end knows how much arrived.
Without that number a sender measures how fast it can transmit, which is usually
just the speed of the local NIC and is not the question being asked.
A new wire type the server counts and deliberately never answers: a reply would
double the traffic and drag the return path into a measurement that is
specifically about the outbound one.
The tally is a counter, not a list, and short-circuits before the observation
log. A five-second run at 20 Mbps is around ten thousand packets; one struct
each would turn a measurement into an allocation storm on a shared server, and
nothing needs the per-packet detail since the client holds the send-side record.
The gap between the two counts is the loss.
direction=up on the throughput action sends nothing - it zeroes the counter, so
a second run in one session measures itself instead of inheriting the first.
Same honesty rule as downstream: measures_network is false when what arrived
matches what was offered, because then the path was never the constraint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The registry was only used in core-engine. The app still emitted seven codes as
raw strings, so the registry test passed while codes lived outside it - among
them ipv6.broken, which fired on a real network and was in no registry at all.
All seven now take their code, category and severity from a registry entry, so
those three cannot disagree at a call site. Grepping for code = "..." across the
app, engine and probe modules now returns nothing.
ipv6.* -> v6.* is the third instance of the same rule being broken: they
declared Category.IPV6 while the prefix map only knows "v6", so
TestType.category("ipv6.broken") fell through to connectivity and the finding
rolled up under the wrong verdict light. The test-type registry already used v6.
Two severities reconciled rather than assumed:
connectivity.captive_portal is medium, not high. The registry had guessed
high; the probe emitting it had always said medium, and the probe was the
considered value - a captive portal on hotel wifi is what should be there.
no_internet keeps high, since nothing local fixes that.
v6.not_offered stays info, and the registry now says why it must. Most
networks still do not offer IPv6; a warning there lights a yellow verdict on a
healthy network and teaches people to ignore the light.
Plus a BackHandler: the screen was a plain state variable with nothing tying it
to the back stack, so Back left the app from Settings/History instead of
returning to the run screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A row read "22 kB · full" directly beneath "uploaded to fmr", while the status
line above said the upload went as BALANCED. Both were true and they described
different documents: the row showed ArchivedRun.anonymization, which describes
the *archived* copy - deliberately unredacted, so always "full" - and the status
line described the *uploaded* copy.
Read together, that says the complete data was uploaded when a redacted copy was
sent. A privacy display that overstates what left the device is worse than none,
and telling the user what left the device is the one thing this screen is for.
The level a run was uploaded at is now recorded separately (uploaded_as) and the
row says "kept complete on this device" / "uploaded to fmr as balanced" - each
label naming the copy it belongs to.
Two more from the same screenshot:
- Every row showed no verdict. The archive read summary.verdict; the schema
calls it summary.overall. Silently null on every run, so the list's most
prominent element was blank while everything else looked fine. The test
fixture had the same wrong field name, which is why it passed.
- The status line rendered the server's raw JSON index entry into the UI.
Verified on device: a fresh run archives with verdict "yellow" and
uploaded_as "balanced" beside anonymization "full".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found in a real uploaded run from the phone: the server held
fda1:3fb1:ff92:6696::2662 for a DNS server. The general IPv6 path keeps the
leading two groups on purpose - for a global address that preserves the ISP
allocation, which is the useful part - but for a ULA that passes through 32 of
the 40 random bits of the global ID.
A ULA looks like the v6 RFC1918 and the instinct is to treat it the same. It is
not analogous, and the difference is the point: an RFC1918 prefix is shared by
millions of networks and identifies none of them, while a ULA global ID is
random and unique to one network by construction (RFC 4193). The prefix IS the
identifier, so it was a network fingerprint surviving redaction.
Pseudonymized as a unit now, so two addresses on one ULA subnet still share a
pseudonymous prefix - "these hosts are on one network" survives, "this is that
network" does not. RFC1918 stays readable, and the contrast is what justifies
it; a test pins both halves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-device verification found both.
safeDrawingPadding() was on the run screen but not on Settings or History -
they were added later and never got it - so "< Back Settings" sat under the
status-bar clock. The same fault the run screen had already fixed, reintroduced
by new code that did not know about it.
Settings also read "0 run(s), 23 kB stored": the count came from
UiState.history, which stays empty until the History screen has been opened,
while the size read the archive directly. Two sources for one fact; the count
now reads the archive too.
Verified on a OnePlus 15 (A16): header clears the status bar, count reads
"1 run(s), 23 kB stored".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
A finding code is the stable half of a result - what a dashboard groups by and
what someone greps a year of archived runs for. That only holds if a code means
exactly one thing forever, which fifteen ad-hoc string literals cannot promise.
By the time this was written the failure had happened twice:
- Two emitters independently produced connectivity.downstream_loss and
connectivity.loss_downstream for the same claim. Nothing objected. Anyone
aggregating either would have silently seen half their data.
- Two codes sat under nat.* while being declared Category.CONNECTIVITY.
nat.udp_unreachable is not about NAT, and the prefix decides the category,
which decides which verdict light the finding rolls up into. Renamed while
that is still cheap.
Codes are now typed FindingSpecs carrying category and default severity;
emitters reference the spec rather than 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, failing when the
document and the code disagree on which codes exist or how severe they are.
Documentation that drifts from its implementation is worse than none, because it
still looks authoritative. The check reads table rows only, so the prose can go
on explaining which codes were retired and why.
Closes open item 1 of measurement-schema.md section 9.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A 5-second run at 50 Mbps moves ~30 MB. On a metered connection that is the
user's money, and a measurement tool that spends it unasked is not one people
keep installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
"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>
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>
The previous commit's edit to the admin handler silently did not apply, so the
endpoint still returned just the token. Caught by deploying and looking at the
response rather than by trusting the build to have picked it up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
The grant is the keystone that makes server->client sends safe: created
only by an authenticated control-plane action, bound at creation to the
session's OBSERVED data-plane source (so it can never be aimed at a third
party), and bounded by bytes, average rate and expiry. Sends stop the
moment the budget runs out, so a buggy action cannot become a flood.
Two granted actions on top of it:
- downtrain: N packets at a given size/interval toward the client, with
seq + send-timestamp in the payload — downstream loss/reorder/jitter,
which an upstream-only train cannot measure.
- big_send: one datagram per requested size, echoing the intended size in
the payload — downstream MTU / black-hole evidence the client cannot
produce for itself (only the far end can emit a large packet toward it).
Tests cover the security properties: no grant without a verified
destination, client requests clamped to server limits, byte budget stops
sending exactly, expiry refuses, and the rate ceiling throttles a burst.
Capabilities gain downtrain + big-send.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
Network churn (SSID jump/roam) makes adbd re-publish its advertisement
repeatedly; each resolve re-arms the connection and posts a notification,
so the resolve-once guard can't fully prevent spam on the OnePlus. Noted
the alternatives (manual port, or Shizuku `ss` with no mDNS involved).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-resolving adbd's own mDNS advertisement is what caused the notification
spam; resolving once per service instance (guard cleared on loss) keeps
rotation tracking intact — verified live, a 37089->33667 rotation was
reported 6s later and reconnected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
A test device that can only reach fmr on 443 (LAN blocks other outbound
ports) can pull an APK via its own downloader — more resilient than adb's
sustained transport over flaky wifi. GET /apk serves APK_PATH. (Doesn't
help the Lenovo tablet, which ships no curl; kept for devices that do.)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
The connector tore down a working adb link whenever the beacon reported a
new port, reconnecting every loop and spamming the phone with "wireless
debugging connected" notifications (~every 8s). Existing connections
survive rotation, so now: if any device-state entry exists for the IP,
leave it; only (dis)connect when there's no working link at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
connect.sh polls the fmr beacon map and keeps `adb connect` current for
every device, now handling offline/stale entries and port changes
(disconnect+reconnect). Verified end to end: after Shizuku start + a
wireless-debugging toggle, the tablet's port rotated 38309->46667, the
beacon caught it, the connector reconnected, and Shizuku kept running as
an independent shell(2000) process.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified live: starting Shizuku (non-root, via wireless debugging) hijacks
the debug channel — adb drops and adbd advertises a stale mDNS port, so the
beacon can't auto-recover. Workaround: toggle wireless debugging off/on
after starting Shizuku (it keeps running). Baked into the dev notes so the
Shizuku-tier build loop plans around it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Overall YELLOW on OnePlus 15 (A16) + Lenovo TB330FU (A15), driven by the
real broken-LAN IPv6 finding — full probe→schema→verdict→UI vertical on
hardware.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>