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>
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>
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>
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>
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>
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>
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>