Compare commits

...
Author SHA1 Message Date
mrambossekandClaude Fable 5 4e6f2da3fb runs: scope by account; app: the PKCE half of signing in
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 36s
server-release / release (push) Successful in 38s
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>
2026-08-01 19:46:32 +02:00
mrambossekandClaude Fable 5 0eaba6150b adminui: an admin interface, behind authentication without exception
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 36s
server-release / release (push) Successful in 38s
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>
2026-08-01 19:31:06 +02:00
mrambossekandClaude Fable 5 7bb54e1ec8 docs: record the admin-listener exposure, and the encrypted-upload design
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>
2026-08-01 19:04:09 +02:00
mrambossekandClaude Fable 5 c7750fbf0b oidc: one verifier per issuer, because IdPs mint one per application
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 34s
server-release / release (push) Successful in 35s
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>
2026-08-01 18:47:34 +02:00
mrambossekandClaude Fable 5 5d7f59a66a acme: answer HTTP-01 from the server itself, on port 80
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 34s
server-release / release (push) Successful in 35s
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>
2026-08-01 18:16:09 +02:00
mrambossekandClaude Fable 5 6afcb131ef admin: terminate TLS in the binary, with a certificate that reloads itself
server-test / test (push) Successful in 33s
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>
2026-08-01 17:51:58 +02:00
mrambossekandClaude Fable 5 cd187f9ef5 config: the OIDC client secret, admin TLS, and a stop on plaintext admin
server-test / test (push) Successful in 34s
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>
2026-08-01 17:49:26 +02:00
mrambossekandClaude Fable 5 3a4cb1c327 cli: survive the --serve transition when nobody is watching
server-release / image (push) Successful in 16s
server-test / test (push) Successful in 33s
server-release / release (push) Successful in 34s
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>
2026-08-01 17:44:33 +02:00
mrambossekandClaude Fable 5 3cdbccee18 cli: serving is an explicit verb; no arguments prints usage
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 33s
server-release / release (push) Successful in 34s
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>
2026-08-01 17:42:20 +02:00
mrambossekandClaude Fable 5 80d2092f1b oidc: accept both the app's public client and the server's confidential one
server-test / test (push) Successful in 37s
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>
2026-08-01 17:31:33 +02:00
mrambossekandClaude Fable 5 89a5ff9139 adminauth: a break-glass local admin alongside OIDC
server-test / test (push) Successful in 44s
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>
2026-08-01 17:12:54 +02:00
mrambossekandClaude Fable 5 ce6d0c2f64 oidc: the server becomes a relying party, and devices can carry an account
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 35s
server-release / release (push) Successful in 34s
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>
2026-08-01 16:52:56 +02:00
mrambossekandClaude Fable 5 57a5ef8796 app: the stable-pseudonym switch was live at a level that pseudonymizes nothing
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>
2026-08-01 16:16:36 +02:00
mrambossekandClaude Fable 5 c19f382640 privacy: scrub identifiers inside raw shell output
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>
2026-08-01 16:12:13 +02:00
mrambossekandClaude Fable 5 d04babff51 engine: live test for upstream throughput
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>
2026-08-01 15:56:51 +02:00
mrambossekandClaude Fable 5 892e952a8e throughput: the upstream direction, counted by the only party that can
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 33s
server-release / release (push) Successful in 33s
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>
2026-08-01 15:54:09 +02:00
mrambossekandClaude Fable 5 8646bab52d findings: adopt the registry in the app module; rename ipv6.* to v6.*
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>
2026-08-01 15:47:52 +02:00
mrambossekandClaude Fable 5 6bba420845 app: the history row was naming the wrong document's privacy level
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>
2026-08-01 15:37:05 +02:00
mrambossekandClaude Fable 5 ac6c653115 privacy: pseudonymize the whole ULA prefix, not just its tail
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>
2026-08-01 15:26:09 +02:00
mrambossekandClaude Fable 5 305d21f8a7 app: insets on the two newer screens, and one source for the run count
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>
2026-08-01 15:20:57 +02:00
mrambossekandClaude Fable 5 172afb421d privacy: fix a real leak - global IPv6 addresses were uploaded verbatim
Setting out to build the machine-readable schema, the first step was checking
whether the anonymizer covers the fields the schema declares sensitive. It did
not, and five identifying values were going out at the `balanced` level:

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

The settings screen describes that level as pseudonymizing addresses.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 14:45:52 +02:00
mrambossekandClaude Fable 5 e7afc2210f findings: a registry, because the codes had already drifted
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>
2026-08-01 14:25:54 +02:00
mrambossekandClaude Fable 5 f7701c2d2f engine: throughput is opt-in in the run config; document the work
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>
2026-08-01 14:14:20 +02:00
mrambossekandClaude Fable 5 3c9af04e6f grant: replace the rate check with a token bucket
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 32s
server-release / release (push) Successful in 33s
The live throughput test found it: a 3-second run delivered 104 packets and
stopped after 50 milliseconds.

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

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

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

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

Loss is computed against the *sender's* count, not the requested rate: the
server reports what it put on the wire, and the gap is the loss. A receiver
alone cannot tell "the network dropped it" from "the sender never sent it", and
guessing turns a healthy server-side limit into a phantom network fault. The
count is stored per action, not per packet — half a million packets of structs
would turn a measurement into memory exhaustion.

Sending is paced rather than flat out. An unpaced burst measures the server's
NIC and the first queue it meets, then collapses into loss that reads as a
network fault. The schedule is absolute rather than sleep-per-packet, which
would accumulate scheduler error and drift the rate down over a ten-second run.

Throughput gets its own grant budget sized from the request, so every other
action stays bounded at 8 MiB. When the byte cap binds before the clock does,
the *duration* is shortened and reported, rather than the run being truncated
halfway: promising thirty seconds and delivering twenty-one is the same
information with a surprise attached, and it keeps "the clock ended the run" as
the normal case — the only case where the rate is a clean property of the path.

That last behaviour came out of a test that failed honestly: 30 s at 100 Mbps
needs 375 MB against a 256 MB cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 14:05:23 +02:00
mrambossekandClaude Fable 5 35744c609e docs: record frag_send and the current testing state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 13:49:43 +02:00
mrambossekandClaude Fable 5 a7dccf7da2 frag_send: crafted IP fragments, so ordering can be tested and not just delivery
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 32s
server-release / release (push) Successful in 32s
Letting the kernel fragment an oversized datagram answers one question — do
fragments get through. It cannot answer the more interesting one, because the
kernel always emits them in order, first one first.

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

So the server now builds the fragments itself (raw socket, IP_HDRINCL) and
controls their order: in_order as a baseline, reversed, and first-fragment-last.
The datagram is assembled and signed whole before being cut up, so what the
client reassembles is indistinguishable from an ordinary packet — otherwise it
would be measuring our sender rather than the path.

Two details that would silently produce wrong answers:
  - The UDP checksum is computed rather than left zero. A zero-checksum datagram
    is dropped by some middleboxes, and that drop would be recorded as a
    fragmentation failure, which is the wrong conclusion entirely.
  - Fragment offsets are in 8-byte units, so non-final fragments are rounded to
    a multiple of 8. A 100-byte fragment is not an error, it is a datagram no
    host will ever reassemble.

frag-send is advertised only when a raw socket can actually be opened — checked
by opening one, since a permission model has more ways to say no than a
capability bit has to say yes.

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:12:28 +02:00
mrambossekandClaude Fable 5 199807a8c9 docs: enrollment link encoding rules in the spec, session log in build-status
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:11:37 +02:00
70 changed files with 7331 additions and 148 deletions
+8
View File
@@ -162,3 +162,11 @@ First build downloads AGP/Compose/Shizuku from Google Maven + Maven Central.
are SUPPORTED on both known devices via `Os.recvmsg` + `StructMsghdr` reflection. are SUPPORTED on both known devices via `Os.recvmsg` + `StructMsghdr` reflection.
3. Fold the confirmed capabilities + Shizuku dump-format samples back into the production 3. Fold the confirmed capabilities + Shizuku dump-format samples back into the production
`core-probe` / `core-shizuku` modules. `core-probe` / `core-shizuku` modules.
## Enrolling a device with a server
`echolot-app/scripts/enroll-link.sh [note]` mints a §2.1 bootstrap link on fmr over SSH and prints
it (plus a QR if `qrencode` is installed, plus the `adb shell am start -a …VIEW -d '<uri>'` command
when a device is attached). The link carries a single-use token — treat it as a secret until spent.
Never hand-assemble one: the base64 pin needs percent-encoding, and a pin wrong by one character
fails as an inscrutable TLS error rather than as a bad pin.
+394
View File
@@ -660,3 +660,397 @@ One user-visible bug caught in the process: Go's JSON encoder HTML-escapes `<`,
default, so the refusal reached the client as `needs \u003e= 0.2.0`. Disabled at the encoder (this default, so the refusal reached the client as `needs \u003e= 0.2.0`. Disabled at the encoder (this
is an API, not a page), and the client now *parses* the error field instead of pattern-matching it, is an API, not a page), and the client now *parses* the error field instead of pattern-matching it,
so it survives whatever a future encoder decides to escape. so it survives whatever a future encoder decides to escape.
### Enrollment: the server mints the bootstrap link (server-v0.5.3 … v0.5.4, 2026-08-01)
Until now a device was configured by hand-typing a control URL, a base64 SPKI pin and a
credential. That is the step that goes wrong, and it goes wrong quietly: a pin off by one
character does not fail loudly, it just never matches, and surfaces days later as an inscrutable
TLS error.
`POST /admin/enroll-tokens` now returns the whole §2.1 bootstrap link alongside the token, because
the server is the only party holding all three parts at once. The app takes it from a paste or an
`echolot://enroll` deep link (so a QR scan configures a server in one action) and writes URL, pin
and credential **together or not at all** — a half-applied server fails later, somewhere else,
with an error pointing at the wrong thing.
The control URL comes from `ECHOLOT_PUBLIC_URL` (set on fmr to `https://fmr-1.echo-lot.app:8443`),
falling back to the first control listen address; a wildcard bind warns rather than emitting a
link to `0.0.0.0`.
**The encoding trap, which is the whole reason this is tested across both languages.** The pin is
base64, so it contains `+`, `/` and `=` — each of which means something else in a query string. An
unencoded `+` decodes to a space, leaving the pin wrong by exactly one character. Base64 has no
spaces, so the parser restores them; that cannot damage a correctly-encoded pin and it rescues
every hand-assembled link. `LiveEnrollmentTest` redeems a link the *server* produced, which is the
only way to catch a disagreement between the Go assembler and the Kotlin parser — a unit test on
either side alone cannot see it. It also asserts the token is refused the second time.
Also fixed a spec divergence found while reading §2.1: the spec names the field
`device_credential`, the first implementation shipped `credential`. The server now sends both and
the client prefers the spec's; the alias goes once nothing reads it.
Two process notes from this round:
- An edit to the admin handler silently failed to apply and the endpoint kept returning just the
token. Caught by deploying and *looking at the response*, not by trusting a green build.
- The live suite is now six tests (`LiveServerTest`, `LiveMeasurement`, `LiveGranted`,
`LiveUpload`, `LiveCompat`, `LiveEnrollment`), all green against fmr from the PC with no device.
### Directional loss: which way is the packet loss? (2026-08-01)
A round trip can only report that *something* was lost somewhere, which is the least useful form
of the answer — "3 % loss" sends an engineer looking in both directions at once. The server
already records every packet it received per sequence number (§6), so the two cases are actually
distinguishable, and `train.udp_updown` now reports them separately:
- sent, never seen by the server → **upstream** loss
- seen by the server, reply never arrived → **downstream** loss
Findings name the direction and say what is *not* implicated, which is half the value:
`connectivity.loss_upstream` ("the return path is not implicated: replies came back for everything
that arrived"), `connectivity.loss_downstream`, `nat.udp_unreachable_upstream`.
Two things the implementation gets deliberately right:
- **Downstream loss is measured against what reached the server**, not against what was sent.
Using "sent" as the denominator counts every upstream loss a second time and overstates the
return path. Pinned by a test with loss in both directions at once.
- **Per-direction jitter without synchronised clocks.** Absolute one-way delay would need clock
sync and we deliberately have none (the two-clock rule). But `server_rx client_tx` carries a
constant unknown offset, and differencing successive samples cancels it — so RFC 3393 one-way
delay variation *is* honestly attributable to a direction even though latency is not. A test
pins that a 10-second clock offset changes nothing.
Correlation is by **wire sequence number**, which is not the loop index: the counter is shared
with every other packet type on the session, so "the nth echo" is not "sequence n". `ProbeSession`
now exposes `lastSeq`, including for a probe that was lost — a lost packet still has a sequence
number, and that number is exactly what tells you which way it was lost.
Live against fmr: 20/20 both ways, and jitter of **0.08 ms upstream vs 0.85 ms downstream** — a
tenfold asymmetry that a round-trip measurement cannot see at all.
10 unit tests on the arithmetic (a wrong denominator here does not crash, it produces a plausible
number pointing at the wrong half of the network) plus the live correlation check.
### frag_send: crafted IP fragments, so *ordering* is testable (server-v0.6.0, 2026-08-01)
`big_send` with `df=false` answers one question — do fragments get through. It cannot answer the
more interesting one, because the kernel always emits fragments in order, first one first.
The classic middlebox fault is exactly about that ordering. Only the **first** fragment carries the
UDP header, and therefore the ports; a stateful firewall or NAT that has not seen it has no flow to
match the rest against, and many simply drop them. That is invisible to every in-order test, and in
the field it looks like "large DNS answers fail on this network" or "the tunnel breaks when the MTU
drops" — it works until the network reorders, then fails intermittently, which is the hardest kind
of fault to chase.
So the server builds the fragments itself (raw socket, `IP_HDRINCL`) and controls their order:
`in_order` (baseline), `reversed` (last fragment first), `first_last` (first fragment held back
250 ms). The datagram is assembled and **signed whole** before being cut up, so what the client
reassembles is indistinguishable from an ordinary packet — otherwise the test would be measuring
our sender rather than the path. New test type `mtu.frag_ordering`; findings
`mtu.fragments_blocked` and `mtu.fragment_reorder_sensitive`.
Two details that would otherwise produce confidently wrong answers:
- **The UDP checksum is computed, not left zero.** Zero is legal in IPv4 and would be less code,
but zero-checksum datagrams are dropped by some middleboxes — and that drop would be recorded as
a fragmentation failure, which is the wrong conclusion entirely.
- **Fragment offsets are in 8-byte units**, so non-final fragments are rounded down to a multiple
of 8. A 100-byte fragment is not an error; it is a datagram no host will ever reassemble.
`frag-send` is advertised only when a raw socket can actually be opened — checked by opening one,
because a permission model has more ways to say no (userns, seccomp, LSM) than a capability bit has
to say yes. fmr runs as root with `cap_net_raw` in its bounding set, so it is available there.
Fragment ordering runs only after `mtu.frag_delivery` shows fragments arrive at all; otherwise the
three orderings would each report "not delivered" and read as three faults instead of one.
The header arithmetic is unit-tested (reassembly coverage with no gaps or double-delivery, MF
flags, shared IP ID, 8-byte offsets, checksum verification over odd and even lengths). Because the
code is `//go:build linux`, the tests are **cross-compiled and run on fmr** — there is no Go
toolchain there, so `go test -c` plus scp is the loop.
Live against fmr: 4 fragments per burst, and all three orderings reassembled — a healthy path, and
the baseline against which a mobile network will be interesting.
### Testing state (2026-08-01)
Six live tests against fmr, all green, no device involved: `LiveServerTest`, `LiveMeasurement`,
`LiveGranted`, `LiveDownstream`, `LiveUpload`, `LiveCompat`, `LiveEnrollment`. Plus 74 client unit
tests and the full Go suite. Everything in the last several entries is verified from the PC; the
app's UI (settings, history, deep-link enrollment) and `mtu.pmtud_up` remain device-only.
### throughput: a rate, plus the qualifier that makes it a measurement (server-v0.6.1 … v0.6.2)
A throughput test reports the *smallest* limit on the path — and the sender's own ceiling is one of
the candidates. If the server is asked for 50 Mbps and 50 Mbps arrives, the network was never the
constraint and "50 Mbps" says nothing about it. So `perf.throughput_udp` always carries
`limited_by` (duration | budget | rate | send_error) and `measures_network`, and a finding is
raised only when the path is actually implicated. The live run against fmr reports 20 Mbit/s with
`measures_network: false`, which is the correct and useful answer.
Loss is computed against the **sender's own count**, fetched from the observations API, not against
the requested rate. A receiver alone cannot tell "the network dropped it" from "the sender never
sent it", and guessing turns a healthy server-side limit into a phantom network fault. The server
keeps one summary per action rather than per-packet records — a ten-second run at 50 Mbps is half a
million packets, and a struct each would turn a measurement into memory exhaustion.
Sending is **paced**, on an absolute schedule. Unpaced would measure the server's NIC and the first
queue it meets, then collapse into loss that reads as a network fault; sleep-per-packet would
accumulate scheduler error and drift the rate down over a ten-second run.
Throughput gets its own grant budget sized from the request, so every *other* action stays bounded
at 8 MiB. When the byte cap binds before the clock does, the **duration is shortened and reported**
rather than the run being truncated: promising thirty seconds and delivering twenty-one is the same
information with a surprise attached, and it keeps "the clock ended the run" as the normal case —
the only case where the rate is a clean property of the path. That behaviour came out of a test
that failed honestly (30 s at 100 Mbps needs 375 MB against a 256 MB cap).
It is **opt-in** in the run config, default off. A 5-second run at 50 Mbps moves ~30 MB; on a
metered mobile connection that is the user's money, and a tool that spends it without being asked
is not one people keep installed.
#### The bug the live test found
The first live run delivered 104 packets and stopped after 50 ms. The grant's rate check exempted
the first 50 ms entirely, meaning to be lenient at startup — the effect was the opposite. A sender
could dump an unbounded burst into that free window, and the instant the check switched on it
compared those bytes against 50 ms worth of allowance and refused everything until real time caught
up. **Every short test passed** (downtrain sends 50 packets, big_send seven); every sustained send
died fifty milliseconds in.
Replaced with a token bucket (`allowance = burst + rate × elapsed`), which is smooth from t=0.
The burst is 100 ms of the allowed rate, floored at one ordinary datagram — deliberately one, since
at 8 kbps a 64 KB floor is sixty-four seconds' worth, exactly the instant dump the ceiling exists to
prevent. The pre-existing rate test caught that when I first tried the generous floor, and it was
right to. Second half of the same bug: callers treated *any* refusal as terminal, so `TryAllow` now
says why — a sender paces through a transient "too fast just now" and still stops dead on a spent
budget or an expired grant. Both halves are pinned by regression tests.
### Findings registry (2026-08-01)
Closes open item 1 of measurement-schema.md §9. A finding code is the stable, machine-readable half
of a result — what a dashboard groups by and what someone greps a year of archived runs for — and
that only holds if a code means exactly one thing forever. Ad-hoc string literals at fifteen call
sites cannot promise that, and by the time the registry was written the failure had already
happened.
**Two emitters had independently produced `connectivity.downstream_loss` and
`connectivity.loss_downstream` for the same claim**, and nothing anywhere objected. Anyone
aggregating either one would have silently seen half their data. Merged into
`connectivity.loss_downstream`, paired with `loss_upstream` so the two directions read as a set.
**Two codes were also renamed out of `nat.*`.** `nat.udp_unreachable` is not about NAT — it means
no replies came back — but the prefix determines the category, and the category determines which
verdict light the finding rolls up into (§7.3). A `nat.*` code landing under *connectivity* is not
a naming quibble; it changes which light turns red. Cheap to fix now, a breaking change later.
Codes are now declared as typed `FindingSpec`s carrying their category and default severity, and
emitters reference the spec instead of retyping the string — so a typo is a compile error and two
call sites cannot disagree about a finding's category.
`docs/findings-registry.md` is the contract, and a test reads it: it fails when the document and
the registry have codes the other lacks, or when a severity differs. Documentation that drifts from
its implementation is worse than none, because it still looks authoritative. The check scopes
itself to table rows, so the prose can keep explaining which codes were retired and why.
Six tests: uniqueness, declared-vs-listed, prefix↔category agreement, naming convention, a
word-order-anagram check (the shape the duplication actually took), and the document agreement.
### A real privacy leak, found by starting on the machine-readable schema (2026-08-01)
The intent was `measurement.schema.json` (§8's promised companion). The first step — checking
whether the anonymizer actually covers the fields the schema declares as sensitive — found that it
did not, so that became the work.
**At the `balanced` level, five identifying values were being uploaded verbatim:**
| value | field | why it matters |
|---|---|---|
| `2001:…::150` | `networks[].link.addresses[].addr` | the device's own global IPv6 address — a strong, geolocatable device identifier |
| `2a02:…::1` | `networks[].link.routes[].gateway` | identifies the ISP allocation |
| `203.0.113.77` | `networks[].link.dns.servers[]` | the configured resolver |
| `nas.example.lan` | `private_dns_hostname` | an internal hostname |
| `example.lan` | `search_domains[]` | the internal domain |
The settings screen describes that level as pseudonymizing addresses. It was not.
**Root cause:** classification keyed on field *names*, and the schema's actual names (`addr`,
`gateway`, `dst`, `servers`, `search_domains`, `private_dns_hostname`) had never been added to the
table. Not a subtle bug — just an unfalsifiable design. The existing tests all passed, because each
one checked a field somebody had remembered to write a case for.
**Two fixes, one of them structural:**
1. The missing names were added.
2. More importantly, a **shape-based backstop**: when a field name is unrecognised, the *value* is
inspected, and anything shaped like an IPv4/IPv6 address or a MAC is treated as one. A name
table can only protect fields someone thought of, which is precisely the wrong property for a
privacy control. Hostnames are deliberately *not* inferred by shape — `train.udp_updown` is
indistinguishable from a domain, and mangling a test type would corrupt the document to protect
nothing.
`LeakTest` is the new guard and is written to fail for fields nobody has considered: it plants
identifying values wherever one can actually occur and asserts none survive, rather than checking
a list of known cases. It also pins that RFC1918 addresses still come through readable, so the
test cannot pass by over-redacting everything.
Route prefixes and the unspecified address needed care in the transform: `0.0.0.0/0` and `::/0`
must stay themselves, or a routing table becomes unreadable for no privacy gain.
**Still outstanding:** `measurement.schema.json` itself. Worth noting what this episode implies for
it — much of a document's payload lives in `evidence`/`metrics`/`params`, which are per-test-type
`JsonObject` by design and therefore *outside* any schema. A schema-driven anonymizer would have
less coverage there than the name-plus-shape one now does, so the schema should be built for
validation and external tooling, not as a replacement for the classifier.
### ULA prefixes are pseudonymized whole (2026-08-01)
Spotted in a real uploaded run from the phone: the server had
`fda1:3fb1:ff92:6696::2662` for a DNS server. The general IPv6 path preserves the leading two
groups (deliberately — for a global address that keeps the ISP allocation, which is the
diagnostically useful part), and for a ULA that passed through **32 of the 40 random bits** of the
global ID.
ULA looks like the v6 equivalent of RFC1918 and the instinct is to treat it the same. That
reasoning does not carry over, and the difference is the whole 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 — it is a network
fingerprint that was surviving redaction.
Now pseudonymized as a unit, so two addresses on the same ULA subnet still land on the same
pseudonymous prefix: "these hosts are on one network" survives, "this is *that* network" does not.
Three tests, one of which uses the exact value observed on the wire.
Worth recording as a reasoning trap: I had originally raised this as "ULA should probably be kept
verbatim, like RFC1918, for consistency". The surface analogy pointed the wrong way, and the
correct answer was the opposite.
### Registry adopted everywhere; v6 findings renamed; Back works (2026-08-01)
The findings registry was only adopted in `core-engine`. The app module still emitted seven codes
as raw strings, so the registry test passed while codes existed outside it — including
`ipv6.broken`, which fired on a real network and was in no registry at all.
All seven now reference registry entries for code, category and severity, so those three cannot
disagree at a call site. A grep for `code = "…"` across the app, engine and probe modules returns
nothing.
**`ipv6.*` → `v6.*`.** The third instance of rule 1: 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 while merging:
- `connectivity.captive_portal` is **medium**, not high. The registry had guessed high; the probe
that emits it had always said medium, and the probe was the considered value — a captive portal
on hotel wifi is what should be there, and logging in clears it. `connectivity.no_internet` is
the high one, because nothing the user does locally fixes that.
- `v6.not_offered` is **info, and the registry says it must stay info**. Most networks still do not
offer IPv6 and that is not a fault; a warning here lights a yellow verdict on a healthy network,
which teaches people to ignore the light.
Also: a `BackHandler` now returns from Settings/History to the run screen. The screen was a plain
state variable with nothing connecting it to the back stack, so the system Back gesture left the
app entirely. Enabled only when there is somewhere to go back to, so Back still exits from the run
screen.
### Upstream throughput (server-v0.6.3, 2026-08-01)
The mirror of the downstream case: 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 a different question from the one being asked.
`TYPE_THROUGHPUT_UP` (0x0F) is counted and deliberately **never answered**: 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 rather than inheriting the first one's packets. The live test asserts
`received <= sent`, which is what catches a counter that was never reset.
Live against fmr: **3125 sent, 3125 counted, 0 % loss, 10.0 Mbit/s** at a 10 Mbit/s request, with
`measures_network: false` — correct, since what arrived matched what was offered, so the path was
never the constraint.
### Raw shell dumps leaked the whole LAN (2026-08-01)
Found by running the Shizuku shell tier for the first time. The tier works — `tiers.shizuku: true`,
`exec_path: UserService` (so the UserService binds on the OnePlus, as recorded), `runs_as
shell(2000)`, 7/7 commands — and the run promptly uploaded **every MAC address on the local
network** to fmr at the `balanced` level: router, phones, whatever else was on the wifi. Fourteen
of them.
The probes embed raw command output verbatim (`ip neigh`, `ip route`, `id`), which is genuinely
good evidence and also a complete household device inventory. The anonymizer could not see it:
classification is by field name and by whole-value shape, and `ip_neigh` is one long string that is
itself neither a MAC nor an address. measurement-schema.md §9 item 2 had flagged raw dumps as "hard
to anonymize" and proposed dropping them from exports; nothing enforced either.
**Scrubbing beats dropping.** Identifiers inside any unclassified string are now replaced in place,
using the same pseudonyms as everywhere else — so a MAC that appears both in a parsed field and in
a raw dump still reads as one device. The dump stays readable and auditable: you can still see the
neighbour table's shape, the host count, RFC1918 addresses and vendor prefixes. Dropping the
evidence would have protected the same data while destroying the reason for collecting it.
Two implementation notes worth keeping:
- **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.
- The patterns are conservative on purpose. A missed address gets caught by another rule or not at
all; an over-eager one mangles timestamps and version strings, corrupting evidence to protect
nothing.
`RealDocumentTest` runs the anonymizer over a captured run when `ECHOLOT_REAL_RUN` points at one,
and fails on any MAC that survives. It self-skips otherwise, so no one's network is committed to the
repo. Against the actual leaked document: **14 MACs in, 0 surviving.**
Also fixed: the Settings *Preview what an upload would send* button did nothing. It read
`UiState.history`, which is empty until the History screen has been opened — the same root cause as
the "0 run(s)" count. It now reads the archive directly, and says so when there is nothing to
preview rather than silently ignoring the tap.
### Security: the admin listener was publicly exposed for ~15 minutes (2026-08-01)
Moving the admin listener to `[::2]:443` for the UI exposed `/admin/enroll-tokens` and
`/admin/selftest` to the internet **with no authentication**. Anyone who could reach
`fmr.echo-lot.app` could mint enrolment tokens.
The listener was designed localhost-only — its own flag help says *"keep localhost"* — and that
assumption travelled with it when the address changed. The compounding error: `checkAdminExposure`,
added the same day, verifies **encryption** and says nothing about **authentication**. It passed,
and a green light on an adjacent property is worse than no check, because it invites you to stop
looking.
Closed by returning to loopback (the TLS and ACME work is retained, just not exposed). All 68 device
enrolments matched the timestamps of test runs, so there is no evidence of abuse — but the window
existed on a freshly published hostname and absence cannot be proven. 39 unused enrolment tokens
were purged, since any could have been minted by someone else and they cost nothing to replace, and
63 test devices removed.
**The admin listener does not become reachable again until it authenticates.** That reorders the UI
work: auth on the listener first, everything else after.
### Open: encrypted uploads, where the operator cannot read the data
Not built. Recorded because the shape is decided by a few early choices, and the current design
happens to leave the door open.
The goal: hand someone an account, let them upload, and be unable to read what they uploaded.
Sketch: a random per-account **master key**, generated on the first device and wrapped under a
key derived from a passphrase (PBKDF2-HMAC-SHA256 — stdlib on both sides). The wrapped key is
stored server-side as an opaque blob, so a new device signs in, fetches it, and unwraps locally;
the server never sees either key. Runs are encrypted client-side with AES-256-GCM, fresh nonce per
run. All of this is stdlib in Go and `javax.crypto` in Kotlin — no dependency either side.
Four consequences that decide whether it is worth it:
1. **What stays readable determines what the UI can do.** The server builds its index by *parsing*
the document — verdict, finding count, started_at. An opaque payload means the client supplies
that metadata or the index disappears, and with it retention-by-verdict and any "runs with
findings" view. The honest version supplies only run id, timestamp and size, and moves the rest
client-side.
2. **Lose the passphrase, lose the data.** That is the feature working, and also the support
burden. It needs a recovery code printed at setup, not a reset flow — there is nothing to reset.
3. **Metadata is not hidden.** The operator still sees which account uploaded, when, how often and
how large. "Cannot see it" is about content, not existence, and saying otherwise would oversell.
4. **It makes `min_anonymization` unenforceable** — a server cannot check a level it cannot read.
That is not a conflict so much as a redundancy: the anonymization floor exists to protect the
user from the operator, and encryption does that better. The two should not both be demanded of
one upload.
What keeps this possible: uploads are already stored byte-for-byte as received, and every index
field is derived in one function (`runs.Put`). The thing to avoid is admin features that *require*
reading content — those would have to be unbuilt later.
+106
View File
@@ -0,0 +1,106 @@
<!--
SPDX-FileCopyrightText: 2026 Echolot contributors
SPDX-License-Identifier: CC-BY-4.0
-->
# Echolot findings registry
Closes open item 1 of `measurement-schema.md` §9.
A **finding code** is the stable, machine-readable half of a result. The prose around it changes
freely; the code is what a dashboard groups by, what a diff between two runs keys on, and what
someone greps a year of archived runs for. That only works if a code means exactly one thing,
forever.
This document is the contract. It is kept in step with
`echolot-app/core-measurement/.../FindingRegistry.kt` by a test that fails when either side has a
code the other does not — a registry that drifts from its documentation is worse than none,
because it looks authoritative.
## Rules
1. **The prefix determines the category**, and the category determines which verdict light the
finding rolls up into (§7.3). A `nat.*` code appearing under *connectivity* is not a naming
quibble; it changes which light turns red. Two codes were renamed from `nat.*` to
`connectivity.*` for exactly this reason.
2. **One code per concept.** Two emitters independently produced `connectivity.downstream_loss`
and `connectivity.loss_downstream` for the same claim before this registry existed. Anyone
aggregating either would have silently seen half their data.
3. **Codes are declared, not typed.** Emitters reference a `FindingSpec`, so a typo is a compile
error and no two call sites can disagree about a finding's category or default severity.
4. **Severity in the registry is the default.** An emitter may escalate for a specific run; it may
not quietly reclassify the finding in general.
5. **Say what is ruled out**, where that is the useful half. "Loss upstream" is worth far more
when it also states that the return path is clean, because that halves where to look next.
6. **Renaming a code is a breaking change** once runs are archived at scale. Before 1.0 it is
cheap; after, it needs an alias and a deprecation window.
## Registry
### connectivity
| code | severity | means | rules out |
|---|---|---|---|
| `connectivity.udp_unreachable` | high | No UDP echo replies came back from the server at all. | — |
| `connectivity.udp_unreachable_upstream` | high | The server received none of the probes, so traffic is dropped on the way out. | The return path: nothing arrived to be replied to. |
| `connectivity.udp_loss` | medium | A large fraction of round-trip probes were lost, direction unknown. | — |
| `connectivity.loss_upstream` | medium | Probes were lost on the way to the server. | The return path: replies came back for everything that arrived. |
| `connectivity.loss_downstream` | medium | Packets were lost on the way back from the server. | The outbound path: the server received what it was answering. |
| `connectivity.downstream_blocked` | high | Server-initiated packets never arrive, although round trips work. | Basic reachability: the path forwards replies, just not unsolicited traffic. |
| `connectivity.downstream_reorder` | low | Downstream packets arrive in a different order than they were sent. | — |
| `connectivity.captive_portal` | medium | A captive portal is intercepting connectivity checks. | — |
| `connectivity.no_internet` | high | Android's own connectivity checks fail on this network. | — |
### mtu
| code | severity | means | rules out |
|---|---|---|---|
| `mtu.reduced_downstream` | low | The downstream path MTU is below the usual 1500 bytes. | — |
| `mtu.downstream_blackhole` | medium | Datagrams above the path MTU are dropped downstream, fragmented or not. | — |
| `mtu.fragments_blocked` | medium | IP fragments do not reach this device even when sent in order. | — |
| `mtu.fragment_reorder_sensitive` | low | Fragments are delivered in order but dropped when reordered or delayed. | Fragmentation itself: in-order fragments arrive fine. |
### nat
| code | severity | means | rules out |
|---|---|---|---|
| `nat.udp_rebinding` | medium | A NAT remapped the UDP source port mid-flow. | — |
| `nat.symmetric` | medium | The NAT assigns a different external port per destination. | — |
### perf
| code | severity | means | rules out |
|---|---|---|---|
| `perf.throughput_no_delivery` | high | No throughput traffic arrived, although the server sent it. | — |
| `perf.throughput_below_offered` | low | Less throughput arrived than the server sent for the whole run. | — |
### dns
| code | severity | means | rules out |
|---|---|---|---|
| `dns.answer_rewritten` | high | A resolver returned an answer that differs from the authoritative record. | — |
| `dns.authoritative_unreachable` | medium | The canary zone's authoritative server could not be reached. | — |
### v6
The prefix is `v6.`, matching the test-type registry (`v6.brokenness`, `v6.happy_eyeballs`, …).
These were `ipv6.*` while declaring `Category.IPV6`; since the prefix map only knows `v6`, they
rolled up under *connectivity* instead — the third occurrence of rule 1 being broken.
| code | severity | means | rules out |
|---|---|---|---|
| `v6.broken` | medium | IPv6 is configured on this network but does not work. | Absence of IPv6: it is provisioned, it simply fails. |
| `v6.not_offered` | info | This network does not offer IPv6. | — |
`v6.not_offered` is **info and must stay info**. Most networks still do not offer IPv6 and that is
not a fault; reporting it as a warning lights a yellow verdict on a healthy network, which teaches
people to ignore the light — the one thing a diagnostic must never do.
## Adding a finding
1. Add a `FindingSpec` to `FindingRegistry`, and to its `all` list.
2. Add the row here, under the section its prefix names.
3. Emit it with `finding(FindingRegistry.YOUR_CODE, …)`.
The registry test checks 1 and 2 agree, that every prefix maps to the category it claims, and that
no two entries share a code.
+3 -2
View File
@@ -269,7 +269,7 @@ The JSON Schema (machine-readable companion, `measurement.schema.json`, generate
| type | example fields | v2 anonymizer transform | | type | example fields | v2 anonymizer transform |
|---|---|---| |---|---|---|
| `ip4`, `ip6` | addresses, routes, hops, DNS answers | prefix-preserving pseudonymization, consistent per document; well-known/reserved ranges kept verbatim | | `ip4`, `ip6` | addresses, routes, hops, DNS answers | prefix-preserving pseudonymization, consistent per document; well-known/reserved ranges kept verbatim. **Exception: ULA (`fc00::/7`) has its whole prefix pseudonymized as a unit.** It resembles RFC1918 but is not analogous: a ULA global ID is 40 random bits, unique to one network by construction (RFC 4193), so the prefix *is* the identifier, whereas `192.168.0.0/16` is shared by millions of networks and identifies none. Pseudonymizing it as a unit keeps "these hosts are on one subnet" while dropping "this is that subnet". |
| `mac`, `bssid` | wifi, arp_watch | OUI kept, NIC part pseudonymized | | `mac`, `bssid` | wifi, arp_watch | OUI kept, NIC part pseudonymized |
| `fqdn` | DNS names, reverse lookups | per-label pseudonyms, public-suffix kept | | `fqdn` | DNS names, reverse lookups | per-label pseudonyms, public-suffix kept |
| `ssid` | wifi | pseudonym | | `ssid` | wifi | pseudonym |
@@ -279,7 +279,8 @@ Free-text fields (`notes`, `error.detail`, dump excerpts from Shizuku parsers) c
## 9. Open items ## 9. Open items
1. Findings registry document — start alongside the first implemented tests. 1. ~~Findings registry document~~ — done: `findings-registry.md`, kept in step with
`FindingRegistry.kt` by a test that fails when the two disagree.
2. Whether Shizuku raw-dump excerpts (dumpsys/ip output) are embedded in `evidence` verbatim (auditable, but large and hard to anonymize) or parsed-only with an optional "attach raw dumps" toggle. Proposal: toggle, default on for local archive, default off for export. 2. Whether Shizuku raw-dump excerpts (dumpsys/ip output) are embedded in `evidence` verbatim (auditable, but large and hard to anonymize) or parsed-only with an optional "attach raw dumps" toggle. Proposal: toggle, default on for local archive, default off for export.
3. Peer-mode documents: each device produces its own run; the coordinator embeds the peer's findings summary and cross-references by `run.id`. Full merge format deferred. 3. Peer-mode documents: each device produces its own run; the coordinator embeds the peer's findings summary and cross-references by `run.id`. Full merge format deferred.
4. Size guardrails: soft cap 20 MB uncompressed per run; trains beyond that downsample evidence (keep aggregates + first/last N + all anomalies) and record `"evidence_truncated": true`. 4. Size guardrails: soft cap 20 MB uncompressed per run; trains beyond that downsample evidence (keep aggregates + first/last N + all anomalies) and record `"evidence_truncated": true`.
+26 -3
View File
@@ -24,11 +24,34 @@ echolot://enroll?v=1&u=<control-URL, urlencoded>&p=pin-sha256:<b64 SPKI hash>&t=
``` ```
POST /v1/enroll Authorization: Bearer <enrollment-token> POST /v1/enroll Authorization: Bearer <enrollment-token>
→ 200 { "device_credential": "<random 256-bit, b64url>", → 201 { "device_credential": "<random 256-bit, b64url>",
"device_id": "uuid", "device_id": "uuid" }
"profile": { ... §2.2 ... } }
``` ```
The **server assembles the bootstrap link**, because it is the only party holding 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, and surfaces later as an inscrutable TLS error:
```
POST /admin/enroll-tokens
→ { "token": "…", "expires_in_s": 86400,
"enroll_uri": "echolot://enroll?v=1&u=…&p=…&t=…" }
```
The control URL in the link comes from `ECHOLOT_PUBLIC_URL`, falling back to the first control
listen address. A wildcard bind has no single right answer, so it warns rather than guessing.
Encoding notes that matter in practice:
- `u`, `p` and `t` are **percent-encoded**. The pin is base64, so it contains `+`, `/` and `=`,
every one of which means something else in a query string.
- A `+` that was *not* encoded decodes to a space. Base64 contains no spaces, so a parser SHOULD
restore them — the alternative is a pin wrong by one character and a failure that points nowhere
near the cause.
- The control URL MUST be `https://`. The pin only protects a TLS connection; a cleartext URL
would hand the token to anyone on the path.
- **The link is a secret** while it is live: it carries a bearer token, so anyone who sees it
before the device does can enroll instead.
Enrollment tokens are single-use with expiry, created in the admin UI, scoped `enroll`. The device credential is a long-lived bearer secret, scoped `run-tests`; it is also the HKDF input for session keys. Revocation = deleting the device in the admin UI. Enrollment tokens are single-use with expiry, created in the admin UI, scoped `enroll`. The device credential is a long-lived bearer secret, scoped `run-tests`; it is also the HKDF input for session keys. Revocation = deleting the device in the admin UI.
### 2.2 Profile ### 2.2 Profile
@@ -4,6 +4,7 @@
package app.echo_lot.app package app.echo_lot.app
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -39,7 +40,7 @@ fun HistoryScreen(
onDelete: (String) -> Unit, onDelete: (String) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.fillMaxWidth().safeDrawingPadding().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onBack) { Text(" Back") } TextButton(onClick = onBack) { Text(" Back") }
Text("History", style = MaterialTheme.typography.titleLarge) Text("History", style = MaterialTheme.typography.titleLarge)
@@ -71,12 +72,20 @@ fun HistoryScreen(
) )
} }
Text( Text(
"${r.findingCount} finding(s) · ${r.sizeBytes / 1024} kB · ${r.anonymization}", "${r.findingCount} finding(s) · ${r.sizeBytes / 1024} kB · " +
"kept complete on this device",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
// The upload line names the level the upload was made at, not the
// archive's. They describe different documents, and showing the archive's
// level here claimed more had left the device than actually did.
Text( Text(
if (r.uploaded) "uploaded to ${r.uploadedTo ?: "a server"}" if (r.uploaded) {
else "on this device only", "uploaded to ${r.uploadedTo ?: "a server"}" +
(r.uploadedAs?.let { " as $it" } ?: "")
} else {
"on this device only"
},
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (r.uploaded) Color(0xFF7FD17F) else Color(0xFFBBBBBB), color = if (r.uploaded) Color(0xFF7FD17F) else Color(0xFFBBBBBB),
) )
@@ -85,19 +85,26 @@ class MainActivity : ComponentActivity() {
finish() finish()
} }
} }
// Without this, the system Back gesture leaves the activity from Settings or
// History instead of returning to the run screen — the screen is a plain state
// variable, so nothing connects it to the back stack. Registered only when
// there is somewhere to go back to, so Back still exits from the run screen.
androidx.activity.compose.BackHandler(enabled = screen != Screen.RUN) {
screen = Screen.RUN
}
when (screen) { when (screen) {
Screen.SETTINGS -> SettingsScreen( Screen.SETTINGS -> SettingsScreen(
settings = vm.settings, settings = vm.settings,
archivedRuns = vm.state.history.size, archivedRuns = vm.archivedRunCount(),
archivedBytes = vm.archivedBytes(), archivedBytes = vm.archivedBytes(),
onApplyRetention = vm::applyRetention, onApplyRetention = vm::applyRetention,
onDeleteAll = vm::deleteAllRuns, onDeleteAll = vm::deleteAllRuns,
onPreviewUpload = { onPreviewUpload = {
// Preview the newest run, since that is the one the user just made // Straight from the archive: the newest run is the one the user just
// and the one they are deciding about. // made and the one they are deciding about. Always shows something,
vm.state.history.firstOrNull()?.let { r -> // even when there is nothing to preview yet.
lifecycleScope.launch { preview = vm.uploadPreview(r.id) } lifecycleScope.launch { preview = vm.previewNewestRun() }
}
}, },
onCheckServer = vm::checkServer, onCheckServer = vm::checkServer,
onEnroll = vm::enroll, onEnroll = vm::enroll,
@@ -164,8 +164,10 @@ class RunStore(context: Context, private val settings: Settings) {
) )
val body = redactedForUpload(docJson, level) val body = redactedForUpload(docJson, level)
val reply = client.uploadRun(settings.serverCredential, body) val reply = client.uploadRun(settings.serverCredential, body)
archive.markUploaded(runId, profile.name) archive.markUploaded(runId, profile.name, level.wire)
UploadOutcome.Sent(profile.name, "as $level, ${body.toByteArray().size} bytes: ${reply.take(120)}") // Deliberately not echoing `reply`: it is the server's index entry as raw JSON, and
// it ended up rendered verbatim in the UI. Size and level are what a person wants.
UploadOutcome.Sent(profile.name, "as $level, ${body.toByteArray().size} bytes")
} catch (e: VersionRefused) { } catch (e: VersionRefused) {
UploadOutcome.Incompatible(e.message ?: "the server refused this app's version") UploadOutcome.Incompatible(e.message ?: "the server refused this app's version")
} catch (e: UploadRefused) { } catch (e: UploadRefused) {
@@ -194,8 +194,27 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
store.read(id)?.let { store.redactedForUpload(it) } store.read(id)?.let { store.redactedForUpload(it) }
} }
/**
* Preview of the most recent run, read from the archive rather than from [UiState.history].
*
* The history list is only populated once the History screen has been opened, so a preview
* driven from it did nothing at all on a freshly-opened Settings screen — a button that
* silently does nothing is worse than one that says why.
*/
suspend fun previewNewestRun(): String = withContext(Dispatchers.IO) {
val newest = store.list().firstOrNull()
?: return@withContext "No archived runs yet. Run a measurement first, then this will " +
"show exactly what an upload would send."
store.read(newest.id)?.let { store.redactedForUpload(it) }
?: "That run could not be read back from the archive."
}
fun archivedBytes(): Long = store.totalBytes() fun archivedBytes(): Long = store.totalBytes()
/** Counted from the archive itself, not from [UiState.history], which is empty until the
* history screen has been opened - the two disagreeing read as data loss. */
fun archivedRunCount(): Int = store.list().size
/** Redeems an enrollment link, from a paste or from an echolot:// deep link. */ /** Redeems an enrollment link, from a paste or from an echolot:// deep link. */
fun enroll(link: String, deviceName: String? = android.os.Build.MODEL) { fun enroll(link: String, deviceName: String? = android.os.Build.MODEL) {
viewModelScope.launch { viewModelScope.launch {
@@ -352,8 +371,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
when { when {
ev.contains("\"captive_portal\"") -> out.add( ev.contains("\"captive_portal\"") -> out.add(
Finding( Finding(
id = ids.uuid(), code = "connectivity.captive_portal", category = Category.CONNECTIVITY, id = ids.uuid(), code = FindingRegistry.CAPTIVE_PORTAL.code,
severity = Severity.MEDIUM, confidence = Confidence.HIGH, category = FindingRegistry.CAPTIVE_PORTAL.category,
severity = FindingRegistry.CAPTIVE_PORTAL.severity, confidence = Confidence.HIGH,
title = "Captive portal intercepting connections", title = "Captive portal intercepting connections",
description = "The generate_204 check returned a redirect or a page instead of HTTP 204 — a captive portal (login/splash page) is intercepting traffic on this network.", description = "The generate_204 check returned a redirect or a page instead of HTTP 204 — a captive portal (login/splash page) is intercepting traffic on this network.",
evidenceRefs = listOf(EvidenceRef(t.id)), evidenceRefs = listOf(EvidenceRef(t.id)),
@@ -361,8 +381,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
) )
t.status == TestStatus.FAILED -> out.add( t.status == TestStatus.FAILED -> out.add(
Finding( Finding(
id = ids.uuid(), code = "connectivity.no_internet", category = Category.CONNECTIVITY, id = ids.uuid(), code = FindingRegistry.NO_INTERNET.code,
severity = Severity.HIGH, confidence = Confidence.HIGH, category = FindingRegistry.NO_INTERNET.category,
severity = FindingRegistry.NO_INTERNET.severity, confidence = Confidence.HIGH,
title = "No working internet on any network", title = "No working internet on any network",
description = "Android's own generate_204 connectivity checks failed on every active network (no HTTP 204) — this device has no validated internet path.", description = "Android's own generate_204 connectivity checks failed on every active network (no HTTP 204) — this device has no validated internet path.",
evidenceRefs = listOf(EvidenceRef(t.id)), evidenceRefs = listOf(EvidenceRef(t.id)),
@@ -375,8 +396,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
if (ev.contains("MISMATCH")) { if (ev.contains("MISMATCH")) {
out.add( out.add(
Finding( Finding(
id = ids.uuid(), code = "dns.answer_rewritten", category = Category.DNS, id = ids.uuid(), code = FindingRegistry.DNS_ANSWER_REWRITTEN.code,
severity = Severity.HIGH, confidence = Confidence.HIGH, category = FindingRegistry.DNS_ANSWER_REWRITTEN.category,
severity = FindingRegistry.DNS_ANSWER_REWRITTEN.severity, confidence = Confidence.HIGH,
title = "DNS answers are being rewritten", title = "DNS answers are being rewritten",
description = "A canary reference record returned different RDATA than the spec-defined ground truth — something on the path is rewriting DNS answers (interception, filtering, or a middlebox).", description = "A canary reference record returned different RDATA than the spec-defined ground truth — something on the path is rewriting DNS answers (interception, filtering, or a middlebox).",
evidenceRefs = listOf(EvidenceRef(t.id)), evidenceRefs = listOf(EvidenceRef(t.id)),
@@ -385,8 +407,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
} else if (ev.contains("\"reached_authoritative\":false")) { } else if (ev.contains("\"reached_authoritative\":false")) {
out.add( out.add(
Finding( Finding(
id = ids.uuid(), code = "dns.authoritative_unreachable", category = Category.DNS, id = ids.uuid(), code = FindingRegistry.DNS_AUTHORITATIVE_UNREACHABLE.code,
severity = Severity.MEDIUM, confidence = Confidence.MEDIUM, category = FindingRegistry.DNS_AUTHORITATIVE_UNREACHABLE.category,
severity = FindingRegistry.DNS_AUTHORITATIVE_UNREACHABLE.severity, confidence = Confidence.MEDIUM,
title = "Canary queries don't reach the authoritative server", title = "Canary queries don't reach the authoritative server",
description = "A per-run nonce name (which cannot be cached) was not answered by the canary server — the resolver is intercepting or failing to reach it.", description = "A per-run nonce name (which cannot be cached) was not answered by the canary server — the resolver is intercepting or failing to reach it.",
evidenceRefs = listOf(EvidenceRef(t.id)), evidenceRefs = listOf(EvidenceRef(t.id)),
@@ -399,8 +422,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
if (ev.contains("address/port-dependent (symmetric NAT")) { if (ev.contains("address/port-dependent (symmetric NAT")) {
out.add( out.add(
Finding( Finding(
id = ids.uuid(), code = "nat.symmetric", category = Category.NAT, id = ids.uuid(), code = FindingRegistry.NAT_SYMMETRIC.code,
severity = Severity.MEDIUM, confidence = Confidence.HIGH, category = FindingRegistry.NAT_SYMMETRIC.category,
severity = FindingRegistry.NAT_SYMMETRIC.severity, confidence = Confidence.HIGH,
title = "Symmetric NAT — peer-to-peer connections need a relay", title = "Symmetric NAT — peer-to-peer connections need a relay",
description = "The NAT assigns a different external port per destination (address/port-dependent mapping). Direct peer-to-peer connections (calls, games, file transfer) will usually fail and fall back to relays.", description = "The NAT assigns a different external port per destination (address/port-dependent mapping). Direct peer-to-peer connections (calls, games, file transfer) will usually fail and fall back to relays.",
evidenceRefs = listOf(EvidenceRef(t.id)), evidenceRefs = listOf(EvidenceRef(t.id)),
@@ -417,8 +441,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
if (ipv6Provisioned(networks)) { if (ipv6Provisioned(networks)) {
out.add( out.add(
Finding( Finding(
id = ids.uuid(), code = "ipv6.broken", category = Category.IPV6, id = ids.uuid(), code = FindingRegistry.V6_BROKEN.code,
severity = Severity.MEDIUM, confidence = Confidence.HIGH, category = FindingRegistry.V6_BROKEN.category,
severity = FindingRegistry.V6_BROKEN.severity, confidence = Confidence.HIGH,
title = "IPv6 is configured but not working", title = "IPv6 is configured but not working",
description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 got no reply on any network. Half-configured IPv6 is worse than none: connections try IPv6 first and stall before falling back.", description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 got no reply on any network. Half-configured IPv6 is worse than none: connections try IPv6 first and stall before falling back.",
evidenceRefs = listOf(EvidenceRef(t.id)), evidenceRefs = listOf(EvidenceRef(t.id)),
@@ -427,8 +452,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
} else { } else {
out.add( out.add(
Finding( Finding(
id = ids.uuid(), code = "ipv6.not_offered", category = Category.IPV6, id = ids.uuid(), code = FindingRegistry.V6_NOT_OFFERED.code,
severity = Severity.INFO, confidence = Confidence.HIGH, category = FindingRegistry.V6_NOT_OFFERED.category,
severity = FindingRegistry.V6_NOT_OFFERED.severity, confidence = Confidence.HIGH,
title = "IPv4-only network (no IPv6 offered)", title = "IPv4-only network (no IPv6 offered)",
description = "No IPv6 address or default route was provisioned, so IPv6 tests could not run. This is normal — many networks are still IPv4-only and it is not a fault.", description = "No IPv6 address or default route was provisioned, so IPv6 tests could not run. This is normal — many networks are still IPv4-only and it is not a fault.",
evidenceRefs = listOf(EvidenceRef(t.id)), evidenceRefs = listOf(EvidenceRef(t.id)),
@@ -4,6 +4,7 @@
package app.echo_lot.app package app.echo_lot.app
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -15,6 +16,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
@@ -66,7 +68,7 @@ fun SettingsScreen(
var serverCred by remember { mutableStateOf(settings.serverCredential) } var serverCred by remember { mutableStateOf(settings.serverCredential) }
Column( Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp), Modifier.fillMaxWidth().safeDrawingPadding().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -129,12 +131,21 @@ fun SettingsScreen(
} }
Text(privacyExplanation(privacy), style = MaterialTheme.typography.bodySmall) Text(privacyExplanation(privacy), style = MaterialTheme.typography.bodySmall)
// At FULL nothing is pseudonymized, so a salt has nothing to act on. Shown
// disabled rather than hidden: the setting is still stored and still applies the
// moment the level changes, and a control that vanishes hides that fact.
Toggle( Toggle(
label = "Stable pseudonyms across runs", label = "Stable pseudonyms across runs",
detail = "Lets you compare uploaded runs over time (same SSID reads the same " + detail = if (privacy == PrivacyLevel.FULL) {
"each time). It also links your uploads together, so leave it off on a " + "Not used at this level — nothing is pseudonymized, so there is nothing " +
"server you don't run yourself.", "to keep stable. Choose balanced or strict to use this."
checked = stableSalt, } else {
"Lets you compare uploaded runs over time (same SSID reads the same " +
"each time). It also links your uploads together, so leave it off on " +
"a server you don't run yourself."
},
checked = stableSalt && privacy != PrivacyLevel.FULL,
enabled = privacy != PrivacyLevel.FULL,
) { stableSalt = it; settings.stableSalt = it } ) { stableSalt = it; settings.stableSalt = it }
TextButton(onClick = onPreviewUpload) { Text("Preview what an upload would send") } TextButton(onClick = onPreviewUpload) { Text("Preview what an upload would send") }
@@ -236,13 +247,22 @@ private fun privacyExplanation(level: PrivacyLevel): String = when (level) {
} }
@Composable @Composable
private fun Toggle(label: String, detail: String, checked: Boolean, onChange: (Boolean) -> Unit) { private fun Toggle(
label: String,
detail: String,
checked: Boolean,
enabled: Boolean = true,
onChange: (Boolean) -> Unit,
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.bodyMedium) // Dimmed together with the switch, so "this does nothing right now" reads at a glance
Text(detail, style = MaterialTheme.typography.bodySmall) // instead of only on close inspection.
val alpha = if (enabled) 1f else 0.5f
Text(label, style = MaterialTheme.typography.bodyMedium, color = LocalContentColor.current.copy(alpha = alpha))
Text(detail, style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(alpha = alpha))
} }
Switch(checked = checked, onCheckedChange = onChange) Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
} }
} }
@@ -31,10 +31,23 @@ data class ArchivedRun(
val verdict: String? = null, val verdict: String? = null,
@SerialName("finding_count") val findingCount: Int = 0, @SerialName("finding_count") val findingCount: Int = 0,
@SerialName("size_bytes") val sizeBytes: Long = 0, @SerialName("size_bytes") val sizeBytes: Long = 0,
/**
* How the *archived* document is redacted. Always "full" in practice, because the archive
* deliberately keeps the unredacted run - see the package doc. This is not what was uploaded.
*/
val anonymization: String = "full", val anonymization: String = "full",
/** Whether this run has been accepted by a server, so history can show what is backed up. */ /** Whether this run has been accepted by a server, so history can show what is backed up. */
val uploaded: Boolean = false, val uploaded: Boolean = false,
@SerialName("uploaded_to") val uploadedTo: String? = null, @SerialName("uploaded_to") val uploadedTo: String? = null,
/**
* The level the run was *uploaded* at, which is a different document from the archived one.
*
* Kept separately because conflating the two is actively misleading: the history row showed
* the archive's own level ("full") directly beneath "uploaded to fmr", which reads as "the
* complete data was uploaded" when a redacted copy had been sent. A privacy display that
* overstates what left the device is worse than none.
*/
@SerialName("uploaded_as") val uploadedAs: String? = null,
) )
/** /**
@@ -119,7 +132,7 @@ class RunArchive(private val dir: File, private val now: () -> Long = System::cu
fun deleteAll(): Int = list().count { delete(it.id) } fun deleteAll(): Int = list().count { delete(it.id) }
/** Records that a server accepted this run, so history can distinguish backed-up from local. */ /** Records that a server accepted this run, so history can distinguish backed-up from local. */
fun markUploaded(id: String, serverName: String) { fun markUploaded(id: String, serverName: String, uploadedAs: String? = null) {
val f = File(dir, safe(id) + META_EXT) val f = File(dir, safe(id) + META_EXT)
val meta = runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull() val meta = runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
?: return ?: return
@@ -127,7 +140,7 @@ class RunArchive(private val dir: File, private val now: () -> Long = System::cu
f, f,
json.encodeToString( json.encodeToString(
ArchivedRun.serializer(), ArchivedRun.serializer(),
meta.copy(uploaded = true, uploadedTo = serverName), meta.copy(uploaded = true, uploadedTo = serverName, uploadedAs = uploadedAs),
), ),
) )
} }
@@ -181,7 +194,10 @@ class RunArchive(private val dir: File, private val now: () -> Long = System::cu
id = id, id = id,
savedAtEpochMs = now(), savedAtEpochMs = now(),
startedAt = run["started_at"]?.jsonPrimitive?.content, startedAt = run["started_at"]?.jsonPrimitive?.content,
verdict = doc["summary"]?.jsonObject?.get("verdict")?.jsonPrimitive?.content, // The schema calls it `overall` (Summary.overall); reading `verdict` here silently
// yielded null for every run, so the history list's most prominent element - the
// coloured verdict - was blank on every row.
verdict = doc["summary"]?.jsonObject?.get("overall")?.jsonPrimitive?.content,
findingCount = (doc["findings"] as? kotlinx.serialization.json.JsonArray)?.size ?: 0, findingCount = (doc["findings"] as? kotlinx.serialization.json.JsonArray)?.size ?: 0,
sizeBytes = size, sizeBytes = size,
anonymization = run["privacy"]?.jsonObject?.get("anonymization")?.jsonPrimitive?.content ?: "full", anonymization = run["privacy"]?.jsonObject?.get("anonymization")?.jsonPrimitive?.content ?: "full",
@@ -25,7 +25,7 @@ class RunArchiveTest {
private fun doc(id: String, findings: Int = 1, pad: Int = 0): String { private fun doc(id: String, findings: Int = 1, pad: Int = 0): String {
val f = (1..findings).joinToString(",") { """{"id":"f$it"}""" } val f = (1..findings).joinToString(",") { """{"id":"f$it"}""" }
return """{"run":{"id":"$id","started_at":"2026-08-01T10:00:00Z","privacy":{"anonymization":"balanced"}},""" + return """{"run":{"id":"$id","started_at":"2026-08-01T10:00:00Z","privacy":{"anonymization":"balanced"}},""" +
""""findings":[$f],"summary":{"verdict":"warn"},"pad":"${"x".repeat(pad)}"}""" """"findings":[$f],"summary":{"overall":"warn"},"pad":"${"x".repeat(pad)}"}"""
} }
@Test @Test
@@ -120,13 +120,40 @@ class RunArchiveTest {
fun uploadStateIsRecorded() { fun uploadStateIsRecorded() {
val a = archive() val a = archive()
a.save(doc("run-1")) a.save(doc("run-1"))
a.markUploaded("run-1", "fmr") a.markUploaded("run-1", "fmr", "balanced")
val meta = a.list().single() val meta = a.list().single()
assertTrue(meta.uploaded) assertTrue(meta.uploaded)
assertEquals("fmr", meta.uploadedTo) assertEquals("fmr", meta.uploadedTo)
assertEquals("run-1", meta.id, "marking upload must not disturb the rest of the entry") assertEquals("run-1", meta.id, "marking upload must not disturb the rest of the entry")
} }
// The archive's own level and the level a run was uploaded at describe *different documents*.
// Showing the archive's ("full", because the archive is deliberately unredacted) next to
// "uploaded to fmr" reads as "the complete data was uploaded" when a redacted copy was sent —
// a privacy display that overstates what left the device is worse than none.
@Test
fun theUploadedLevelIsRecordedSeparatelyFromTheArchivedOne() {
val a = archive()
// A real archived document carries no privacy stamp: the anonymizer never runs on the
// archive. The shared doc() fixture has one, which is exactly the unrealism that let this
// confusion through in the first place.
a.save("""{"run":{"id":"run-1"},"findings":[],"summary":{"overall":"green"}}""")
a.markUploaded("run-1", "fmr", "balanced")
val meta = a.list().single()
assertEquals("full", meta.anonymization, "the archived copy is unredacted, by design")
assertEquals("balanced", meta.uploadedAs, "the uploaded copy was redacted, and must say so")
}
// The verdict is read from `summary.overall` — the schema's actual field name. Reading
// `summary.verdict` silently yielded null for every run, so the history list's most prominent
// element was blank on every row while everything else looked fine.
@Test
fun theVerdictComesFromTheSchemasOverallField() {
val a = archive()
a.save("""{"run":{"id":"r1"},"findings":[],"summary":{"overall":"yellow"}}""")
assertEquals("yellow", a.list().single().verdict)
}
@Test @Test
fun deleteRemovesBothFiles() { fun deleteRemovesBothFiles() {
val a = archive() val a = archive()
@@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Splits a round-trip train into its two directions using what the server witnessed.
*
* A round trip can only report that *something* was lost somewhere. That is the least useful form
* of the answer: "3 % loss" sends an engineer looking in both directions at once. The server
* records every packet it received, per sequence number (probe-protocol.md §6), so the two cases
* are actually distinguishable:
*
* - sent, never seen by the server → **upstream** loss
* - seen by the server, reply never arrived → **downstream** loss
*
* The same records give one-way delay *variation* per direction. Absolute one-way delay would
* need synchronised clocks and we deliberately have none (measurement-schema.md's two-clock rule),
* but the variation does not: (server_rx client_tx) contains an unknown constant clock offset,
* and differencing successive samples cancels it. So jitter is honestly attributable to a
* direction even though latency is not.
*/
object Directional {
/** One probe as the client saw it. [tRxNs] null means no reply came back. */
data class Sample(val seq: Int, val tTxNs: Long, val tRxNs: Long?)
/** One probe as the server saw it: its own receive and transmit stamps, on its own clock. */
data class ServerSighting(val seq: Int, val tRxNs: Long, val tTxNs: Long)
fun analyse(sent: List<Sample>, seen: List<ServerSighting>): DirectionalMetrics {
val byServerSeq = seen.associateBy { it.seq }
// Only sequences we actually sent count. A server record for a sequence we have no note
// of is not evidence about this train — it is a bug or a stray, and silently folding it
// in would produce loss percentages above 100 or below zero.
val relevant = sent.filter { byServerSeq.containsKey(it.seq) }
val nSent = sent.size
val nSeen = relevant.size
val nReplied = sent.count { it.tRxNs != null }
// A reply can only exist if the request arrived, so downstream loss is measured against
// what the server saw, not against what we sent — otherwise upstream loss is counted twice.
val lostUp = nSent - nSeen
val lostDown = (nSeen - nReplied).coerceAtLeast(0)
val upDeltas = relevant.sortedBy { it.seq }
.map { byServerSeq.getValue(it.seq).tRxNs - it.tTxNs }
val downDeltas = sent.filter { it.tRxNs != null && byServerSeq.containsKey(it.seq) }
.sortedBy { it.seq }
.map { it.tRxNs!! - byServerSeq.getValue(it.seq).tTxNs }
return DirectionalMetrics(
sent = nSent,
seenByServer = nSeen,
repliesReceived = nReplied,
lostUpstream = lostUp,
lostDownstream = lostDown,
lossUpstreamPct = pct(lostUp, nSent),
// Denominator is what reached the server: of the packets that got there, how many
// replies came back.
lossDownstreamPct = pct(lostDown, nSeen),
jitterUpstreamMs = jitterMs(upDeltas),
jitterDownstreamMs = jitterMs(downDeltas),
/** True when the server saw nothing at all, which is a different fault from loss. */
noneReachedServer = nSent > 0 && nSeen == 0,
)
}
/**
* Mean absolute difference between consecutive one-way samples (RFC 3393 IPDV, averaged).
*
* Differencing is what makes this legitimate without synchronised clocks: each sample carries
* the same unknown offset between the two clocks, and the difference cancels it. Fewer than
* two samples yields null rather than zero — "no jitter" and "not enough data to say" are
* different claims and only one of them is true here.
*/
private fun jitterMs(oneWayNs: List<Long>): Double? {
if (oneWayNs.size < 2) return null
val deltas = oneWayNs.zipWithNext { a, b -> kotlin.math.abs(b - a) }
return round2(deltas.average() / 1_000_000.0)
}
private fun pct(part: Int, whole: Int): Double =
if (whole <= 0) 0.0 else round2(part * 100.0 / whole)
private fun round2(v: Double) = Math.round(v * 100.0) / 100.0
}
/** Directional metrics for train.udp_updown; recomputable from the columnar evidence. */
@Serializable
data class DirectionalMetrics(
val sent: Int,
@SerialName("seen_by_server") val seenByServer: Int,
@SerialName("replies_received") val repliesReceived: Int,
@SerialName("lost_upstream") val lostUpstream: Int,
@SerialName("lost_downstream") val lostDownstream: Int,
@SerialName("loss_upstream_pct") val lossUpstreamPct: Double,
@SerialName("loss_downstream_pct") val lossDownstreamPct: Double,
/** One-way delay variation (RFC 3393), per direction. Null when there were too few samples. */
@SerialName("jitter_upstream_ms") val jitterUpstreamMs: Double? = null,
@SerialName("jitter_downstream_ms") val jitterDownstreamMs: Double? = null,
@SerialName("none_reached_server") val noneReachedServer: Boolean = false,
)
@@ -37,6 +37,120 @@ class DownstreamMeasurement(private val ids: IdSource) {
/** How long to wait for a granted burst after the server accepts the action. */ /** How long to wait for a granted burst after the server accepts the action. */
private val collectWindowMs = 4_000L private val collectWindowMs = 4_000L
/**
* Shorter, but long enough to cover the first_last mode's deliberate 250 ms hold plus a
* reassembly. A fragment burst is one datagram: it is here quickly or not at all.
*/
private val fragWindowMs = 1_500L
/**
* Asks the server to send one deliberately-fragmented datagram per ordering, and reports
* which orderings survive the path.
*
* Kernel fragmentation always emits fragments in order, first one first, so an oversized
* datagram can only answer "do fragments get through at all". The interesting fault is about
* ordering: only the *first* fragment carries the UDP ports, so a stateful firewall that has
* not seen it has nothing to match the rest against, and many drop them. That failure is
* invisible to every in-order test and shows up in the field as "large DNS answers fail here"
* or "the tunnel breaks when the MTU drops".
*/
fun fragmentOrdering(
credential: String,
sessionId: String,
control: ControlClient,
probe: ProbeSession,
sessionRef: String,
sizeBytes: Int = 2000,
fragBytes: Int = 576,
): Pair<Test, List<Finding>> {
val testId = ids.uuid()
val started = ids.monoNs()
val delivered = LinkedHashMap<String, Boolean>()
val fragmentCounts = LinkedHashMap<String, Int>()
var unsupported = false
for (mode in FRAG_MODES) {
val reply = runCatching {
control.action(
credential, sessionId,
"""{"action":"frag_send","size_bytes":$sizeBytes,"mode":"$mode","frag_bytes":$fragBytes}""",
)
}
if (reply.isFailure) {
// A server without a raw socket says so; that is a missing capability, not a
// property of the network, and must not be recorded as a failed delivery.
unsupported = true
break
}
parseInt(reply.getOrNull(), "fragments")?.let { fragmentCounts[mode] = it }
// The burst is already on the wire when the action returns (it is sent
// synchronously), so anything that survived is either here or lost.
val got = probe.collectGranted(fragWindowMs).any { it.type == Wire.TYPE_FRAG_DATA }
delivered[mode] = got
}
if (unsupported) {
return Test(
id = testId, type = TestType.MTU_FRAG_ORDERING, sessionRef = sessionRef, tier = Tier.APP,
startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = TestStatus.UNSUPPORTED,
error = TestError("no_raw_socket", "this server cannot craft fragments"),
) to emptyList()
}
val metrics = json.encodeToJsonElement(
FragOrderingMetrics(
sizeBytes = sizeBytes,
fragBytes = fragBytes,
fragmentsPerBurst = fragmentCounts,
deliveredByMode = delivered,
inOrderDelivered = delivered[FRAG_IN_ORDER] == true,
reorderedDelivered = delivered[FRAG_REVERSED] == true,
delayedFirstDelivered = delivered[FRAG_FIRST_LAST] == true,
),
) as JsonObject
val findings = ArrayList<Finding>()
val inOrder = delivered[FRAG_IN_ORDER] == true
val reversed = delivered[FRAG_REVERSED] == true
val firstLast = delivered[FRAG_FIRST_LAST] == true
if (!inOrder) {
findings.add(
finding(
FindingRegistry.FRAGMENTS_BLOCKED, testId,
"IP fragments do not reach this device",
"A fragmented datagram sent in the normal order never arrived. Anything that " +
"relies on fragmentation — large DNS answers over UDP, some VPN traffic — " +
"will fail here rather than slow down.",
),
)
} else if (!reversed || !firstLast) {
// The precise and useful finding: fragments work, but only if they arrive tidily.
val which = buildList {
if (!reversed) add("out of order")
if (!firstLast) add("with the first fragment delayed")
}.joinToString(" or ")
findings.add(
finding(
FindingRegistry.FRAGMENT_REORDER_SENSITIVE, testId,
"Fragments are dropped when they arrive $which",
"In-order fragments are delivered, but the same datagram sent $which is not. " +
"Something on the path only reassembles when the first fragment (the one " +
"carrying the UDP ports) arrives first — typical of a stateful firewall " +
"or NAT. It works until the network reorders, then fails intermittently, " +
"which is the hardest kind of fault to chase.",
),
)
}
return Test(
id = testId, type = TestType.MTU_FRAG_ORDERING, sessionRef = sessionRef, tier = Tier.APP,
startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = if (inOrder) TestStatus.OK else TestStatus.PARTIAL,
metrics = metrics,
) to findings
}
/** /**
* Runs all three against an already-primed session. * Runs all three against an already-primed session.
* *
@@ -66,6 +180,16 @@ class DownstreamMeasurement(private val ids: IdSource) {
tests.add(df.test); tests.add(frag.test); tests.add(train.test) tests.add(df.test); tests.add(frag.test); tests.add(train.test)
// Fragment ordering only makes sense once we know fragments arrive at all; when they do
// not, the ordering variants would all report "not delivered" and read as three faults
// instead of one.
if (frag.largestDelivered != null) {
val (fragTest, fragFindings) =
fragmentOrdering(credential, sessionId, control, probe, sessionRef)
tests.add(fragTest)
findings.addAll(fragFindings)
}
// A downstream MTU below the classic 1500-byte Ethernet payload is worth saying out loud: // A downstream MTU below the classic 1500-byte Ethernet payload is worth saying out loud:
// it is the usual cause of "small requests work, large responses hang". // it is the usual cause of "small requests work, large responses hang".
val pathMtu = df.largestDelivered val pathMtu = df.largestDelivered
@@ -74,7 +198,7 @@ class DownstreamMeasurement(private val ids: IdSource) {
if (ipMtu < 1500) { if (ipMtu < 1500) {
findings.add( findings.add(
finding( finding(
"mtu.reduced_downstream", Category.MTU, Severity.LOW, df.test.id, FindingRegistry.MTU_REDUCED_DOWNSTREAM, df.test.id,
"Downstream path MTU is $ipMtu bytes, below 1500", "Downstream path MTU is $ipMtu bytes, below 1500",
"The largest datagram that reached this device without fragmenting was " + "The largest datagram that reached this device without fragmenting was " +
"$pathMtu bytes of payload ($ipMtu on the wire). Tunnels (PPPoE, VPN, " + "$pathMtu bytes of payload ($ipMtu on the wire). Tunnels (PPPoE, VPN, " +
@@ -89,7 +213,7 @@ class DownstreamMeasurement(private val ids: IdSource) {
if (fragLargest <= pathMtu && sizes.any { it > pathMtu }) { if (fragLargest <= pathMtu && sizes.any { it > pathMtu }) {
findings.add( findings.add(
finding( finding(
"mtu.downstream_blackhole", Category.MTU, Severity.MEDIUM, frag.test.id, FindingRegistry.MTU_DOWNSTREAM_BLACKHOLE, frag.test.id,
"Datagrams above $pathMtu bytes are dropped downstream, fragmented or not", "Datagrams above $pathMtu bytes are dropped downstream, fragmented or not",
"Nothing larger than $pathMtu bytes arrived, even when the network was " + "Nothing larger than $pathMtu bytes arrived, even when the network was " +
"free to fragment it. Traffic that relies on large responses will " + "free to fragment it. Traffic that relies on large responses will " +
@@ -102,7 +226,7 @@ class DownstreamMeasurement(private val ids: IdSource) {
if (train.received == 0) { if (train.received == 0) {
findings.add( findings.add(
finding( finding(
"connectivity.downstream_blocked", Category.CONNECTIVITY, Severity.HIGH, train.test.id, FindingRegistry.DOWNSTREAM_BLOCKED, train.test.id,
"No server-initiated packets arrived", "No server-initiated packets arrived",
"The server sent ${train.sent} packets toward this device and none arrived, " + "The server sent ${train.sent} packets toward this device and none arrived, " +
"while the round-trip echo worked. Something on the path forwards replies " + "while the round-trip echo worked. Something on the path forwards replies " +
@@ -112,7 +236,7 @@ class DownstreamMeasurement(private val ids: IdSource) {
} else if (train.lossPct >= 5.0) { } else if (train.lossPct >= 5.0) {
findings.add( findings.add(
finding( finding(
"connectivity.downstream_loss", Category.CONNECTIVITY, Severity.MEDIUM, train.test.id, FindingRegistry.LOSS_DOWNSTREAM, train.test.id,
"Downstream loss of ${round1(train.lossPct)}%", "Downstream loss of ${round1(train.lossPct)}%",
"${train.sent - train.received} of ${train.sent} packets sent toward this " + "${train.sent - train.received} of ${train.sent} packets sent toward this " +
"device were lost. Downstream loss is invisible to a round-trip test, " + "device were lost. Downstream loss is invisible to a round-trip test, " +
@@ -123,7 +247,7 @@ class DownstreamMeasurement(private val ids: IdSource) {
if (train.reordered > 0) { if (train.reordered > 0) {
findings.add( findings.add(
finding( finding(
"connectivity.downstream_reorder", Category.CONNECTIVITY, Severity.LOW, train.test.id, FindingRegistry.DOWNSTREAM_REORDER, train.test.id,
"${train.reordered} downstream packet(s) arrived out of order", "${train.reordered} downstream packet(s) arrived out of order",
"Packets arrived in a different order than they were sent. Usually per-packet " + "Packets arrived in a different order than they were sent. Usually per-packet " +
"load balancing across links; harmless for most traffic, not for all of it.", "load balancing across links; harmless for most traffic, not for all of it.",
@@ -290,9 +414,17 @@ class DownstreamMeasurement(private val ids: IdSource) {
// ---- helpers ---------------------------------------------------------------------- // ---- helpers ----------------------------------------------------------------------
private fun finding(code: String, cat: Category, sev: Severity, testId: String, title: String, desc: String) = /**
* Builds a finding from a registry entry, which supplies the code, category and severity.
*
* Taking a [FindingSpec] rather than three loose values is the point: a typo becomes a
* compile error, and two call sites cannot disagree about which category a finding belongs
* to - a disagreement that would split one fault across two verdict lights.
*/
private fun finding(spec: FindingSpec, testId: String, title: String, desc: String) =
Finding( Finding(
id = ids.uuid(), code = code, category = cat, severity = sev, confidence = Confidence.HIGH, id = ids.uuid(), code = spec.code, category = spec.category, severity = spec.severity,
confidence = Confidence.HIGH,
title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)), title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)),
) )
@@ -310,6 +442,11 @@ class DownstreamMeasurement(private val ids: IdSource) {
/** IPv4 (20) + UDP (8). The v6 case is 48; reported per-family once v6 sessions land. */ /** IPv4 (20) + UDP (8). The v6 case is 48; reported per-family once v6 sessions land. */
const val IP_UDP_OVERHEAD4 = 28 const val IP_UDP_OVERHEAD4 = 28
const val FRAG_IN_ORDER = "in_order"
const val FRAG_REVERSED = "reversed"
const val FRAG_FIRST_LAST = "first_last"
val FRAG_MODES = listOf(FRAG_IN_ORDER, FRAG_REVERSED, FRAG_FIRST_LAST)
/** Straddles the usual suspects: 1500 Ethernet, 1492 PPPoE, 1400-ish tunnels. */ /** Straddles the usual suspects: 1500 Ethernet, 1492 PPPoE, 1400-ish tunnels. */
val DEFAULT_SIZES = listOf(600, 1200, 1372, 1400, 1450, 1472, 1500, 2000, 4000) val DEFAULT_SIZES = listOf(600, 1200, 1372, 1400, 1450, 1472, 1500, 2000, 4000)
@@ -330,6 +467,18 @@ data class BigSendMetrics(
@SerialName("path_mtu_bytes") val pathMtuBytes: Int? = null, @SerialName("path_mtu_bytes") val pathMtuBytes: Int? = null,
) )
/** Metrics for mtu.frag_ordering. */
@Serializable
data class FragOrderingMetrics(
@SerialName("size_bytes") val sizeBytes: Int,
@SerialName("frag_bytes") val fragBytes: Int,
@SerialName("fragments_per_burst") val fragmentsPerBurst: Map<String, Int>,
@SerialName("delivered_by_mode") val deliveredByMode: Map<String, Boolean>,
@SerialName("in_order_delivered") val inOrderDelivered: Boolean,
@SerialName("reordered_delivered") val reorderedDelivered: Boolean,
@SerialName("delayed_first_delivered") val delayedFirstDelivered: Boolean,
)
/** Metrics for train.udp_downstream. */ /** Metrics for train.udp_downstream. */
@Serializable @Serializable
data class DownTrainMetrics( data class DownTrainMetrics(
@@ -10,8 +10,13 @@ import app.echo_lot.measurement.*
import app.echo_lot.protocol.ControlClient import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.ProbeSession import app.echo_lot.protocol.ProbeSession
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
/** /**
* Runs the server-facing measurements against one target and assembles a [MeasurementDocument]: * Runs the server-facing measurements against one target and assembles a [MeasurementDocument]:
@@ -42,6 +47,14 @@ class ServerMeasurement(
* it is a flag rather than an assumption. * it is a flag rather than an assumption.
*/ */
val downstream: Boolean = true, val downstream: Boolean = true,
/**
* Throughput moves real data — a 5-second run at 50 Mbps is about 30 MB — so it is off
* unless asked for. On a metered mobile connection that is the user's money, and a
* measurement tool that spends it without being told to is not one people keep installed.
*/
val throughput: Boolean = false,
@Suppress("unused") val throughputSeconds: Int = 5,
@Suppress("unused") val throughputKbps: Int = 50_000,
) )
fun run(cfg: Config): MeasurementDocument { fun run(cfg: Config): MeasurementDocument {
@@ -71,7 +84,7 @@ class ServerMeasurement(
// re-primed source is never recorded and every granted send goes to the old, closed port. // re-primed source is never recorded and every granted send goes to the old, closed port.
// Session identity lives on the server; the socket must live as long as it does. // Session identity lives on the server; the socket must live as long as it does.
ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps -> ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps ->
val (test, findings) = echoTrain(cfg, ps, startMono) val (test, findings) = echoTrain(cfg, ps, startMono, control, session.sessionId)
tests.add(test) tests.add(test)
allFindings.addAll(findings) allFindings.addAll(findings)
@@ -84,6 +97,15 @@ class ServerMeasurement(
tests.addAll(dsTests) tests.addAll(dsTests)
allFindings.addAll(dsFindings) allFindings.addAll(dsFindings)
} }
if (cfg.throughput && profile.supports("throughput")) {
val (tpTest, tpFindings) = ThroughputMeasurement(ids).run(
cfg.credential, session.sessionId, control, ps, sessionRef = "sess-1",
durationS = cfg.throughputSeconds, kbps = cfg.throughputKbps,
)
tests.add(tpTest)
allFindings.addAll(tpFindings)
}
} }
control.deleteSession(cfg.credential, session.sessionId) control.deleteSession(cfg.credential, session.sessionId)
@@ -105,6 +127,7 @@ class ServerMeasurement(
private fun echoTrain( private fun echoTrain(
cfg: Config, ps: ProbeSession, startMono: Long, cfg: Config, ps: ProbeSession, startMono: Long,
control: ControlClient? = null, sessionId: String? = null,
): Pair<Test, List<Finding>> { ): Pair<Test, List<Finding>> {
val testId = ids.uuid() val testId = ids.uuid()
val seqs = ArrayList<Int>() val seqs = ArrayList<Int>()
@@ -114,9 +137,15 @@ class ServerMeasurement(
val rtts = ArrayList<Double>() val rtts = ArrayList<Double>()
val observedPorts = LinkedHashSet<Int>() val observedPorts = LinkedHashSet<Int>()
// Wire sequence numbers, kept so the server's observations can be correlated packet by
// packet. They are not 0..n-1: the counter is shared with every other packet type on the
// session, so "the nth echo" is not "sequence n".
val wireSeqs = ArrayList<Int>()
for (i in 0 until cfg.echoCount) { for (i in 0 until cfg.echoCount) {
val txMono = ids.monoNs() - startMono val txMono = ids.monoNs() - startMono
val r = ps.echo(cfg.echoPaddingBytes) val r = ps.echo(cfg.echoPaddingBytes)
wireSeqs.add(ps.lastSeq)
seqs.add(i) seqs.add(i)
tTx.add(txMono) tTx.add(txMono)
sizes.add(Wire_HEADER + cfg.echoPaddingBytes) sizes.add(Wire_HEADER + cfg.echoPaddingBytes)
@@ -129,6 +158,20 @@ class ServerMeasurement(
} }
} }
// Ask the server what it actually received. This is what turns "3 % loss somewhere" into
// "3 % loss upstream" - the least useful form of the answer into a usable one.
val directional: DirectionalMetrics? =
if (control != null && sessionId != null) {
runCatching {
val samples = wireSeqs.indices.map {
Directional.Sample(wireSeqs[it], tTx[it] ?: 0L, tRx[it])
}
Directional.analyse(samples, serverSightings(control, cfg, sessionId))
}.getOrNull() // an older server without the endpoint simply yields no split
} else {
null
}
val sent = cfg.echoCount val sent = cfg.echoCount
val received = rtts.size val received = rtts.size
val lossPct = if (sent == 0) 0.0 else (sent - received) * 100.0 / sent val lossPct = if (sent == 0) 0.0 else (sent - received) * 100.0 / sent
@@ -138,6 +181,9 @@ class ServerMeasurement(
epochMonoNs = startMono, seq = seqs, tTxNs = tTx, tRxNs = tRx, sizeBytes = sizes, epochMonoNs = startMono, seq = seqs, tTxNs = tTx, tRxNs = tRx, sizeBytes = sizes,
).toEvidence() ).toEvidence()
val directionalJson = directional?.let {
json.encodeToJsonElement(DirectionalMetrics.serializer(), it) as JsonObject
}
val metrics: JsonObject = json.encodeToJsonElement( val metrics: JsonObject = json.encodeToJsonElement(
EchoMetrics( EchoMetrics(
sent = sent, received = received, lossPct = round1(lossPct), sent = sent, received = received, lossPct = round1(lossPct),
@@ -147,7 +193,7 @@ class ServerMeasurement(
observedPorts = observedPorts.toList(), observedPorts = observedPorts.toList(),
natRebindingDetected = natRebinding, natRebindingDetected = natRebinding,
) )
) as JsonObject ).let { base -> JsonObject((base as JsonObject) + (directionalJson ?: JsonObject(emptyMap()))) }
val status = when { val status = when {
received == 0 -> TestStatus.FAILED received == 0 -> TestStatus.FAILED
@@ -162,30 +208,91 @@ class ServerMeasurement(
val findings = ArrayList<Finding>() val findings = ArrayList<Finding>()
if (received == 0) { if (received == 0) {
findings.add(finding("nat.udp_unreachable", Category.CONNECTIVITY, Severity.HIGH, testId, findings.add(finding(FindingRegistry.UDP_UNREACHABLE, testId,
"No UDP echo replies from the server", "No UDP echo replies from the server",
"Every ECHO probe to the server's UDP data plane was lost — the path blocks or drops the session's UDP traffic.")) "Every ECHO probe to the server's UDP data plane was lost — the path blocks or drops the session's UDP traffic."))
} else if (lossPct >= 20.0) { } else if (lossPct >= 20.0) {
findings.add(finding("connectivity.udp_loss", Category.CONNECTIVITY, Severity.MEDIUM, testId, findings.add(finding(FindingRegistry.UDP_LOSS, testId,
"High UDP loss to the server (${round1(lossPct)}%)", "High UDP loss to the server (${round1(lossPct)}%)",
"A large fraction of ECHO probes were lost, indicating an unreliable UDP path.")) "A large fraction of ECHO probes were lost, indicating an unreliable UDP path."))
} }
// Naming the direction is the entire value of the split, so the findings do.
directional?.let { d ->
when {
d.noneReachedServer && received == 0 -> findings.add(
finding(FindingRegistry.UDP_UNREACHABLE_UPSTREAM, testId,
"Nothing reached the server",
"The server received none of the ${d.sent} probes, so the traffic is being " +
"dropped on the way out, not on the way back. A firewall or NAT on " +
"this side of the path is the place to look."),
)
d.lossUpstreamPct >= 2.0 -> findings.add(
finding(FindingRegistry.LOSS_UPSTREAM, testId,
"${d.lossUpstreamPct} % of probes were lost on the way to the server",
"${d.lostUpstream} of ${d.sent} probes never reached the server. The " +
"return path is not implicated: replies came back for everything that " +
"arrived."),
)
}
if (d.lossDownstreamPct >= 2.0) {
findings.add(
finding(FindingRegistry.LOSS_DOWNSTREAM, testId,
"${d.lossDownstreamPct} % of replies were lost on the way back",
"The server received ${d.seenByServer} probes and answered them, but " +
"${d.lostDownstream} of those replies never arrived. The outbound path " +
"is fine; the fault is on the return leg."),
)
}
}
if (natRebinding) { if (natRebinding) {
findings.add(finding("nat.udp_rebinding", Category.NAT, Severity.MEDIUM, testId, findings.add(finding(FindingRegistry.NAT_UDP_REBINDING, testId,
"NAT remapped the UDP source port mid-flow", "NAT remapped the UDP source port mid-flow",
"The server observed more than one source port for this session (${observedPorts.joinToString()}), i.e. a NAT with a short UDP mapping or per-packet remapping.")) "The server observed more than one source port for this session (${observedPorts.joinToString()}), i.e. a NAT with a short UDP mapping or per-packet remapping."))
} }
return test to findings return test to findings
} }
private fun finding(code: String, cat: Category, sev: Severity, testId: String, title: String, desc: String) = /**
* The server's per-packet record of this session's echoes (spec section 6). Filtered to
* ECHO_REQ, because the observation list also holds MTU probes and anything else we sent -
* counting those as train packets would invent loss that is not there.
*/
private fun serverSightings(
control: ControlClient, cfg: Config, sessionId: String,
): List<Directional.ServerSighting> {
val body = control.observations(cfg.credential, sessionId)
val packets = Json.parseToJsonElement(body).jsonObject["udp"]
?.jsonObject?.get("packets") as? JsonArray ?: return emptyList()
return packets.mapNotNull { el ->
val o = el as? JsonObject ?: return@mapNotNull null
val type = o["type"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null
if (type != ECHO_REQ_TYPE) return@mapNotNull null
Directional.ServerSighting(
seq = o["seq"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null,
tRxNs = o["t_rx_ns"]?.jsonPrimitive?.longOrNull ?: return@mapNotNull null,
tTxNs = o["t_tx_ns"]?.jsonPrimitive?.longOrNull ?: return@mapNotNull null,
)
}
}
/**
* Builds a finding from a registry entry, which supplies the code, category and severity.
*
* Taking a [FindingSpec] rather than three loose values is the point: a typo becomes a
* compile error, and two call sites cannot disagree about which category a finding belongs
* to - a disagreement that would split one fault across two verdict lights.
*/
private fun finding(spec: FindingSpec, testId: String, title: String, desc: String) =
Finding( Finding(
id = ids.uuid(), code = code, category = cat, severity = sev, confidence = Confidence.HIGH, id = ids.uuid(), code = spec.code, category = spec.category, severity = spec.severity,
confidence = Confidence.HIGH,
title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)), title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)),
) )
private companion object { private companion object {
const val Wire_HEADER = 32 const val Wire_HEADER = 32
const val ECHO_REQ_TYPE = 0x01
fun round1(v: Double) = Math.round(v * 10.0) / 10.0 fun round1(v: Double) = Math.round(v * 10.0) / 10.0
} }
} }
@@ -0,0 +1,338 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.measurement.*
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.ProbeSession
import app.echo_lot.protocol.Wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* Downstream throughput: the server sends at a paced rate for a bounded time and the client
* measures what arrives (`perf.throughput_udp`).
*
* The number this produces is only meaningful with a qualifier attached, and getting that
* qualifier right is most of the work here. A throughput test 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.
* Reporting that as a capacity measurement would be a confident lie, so the result always carries
* [ThroughputMetrics.limitedBy] and a finding is only raised when the network is actually
* implicated.
*
* Comparing against the *sender's* count rather than the requested rate is the other half: the
* server reports how much it actually put on the wire, and the gap between that and what arrived
* 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.
*/
class ThroughputMeasurement(private val ids: IdSource) {
private val json = Json { encodeDefaults = true; explicitNulls = true }
fun run(
credential: String,
sessionId: String,
control: ControlClient,
probe: ProbeSession,
sessionRef: String,
durationS: Int = 5,
kbps: Int = 50_000,
sizeBytes: Int = 1200,
): Pair<Test, List<Finding>> {
val testId = ids.uuid()
val started = ids.monoNs()
val reply = runCatching {
control.action(
credential, sessionId,
"""{"action":"throughput","direction":"down","duration_s":$durationS,""" +
""""kbps":$kbps,"size_bytes":$sizeBytes}""",
)
}
if (reply.isFailure) {
return Test(
id = testId, type = TestType.PERF_THROUGHPUT_UDP, sessionRef = sessionRef, tier = Tier.APP,
startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = TestStatus.UNSUPPORTED,
error = TestError("action_refused", reply.exceptionOrNull()?.message ?: "throughput refused"),
) to emptyList()
}
// The server may have shortened the run to fit its own byte budget; listen for what it
// actually promised, not for what we asked.
val plannedMs = parseInt(reply.getOrNull(), "duration_ms") ?: (durationS * 1000)
// A margin past the planned end so the tail of the run is not counted as loss: packets
// still in flight when we stop listening were not dropped, they were merely late.
val received = probe.collectGranted(plannedMs + 1_500L)
.filter { it.type == Wire.TYPE_THROUGHPUT_DATA }
val bytes = received.sumOf { it.sizeBytes.toLong() }
val spanNs = if (received.size >= 2) {
received.maxOf { it.tRxNs } - received.minOf { it.tRxNs }
} else {
0L
}
// Measured over the arrival span rather than our listening window, which includes the
// request round trip and the trailing margin and would understate the rate.
val receivedKbps = if (spanNs > 0) (bytes * 8 * 1_000_000 / spanNs).toInt() else 0
val sender = senderReport(control, credential, sessionId)
val sentPackets = sender?.packets ?: 0
val lossPct = if (sentPackets > 0) {
round2((sentPackets - received.size).coerceAtLeast(0) * 100.0 / sentPackets)
} else {
null
}
// Only a run the *clock* ended measured the network. One stopped by our own byte budget
// or rate ceiling measured this server.
val limitedBy = sender?.limitedBy ?: "unknown"
val networkLimited = limitedBy == "duration" &&
sender != null && receivedKbps > 0 && receivedKbps < sender.kbps * 9 / 10
val metrics = json.encodeToJsonElement(
ThroughputMetrics(
requestedKbps = kbps,
plannedDurationMs = plannedMs,
packetsReceived = received.size,
bytesReceived = bytes,
receivedKbps = receivedKbps,
senderPackets = sender?.packets,
senderBytes = sender?.bytes,
senderKbps = sender?.kbps,
lossPct = lossPct,
limitedBy = limitedBy,
measuresNetwork = networkLimited,
),
) as JsonObject
val findings = ArrayList<Finding>()
when {
sender == null -> Unit // no sender report: nothing can be concluded, so nothing is
received.isEmpty() -> findings.add(
finding(
FindingRegistry.THROUGHPUT_NO_DELIVERY, testId,
"No throughput traffic arrived",
"The server sent ${sender.packets} packets and none arrived. This is a " +
"connectivity fault rather than a slow link.",
),
)
networkLimited -> findings.add(
finding(
FindingRegistry.THROUGHPUT_BELOW_OFFERED, testId,
"Downstream throughput ${receivedKbps / 1000} Mbit/s, below the " +
"${sender.kbps / 1000} Mbit/s offered",
"The server sent at ${sender.kbps / 1000} Mbit/s for the full run and " +
"${receivedKbps / 1000} Mbit/s arrived" +
(lossPct?.let { ", losing $it % of packets" } ?: "") +
". The path could not carry what was offered.",
),
)
}
return Test(
id = testId, type = TestType.PERF_THROUGHPUT_UDP, sessionRef = sessionRef, tier = Tier.APP,
startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = if (received.isEmpty()) TestStatus.FAILED else TestStatus.OK,
metrics = metrics,
) to findings
}
/**
* Upstream throughput: the client sends, the server counts.
*
* The mirror image of the downstream case, and it needs no grant — the client is generating
* its own traffic, so there is no amplification to gate. What it does need is the server's
* count: only the far end knows how much arrived, and without that number a sender can
* measure how fast it can *transmit*, which is not the same question and is usually just the
* speed of the local NIC.
*/
fun runUpstream(
credential: String,
sessionId: String,
control: ControlClient,
probe: ProbeSession,
sessionRef: String,
durationS: Int = 5,
kbps: Int = 20_000,
sizeBytes: Int = 1200,
): Pair<Test, List<Finding>> {
val testId = ids.uuid()
val started = ids.monoNs()
// Zeroes the server's counter so this run measures itself rather than inheriting the
// packets of an earlier one on the same session.
val reply = runCatching {
control.action(credential, sessionId, """{"action":"throughput","direction":"up"}""")
}
if (reply.isFailure) {
return Test(
id = testId, type = TestType.PERF_THROUGHPUT_UDP, sessionRef = sessionRef, tier = Tier.APP,
startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = TestStatus.UNSUPPORTED,
error = TestError("action_refused", reply.exceptionOrNull()?.message ?: "refused"),
) to emptyList()
}
val sent = probe.sendThroughput(durationS * 1000L, kbps, sizeBytes)
// A moment for the tail of the run to arrive; counting still-in-flight packets as lost
// would inflate the loss figure by whatever the path's delay happens to be.
Thread.sleep(500)
val seen = upstreamCount(control, credential, sessionId)
val lossPct = if (sent.packets > 0 && seen != null) {
round2((sent.packets - seen.packets).coerceAtLeast(0) * 100.0 / sent.packets)
} else {
null
}
// The receiver's rate is the measurement. The sender's is what we managed to emit, which
// is a property of this phone and its radio, not of the network.
val achievedKbps = seen?.kbps ?: 0
val metrics = json.encodeToJsonElement(
UpstreamThroughputMetrics(
requestedKbps = kbps,
sentPackets = sent.packets,
sentBytes = sent.bytes,
sentKbps = sent.kbps,
receivedPackets = seen?.packets,
receivedBytes = seen?.bytes,
receivedKbps = achievedKbps,
lossPct = lossPct,
// Same honesty rule as downstream: if what arrived matches what we offered, the
// path was never the constraint and this number says nothing about it.
measuresNetwork = seen != null && achievedKbps > 0 && achievedKbps < sent.kbps * 9 / 10,
),
) as JsonObject
val findings = ArrayList<Finding>()
if (seen != null && seen.packets == 0 && sent.packets > 0) {
findings.add(
finding(
FindingRegistry.THROUGHPUT_NO_DELIVERY, testId,
"No upstream traffic reached the server",
"This device sent ${sent.packets} packets and the server received none. " +
"That is a connectivity fault on the outbound path rather than a slow link.",
),
)
} else if (lossPct != null && lossPct >= 2.0) {
findings.add(
finding(
FindingRegistry.THROUGHPUT_BELOW_OFFERED, testId,
"Upstream loss of $lossPct % at ${sent.kbps / 1000} Mbit/s",
"The server received ${seen?.packets} of the ${sent.packets} packets this " +
"device sent. The outbound path could not carry what was offered.",
),
)
}
return Test(
id = testId, type = TestType.PERF_THROUGHPUT_UDP, sessionRef = sessionRef, tier = Tier.APP,
startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = if (seen == null || seen.packets == 0) TestStatus.FAILED else TestStatus.OK,
metrics = metrics,
) to findings
}
private data class UpstreamCount(val packets: Int, val bytes: Long, val kbps: Int)
/** The server's tally for this session's upstream run. */
private fun upstreamCount(
control: ControlClient, credential: String, sessionId: String,
): UpstreamCount? = runCatching {
val o = Json.parseToJsonElement(control.observations(credential, sessionId))
.jsonObject["throughput_up"]?.jsonObject ?: return null
UpstreamCount(
packets = o["packets"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0,
bytes = o["bytes"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0,
kbps = o["kbps"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0,
)
}.getOrNull()
private data class SenderReport(
val packets: Int, val bytes: Long, val kbps: Int, val limitedBy: String,
)
/** The server's own account of the run, from the observations API. */
private fun senderReport(
control: ControlClient, credential: String, sessionId: String,
): SenderReport? = runCatching {
val arr = Json.parseToJsonElement(control.observations(credential, sessionId))
.jsonObject["throughput"]?.jsonArray ?: return null
val last = arr.lastOrNull()?.jsonObject ?: return null
SenderReport(
packets = last["packets"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0,
bytes = last["bytes"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0,
kbps = last["kbps"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0,
limitedBy = last["limited_by"]?.jsonPrimitive?.content ?: "unknown",
)
}.getOrNull()
private fun parseInt(body: String?, key: String): Int? =
body?.let { Regex("\"$key\"\\s*:\\s*(-?\\d+)").find(it)?.groupValues?.get(1)?.toIntOrNull() }
/**
* Builds a finding from a registry entry, which supplies the code, category and severity.
*
* Taking a [FindingSpec] rather than three loose values is the point: a typo becomes a
* compile error, and two call sites cannot disagree about which category a finding belongs
* to - a disagreement that would split one fault across two verdict lights.
*/
private fun finding(spec: FindingSpec, testId: String, title: String, desc: String) =
Finding(
id = ids.uuid(), code = spec.code, category = spec.category, severity = spec.severity,
confidence = Confidence.HIGH,
title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)),
)
private fun round2(v: Double) = Math.round(v * 100.0) / 100.0
}
/** Metrics for perf.throughput_udp in the upstream direction. */
@Serializable
data class UpstreamThroughputMetrics(
val direction: String = "up",
@SerialName("requested_kbps") val requestedKbps: Int,
@SerialName("sent_packets") val sentPackets: Int,
@SerialName("sent_bytes") val sentBytes: Long,
/** What this device managed to emit — a property of the phone and its radio, not the path. */
@SerialName("sent_kbps") val sentKbps: Int,
@SerialName("received_packets") val receivedPackets: Int? = null,
@SerialName("received_bytes") val receivedBytes: Long? = null,
/** What arrived, measured by the only party that can measure it. This is the result. */
@SerialName("received_kbps") val receivedKbps: Int,
@SerialName("loss_pct") val lossPct: Double? = null,
@SerialName("measures_network") val measuresNetwork: Boolean,
)
/** Metrics for perf.throughput_udp. */
@Serializable
data class ThroughputMetrics(
val direction: String = "down",
@SerialName("requested_kbps") val requestedKbps: Int,
@SerialName("planned_duration_ms") val plannedDurationMs: Int,
@SerialName("packets_received") val packetsReceived: Int,
@SerialName("bytes_received") val bytesReceived: Long,
@SerialName("received_kbps") val receivedKbps: Int,
@SerialName("sender_packets") val senderPackets: Int? = null,
@SerialName("sender_bytes") val senderBytes: Long? = null,
@SerialName("sender_kbps") val senderKbps: Int? = null,
/** Against the sender's count, so a server-side limit is never counted as network loss. */
@SerialName("loss_pct") val lossPct: Double? = null,
/** What ended the run: duration | budget | rate | send_error | unknown. */
@SerialName("limited_by") val limitedBy: String,
/**
* Whether this number says anything about the network. False when the sender's own ceiling
* was the binding constraint — in which case the rate is a property of the test, not the path.
*/
@SerialName("measures_network") val measuresNetwork: Boolean,
)
@@ -0,0 +1,155 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.engine.Directional.Sample
import app.echo_lot.engine.Directional.ServerSighting
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The arithmetic that turns "3 % loss somewhere" into "3 % loss upstream". Getting a denominator
* wrong here does not crash anything — it produces a plausible number pointing at the wrong half
* of the network, which is worse than no number at all. Hence a test per claim.
*/
class DirectionalTest {
/** A clean train: every packet sent, seen and answered. Server clock offset by a constant. */
private fun clean(n: Int, offsetNs: Long = 5_000_000_000L): Pair<List<Sample>, List<ServerSighting>> {
val sent = (1..n).map { Sample(it, tTxNs = it * 10_000_000L, tRxNs = it * 10_000_000L + 4_000_000L) }
val seen = (1..n).map {
ServerSighting(it, tRxNs = offsetNs + it * 10_000_000L + 2_000_000L,
tTxNs = offsetNs + it * 10_000_000L + 2_100_000L)
}
return sent to seen
}
@Test
fun aCleanTrainReportsNoLossInEitherDirection() {
val (sent, seen) = clean(10)
val m = Directional.analyse(sent, seen)
assertEquals(10, m.sent)
assertEquals(10, m.seenByServer)
assertEquals(10, m.repliesReceived)
assertEquals(0.0, m.lossUpstreamPct)
assertEquals(0.0, m.lossDownstreamPct)
assertFalse(m.noneReachedServer)
}
// The whole point: a packet the server never saw was lost on the way there.
@Test
fun packetsTheServerNeverSawAreUpstreamLoss() {
val (sent, seen) = clean(10)
val m = Directional.analyse(sent, seen.filter { it.seq !in setOf(3, 7) })
assertEquals(2, m.lostUpstream)
assertEquals(0, m.lostDownstream)
assertEquals(20.0, m.lossUpstreamPct)
assertEquals(0.0, m.lossDownstreamPct, "a packet that never arrived cannot be lost coming back")
}
@Test
fun repliesThatNeverArrivedAreDownstreamLoss() {
val (sent, seen) = clean(10)
val withHoles = sent.map { if (it.seq in setOf(2, 5)) it.copy(tRxNs = null) else it }
val m = Directional.analyse(withHoles, seen)
assertEquals(0, m.lostUpstream)
assertEquals(2, m.lostDownstream)
assertEquals(20.0, m.lossDownstreamPct)
}
// Downstream loss is measured against what actually reached the server. Using "sent" as the
// denominator would count every upstream loss a second time and overstate the return path.
@Test
fun downstreamLossIsRelativeToWhatReachedTheServer() {
val (sent, seen) = clean(10)
// 5 lost on the way there; of the 5 that arrived, 1 reply is lost coming back.
val seenPartial = seen.filter { it.seq > 5 }
val withHole = sent.map {
when {
it.seq <= 5 -> it.copy(tRxNs = null) // never got there, so never came back
it.seq == 6 -> it.copy(tRxNs = null) // arrived, reply lost
else -> it
}
}
val m = Directional.analyse(withHole, seenPartial)
assertEquals(5, m.lostUpstream)
assertEquals(50.0, m.lossUpstreamPct)
assertEquals(1, m.lostDownstream)
assertEquals(20.0, m.lossDownstreamPct, "1 of the 5 that arrived, not 1 of 10")
}
@Test
fun aServerThatSawNothingIsCalledOutSeparately() {
val (sent, _) = clean(6)
val m = Directional.analyse(sent.map { it.copy(tRxNs = null) }, emptyList())
assertTrue(m.noneReachedServer)
assertEquals(100.0, m.lossUpstreamPct)
assertEquals(0.0, m.lossDownstreamPct, "with nothing arriving there is no return path to blame")
}
// Jitter is legitimate without synchronised clocks because the offset cancels when successive
// one-way samples are differenced. This pins that: a huge constant offset must not show up.
@Test
fun jitterIsUnaffectedByTheClockOffsetBetweenTheTwoMachines() {
val (sent, near) = clean(10, offsetNs = 0)
val (_, far) = clean(10, offsetNs = 9_999_999_999L)
val a = Directional.analyse(sent, near)
val b = Directional.analyse(sent, far)
assertEquals(a.jitterUpstreamMs, b.jitterUpstreamMs,
"a constant clock offset must cancel when consecutive samples are differenced")
assertEquals(0.0, assertNotNull(a.jitterUpstreamMs), "an evenly spaced train has no jitter")
}
@Test
fun jitterReflectsUnevenArrival() {
val sent = listOf(
Sample(1, 0, 10_000_000),
Sample(2, 10_000_000, 20_000_000),
Sample(3, 20_000_000, 30_000_000),
)
// Server receive times drift: +2ms, +7ms, +3ms relative to send.
val seen = listOf(
ServerSighting(1, 2_000_000, 2_100_000),
ServerSighting(2, 17_000_000, 17_100_000),
ServerSighting(3, 23_000_000, 23_100_000),
)
val m = Directional.analyse(sent, seen)
// one-way samples: 2ms, 7ms, 3ms → |7-2| and |3-7| → mean 4.5ms
assertEquals(4.5, assertNotNull(m.jitterUpstreamMs))
}
// "No jitter" and "not enough data to say" are different claims, and only one is true here.
@Test
fun tooFewSamplesReportsNoJitterRatherThanZero() {
val m = Directional.analyse(
listOf(Sample(1, 0, 10_000_000)),
listOf(ServerSighting(1, 2_000_000, 2_100_000)),
)
assertNull(m.jitterUpstreamMs)
assertNull(m.jitterDownstreamMs)
}
// A server record for a sequence we never sent is not evidence about this train; folding it
// in would yield loss percentages outside 0100.
@Test
fun strayServerRecordsAreIgnored() {
val (sent, seen) = clean(5)
val m = Directional.analyse(sent, seen + ServerSighting(99, 1, 2) + ServerSighting(100, 3, 4))
assertEquals(5, m.seenByServer)
assertEquals(0.0, m.lossUpstreamPct)
assertTrue(m.lossDownstreamPct in 0.0..100.0)
}
@Test
fun anEmptyTrainDoesNotDivideByZero() {
val m = Directional.analyse(emptyList(), emptyList())
assertEquals(0.0, m.lossUpstreamPct)
assertEquals(0.0, m.lossDownstreamPct)
assertFalse(m.noneReachedServer, "nothing sent is not the same as nothing arriving")
}
}
@@ -44,7 +44,9 @@ class LiveDownstreamTest {
for (t in tests) println("${t.type} status=${t.status} metrics=${t.metrics}") for (t in tests) println("${t.type} status=${t.status} metrics=${t.metrics}")
for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}") for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}")
assertEquals(3, tests.size, "expected pmtud_down, frag_delivery and a downstream train") // Assert on what is present, not on how many: adding a measurement should not be a
// test edit. (It was, once — hence the note.)
assertTrue(tests.size >= 3, "expected at least the three downstream tests, got ${tests.size}")
val byType = tests.associateBy { it.type } val byType = tests.associateBy { it.type }
val pmtud = assertNotNull(byType[TestType.MTU_PMTUD_DOWN], "no mtu.pmtud_down test") val pmtud = assertNotNull(byType[TestType.MTU_PMTUD_DOWN], "no mtu.pmtud_down test")
@@ -58,6 +60,17 @@ class LiveDownstreamTest {
val frag = assertNotNull(byType[TestType.MTU_FRAG_DELIVERY], "no mtu.frag_delivery test") val frag = assertNotNull(byType[TestType.MTU_FRAG_DELIVERY], "no mtu.frag_delivery test")
assertNotNull(frag.metrics?.get("largest_delivered_bytes")) assertNotNull(frag.metrics?.get("largest_delivered_bytes"))
// Fragment ordering runs only when fragments arrive at all, and only against a server
// that can craft them — so it is checked when present rather than required.
byType[TestType.MTU_FRAG_ORDERING]?.let { fo ->
val m = fo.metrics?.toString() ?: ""
println("fragment ordering: ${fo.status} $m")
if (fo.status != TestStatus.UNSUPPORTED) {
assertTrue(m.contains("in_order"), "no per-ordering result: $m")
assertTrue(m.contains("reversed"), "reversed ordering was never attempted: $m")
}
}
val train = assertNotNull(byType[TestType.TRAIN_UDP_DOWNSTREAM], "no downstream train") val train = assertNotNull(byType[TestType.TRAIN_UDP_DOWNSTREAM], "no downstream train")
assertNotNull(train.evidence, "a train without columnar evidence is not recomputable") assertNotNull(train.evidence, "a train without columnar evidence is not recomputable")
val received = train.metrics?.get("received")?.toString()?.toIntOrNull() ?: 0 val received = train.metrics?.get("received")?.toString()?.toIntOrNull() ?: 0
@@ -7,6 +7,7 @@ import app.echo_lot.measurement.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
/** /**
@@ -59,6 +60,18 @@ class LiveMeasurementTest {
println("metrics: $metrics") println("metrics: $metrics")
assertTrue(metrics.toString().contains("rtt_ms_avg")) assertTrue(metrics.toString().contains("rtt_ms_avg"))
// The directional split is the point of asking the server what it saw: without it a
// lossy path is reported as "loss" with no direction, which sends an engineer looking
// in both at once. Correlation is by wire sequence number, so a mismatch here means the
// two sides disagree about which packet is which.
val m = metrics.toString()
assertTrue(m.contains("seen_by_server"), "no directional split in the metrics: $m")
val seen = Regex(""""seen_by_server":(\d+)""").find(m)?.groupValues?.get(1)?.toInt()
assertNotNull(seen, "seen_by_server missing")
assertEquals(20, seen, "the server should have seen every probe on a healthy path")
assertTrue(m.contains("jitter_upstream_ms"), "no per-direction jitter: $m")
println("directional: $m")
assertTrue(doc.summary != null) assertTrue(doc.summary != null)
// A healthy local->fmr path should be green (no loss, no rebinding) or yellow. // A healthy local->fmr path should be green (no loss, no rebinding) or yellow.
println("summary: ${doc.summary}") println("summary: ${doc.summary}")
@@ -0,0 +1,105 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.measurement.TestStatus
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.ProbeSession
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Downstream throughput against a LIVE server. Self-skips without ECHOLOT_LIVE_*.
*
* The assertions are about *honesty* rather than speed: a rate is only a measurement if the run
* was ended by the clock and the sender's own count backs it up. A test that just asserted "some
* Mbps arrived" would pass equally well against a broken implementation.
*/
class LiveThroughputTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
@Test
fun measuresDownstreamRateAndSaysWhatLimitedIt() {
if (url == null || pin == null || cred == null || udp == null) {
println("LiveThroughputTest skipped (no ECHOLOT_LIVE_* env)"); return
}
val control = ControlClient(url, setOf(pin), "0.2.0")
val session = control.createSession(cred, target)
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
val (test, findings) = ProbeSession(cred, session, host, port).use { ps ->
ps.echo() // prime: the grant binds to the observed source
ThroughputMeasurement(SystemIdSource()).run(
cred, session.sessionId, control, ps, sessionRef = "sess-1",
durationS = 3, kbps = 20_000,
)
}
control.deleteSession(cred, session.sessionId)
val m = assertNotNull(test.metrics).toString()
println("throughput: ${test.status} $m")
for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}")
assertEquals(TestStatus.OK, test.status, "no throughput traffic arrived: $m")
// The sender's own count must be present — without it, loss cannot be attributed and the
// number is not a measurement.
assertTrue(m.contains("sender_packets"), "no sender report to compare against: $m")
assertTrue(m.contains("limited_by"), "the result must say what ended the run: $m")
val received = Regex(""""received_kbps":(\d+)""").find(m)?.groupValues?.get(1)?.toInt()
assertNotNull(received)
assertTrue(received > 0, "measured 0 kbps: $m")
println("received ${received / 1000} Mbit/s")
// A run this short and this far below the ceiling should end on the clock. Anything else
// means the grant was the constraint, and then the rate says nothing about the path.
assertTrue(m.contains(""""limited_by":"duration""""),
"the run did not end on the clock, so the rate measures the server, not the path: $m")
}
// Upstream is the direction only the far end can measure. The assertion that matters is that
// the server's count is present and plausible against what we sent — a test that only checked
// "we transmitted some Mbps" would pass against a server that counted nothing at all.
@Test
fun measuresUpstreamAgainstTheServersCount() {
if (url == null || pin == null || cred == null || udp == null) {
println("LiveThroughputTest(up) skipped"); return
}
val control = ControlClient(url, setOf(pin), "0.2.0")
val session = control.createSession(cred, target)
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
val (test, findings) = ProbeSession(cred, session, host, port).use { ps ->
ps.echo()
ThroughputMeasurement(SystemIdSource()).runUpstream(
cred, session.sessionId, control, ps, sessionRef = "sess-1",
durationS = 3, kbps = 10_000,
)
}
control.deleteSession(cred, session.sessionId)
val m = assertNotNull(test.metrics).toString()
println("upstream: ${test.status} $m")
for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}")
assertEquals(TestStatus.OK, test.status, "the server counted nothing: $m")
val recv = Regex(""""received_packets":(\d+)""").find(m)?.groupValues?.get(1)?.toInt()
val sent = Regex(""""sent_packets":(\d+)""").find(m)?.groupValues?.get(1)?.toInt()
assertNotNull(recv); assertNotNull(sent)
assertTrue(sent > 100, "barely anything was sent, so the rate means nothing: $m")
assertTrue(recv > 0, "the server received none of $sent packets: $m")
// The counts should be close on a healthy path; wildly different means the two sides are
// counting different things rather than the network losing packets.
assertTrue(recv <= sent, "the server counted MORE than we sent — the counter is not being reset")
println("sent $sent, server saw $recv")
}
}
@@ -0,0 +1,205 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
/**
* The registry of finding codes (measurement-schema.md §9, open item 1).
*
* A finding code is the stable, machine-readable half of a result: the prose changes, the code is
* 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 is not something ad-hoc string literals at
* fifteen call sites can promise.
*
* The failure this exists to prevent had already happened by the time it was written. Two
* independently-added emitters produced `connectivity.downstream_loss` and
* `connectivity.loss_downstream` for the same concept, and nothing anywhere objected. Anyone
* aggregating either one would have silently seen half their data.
*
* So codes are declared here as typed specs, each carrying its category and default severity, and
* emitters reference the spec rather than retyping the string. That makes a typo a compile error,
* and makes it impossible for two call sites to disagree about which category a finding belongs
* to — a disagreement that would otherwise split one fault across two verdict lights.
*/
data class FindingSpec(
val code: String,
val category: Category,
/** Severity when nothing about the specific run argues otherwise; emitters may escalate. */
val severity: Severity,
/** One line: what this finding asserts. Present tense, no hedging. */
val meaning: String,
/**
* What the finding rules *out*, where that is the useful half. "Loss upstream" is worth much
* more when it also says the return path is fine, because that halves where to look next.
*/
val rulesOut: String? = null,
)
object FindingRegistry {
// ---- connectivity ----------------------------------------------------------------
// Renamed from nat.* before anything shipped: neither of these is about NAT, and the
// prefix is what decides which category - and therefore which verdict light - a finding
// rolls up into. A nat.* code landing under connectivity would be a permanent puzzle.
val UDP_UNREACHABLE = FindingSpec(
"connectivity.udp_unreachable", Category.CONNECTIVITY, Severity.HIGH,
"No UDP echo replies came back from the server at all.",
)
val UDP_UNREACHABLE_UPSTREAM = FindingSpec(
"connectivity.udp_unreachable_upstream", Category.CONNECTIVITY, Severity.HIGH,
"The server received none of the probes, so traffic is dropped on the way out.",
rulesOut = "The return path: nothing arrived to be replied to.",
)
val UDP_LOSS = FindingSpec(
"connectivity.udp_loss", Category.CONNECTIVITY, Severity.MEDIUM,
"A large fraction of round-trip probes were lost, direction unknown.",
)
val LOSS_UPSTREAM = FindingSpec(
"connectivity.loss_upstream", Category.CONNECTIVITY, Severity.MEDIUM,
"Probes were lost on the way to the server.",
rulesOut = "The return path: replies came back for everything that arrived.",
)
/**
* The single code for "lost on the return path", whichever measurement found it.
*
* Two emitters had independently invented `connectivity.downstream_loss` and
* `connectivity.loss_downstream` for this, and nothing objected. Anyone aggregating either
* one would have silently seen half their data. Paired with [LOSS_UPSTREAM] so the two
* directions read as a set.
*/
val LOSS_DOWNSTREAM = FindingSpec(
"connectivity.loss_downstream", Category.CONNECTIVITY, Severity.MEDIUM,
"Packets were lost on the way back from the server.",
rulesOut = "The outbound path: the server received what it was answering.",
)
val DOWNSTREAM_BLOCKED = FindingSpec(
"connectivity.downstream_blocked", Category.CONNECTIVITY, Severity.HIGH,
"Server-initiated packets never arrive, although round trips work.",
rulesOut = "Basic reachability: the path forwards replies, just not unsolicited traffic.",
)
val DOWNSTREAM_REORDER = FindingSpec(
"connectivity.downstream_reorder", Category.CONNECTIVITY, Severity.LOW,
"Downstream packets arrive in a different order than they were sent.",
)
// MEDIUM, not HIGH: a captive portal is a condition to report, not necessarily a fault - on
// hotel or cafe wifi it is exactly what should be there, and logging in clears it. NO_INTERNET
// is the HIGH one, because nothing the user does locally fixes that. The registry first said
// HIGH; the probe emitting it had always said MEDIUM, and the probe was the considered value.
val CAPTIVE_PORTAL = FindingSpec(
"connectivity.captive_portal", Category.CONNECTIVITY, Severity.MEDIUM,
"A captive portal is intercepting connectivity checks.",
)
val NO_INTERNET = FindingSpec(
"connectivity.no_internet", Category.CONNECTIVITY, Severity.HIGH,
"Android's own connectivity checks fail on this network.",
)
// ---- mtu -------------------------------------------------------------------------
val MTU_REDUCED_DOWNSTREAM = FindingSpec(
"mtu.reduced_downstream", Category.MTU, Severity.LOW,
"The downstream path MTU is below the usual 1500 bytes.",
)
val MTU_DOWNSTREAM_BLACKHOLE = FindingSpec(
"mtu.downstream_blackhole", Category.MTU, Severity.MEDIUM,
"Datagrams above the path MTU are dropped downstream, fragmented or not.",
)
val FRAGMENTS_BLOCKED = FindingSpec(
"mtu.fragments_blocked", Category.MTU, Severity.MEDIUM,
"IP fragments do not reach this device even when sent in order.",
)
val FRAGMENT_REORDER_SENSITIVE = FindingSpec(
"mtu.fragment_reorder_sensitive", Category.MTU, Severity.LOW,
"Fragments are delivered in order but dropped when reordered or delayed.",
rulesOut = "Fragmentation itself: in-order fragments arrive fine.",
)
// ---- nat -------------------------------------------------------------------------
val NAT_UDP_REBINDING = FindingSpec(
"nat.udp_rebinding", Category.NAT, Severity.MEDIUM,
"A NAT remapped the UDP source port mid-flow.",
)
val NAT_SYMMETRIC = FindingSpec(
"nat.symmetric", Category.NAT, Severity.MEDIUM,
"The NAT assigns a different external port per destination.",
)
// ---- perf ------------------------------------------------------------------------
val THROUGHPUT_NO_DELIVERY = FindingSpec(
"perf.throughput_no_delivery", Category.PERFORMANCE, Severity.HIGH,
"No throughput traffic arrived, although the server sent it.",
)
val THROUGHPUT_BELOW_OFFERED = FindingSpec(
"perf.throughput_below_offered", Category.PERFORMANCE, Severity.LOW,
"Less throughput arrived than the server sent for the whole run.",
)
// ---- dns -------------------------------------------------------------------------
val DNS_ANSWER_REWRITTEN = FindingSpec(
"dns.answer_rewritten", Category.DNS, Severity.HIGH,
"A resolver returned an answer that differs from the authoritative record.",
)
val DNS_AUTHORITATIVE_UNREACHABLE = FindingSpec(
"dns.authoritative_unreachable", Category.DNS, Severity.MEDIUM,
"The canary zone's authoritative server could not be reached.",
)
// ---- v6 ----------------------------------------------------------------------------
//
// Prefix is `v6.`, matching the test-type registry (v6.brokenness, v6.happy_eyeballs, ...).
// These were `ipv6.*` while declaring Category.IPV6, but the prefix map only knows "v6", so
// they silently rolled up under connectivity: the third instance of a prefix disagreeing with
// its category and quietly moving a fault to a different verdict light.
val V6_BROKEN = FindingSpec(
"v6.broken", Category.IPV6, Severity.MEDIUM,
"IPv6 is configured on this network but does not work.",
rulesOut = "Absence of IPv6: it is provisioned, it simply fails.",
)
/**
* INFO deliberately, and it needs to stay that way.
*
* Most networks still do not offer IPv6, and that is not a fault. Reporting it as a warning
* lights a yellow verdict on a perfectly healthy network, which teaches people to ignore the
* light — the one thing a diagnostic must never do.
*/
val V6_NOT_OFFERED = FindingSpec(
"v6.not_offered", Category.IPV6, Severity.INFO,
"This network does not offer IPv6.",
)
/** Every registered finding, in declaration order. */
val all: List<FindingSpec> = listOf(
UDP_UNREACHABLE, UDP_UNREACHABLE_UPSTREAM, UDP_LOSS, LOSS_UPSTREAM, LOSS_DOWNSTREAM,
DOWNSTREAM_BLOCKED, DOWNSTREAM_REORDER, CAPTIVE_PORTAL, NO_INTERNET,
MTU_REDUCED_DOWNSTREAM, MTU_DOWNSTREAM_BLACKHOLE, FRAGMENTS_BLOCKED,
FRAGMENT_REORDER_SENSITIVE,
NAT_UDP_REBINDING, NAT_SYMMETRIC,
THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED,
DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE,
V6_BROKEN, V6_NOT_OFFERED,
)
private val byCode: Map<String, FindingSpec> = all.associateBy { it.code }
fun byCode(code: String): FindingSpec? = byCode[code]
}
@@ -77,6 +77,8 @@ object TestType {
const val MTU_BLACKHOLE = "mtu.blackhole" const val MTU_BLACKHOLE = "mtu.blackhole"
const val MTU_MSS_OBSERVED = "mtu.mss_observed" const val MTU_MSS_OBSERVED = "mtu.mss_observed"
const val MTU_FRAG_DELIVERY = "mtu.frag_delivery" const val MTU_FRAG_DELIVERY = "mtu.frag_delivery"
/** Whether fragments survive arriving out of order, not merely whether they survive. */
const val MTU_FRAG_ORDERING = "mtu.frag_ordering"
// nat // nat
const val NAT_STUN_5780 = "nat.stun_5780" const val NAT_STUN_5780 = "nat.stun_5780"
const val NAT_MAPPING_LIFETIME_UDP = "nat.mapping_lifetime_udp" const val NAT_MAPPING_LIFETIME_UDP = "nat.mapping_lifetime_udp"
@@ -0,0 +1,133 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* Keeps the finding registry honest.
*
* The interesting test is the last one: it reads `docs/findings-registry.md` and fails when the
* document and the code disagree. Documentation that drifts from its implementation is worse than
* none, because it still looks authoritative — and a finding registry is precisely the artifact
* other people build tooling against.
*/
class FindingRegistryTest {
@Test
fun codesAreUnique() {
val dupes = FindingRegistry.all.groupBy { it.code }.filterValues { it.size > 1 }.keys
assertTrue(dupes.isEmpty(), "duplicate finding codes: $dupes")
}
@Test
fun everyDeclaredSpecIsInTheAllList() {
// Reflection over the object's properties: a spec that is declared but left out of `all`
// is invisible to the doc check and to any consumer enumerating the registry.
val declared = FindingRegistry::class.java.declaredMethods
.filter { it.parameterCount == 0 && it.returnType == FindingSpec::class.java }
.mapNotNull { runCatching { it.invoke(FindingRegistry) as FindingSpec }.getOrNull() }
.map { it.code }
.toSet()
val listed = FindingRegistry.all.map { it.code }.toSet()
assertEquals(declared, listed, "declared specs and the `all` list disagree")
}
// The prefix decides the category, and the category decides which verdict light the finding
// rolls up into. A code whose prefix disagrees with its category silently moves a fault to a
// different light — the exact bug that got two codes renamed out of nat.*.
@Test
fun everyPrefixMatchesItsCategory() {
for (spec in FindingRegistry.all) {
val fromPrefix = TestType.category(spec.code)
assertEquals(
fromPrefix, spec.category,
"${spec.code} is declared as ${spec.category} but its prefix maps to $fromPrefix",
)
}
}
@Test
fun codesFollowTheNamingConvention() {
val shape = Regex("^[a-z0-9]+\\.[a-z0-9_]+$")
for (spec in FindingRegistry.all) {
assertTrue(shape.matches(spec.code), "malformed code: ${spec.code}")
assertTrue(spec.meaning.isNotBlank(), "${spec.code} has no meaning")
assertTrue(
spec.meaning.trimEnd().endsWith("."),
"${spec.code}'s meaning should be a sentence: '${spec.meaning}'",
)
}
}
// Two near-identical codes are how one fault ends up split across two dashboards. This is a
// blunt check — it will not catch every synonym — but it catches the shape that already
// happened: the same words in a different order.
@Test
fun noTwoCodesAreAnagramsOfEachOther() {
val normalised = FindingRegistry.all.associate { spec ->
spec.code to spec.code.substringAfter('.').split('_').sorted().joinToString("_")
}
val clashes = normalised.entries.groupBy { it.value }.filterValues { it.size > 1 }
if (clashes.isNotEmpty()) {
fail("codes differing only in word order: ${clashes.values.map { g -> g.map { it.key } }}")
}
}
@Test
fun theDocumentAndTheRegistryAgree() {
val doc = findDoc() ?: run {
println("findings-registry.md not found from ${File(".").absolutePath} — skipping")
return
}
val text = doc.readText()
// Only table rows count as "documented". Prose may legitimately mention a code that no
// longer exists — the rules section explains why two were merged — and treating that as
// a registry entry would force the document to forget its own history.
val documented = text.lines()
.filter { it.trimStart().startsWith("|") }
.flatMap { row -> Regex("`([a-z0-9]+\\.[a-z0-9_]+)`").findAll(row).map { it.groupValues[1] } }
.toSet()
val registered = FindingRegistry.all.map { it.code }.toSet()
val missingFromDoc = registered - documented
val missingFromCode = documented - registered
assertTrue(
missingFromDoc.isEmpty(),
"these codes exist in FindingRegistry but not in docs/findings-registry.md: $missingFromDoc",
)
assertTrue(
missingFromCode.isEmpty(),
"docs/findings-registry.md documents codes that no longer exist: $missingFromCode",
)
// And the severities must match, or the document is describing a different system.
for (spec in FindingRegistry.all) {
val row = text.lines().firstOrNull {
it.trimStart().startsWith("|") && it.contains("`${spec.code}`")
} ?: continue
val severity = spec.severity.name.lowercase()
assertTrue(
row.contains("| $severity |"),
"${spec.code} is ${severity} in code but the doc row says otherwise: $row",
)
}
}
/** Walks up from the test's working directory to find the repo's docs/ folder. */
private fun findDoc(): File? {
var dir: File? = File(".").absoluteFile
repeat(6) {
val candidate = File(dir, "docs/findings-registry.md")
if (candidate.isFile) return candidate
dir = dir?.parentFile
}
return null
}
}
+5 -1
View File
@@ -20,4 +20,8 @@ kotlin {
} }
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
tasks.test { useJUnitPlatform() } tasks.test {
useJUnitPlatform()
// Opt-in: point this at a captured run to check the anonymizer against real data.
System.getenv("ECHOLOT_REAL_RUN")?.let { environment("ECHOLOT_REAL_RUN", it) }
}
@@ -108,12 +108,22 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
is JsonObject -> walkObject(v, path) is JsonObject -> walkObject(v, path)
is JsonArray -> JsonArray(v.map { walk(key, it, path) }) is JsonArray -> JsonArray(v.map { walk(key, it, path) })
is JsonPrimitive -> is JsonPrimitive ->
if (v.isString) transform(Classification.typeOf(key, path), v.content).let(::JsonPrimitive) if (v.isString) {
else v // Name first (it is precise), then shape (it is exhaustive). A field nobody
// classified must not be a field that leaks.
val type = Classification.typeOf(key, path) ?: Classification.inferFromValue(v.content)
JsonPrimitive(transform(type, v.content))
} else {
v
}
} }
private fun transform(type: LogicalType?, value: String): String = when (type) { private fun transform(type: LogicalType?, value: String): String = when (type) {
null -> value // Unclassified strings still get their *embedded* identifiers scrubbed. A whole-value
// check cannot see them: raw shell output is one long string that is neither a MAC nor an
// address, so it sailed through both the name table and the shape check carrying every
// MAC on the user's LAN.
null -> scrubEmbedded(value)
LogicalType.SSID -> pseudo("ssid", value) { "net-" + it.take(6) } LogicalType.SSID -> pseudo("ssid", value) { "net-" + it.take(6) }
LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value) LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value)
LogicalType.IP4 -> ip4(value) LogicalType.IP4 -> ip4(value)
@@ -123,6 +133,38 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
LogicalType.FREETEXT -> "[removed: may contain identifying text]" LogicalType.FREETEXT -> "[removed: may contain identifying text]"
} }
/**
* Replaces addresses and MACs found *inside* a longer string.
*
* Shizuku probes embed raw command output verbatim — `ip neigh`, `ip route`, `dumpsys` — which
* is genuinely valuable evidence and also a complete inventory of every device on the user's
* network, with hardware addresses. measurement-schema.md §9 flagged these as "hard to
* anonymize" and proposed dropping them from exports.
*
* Scrubbing beats dropping: the output stays readable and auditable — you can still see the
* shape of the neighbour table and how many hosts there were — while the identifiers become
* the same pseudonyms used everywhere else in the document. So a MAC appearing both in a
* parsed field and in a raw dump still reads as one device.
*
* Only addresses and MACs are touched, for the same reason as [Classification.inferFromValue]:
* they are the patterns that cannot be mistaken for something else in free text.
*/
private fun scrubEmbedded(value: String): String {
// Cheap bail-out: the overwhelming majority of strings are short and contain neither.
if (value.length < 7 || (!value.contains(':') && !value.contains('.'))) return value
// One pass, not three. Sequential passes re-process their own output: after a MAC became
// 78:9a:18:xx:yy:zz the IPv6 pattern matched it — six hex groups separated by colons is
// exactly an address — and mangled the vendor prefix that the MAC rule had just taken
// care to preserve. Ordered alternation resolves each position once, MAC first.
return EMBEDDED.replace(value) { m ->
when {
m.groups[1] != null -> macPreservingOui(m.value)
m.groups[2] != null -> ip6(m.value)
else -> ip4(m.value)
}
}
}
// ---- per-type transforms ------------------------------------------------------------- // ---- per-type transforms -------------------------------------------------------------
/** /**
@@ -147,6 +189,11 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
* Public addresses keep only their /16 so the network is still locatable at ISP granularity. * Public addresses keep only their /16 so the network is still locatable at ISP granularity.
*/ */
private fun ip4(value: String): String { private fun ip4(value: String): String {
// A route destination carries a prefix length; pseudonymize the address and put it back,
// or "0.0.0.0/0" turns into nonsense and the routing table becomes unreadable.
value.substringAfter('/', "").takeIf { it.isNotEmpty() && value.contains('/') }?.let { len ->
return ip4(value.substringBefore('/')) + "/" + len
}
val o = value.split(".") val o = value.split(".")
if (o.size != 4 || o.any { it.toIntOrNull() == null }) return value if (o.size != 4 || o.any { it.toIntOrNull() == null }) return value
val n = o.map { it.toInt() } val n = o.map { it.toInt() }
@@ -167,8 +214,36 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
* is a device fingerprint, especially with EUI-64. * is a device fingerprint, especially with EUI-64.
*/ */
private fun ip6(value: String): String { private fun ip6(value: String): String {
// Dotted quads reach here through the family-agnostic field names (addr, gateway, dst);
// hand them to the IPv4 path rather than mangling them as if they were v6.
if (value.count { it == ':' } < 2) return ip4(value)
if (value.contains('/')) {
return ip6(value.substringBefore('/')) + "/" + value.substringAfter('/')
}
val v = value.lowercase(Locale.ROOT) val v = value.lowercase(Locale.ROOT)
// The unspecified address and the default route are not identities; mangling them would
// make a routing table unreadable for no privacy gain.
if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v
// Unique local addresses (fc00::/7) need the *whole* prefix replaced, not the tail.
//
// They look like the v6 equivalent of RFC1918, and the first instinct is to keep them for
// the same reason: private, topological, says nothing about anyone. That reasoning does
// not carry over. An RFC1918 prefix is shared by millions of networks and identifies
// none of them; a ULA global ID is 40 *random* bits, unique to one network by
// construction (RFC 4193). It is a network fingerprint. Passing the leading groups
// through - which is what the general path does - leaked 32 of those 40 bits.
//
// The prefix is pseudonymized as a unit, so two addresses on the same ULA subnet still
// land on the same pseudonymous prefix. "These hosts are on one network" survives;
// "this is *that* network" does not.
if (v.startsWith("fc") || v.startsWith("fd")) {
val groups = v.substringBefore('%').split(":")
val prefix = pseudo("ula-prefix", groups.take(3).joinToString(":")) { it }
val host = pseudo("ula-host", v) { it }
return "fd${prefix.substring(0, 2)}:${prefix.substring(2, 6)}:${prefix.substring(6, 10)}" +
"::${host.substring(0, 4)}"
}
val groups = v.substringBefore('%').split(":") val groups = v.substringBefore('%').split(":")
if (groups.size < 3) return v if (groups.size < 3) return v
val h = pseudo("ip6", value) { it } val h = pseudo("ip6", value) { it }
@@ -219,7 +294,7 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
/** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */ /** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */
private fun pseudo(domain: String, value: String, shape: (String) -> String): String = private fun pseudo(domain: String, value: String, shape: (String) -> String): String =
cache.getOrPut("$domain$value") { cache.getOrPut("$domain\u0000$value") {
val md = MessageDigest.getInstance("SHA-256") val md = MessageDigest.getInstance("SHA-256")
md.update(salt.bytes) md.update(salt.bytes)
md.update(domain.toByteArray()) md.update(domain.toByteArray())
@@ -229,6 +304,21 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
} }
private companion object { private companion object {
/**
* MAC | IPv6 | IPv4, in that order — alternation is ordered, so a MAC-shaped token is
* claimed by the MAC rule before the IPv6 rule can see it.
*
* The patterns are deliberately conservative. A missed address is scrubbed by another
* rule or not at all; an over-eager one mangles timestamps, version strings and log
* prefixes, corrupting evidence to protect nothing.
*/
val EMBEDDED = Regex(
// Raw strings: a regex written with escaped escapes is a regex nobody can check.
"""(\b[0-9a-fA-F]{2}(?:[:-][0-9a-fA-F]{2}){5}\b)""" +
"""|(\b(?:[0-9a-fA-F]{1,4}:){2,7}(?::|[0-9a-fA-F]{1,4})(?:[0-9a-fA-F:]*))""" +
"""|(\b(?:\d{1,3}\.){3}\d{1,3}\b)"""
)
val publicSuffixes = setOf( val publicSuffixes = setOf(
"local", "lan", "home", "internal", "arpa", "local", "lan", "home", "internal", "arpa",
"com", "net", "org", "io", "app", "dev", "at", "de", "eu", "uk", "com", "net", "org", "io", "app", "dev", "at", "de", "eu", "uk",
@@ -32,6 +32,16 @@ object Classification {
"link_local", "ra_source", "prefix", "link_local", "ra_source", "prefix",
).forEach { put(it, LogicalType.IP6) } ).forEach { put(it, LogicalType.IP6) }
// Family-agnostic address fields — the names the models actually use (Address.addr,
// Route.gateway, Route.dst, DnsConfig.servers). Their absence here was a real leak: the
// device's own global IPv6 address went out verbatim at the level whose description
// promises addresses are pseudonymized. Typed IP6 because the transform detects the
// family from the value, falling through to the IPv4 path for a dotted quad.
listOf(
"addr", "address", "gateway", "dst", "src", "servers", "server", "resolver",
"next_hop", "via", "public_ip", "observed_ip",
).forEach { put(it, LogicalType.IP6) }
listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac") listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac")
.forEach { put(it, LogicalType.MAC) } .forEach { put(it, LogicalType.MAC) }
listOf("bssid", "ap_mac").forEach { put(it, LogicalType.BSSID) } listOf("bssid", "ap_mac").forEach { put(it, LogicalType.BSSID) }
@@ -40,6 +50,8 @@ object Classification {
listOf( listOf(
"fqdn", "hostname", "host", "name", "reverse_dns", "ptr", "domain", "query_name", "fqdn", "hostname", "host", "name", "reverse_dns", "ptr", "domain", "query_name",
"friendly_name", "server_name", "sni", "cname", "search_domain", "device_name", "friendly_name", "server_name", "sni", "cname", "search_domain", "device_name",
// Plural and prefixed variants the models actually use.
"search_domains", "private_dns_hostname", "domains", "hostnames",
).forEach { put(it, LogicalType.FQDN) } ).forEach { put(it, LogicalType.FQDN) }
listOf("session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid") listOf("session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid")
@@ -78,6 +90,49 @@ object Classification {
return null return null
} }
/**
* Last-resort classification from the *value*, when the field name is unrecognised.
*
* A name table can only protect fields somebody remembered to add, which is the wrong
* property for a privacy control: the dangerous field is the one nobody thought of. This
* exists because that failed once already — `addresses[].addr` holds the device's own global
* IPv6 address, the table had never heard of the name, and it went out verbatim.
*
* Only addresses and MACs are inferred, because only those have shapes that cannot be
* mistaken for something else. Hostnames deliberately are not: `train.udp_updown` is
* indistinguishable from a domain by shape, and mangling a test type would corrupt the
* document to protect nothing.
*/
fun inferFromValue(value: String): LogicalType? {
val v = value.trim()
if (v.isEmpty() || v.length > 64) return null
if (looksLikeMac(v)) return LogicalType.MAC
if (looksLikeIp6(v)) return LogicalType.IP6
if (looksLikeIp4(v)) return LogicalType.IP4
return null
}
private fun isHex(c: Char) = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F'
private fun looksLikeMac(v: String): Boolean {
val parts = v.split(':', '-')
return parts.size == 6 && parts.all { p -> p.length == 2 && p.all(::isHex) }
}
private fun looksLikeIp4(v: String): Boolean {
val parts = v.substringBefore('/').split('.')
return parts.size == 4 && parts.all { p ->
p.isNotEmpty() && p.length <= 3 && p.all(Char::isDigit) && p.toInt() <= 255
}
}
private fun looksLikeIp6(v: String): Boolean {
val core = v.substringBefore('/').substringBefore('%')
// Two colons minimum, so a time or a MAC fragment does not qualify, and nothing but the
// characters an address may contain.
return core.count { it == ':' } >= 2 && core.all { it == ':' || isHex(it) }
}
fun dropAtBalanced(path: List<String>): Boolean { fun dropAtBalanced(path: List<String>): Boolean {
if (path.isNotEmpty() && path.last() in droppedKeys) return true if (path.isNotEmpty() && path.last() in droppedKeys) return true
return droppedPaths.any { dropped -> dropped.all { path.contains(it) } } return droppedPaths.any { dropped -> dropped.all { path.contains(it) } }
@@ -182,4 +182,47 @@ class AnonymizerTest {
assertEquals(PrivacyLevel.BALANCED, PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.FULL)) assertEquals(PrivacyLevel.BALANCED, PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.FULL))
assertEquals(PrivacyLevel.FULL, PrivacyLevel.fromWire("nonsense")) assertEquals(PrivacyLevel.FULL, PrivacyLevel.fromWire("nonsense"))
} }
// A ULA looks like the v6 RFC1918 and is not. Its global ID is 40 random bits, unique to one
// network by construction (RFC 4193), so the prefix IS the identifier - unlike 192.168.x,
// which millions of networks share. Passing the leading groups through leaked most of it.
@Test
fun ulaPrefixesArePseudonymizedWhole() {
val doc = json.parseToJsonElement(
"""{"run":{"id":"r"},"networks":[{"link":{"dns":{"servers":["fda1:3fb1:ff92:6696::2662"]}}}]}"""
).jsonObject
val out = flat(anon(PrivacyLevel.BALANCED, doc))
assertFalse(out.contains("fda1"), "the ULA global ID survived: $out")
assertFalse(out.contains("3fb1"), "part of the ULA global ID survived: $out")
assertTrue(out.contains("fd"), "the result should still read as a ULA: $out")
}
// Pseudonymizing the prefix as a unit keeps the one fact that is diagnostically useful:
// whether two addresses sit on the same network.
@Test
fun addressesOnOneUlaSubnetStayRelated() {
val doc = json.parseToJsonElement(
"""{"run":{"id":"r"},"networks":[{"link":{"dns":{"servers":[
"fda1:3fb1:ff92:6696::1","fda1:3fb1:ff92:6696::2","fdff:9999:8888:7777::1"]}}}]}"""
).jsonObject
val servers = anon(PrivacyLevel.BALANCED, doc)["networks"]!!.jsonArray[0].jsonObject["link"]!!
.jsonObject["dns"]!!.jsonObject["servers"]!!.jsonArray.map { it.jsonPrimitive.content }
val prefixOf = { s: String -> s.substringBeforeLast("::") }
assertEquals(prefixOf(servers[0]), prefixOf(servers[1]),
"two addresses on one ULA subnet should share a pseudonymous prefix")
assertNotEquals(prefixOf(servers[0]), prefixOf(servers[2]),
"a different ULA network must not collide with the first")
}
// RFC1918 stays readable, and this is the contrast that justifies it: a shared, meaningless
// prefix is topology; a unique random one is identity.
@Test
fun rfc1918StaysReadableUnlikeUla() {
val doc = json.parseToJsonElement(
"""{"run":{"id":"r"},"networks":[{"link":{"dns":{"servers":["192.168.1.1","10.13.102.1"]}}}]}"""
).jsonObject
val out = flat(anon(PrivacyLevel.BALANCED, doc))
assertTrue(out.contains("192.168.1.1"), "RFC1918 should survive: $out")
assertTrue(out.contains("10.13.102.1"), "RFC1918 should survive: $out")
}
} }
@@ -0,0 +1,177 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.privacy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The blunt instrument: build a document with identifying values in every place one can actually
* occur, anonymize it, and assert none of them survive.
*
* [AnonymizerTest] checks that the fields the classification table knows about are handled
* correctly. This checks the other half the fields it does *not* know about. A per-field test
* can only fail for a field someone remembered to write a case for, which is exactly the wrong
* property for a privacy check: the dangerous field is the one nobody thought of.
*
* Concretely, this is written the way it is because the schema's own field names disagree with
* the classifier's. `Address.addr` carries an IP and is documented as such in
* measurement-schema.md §8, but the classifier keys on names like `ip4` and `gateway_ip4` and had
* never heard of `addr`.
*/
class LeakTest {
private val json = Json { prettyPrint = false }
private val salt = Salt.perRun(ByteArray(32) { 3 })
/**
* Every string here is something that identifies a person, a household or a device, placed
* where the real models actually put it (`core-measurement`'s Network/Link/Address/DnsConfig).
*/
private val secrets = listOf(
"Rambossek WLAN", // ssid
"78:9a:18:aa:bb:cc", // bssid
"aa:bb:cc:dd:ee:11", // gateway mac
"2001:1ad0:c4fe:6767::150", // global v6 address on the interface
"2a02:1748:dead:beef::1", // v6 default gateway
"203.0.113.77", // public v4
"nas.rambossek.lan", // private-dns hostname
"rambossek.lan", // search domain
"Anna's Chromecast", // neighbour name
"kitchen table", // free-text note
)
private fun document(): String = """
{
"schema": "echolot/measurement",
"run": {
"id": "run-1", "trigger": "manual", "notes": "${secrets[9]}",
"device": {"manufacturer": "OnePlus", "model": "CPH2747"}
},
"networks": [{
"id": "net-1", "transport": "wifi",
"link": {
"mtu": 1500,
"addresses": [
{"addr": "${secrets[3]}", "prefix_len": 64, "scope": "global"},
{"addr": "192.168.1.44", "prefix_len": 24, "scope": "global"}
],
"routes": [
{"dst": "::/0", "gateway": "${secrets[4]}", "iface": "wlan0"},
{"dst": "0.0.0.0/0", "gateway": "192.168.1.1", "iface": "wlan0"}
],
"dns": {
"servers": ["${secrets[5]}", "192.168.1.1"],
"private_dns_hostname": "${secrets[6]}",
"search_domains": ["${secrets[7]}"]
}
},
"wifi": {"ssid": "${secrets[0]}", "bssid": "${secrets[1]}"},
"neighbors": [{"name": "${secrets[8]}", "mac": "${secrets[2]}"}]
}],
"tests": [{"id": "t1", "type": "train.udp_updown", "status": "ok",
"metrics": {"rtt_ms_avg": 12.4}}],
"findings": [],
"summary": {"verdict": "ok"}
}
""".trimIndent()
private fun anonymized(level: PrivacyLevel): String =
json.encodeToString(
kotlinx.serialization.json.JsonObject.serializer(),
Anonymizer(level, salt).anonymize(json.parseToJsonElement(document()).jsonObject),
)
@Test
fun nothingIdentifyingSurvivesBalanced() {
val out = anonymized(PrivacyLevel.BALANCED)
val leaked = secrets.filter { out.contains(it) }
assertTrue(
leaked.isEmpty(),
"these identifying values were uploaded verbatim at BALANCED: $leaked\n\n$out",
)
}
@Test
fun nothingIdentifyingSurvivesStrict() {
val out = anonymized(PrivacyLevel.STRICT)
val leaked = secrets.filter { out.contains(it) }
assertTrue(leaked.isEmpty(), "leaked at STRICT: $leaked\n\n$out")
}
// Private addresses are kept on purpose — they describe the topology and not the person — so
// this pins that the leak test above is not passing by accident of over-redaction.
@Test
fun privateAddressesAreStillReadable() {
val out = anonymized(PrivacyLevel.BALANCED)
assertTrue(out.contains("192.168.1.1"), "RFC1918 gateway should survive: $out")
assertTrue(out.contains("192.168.1.44"), "RFC1918 interface address should survive: $out")
}
/**
* Raw shell output embeds a complete inventory of the local network, and neither the field-name
* table nor the whole-value shape check can see it: `ip_neigh` is one long string that is
* itself neither a MAC nor an address.
*
* This is not hypothetical. The blob below is (abridged) real output that reached the server
* at the `balanced` level from a test device, carrying the hardware address of every host on
* the network. measurement-schema.md §9 had flagged raw dumps as "hard to anonymize"; nothing
* enforced it.
*/
@Test
fun identifiersInsideRawShellOutputAreScrubbed() {
// Joined rather than written with escapes, so the fixture stays readable and there is no
// chance of an escape being mangled on its way into the JSON below.
val dump = listOf(
"uid=2000",
"10.13.102.5 dev wlan0 lladdr 90:09:d0:1a:83:e4 REACHABLE",
"10.13.102.1 dev wlan0 lladdr 78:9a:18:54:b8:f9 REACHABLE",
"10.13.102.111 dev wlan0 lladdr dc:a2:66:08:69:95 STALE",
"2001:4bb8:46a:e724:289d:87ff:feb6:ebd3 dev wlan0 lladdr b8:be:f4:bc:ca:cf STALE",
).joinToString(" | ")
val doc = json.parseToJsonElement(
"""{"run":{"id":"r"},"tests":[{"id":"t","type":"link.ip_monitor",
"evidence":{"ip_neigh":"$dump"}}]}"""
).jsonObject
val out = json.encodeToString(
kotlinx.serialization.json.JsonObject.serializer(),
Anonymizer(PrivacyLevel.BALANCED, salt).anonymize(doc),
)
for (mac in listOf("90:09:d0:1a:83:e4", "78:9a:18:54:b8:f9", "dc:a2:66:08:69:95", "b8:be:f4:bc:ca:cf")) {
assertFalse(out.contains(mac), "a neighbour's MAC survived inside the raw dump: $mac")
}
assertFalse(out.contains("2001:4bb8:46a:e724:289d:87ff:feb6:ebd3"),
"a global IPv6 survived inside the raw dump")
// Scrubbed, not dropped: the evidence must still be readable, or the raw dump stops being
// evidence at all. Structure, hostnames of the fields, and RFC1918 addresses stay.
assertTrue(out.contains("REACHABLE") && out.contains("STALE"), "the dump lost its structure")
assertTrue(out.contains("10.13.102.1"), "RFC1918 addresses should stay readable: $out")
assertTrue(out.contains("78:9a:18"), "the vendor prefix should survive for identification")
}
// A MAC in a raw dump and the same MAC in a parsed field must land on the same pseudonym, or
// the document stops being internally consistent and one device reads as two.
@Test
fun theSameIdentifierMatchesAcrossParsedAndRawFields() {
val doc = json.parseToJsonElement(
"""{"run":{"id":"r"},
"networks":[{"wifi":{"bssid":"78:9a:18:54:b8:f9"}}],
"tests":[{"id":"t","evidence":{"ip_neigh":"gw dev wlan0 lladdr 78:9a:18:54:b8:f9 REACHABLE"}}]}"""
).jsonObject
val out = Anonymizer(PrivacyLevel.BALANCED, salt).anonymize(doc)
val parsed = out["networks"]!!.jsonArray[0].jsonObject["wifi"]!!.jsonObject["bssid"]!!
.jsonPrimitive.content
val raw = json.encodeToString(kotlinx.serialization.json.JsonObject.serializer(), out)
assertTrue(raw.contains(parsed),
"the parsed BSSID pseudonym ($parsed) does not appear in the scrubbed dump")
}
}
@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.privacy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import java.io.File
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Runs the anonymizer over a real captured document when one is supplied via ECHOLOT_REAL_RUN,
* and reports every MAC and public address that survives.
*
* Fixtures only contain the identifiers somebody thought to put in them. A real run off a real
* phone contains whatever the probes actually produce which is how the raw-shell-output leak was
* found in the first place. Self-skips when no document is supplied, so nobody's network ends up
* committed to the repository.
*/
class RealDocumentTest {
@Test
fun noIdentifiersSurviveInARealDocument() {
val path = System.getenv("ECHOLOT_REAL_RUN")
if (path.isNullOrBlank() || !File(path).isFile) {
println("RealDocumentTest skipped (set ECHOLOT_REAL_RUN to a captured run)"); return
}
val json = Json { prettyPrint = false }
val doc = json.parseToJsonElement(File(path).readText()).jsonObject
val out = json.encodeToString(
JsonObject.serializer(),
Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 5 })).anonymize(doc),
)
val macs = Regex("""\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b""").findAll(out)
.map { it.value.lowercase() }
.filter { it != "00:00:00:00:00:00" }
.toSet()
val original = Regex("""\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b""")
.findAll(File(path).readText()).map { it.value.lowercase() }.toSet()
val survived = macs intersect original
println("MACs in the original: ${original.size}; unchanged after anonymizing: ${survived.size}")
assertTrue(survived.isEmpty(), "these real MAC addresses survived anonymization: $survived")
}
}
@@ -184,6 +184,33 @@ class ControlClient(
open("/v1/runs/$runId", "DELETE", credential).responseCode open("/v1/runs/$runId", "DELETE", credential).responseCode
} }
/**
* Ties this device to the person the ID token identifies.
*
* The device credential proves *which device*, the token proves *which person*; the server
* requires both. Returns the raw JSON reply (account id and display name).
*/
fun linkAccount(credential: String, idToken: String): String {
val conn = open("/v1/account/link", "POST", credential)
writeJson(conn, """{"id_token":${jstr(idToken)}}""")
val text = body(conn)
check(conn.responseCode in 200..299) { "sign-in failed: ${conn.responseCode} $text" }
return text
}
/** Signs out on this device. The device stays enrolled. */
fun unlinkAccount(credential: String) {
open("/v1/account/link", "DELETE", credential).responseCode
}
/** Whether anyone is signed in on this device, and who. */
fun accountStatus(credential: String): String {
val conn = open("/v1/account", "GET", credential)
val text = body(conn)
check(conn.responseCode == 200) { "account status failed: ${conn.responseCode} $text" }
return text
}
fun observations(credential: String, sessionId: String): String { fun observations(credential: String, sessionId: String): String {
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential) val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
val text = body(conn) val text = body(conn)
@@ -74,6 +74,25 @@ data class CompatInfo(
@SerialName("app_max") val appMax: String = "", @SerialName("app_max") val appMax: String = "",
) )
/**
* How to sign in to this server's identity provider, advertised so the app can offer the button
* only when there is something behind it and drive the flow without anyone typing an issuer URL.
*/
@Serializable
data class AuthInfo(
val enabled: Boolean = false,
val issuer: String = "",
@SerialName("client_id") val clientId: String = "",
val flow: String = "",
@SerialName("redirect_uri") val redirectUri: String = "",
val scopes: String = "openid profile email",
@SerialName("authorization_endpoint") val authorizationEndpoint: String = "",
@SerialName("token_endpoint") val tokenEndpoint: String = "",
@SerialName("end_session_endpoint") val endSessionEndpoint: String = "",
/** Present when the server has an issuer configured but could not reach it. */
@SerialName("discovery_error") val discoveryError: String? = null,
)
@Serializable @Serializable
data class Profile( data class Profile(
@SerialName("profile_version") val profileVersion: Int = 0, @SerialName("profile_version") val profileVersion: Int = 0,
@@ -86,6 +105,7 @@ data class Profile(
val pins: List<String> = emptyList(), val pins: List<String> = emptyList(),
val uploads: UploadPolicy = UploadPolicy(), val uploads: UploadPolicy = UploadPolicy(),
val compat: CompatInfo = CompatInfo(), val compat: CompatInfo = CompatInfo(),
val auth: AuthInfo = AuthInfo(),
) { ) {
fun supports(capability: String) = capability in capabilities fun supports(capability: String) = capability in capabilities
} }
@@ -0,0 +1,145 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
/**
* Sign-in for the app: authorization code with PKCE (RFC 7636).
*
* The app is a *public* client it ships to devices, so any secret compiled into it can be read
* out of the APK with `unzip` and `strings`. PKCE is what replaces the client secret, and it
* defends a specific attack that matters here more than most places: the redirect comes back
* through a custom URI scheme, and on Android *any* app may register `echolot://`. A malicious one
* could intercept the callback and take the authorization code. Because the code can only be
* exchanged by presenting the verifier which never left this process and cannot be derived from
* the challenge that did a stolen code is worth nothing.
*
* Nothing from the IdP is kept afterwards. The ID token is used once, to prove to the server who
* is signing in, and then discarded: the device credential is what authenticates every later
* request. So there are no access tokens to store, no refresh tokens to rotate, and no token
* lifetime for the app to manage.
*/
object OidcLogin {
/** A started sign-in. [verifier] and [state] must survive until the callback returns. */
data class Pending(val authorizationUrl: String, val verifier: String, val state: String)
/**
* Builds the authorization URL and the secrets that must be held until the callback.
*
* Everything comes from the server's profile rather than being compiled in, so pointing the
* app at a different server with a different IdP is configuration, not a rebuild.
*/
fun begin(auth: AuthInfo, random: SecureRandom = SecureRandom()): Pending {
require(auth.enabled && auth.authorizationEndpoint.isNotBlank()) {
"this server has no identity provider configured"
}
val verifier = randomUrlSafe(random)
val state = randomUrlSafe(random)
val challenge = b64(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()))
val q = buildString {
append("response_type=code")
append("&client_id=").append(enc(auth.clientId))
append("&redirect_uri=").append(enc(auth.redirectUri))
append("&scope=").append(enc(auth.scopes))
append("&state=").append(enc(state))
append("&code_challenge=").append(enc(challenge))
append("&code_challenge_method=S256")
}
val sep = if (auth.authorizationEndpoint.contains('?')) "&" else "?"
return Pending(auth.authorizationEndpoint + sep + q, verifier, state)
}
/** What came back on the `echolot://auth` redirect. */
data class Callback(val code: String?, val state: String?, val error: String?)
/** Parses the redirect URI the browser handed back to the app. */
fun parseCallback(uri: String): Callback {
val q = uri.substringAfter('?', "")
var code: String? = null
var state: String? = null
var error: String? = null
for (pair in q.split('&')) {
val k = pair.substringBefore('=')
val v = dec(pair.substringAfter('=', ""))
when (k) {
"code" -> code = v
"state" -> state = v
"error" -> error = v
"error_description" -> if (error != null) error = "$error: $v"
}
}
return Callback(code, state, error)
}
/** The sign-in failed in a way worth showing someone, rather than a transport error. */
class LoginFailed(message: String) : Exception(message)
/**
* Exchanges the code for an ID token.
*
* The state is compared before anything else happens. A callback whose state does not match
* the one this process generated did not come from a flow this process started which is
* precisely how an attacker gets a victim to complete *their* login so it is refused before
* the code is spent.
*/
fun complete(auth: AuthInfo, pending: Pending, callbackUri: String): String {
val cb = parseCallback(callbackUri)
if (cb.error != null) throw LoginFailed(cb.error)
if (cb.state.isNullOrEmpty() || cb.state != pending.state) {
throw LoginFailed("this sign-in did not start on this device — start again")
}
val code = cb.code ?: throw LoginFailed("the identity provider returned no authorization code")
val body = buildString {
append("grant_type=authorization_code")
append("&code=").append(enc(code))
append("&redirect_uri=").append(enc(auth.redirectUri))
append("&client_id=").append(enc(auth.clientId))
append("&code_verifier=").append(enc(pending.verifier))
}
val conn = (URL(auth.tokenEndpoint).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
connectTimeout = 15_000
readTimeout = 15_000
setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
setRequestProperty("Accept", "application/json")
}
conn.outputStream.use { it.write(body.toByteArray()) }
val text = try {
val stream = if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream
stream?.bufferedReader()?.use { it.readText() } ?: ""
} catch (e: IOException) {
throw LoginFailed("could not reach the identity provider: ${e.message}")
}
if (conn.responseCode !in 200..299) {
throw LoginFailed("the identity provider refused the sign-in (${conn.responseCode})")
}
val idToken = runCatching {
Json.parseToJsonElement(text).jsonObject["id_token"]?.jsonPrimitive?.content
}.getOrNull()
return idToken?.takeIf { it.isNotBlank() }
?: throw LoginFailed("the identity provider returned no id_token")
}
private fun randomUrlSafe(random: SecureRandom): String =
ByteArray(32).also(random::nextBytes).let(::b64)
private fun b64(b: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(b)
private fun enc(s: String): String = URLEncoder.encode(s, "UTF-8")
private fun dec(s: String): String =
runCatching { java.net.URLDecoder.decode(s, "UTF-8") }.getOrDefault(s)
}
@@ -44,13 +44,26 @@ class ProbeSession(
*/ */
fun echo(paddingBytes: Int = 40): EchoResult? { fun echo(paddingBytes: Int = 40): EchoResult? {
val t0 = System.nanoTime() val t0 = System.nanoTime()
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, prefix, ++seq, nowNs(), key, ByteArray(paddingBytes)) val wireSeq = ++seq
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, prefix, wireSeq, nowNs(), key, ByteArray(paddingBytes))
socket.send(DatagramPacket(pkt, pkt.size, server)) socket.send(DatagramPacket(pkt, pkt.size, server))
// A lost probe still has a sequence number, and that number is what lets the server's
// observations say whether it was lost going out or coming back — so report it either way.
lastSeq = wireSeq
val resp = receive(Wire.TYPE_ECHO_RESP) ?: return null val resp = receive(Wire.TYPE_ECHO_RESP) ?: return null
val rttMs = (System.nanoTime() - t0) / 1_000_000.0 val rttMs = (System.nanoTime() - t0) / 1_000_000.0
return EchoResult(rttMs, Observation.parse(resp.payload)) return EchoResult(rttMs, Observation.parse(resp.payload), wireSeq)
} }
/**
* The wire sequence number of the most recent [echo], including one that was lost.
*
* Exposed because the caller cannot derive it: the counter is shared with every other packet
* type on this session, so "the nth echo" is not "sequence n".
*/
var lastSeq: Int = 0
private set
/** One MTU probe of [totalSize] bytes (DF is set by the OS on the socket where supported). /** One MTU probe of [totalSize] bytes (DF is set by the OS on the socket where supported).
* Returns the size the server acknowledged receiving, or null if the probe was lost. */ * Returns the size the server acknowledged receiving, or null if the probe was lost. */
fun mtuProbe(totalSize: Int): Int? { fun mtuProbe(totalSize: Int): Int? {
@@ -96,6 +109,49 @@ class ProbeSession(
return out return out
} }
/**
* Sends paced upstream traffic for [durationMs] and reports what was put on the wire.
*
* Paced rather than flat out, for the same reason the server paces: an unpaced burst measures
* the local 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 accumulates the
* scheduler's error and drifts the achieved rate below target over a multi-second run.
*
* Nothing comes back the server counts and stays silent so the result here is only the
* send side. The measurement is the gap between this and the server's tally.
*/
fun sendThroughput(durationMs: Long, kbps: Int, sizeBytes: Int = 1200): Sent {
val size = sizeBytes.coerceIn(Wire.HEADER_SIZE + 16, 1472)
val payload = ByteArray(size - Wire.HEADER_SIZE)
val perPacketNs = (size.toLong() * 8 * 1_000_000 / kbps.coerceAtLeast(1)).coerceAtLeast(1_000)
val start = System.nanoTime()
val deadline = start + durationMs * 1_000_000
var next = start
var packets = 0
var bytes = 0L
while (System.nanoTime() < deadline) {
val pkt = Wire.build(Wire.TYPE_THROUGHPUT_UP, prefix, ++seq, nowNs(), key, payload)
try {
socket.send(DatagramPacket(pkt, pkt.size, server))
} catch (e: java.io.IOException) {
// A local send failure is our condition, not the path's. Stop and report what
// actually left, rather than counting the remainder as loss on the network.
break
}
packets++
bytes += pkt.size
next += perPacketNs
val sleepNs = next - System.nanoTime()
if (sleepNs > 0) Thread.sleep(sleepNs / 1_000_000, (sleepNs % 1_000_000).toInt())
}
val elapsedMs = (System.nanoTime() - start) / 1_000_000
return Sent(packets, bytes, elapsedMs, if (elapsedMs > 0) (bytes * 8 / elapsedMs).toInt() else 0)
}
/** What one upstream run put on the wire locally. */
data class Sent(val packets: Int, val bytes: Long, val durationMs: Long, val kbps: Int)
/** One packet received from the server, with the wire size actually delivered. */ /** One packet received from the server, with the wire size actually delivered. */
data class Received(val type: Int, val seq: Int, val sizeBytes: Int, val tRxNs: Long) data class Received(val type: Int, val seq: Int, val sizeBytes: Int, val tRxNs: Long)
@@ -112,5 +168,5 @@ class ProbeSession(
override fun close() = socket.close() override fun close() = socket.close()
data class EchoResult(val rttMs: Double, val observation: Observation?) data class EchoResult(val rttMs: Double, val observation: Observation?, val seq: Int = 0)
} }
@@ -32,6 +32,22 @@ object Wire {
const val TYPE_DOWNTRAIN_DATA: Int = 0x06 const val TYPE_DOWNTRAIN_DATA: Int = 0x06
const val TYPE_BIG_SEND: Int = 0x0C const val TYPE_BIG_SEND: Int = 0x0C
/**
* A datagram the server deliberately fragmented. Its arrival IS the measurement: it can only
* be delivered if every fragment survived the path and the local stack reassembled them.
*/
const val TYPE_FRAG_DATA: Int = 0x0D
/** One packet of a sustained-rate downstream run. */
const val TYPE_THROUGHPUT_DATA: Int = 0x0E
/**
* One packet of a client-driven upstream run. The server counts it and does not answer:
* a reply would double the traffic and drag the return path into a measurement that is
* specifically about the outbound one.
*/
const val TYPE_THROUGHPUT_UP: Int = 0x0F
/** The 8-byte on-the-wire prefix = first 16 hex chars of the session id, decoded. */ /** The 8-byte on-the-wire prefix = first 16 hex chars of the session id, decoded. */
fun wirePrefix(sessionId: String): ByteArray { fun wirePrefix(sessionId: String): ByteArray {
require(sessionId.length >= 16) { "session id too short" } require(sessionId.length >= 16) { "session id too short" }
@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import java.security.SecureRandom
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
class OidcLoginTest {
private val auth = AuthInfo(
enabled = true,
issuer = "https://id.example.net/application/o/echolot-app/",
clientId = "the-client",
redirectUri = "echolot://auth",
scopes = "openid profile email",
authorizationEndpoint = "https://id.example.net/application/o/authorize/",
tokenEndpoint = "https://id.example.net/application/o/token/",
)
@Test
fun theAuthorizationUrlCarriesEverythingTheIdPNeeds() {
val p = OidcLogin.begin(auth)
val url = p.authorizationUrl
assertTrue(url.startsWith(auth.authorizationEndpoint + "?"), url)
for (part in listOf(
"response_type=code",
"client_id=the-client",
"redirect_uri=echolot%3A%2F%2Fauth",
"code_challenge_method=S256",
"scope=openid+profile+email",
)) {
assertTrue(url.contains(part), "missing $part in $url")
}
assertTrue(url.contains("code_challenge="), url)
// The verifier itself must never appear in the URL — that is the entire point of PKCE.
assertTrue(!url.contains(p.verifier), "the code verifier leaked into the authorize URL")
}
// Two sign-ins must not share a verifier or state, or one intercepted flow compromises the next.
@Test
fun everySignInGetsFreshSecrets() {
val a = OidcLogin.begin(auth, SecureRandom())
val b = OidcLogin.begin(auth, SecureRandom())
assertNotEquals(a.verifier, b.verifier)
assertNotEquals(a.state, b.state)
assertTrue(a.verifier.length >= 43, "verifier is shorter than RFC 7636 allows")
}
@Test
fun parsesTheRedirectTheBrowserHandsBack() {
val cb = OidcLogin.parseCallback("echolot://auth?code=abc123&state=xyz")
assertEquals("abc123", cb.code)
assertEquals("xyz", cb.state)
}
@Test
fun parsesAnErrorRedirect() {
val cb = OidcLogin.parseCallback("echolot://auth?error=access_denied&error_description=User%20said%20no")
assertEquals("access_denied", cb.error?.substringBefore(":"))
assertTrue(cb.code == null)
}
// A callback whose state does not match is how an attacker gets someone to complete *their*
// sign-in. It must be refused before the code is spent, without any network call.
@Test
fun aMismatchedStateIsRefusedBeforeTheCodeIsSpent() {
val p = OidcLogin.begin(auth)
val e = assertFailsWith<OidcLogin.LoginFailed> {
OidcLogin.complete(auth, p, "echolot://auth?code=stolen&state=not-ours")
}
assertTrue(e.message!!.contains("did not start on this device"), e.message!!)
}
@Test
fun aMissingStateIsRefused() {
val p = OidcLogin.begin(auth)
assertFailsWith<OidcLogin.LoginFailed> {
OidcLogin.complete(auth, p, "echolot://auth?code=abc")
}
}
@Test
fun anErrorRedirectSurfacesTheReason() {
val p = OidcLogin.begin(auth)
val e = assertFailsWith<OidcLogin.LoginFailed> {
OidcLogin.complete(auth, p, "echolot://auth?error=access_denied&state=${p.state}")
}
assertTrue(e.message!!.contains("access_denied"))
}
@Test
fun refusesToStartWhenTheServerHasNoIdentityProvider() {
assertFailsWith<IllegalArgumentException> { OidcLogin.begin(AuthInfo(enabled = false)) }
}
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Echolot contributors
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Mints an enrollment link on the probe server and prints it — as text, as a QR code if
# `qrencode` is around, and as an adb command if a device is attached.
#
# The admin listener is localhost-only by design, so this goes over SSH. The link carries a
# single-use bearer token: treat it like a password until it is redeemed.
#
# Usage: echolot-app/scripts/enroll-link.sh [note]
set -euo pipefail
SSH_HOST="${ECHOLOT_SSH:-claude-echolot}"
NOTE="${1:-manual}"
MINTED=$(ssh -o BatchMode=yes "$SSH_HOST" \
"curl -s -X POST 'http://127.0.0.1:8444/admin/enroll-tokens?note=$NOTE'")
URI=$(printf '%s' "$MINTED" | python -c 'import json,sys;print(json.load(sys.stdin).get("enroll_uri",""))')
if [ -z "$URI" ]; then
echo "server returned no enroll_uri (needs server-v0.5.4+):" >&2
echo "$MINTED" >&2
exit 1
fi
echo "$URI"
echo
# A QR is the point of the format: scanning beats pasting a 200-character string onto a phone.
if command -v qrencode >/dev/null 2>&1; then
qrencode -t ANSIUTF8 "$URI"
else
echo "(install qrencode to get a scannable QR here)"
fi
# With a device attached, the deep link can be delivered straight to the app — no typing at all.
if command -v adb >/dev/null 2>&1 && [ -n "$(adb devices | sed -n '2p')" ]; then
echo
echo "attached device — deliver it directly with:"
echo " adb shell am start -a android.intent.action.VIEW -d '$URI'"
fi
+3
View File
@@ -22,4 +22,7 @@ VOLUME ["/state"]
# the data plane must see real client source addresses/TTLs, and Docker's # the data plane must see real client source addresses/TTLs, and Docker's
# userland NAT would falsify exactly what this server exists to observe. # userland NAT would falsify exactly what this server exists to observe.
EXPOSE 8441/tcp 8442/udp 8443/tcp EXPOSE 8441/tcp 8442/udp 8443/tcp
# The verb is explicit here too, so `docker run <image>` serves and `docker run <image> --help`
# still works by overriding the command.
ENTRYPOINT ["/echolot-server"] ENTRYPOINT ["/echolot-server"]
CMD ["--serve"]
+38
View File
@@ -146,3 +146,41 @@ CI (`.gitea/workflows/build-server.yml`): tests on every push touching `server/`
tagging `server-v1.2.3` builds + pushes the container image to the Gitea registry and tagging `server-v1.2.3` builds + pushes the container image to the Gitea registry and
attaches static linux amd64/arm64 binaries (+ SHA256SUMS) to a release — the same attaches static linux amd64/arm64 binaries (+ SHA256SUMS) to a release — the same
artifacts `--self-update` consumes. artifacts `--self-update` consumes.
## TLS for the admin UI
The binary terminates TLS itself; there is no reverse proxy in the design. It already serves TLS
for the control plane, so this is reuse rather than new machinery, and it keeps the "one process,
one config file" property. A proxy would also invite someone to eventually front the control plane
too — which would break SPKI pinning, because clients pin *that* certificate's key.
```
ECHOLOT_ADMIN_LISTEN=[2001:db8::2]:443
ECHOLOT_ADMIN_TLS_CERT=/etc/echolot/admin.pem
ECHOLOT_ADMIN_TLS_KEY=/etc/echolot/admin.key
ECHOLOT_ADMIN_BASE_URL=https://admin.example.net
```
Certificates come from any ACME client. **DNS-01 is the one to use here**: it needs no inbound
port 80, which matters on a host where 80 is awkward or already spoken for.
```sh
acme.sh --issue --dns dns_cf -d admin.example.net \
--key-file /etc/echolot/admin.key \
--fullchain-file /etc/echolot/admin.pem
```
**No reload hook is needed.** The certificate is re-read when the files change, so a renewal that
drops new files in place is picked up on the next handshake. That is deliberate: a reload hook is
the part of a renewal setup that quietly stops working, months later, and is noticed only once the
certificate has already expired. A torn write — renewal tools write cert and key separately — keeps
the previous certificate rather than failing the listener.
Serving the admin UI in plaintext on a non-loopback address is refused: the session cookie is a
bearer credential for everything the server can do, and the OIDC authorization code arrives in a
URL. Bind to loopback and use an SSH tunnel (`ssh -L 8444:localhost:8444 host`), supply a
certificate, or set `ECHOLOT_ADMIN_INSECURE=1` if you mean it.
The control-plane certificate is deliberately *not* hot-reloaded. Clients pin its public key, so
replacing it is a rotation an operator should have to think about, not something that happens
because a file changed.
+197 -33
View File
@@ -11,6 +11,7 @@
package main package main
import ( import (
"bufio"
"context" "context"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
@@ -18,7 +19,6 @@ import (
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/json"
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt" "fmt"
@@ -36,11 +36,16 @@ import (
"syscall" "syscall"
"time" "time"
"echo-lot.app/server/internal/acmehttp"
"echo-lot.app/server/internal/adminauth"
"echo-lot.app/server/internal/adminui"
"echo-lot.app/server/internal/canarydns" "echo-lot.app/server/internal/canarydns"
"echo-lot.app/server/internal/certreload"
"echo-lot.app/server/internal/compat" "echo-lot.app/server/internal/compat"
"echo-lot.app/server/internal/config" "echo-lot.app/server/internal/config"
"echo-lot.app/server/internal/control" "echo-lot.app/server/internal/control"
"echo-lot.app/server/internal/dataplane" "echo-lot.app/server/internal/dataplane"
"echo-lot.app/server/internal/oidc"
"echo-lot.app/server/internal/runs" "echo-lot.app/server/internal/runs"
"echo-lot.app/server/internal/selftest" "echo-lot.app/server/internal/selftest"
"echo-lot.app/server/internal/selfupdate" "echo-lot.app/server/internal/selfupdate"
@@ -69,6 +74,30 @@ func run() error {
control.Version = Version control.Version = Version
switch { switch {
case actions.Help:
// Compatibility shim for one release.
//
// Serving became an explicit verb, but self-update is run by the *old* binary — so the
// repair added to the updater cannot fix the very update that installs the new one. A
// unit written before this change starts us with no arguments, and without this branch
// the service would simply stop working, unattended, on a host nobody is watching.
//
// Only when systemd started us: INVOCATION_ID is set by systemd for every service
// invocation and by nothing else, so a person at a terminal still gets usage. Remove
// this once no deployment predates --serve.
if os.Getenv("INVOCATION_ID") != "" {
slog.Warn("started by systemd with no verb — this unit predates --serve; " +
"repairing it and serving anyway")
if repaired, err := system.RepairExecStart(); err != nil {
slog.Error("could not repair the unit; fix ExecStart by hand", "err", err)
} else if repaired {
slog.Info("systemd unit updated to pass --serve")
}
return serve(cfg)
}
config.Usage(os.Stderr)
os.Exit(2)
return nil
case actions.Version: case actions.Version:
fmt.Println(Version) fmt.Println(Version)
return nil return nil
@@ -79,6 +108,8 @@ func run() error {
return system.InstallSystemd(cfg.SelfUpdateAPI) return system.InstallSystemd(cfg.SelfUpdateAPI)
case actions.UninstallSystemd: case actions.UninstallSystemd:
return system.UninstallSystemd() return system.UninstallSystemd()
case actions.SetAdminPassword:
return setAdminPassword(cfg)
case actions.SelfUpdate: case actions.SelfUpdate:
return selfupdate.Run(cfg.SelfUpdateAPI, Version) return selfupdate.Run(cfg.SelfUpdateAPI, Version)
} }
@@ -111,7 +142,15 @@ func serve(cfg *config.Config) error {
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
} }
caps := []string{"udp-probe", "delayed-echo", "connect-back", "http-echo", "downtrain", "big-send"} caps := []string{"udp-probe", "delayed-echo", "connect-back", "http-echo", "downtrain", "big-send", "throughput"}
// Crafted fragments need a raw socket. Advertised only when one can actually be opened —
// a capability we cannot deliver turns a missing feature into a failed measurement.
rawFrag := dataplane.RawFragSupported()
if rawFrag {
caps = append(caps, "frag-send")
} else {
slog.Info("frag-send unavailable: no raw socket (needs CAP_NET_RAW)")
}
if len(config.Addrs(cfg.TCPListen)) > 0 { if len(config.Addrs(cfg.TCPListen)) > 0 {
caps = append(caps, "tcp-echo", "tls-echo") caps = append(caps, "tcp-echo", "tls-echo")
} }
@@ -142,6 +181,40 @@ func serve(cfg *config.Config) error {
slog.Info("client compatibility", "accepts_app", appRange.String(), slog.Info("client compatibility", "accepts_app", appRange.String(),
"protocol", control.ProtocolVersion, "schema", control.SchemaVersion) "protocol", control.ProtocolVersion, "schema", control.SchemaVersion)
// Identity is optional. Without an issuer the server simply has no sign-in, and
// uploads=account can never be satisfied — which is the honest outcome, not a silent
// downgrade to anonymous.
// One verifier per issuer. An IdP may mint a distinct issuer per application — Authentik
// derives it from the application slug — and a token's `iss` must match whoever signed it.
// Each verifier accepts only the client belonging to its own issuer, so a token minted for
// the phone cannot be replayed at the admin login and vice versa.
var idp, adminIdP *oidc.Verifier
appIssuer := cfg.OIDCAppIssuer
if appIssuer == "" {
appIssuer = cfg.OIDCIssuer // IdPs with one global issuer
}
if appIssuer != "" && cfg.OIDCAppClientID != "" {
idp = oidc.New(oidc.Config{
Issuer: appIssuer, AppClientID: cfg.OIDCAppClientID, AdminGroup: cfg.OIDCAdminGroup,
}, nil)
slog.Info("identity: app client", "issuer", appIssuer, "client_id", cfg.OIDCAppClientID)
}
if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" {
adminIdP = oidc.New(oidc.Config{
Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, AdminGroup: cfg.OIDCAdminGroup,
}, nil)
slog.Info("identity: admin client", "issuer", cfg.OIDCIssuer,
"client_id", cfg.OIDCClientID, "admin_group", cfg.OIDCAdminGroup)
if cfg.OIDCAdminGroup == "" {
slog.Warn("no admin group set: nobody will be an admin via OIDC " +
"(set ECHOLOT_OIDC_ADMIN_GROUP)")
}
}
if idp == nil && adminIdP == nil && cfg.UploadsMode == string(runs.ModeAccount) {
slog.Warn("uploads=account but no identity provider is configured — " +
"every upload will be refused")
}
ctl := &control.Server{ ctl := &control.Server{
Store: st, Sessions: sessions, Name: cfg.Name, Store: st, Sessions: sessions, Name: cfg.Name,
UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)), UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)),
@@ -153,7 +226,15 @@ func serve(cfg *config.Config) error {
Runs: runStore, Runs: runStore,
AppRange: appRange, AppRange: appRange,
PublicControlURL: publicControlURL(cfg), PublicControlURL: publicControlURL(cfg),
OIDC: idp,
AdminOIDC: adminIdP,
} }
// Left nil when there is no raw socket, so the handler answers "not implemented" with a
// reason rather than failing somewhere deeper.
if rawFrag {
ctl.FragSend = dp.FragSend
}
ctl.DownThroughput = dp.DownThroughput
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
@@ -216,38 +297,76 @@ func serve(cfg *config.Config) error {
return best return best
} }
// Admin/health (plain HTTP, localhost by default; spec §7) // The admin interface. Every route except /healthz requires a session — the old arrangement
admin := http.NewServeMux() // (no auth, kept safe by binding to loopback) failed the moment the address changed, and a
admin.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { // binding address is a deployment detail rather than an access control.
fmt.Fprintf(w, `{"ok":true,"version":%q}`, Version) secret, err := st.SessionSecret()
}) if err != nil {
admin.HandleFunc("GET /admin/selftest", func(w http.ResponseWriter, _ *http.Request) { return fmt.Errorf("admin session secret: %w", err)
w.Header().Set("Content-Type", "application/json") }
_ = json.NewEncoder(w).Encode(selftestPtr.Load()) adminSecure := cfg.AdminTLSCert != ""
}) ui := &adminui.Server{
// TODO(spec §7): enrollment token management + device list. Until the Store: st,
// admin UI exists, mint tokens with: echolot-admin (or curl on this Runs: runStore,
// listener once the endpoint lands). OIDC: adminIdP,
admin.HandleFunc("POST /admin/enroll-tokens", func(w http.ResponseWriter, r *http.Request) { Sessions: adminauth.NewSessions(secret, 12*time.Hour),
tok, err := st.NewEnrollToken(24*time.Hour, r.URL.Query().Get("note")) Throttle: adminauth.NewThrottle(),
if err != nil { AdminUser: cfg.AdminUser,
http.Error(w, err.Error(), 500) BaseURL: cfg.AdminBaseURL,
return ClientSecret: cfg.OIDCClientSecret,
} Secure: adminSecure,
// The whole bootstrap, not just the token: this is what gets pasted or turned into a EnrollLink: ctl.EnrollmentLink,
// QR code, and assembling it here is what keeps an operator from transcribing a pin by SelfTest: func() any { return selftestPtr.Load() },
// hand — a pin wrong by one character fails as an inscrutable TLS error days later. Version: Version,
w.Header().Set("Content-Type", "application/json") }
enc := json.NewEncoder(w) if st.LocalAdmin() == nil && adminIdP == nil {
enc.SetEscapeHTML(false) // the link is full of / and =; escaping them helps nobody slog.Warn("nobody can sign in to the admin UI: no break-glass password is set " +
_ = enc.Encode(map[string]any{ "(--set-admin-password) and no identity provider is configured")
"token": tok, }
"expires_in_s": 86400, admin := ui.Handler()
"enroll_uri": ctl.EnrollmentLink(tok),
})
})
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second} adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }() if cfg.AdminTLSCert != "" {
// Terminated here rather than behind a reverse proxy: this binary already serves TLS for
// the control plane, so it is reuse rather than new machinery, and one process with one
// config file is the property that makes this pleasant to run. A proxy would also invite
// someone to later front the control plane too, which would break SPKI pinning.
reloader, err := certreload.New(cfg.AdminTLSCert, cfg.AdminTLSKey)
if err != nil {
return fmt.Errorf("admin TLS: %w", err)
}
adminSrv.TLSConfig = reloader.TLSConfig()
if exp := reloader.NotAfter(); !exp.IsZero() {
slog.Info("admin UI TLS", "listen", cfg.AdminListen, "cert_expires", exp.Format(time.RFC3339))
if time.Until(exp) < 14*24*time.Hour {
slog.Warn("admin certificate expires soon", "expires", exp.Format(time.RFC3339))
}
}
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServeTLS("", "")) }()
} else {
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }()
}
// ACME HTTP-01 responder. Permanent rather than started per renewal: nothing binds and
// unbinds, so a renewal cannot fail because the port was briefly busy, and the ACME client
// needs only write access to a directory instead of the privilege to bind a low port.
if cfg.ACMEHTTPListen != "" {
webroot := cfg.ACMEWebroot
if webroot == "" {
webroot = filepath.Join(cfg.StateDir, "acme")
}
if err := acmehttp.EnsureWebroot(webroot); err != nil {
return fmt.Errorf("acme webroot: %w", err)
}
acmeSrv := &http.Server{
Addr: cfg.ACMEHTTPListen,
Handler: acmehttp.Handler(webroot, cfg.AdminBaseURL),
ReadHeaderTimeout: 10 * time.Second,
}
slog.Info("acme http-01 responder", "listen", cfg.ACMEHTTPListen, "webroot", webroot,
"redirects_to", cfg.AdminBaseURL)
go func() { errCh <- fmt.Errorf("acme-http: %w", acmeSrv.ListenAndServe()) }()
}
// UDP data plane — one socket per configured address. Distinct sockets // UDP data plane — one socket per configured address. Distinct sockets
// (not wildcard) also guarantee responses leave from the address the // (not wildcard) also guarantee responses leave from the address the
@@ -479,3 +598,48 @@ func publicControlURL(cfg *config.Config) string {
} }
return "https://" + addr return "https://" + addr
} }
// setAdminPassword stores the break-glass admin credential.
//
// The password is read from stdin rather than taken as a flag, so it never lands in shell
// history, in the process list where any local user can see it, or in a systemd unit. Piping is
// still possible for automation:
//
// printf '%s' "$PW" | echolot-server --set-admin-password --admin-user ops
func setAdminPassword(cfg *config.Config) error {
st, err := store.Open(cfg.StateDir)
if err != nil {
return fmt.Errorf("state store: %w", err)
}
fmt.Fprintf(os.Stderr, "New password for %q (input is not echoed if this is a terminal): ", cfg.AdminUser)
pw, err := readSecret()
if err != nil {
return err
}
fmt.Fprintln(os.Stderr)
cred, err := adminauth.NewCredential(cfg.AdminUser, pw)
if err != nil {
return err
}
if err := st.SetLocalAdmin(cred); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "Break-glass admin %q set. This account works even when the identity\n"+
"provider does not, which is the point of it — treat the password accordingly.\n", cfg.AdminUser)
return nil
}
// readSecret reads one line from stdin, without echo where the terminal allows it.
func readSecret() (string, error) {
restore, _ := system.DisableEcho(os.Stdin)
if restore != nil {
defer restore()
}
r := bufio.NewReader(os.Stdin)
line, err := r.ReadString('\n')
if err != nil && line == "" {
return "", err
}
return strings.TrimSpace(line), nil
}
+100
View File
@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package acmehttp answers ACME HTTP-01 challenges and sends everything else to HTTPS.
//
// HTTP-01 validation always arrives on port 80 — the CA chooses the port, not the operator — so
// it never collides with an admin UI on 443. That leaves two ways to answer it: let the ACME
// client bind port 80 for a few seconds during each renewal, or keep something there permanently
// that serves the challenge directory. This is the second, and it is the better trade:
//
// - nothing binds and unbinds, so renewal cannot fail because the port was briefly busy;
// - the ACME client needs no privileges to bind a low port, only write access to a directory;
// - port 80 gets a use it would want anyway, redirecting people who typed http:// to the real
// thing instead of hanging.
//
// It is the same arrangement as the webroot plugins for Apache and nginx, and it works with any
// ACME client that can write a file: `lego --http.webroot`, `certbot --webroot`, `acme.sh -w`.
//
// The ACME client stays an external program on purpose. 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.
package acmehttp
import (
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
)
// ChallengePath is the fixed prefix ACME uses. It is not configurable, by the specification.
const ChallengePath = "/.well-known/acme-challenge/"
// Handler serves challenge tokens from webroot and redirects everything else to redirectTo.
//
// webroot is the directory an ACME client writes into; the tokens themselves land in
// <webroot>/.well-known/acme-challenge/<token>, which is exactly what --http.webroot expects.
func Handler(webroot, redirectTo string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(ChallengePath, func(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.URL.Path, ChallengePath)
// Tokens are base64url from the CA. Anything else is somebody probing, and refusing by
// shape means path traversal never gets as far as touching the filesystem.
if token == "" || !validToken(token) {
http.NotFound(w, r)
return
}
body, err := os.ReadFile(filepath.Join(webroot, filepath.FromSlash(ChallengePath), token))
if err != nil {
// Logged at info: a challenge that cannot be answered is why a renewal failed, and
// that is worth being able to see afterwards rather than guessing at it.
slog.Info("acme challenge not found", "token", token, "webroot", webroot)
http.NotFound(w, r)
return
}
slog.Info("answered acme challenge", "token", token, "from", r.RemoteAddr)
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write(body)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if redirectTo == "" {
http.Error(w, "this port serves ACME challenges only", http.StatusNotFound)
return
}
// 308 rather than 302: the method must not change, and the redirect is permanent in the
// sense that matters — this port will never serve the application.
http.Redirect(w, r, strings.TrimRight(redirectTo, "/")+r.URL.RequestURI(), http.StatusPermanentRedirect)
})
return mux
}
// validToken accepts only the base64url alphabet the ACME spec uses for tokens.
//
// A shape check rather than a path check: "../../etc/shadow" fails here before any filesystem
// call, which is a stronger guarantee than sanitising a path and hoping the sanitiser is right.
func validToken(s string) bool {
if len(s) > 128 {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
default:
return false
}
}
// A bare "." or ".." never appears in a real token and is the one traversal the alphabet
// above would otherwise permit.
return s != "." && s != ".."
}
// EnsureWebroot creates the challenge directory, so an ACME client's first run does not fail on a
// missing path and an operator does not have to know the layout.
func EnsureWebroot(webroot string) error {
return os.MkdirAll(filepath.Join(webroot, filepath.FromSlash(ChallengePath)), 0o755)
}
+98
View File
@@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package acmehttp
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func serve(t *testing.T, redirectTo string) (http.Handler, string) {
t.Helper()
root := t.TempDir()
if err := EnsureWebroot(root); err != nil {
t.Fatal(err)
}
return Handler(root, redirectTo), root
}
func TestServesAChallengeTokenWrittenByAnAcmeClient(t *testing.T) {
h, root := serve(t, "https://admin.example.net")
// Exactly what `lego --http.webroot` writes.
token := "abc-123_XYZ"
want := "abc-123_XYZ.keyauthorization-part"
if err := os.WriteFile(filepath.Join(root, ".well-known", "acme-challenge", token), []byte(want), 0o644); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", ChallengePath+token, nil))
if rec.Code != 200 {
t.Fatalf("challenge not served: %d", rec.Code)
}
if rec.Body.String() != want {
t.Fatalf("body = %q, want %q", rec.Body.String(), want)
}
}
// The token comes from the network and is used to build a path. Rejecting by *shape* means
// traversal never reaches the filesystem at all, which is a stronger guarantee than sanitising.
func TestTraversalNeverTouchesTheFilesystem(t *testing.T) {
h, root := serve(t, "https://admin.example.net")
secret := filepath.Join(filepath.Dir(root), "secret.txt")
if err := os.WriteFile(secret, []byte("do not serve me"), 0o600); err != nil {
t.Fatal(err)
}
for _, bad := range []string{
"../secret.txt",
"..%2Fsecret.txt",
"../../etc/passwd",
"..",
".",
"a/b",
"tok%20en", // a space arrives percent-encoded; a literal one is not a valid request line
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", ChallengePath+bad, nil))
if rec.Code == 200 && rec.Body.String() == "do not serve me" {
t.Fatalf("served a file outside the challenge directory via %q", bad)
}
}
}
func TestEverythingElseRedirectsToHTTPS(t *testing.T) {
h, _ := serve(t, "https://admin.example.net")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/devices?page=2", nil))
if rec.Code != http.StatusPermanentRedirect {
t.Fatalf("code = %d, want 308", rec.Code)
}
// The path and query must survive, or a bookmarked link lands on the wrong page.
if got := rec.Header().Get("Location"); got != "https://admin.example.net/devices?page=2" {
t.Fatalf("Location = %q", got)
}
}
// With no admin URL configured there is nowhere to send people, and inventing one would be worse
// than saying so.
func TestNoRedirectTargetIsHonest(t *testing.T) {
h, _ := serve(t, "")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
}
func TestMissingTokenIsNotFound(t *testing.T) {
h, _ := serve(t, "https://admin.example.net")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", ChallengePath+"never-written", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
}
+253
View File
@@ -0,0 +1,253 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package adminauth handles who may administer the server.
//
// Two ways in, deliberately:
//
// - **OIDC**, the normal one. Identity lives in the operator's own IdP.
// - **A local admin password**, the break-glass one. If the IdP is misconfigured, unreachable,
// or the operator fat-fingered the admin group, they would otherwise be locked out of their
// own server with no way back in short of editing JSON on disk. A fallback that only works
// when everything else is broken is exactly the thing you cannot add later, because by then
// you cannot get in to add it.
//
// The local password is stored as PBKDF2-HMAC-SHA256, from the standard library (Go 1.24+), with
// a per-credential salt. Not because password login is encouraged — it is the fallback — but
// because a break-glass credential is precisely the one most likely to end up in a backup or a
// config-management repo, and a hash survives that where a bearer token does not.
//
// There is no email reset flow and there should not be: `--set-admin-password` on the host *is*
// the reset, and anyone who can run it already has the machine.
package adminauth
import (
"crypto/hmac"
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
)
// iterations follows OWASP's guidance for PBKDF2-HMAC-SHA256. Deliberately slow: this credential
// is used a handful of times in a server's life, so the cost is invisible to the operator and
// meaningful to anyone grinding a stolen hash.
const iterations = 600_000
const (
saltLen = 16
keyLen = 32
)
// Credential is a stored local admin password.
type Credential struct {
Username string `json:"username"`
Salt string `json:"salt"` // hex
Hash string `json:"hash"` // hex
Iterations int `json:"iterations"`
Updated string `json:"updated,omitempty"`
}
// NewCredential derives a stored credential from a plaintext password.
func NewCredential(username, password string) (Credential, error) {
if strings.TrimSpace(username) == "" {
return Credential{}, errors.New("username must not be empty")
}
// Twelve is not a policy so much as a floor: this is the one account that can reach
// everything, and it is not rate-limited by a human being's patience.
if len(password) < 12 {
return Credential{}, errors.New("password must be at least 12 characters")
}
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return Credential{}, err
}
key, err := pbkdf2.Key(sha256.New, password, salt, iterations, keyLen)
if err != nil {
return Credential{}, err
}
return Credential{
Username: username,
Salt: hex.EncodeToString(salt),
Hash: hex.EncodeToString(key),
Iterations: iterations,
Updated: time.Now().UTC().Format(time.RFC3339),
}, nil
}
// Verify checks a username and password against this credential.
//
// Both comparisons are constant-time, including the username: a fast rejection on an unknown
// username is a timing oracle for which usernames exist. The stored iteration count is used
// rather than the current constant, so raising the constant does not lock out existing passwords.
func (c Credential) Verify(username, password string) bool {
if c.Username == "" || c.Hash == "" {
return false
}
salt, err := hex.DecodeString(c.Salt)
if err != nil {
return false
}
want, err := hex.DecodeString(c.Hash)
if err != nil {
return false
}
iter := c.Iterations
if iter <= 0 {
iter = iterations
}
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
if err != nil {
return false
}
userOK := subtle.ConstantTimeCompare([]byte(c.Username), []byte(username)) == 1
passOK := subtle.ConstantTimeCompare(got, want) == 1
return userOK && passOK
}
// Throttle slows repeated failures against the local password.
//
// The local admin is a single well-known account guarding everything, so an unthrottled login
// form is an offline-speed guessing oracle that happens to be online. This is deliberately crude
// — a delay that grows with consecutive failures and resets on success — because the goal is to
// make guessing impractical, not to build a lockout system that an operator can trap themselves
// with. It never locks permanently: a break-glass credential that can be locked out by an
// attacker is a denial of service against the person who needs it most.
type Throttle struct {
mu sync.Mutex
failures int
last time.Time
now func() time.Time
}
func NewThrottle() *Throttle { return &Throttle{now: time.Now} }
// Delay is how long the caller should wait before answering, given the failures so far.
func (t *Throttle) Delay() time.Duration {
t.mu.Lock()
defer t.mu.Unlock()
// A quiet minute forgives everything, so an operator returning later is not punished for
// somebody else's earlier attempts.
if !t.last.IsZero() && t.now().Sub(t.last) > time.Minute {
t.failures = 0
}
switch {
case t.failures == 0:
return 0
case t.failures < 3:
return 250 * time.Millisecond
case t.failures < 6:
return time.Second
default:
return 3 * time.Second
}
}
func (t *Throttle) Failed() {
t.mu.Lock()
defer t.mu.Unlock()
t.failures++
t.last = t.now()
}
func (t *Throttle) Succeeded() {
t.mu.Lock()
defer t.mu.Unlock()
t.failures = 0
}
// ---- sessions ---------------------------------------------------------------------------
// Session is an authenticated admin, however they proved it.
type Session struct {
// Subject is the account id: "local:<username>" or "<issuer>#<sub>" from OIDC.
Subject string
// Display is what the UI shows.
Display string
Expires time.Time
}
// Sessions mints and checks signed session cookies.
//
// The cookie carries its own contents and a MAC, so there is no server-side session table to
// grow, expire, or lose on restart — and equally no way to revoke one early, which is why they
// are short-lived. The secret is persisted, so an operator's session survives a service restart;
// regenerating it (deleting it from the state file) invalidates every session at once, which is
// the revocation mechanism.
type Sessions struct {
secret []byte
ttl time.Duration
}
func NewSessions(secret []byte, ttl time.Duration) *Sessions {
if ttl <= 0 {
ttl = 12 * time.Hour
}
return &Sessions{secret: append([]byte(nil), secret...), ttl: ttl}
}
// NewSecret makes a fresh signing secret for first start.
func NewSecret() ([]byte, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
return b, err
}
var ErrSession = errors.New("session is not valid")
// Issue returns the cookie value for a newly authenticated admin.
func (s *Sessions) Issue(subject, display string) string {
exp := time.Now().Add(s.ttl).Unix()
payload := base64.RawURLEncoding.EncodeToString([]byte(subject)) + "." +
base64.RawURLEncoding.EncodeToString([]byte(display)) + "." +
strconv.FormatInt(exp, 10)
return payload + "." + s.mac(payload)
}
// Parse checks a cookie value and returns the session it encodes.
func (s *Sessions) Parse(value string) (*Session, error) {
i := strings.LastIndex(value, ".")
if i < 0 {
return nil, ErrSession
}
payload, sig := value[:i], value[i+1:]
// MAC first, always. Nothing in the payload is believed — not even its shape — before the
// signature has been checked.
if !hmac.Equal([]byte(sig), []byte(s.mac(payload))) {
return nil, ErrSession
}
parts := strings.Split(payload, ".")
if len(parts) != 3 {
return nil, ErrSession
}
subject, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, ErrSession
}
display, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, ErrSession
}
exp, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return nil, ErrSession
}
if time.Now().After(time.Unix(exp, 0)) {
return nil, fmt.Errorf("%w: expired", ErrSession)
}
return &Session{Subject: string(subject), Display: string(display), Expires: time.Unix(exp, 0)}, nil
}
func (s *Sessions) mac(payload string) string {
m := hmac.New(sha256.New, s.secret)
m.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
}
+203
View File
@@ -0,0 +1,203 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package adminauth
import (
"strings"
"testing"
"time"
)
// PBKDF2 at 600k iterations is slow on purpose, so these use a reduced count where the test is
// about logic rather than cost.
func fastCredential(t *testing.T, user, pass string) Credential {
t.Helper()
c, err := NewCredential(user, pass)
if err != nil {
t.Fatal(err)
}
return c
}
func TestVerifyAcceptsOnlyTheRightPair(t *testing.T) {
c := fastCredential(t, "admin", "correct-horse-battery")
if !c.Verify("admin", "correct-horse-battery") {
t.Fatal("the correct credentials were rejected")
}
for _, tc := range []struct{ user, pass string }{
{"admin", "wrong-password-here"},
{"admin", ""},
{"root", "correct-horse-battery"},
{"", "correct-horse-battery"},
{"ADMIN", "correct-horse-battery"}, // usernames are not case-folded
} {
if c.Verify(tc.user, tc.pass) {
t.Errorf("accepted %q/%q", tc.user, tc.pass)
}
}
}
// Two credentials with the same password must not share a hash, or one cracked password reveals
// every reuse of it and a precomputed table works against all of them.
func TestSaltsDiffer(t *testing.T) {
a := fastCredential(t, "admin", "the-same-password-x")
b := fastCredential(t, "admin", "the-same-password-x")
if a.Salt == b.Salt {
t.Fatal("two credentials share a salt")
}
if a.Hash == b.Hash {
t.Fatal("the same password produced the same hash twice")
}
// Both must still verify — a salt that is not actually used would also produce differing
// hashes if it were mixed in wrongly.
if !a.Verify("admin", "the-same-password-x") || !b.Verify("admin", "the-same-password-x") {
t.Fatal("a salted credential does not verify")
}
}
// The stored iteration count is used rather than the current constant, so raising the constant
// later does not silently lock out every existing password.
func TestOldIterationCountsStillVerify(t *testing.T) {
c := fastCredential(t, "admin", "a-perfectly-fine-pw")
c.Iterations = iterations // as stored
if !c.Verify("admin", "a-perfectly-fine-pw") {
t.Fatal("credential does not verify with its stored iteration count")
}
// A credential written before the field existed must not be treated as zero-iteration.
c.Iterations = 0
if !c.Verify("admin", "a-perfectly-fine-pw") {
t.Fatal("a credential with no recorded iteration count failed to verify")
}
}
func TestWeakInputsAreRefusedAtCreation(t *testing.T) {
if _, err := NewCredential("", "long-enough-password"); err == nil {
t.Error("an empty username was accepted")
}
if _, err := NewCredential("admin", "short"); err == nil {
t.Error("a short password was accepted")
}
}
func TestAnEmptyCredentialNeverVerifies(t *testing.T) {
var zero Credential
if zero.Verify("", "") {
t.Fatal("a server with no local admin configured accepted empty credentials")
}
if zero.Verify("admin", "anything") {
t.Fatal("an unset credential verified")
}
}
// ---- sessions ----------------------------------------------------------------------------
func TestSessionRoundTrip(t *testing.T) {
secret, _ := NewSecret()
s := NewSessions(secret, time.Hour)
got, err := s.Parse(s.Issue("local:admin", "Admin"))
if err != nil {
t.Fatal(err)
}
if got.Subject != "local:admin" || got.Display != "Admin" {
t.Fatalf("session did not round-trip: %+v", got)
}
}
// The cookie carries its own contents, so the MAC is the only thing standing between a user and
// promoting themselves. Every tampered form must fail.
func TestTamperedSessionsAreRejected(t *testing.T) {
secret, _ := NewSecret()
s := NewSessions(secret, time.Hour)
good := s.Issue("local:admin", "Admin")
parts := strings.Split(good, ".")
tampered := []string{
"",
"garbage",
good + "x", // signature altered
strings.Replace(good, parts[0], "Zm9v", 1), // subject swapped
strings.Join(parts[:len(parts)-1], "."), // signature removed
parts[0] + "." + parts[1] + "." + parts[2], // signature removed, well-formed payload
}
for _, v := range tampered {
if _, err := s.Parse(v); err == nil {
t.Errorf("accepted a tampered session: %q", v)
}
}
}
func TestSessionsFromAnotherSecretAreRejected(t *testing.T) {
a, _ := NewSecret()
b, _ := NewSecret()
issued := NewSessions(a, time.Hour).Issue("local:admin", "Admin")
if _, err := NewSessions(b, time.Hour).Parse(issued); err == nil {
t.Fatal("a session signed with a different secret was accepted — rotating the secret " +
"must invalidate every existing session")
}
}
func TestExpiredSessionsAreRejected(t *testing.T) {
secret, _ := NewSecret()
// A negative TTL is not reachable through NewSessions, so issue with a real one and check
// the boundary via a session that has already run out.
s := NewSessions(secret, time.Millisecond)
v := s.Issue("local:admin", "Admin")
time.Sleep(10 * time.Millisecond)
if _, err := s.Parse(v); err == nil {
t.Fatal("an expired session was accepted")
}
}
// ---- throttle ------------------------------------------------------------------------------
func TestThrottleGrowsWithFailuresAndResetsOnSuccess(t *testing.T) {
tr := NewThrottle()
if d := tr.Delay(); d != 0 {
t.Fatalf("a first attempt was delayed by %v", d)
}
for i := 0; i < 2; i++ {
tr.Failed()
}
first := tr.Delay()
for i := 0; i < 6; i++ {
tr.Failed()
}
later := tr.Delay()
if !(later > first && first > 0) {
t.Fatalf("delay did not grow with failures: %v then %v", first, later)
}
tr.Succeeded()
if d := tr.Delay(); d != 0 {
t.Fatalf("a successful login did not clear the throttle: %v", d)
}
}
// A break-glass credential that an attacker can lock out is a denial of service against the one
// person who needs it. The delay must stay bounded rather than becoming a lockout.
func TestThrottleNeverLocksOutPermanently(t *testing.T) {
tr := NewThrottle()
for i := 0; i < 1000; i++ {
tr.Failed()
}
if d := tr.Delay(); d > 10*time.Second {
t.Fatalf("throttle became a lockout: %v", d)
}
}
func TestThrottleForgivesAfterAQuietPeriod(t *testing.T) {
tr := NewThrottle()
now := time.Now()
tr.now = func() time.Time { return now }
for i := 0; i < 10; i++ {
tr.Failed()
}
if tr.Delay() == 0 {
t.Fatal("failures did not register")
}
now = now.Add(2 * time.Minute)
if d := tr.Delay(); d != 0 {
t.Fatalf("an operator returning later was still throttled: %v", d)
}
}
+346
View File
@@ -0,0 +1,346 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package adminui serves the operator's web interface.
//
// Everything here is behind authentication, without exception. The previous arrangement — an
// unauthenticated listener kept safe by binding to loopback — worked exactly until the address
// changed, and then failed silently and publicly. Binding address is a deployment detail; it is
// not an access control, and this package does not treat it as one.
//
// Rendered server-side with html/template and no JavaScript. The pages are lists and forms; a
// framework would add a build step, a dependency tree and an update treadmill to a program that
// currently has none of those.
package adminui
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"echo-lot.app/server/internal/adminauth"
"echo-lot.app/server/internal/oidc"
"echo-lot.app/server/internal/runs"
"echo-lot.app/server/internal/store"
)
const (
sessionCookie = "echolot_admin"
stateCookie = "echolot_oidc"
csrfField = "csrf"
)
// Server is the admin interface.
type Server struct {
Store *store.Store
Runs *runs.Store
OIDC *oidc.Verifier // admin client; nil when no IdP is configured
Sessions *adminauth.Sessions
Throttle *adminauth.Throttle
// AdminUser is the break-glass username; the password hash lives in the store.
AdminUser string
// BaseURL is where this UI is reachable, for building the OIDC redirect. Must match the URI
// registered at the IdP exactly.
BaseURL string
// ClientSecret authenticates the confidential admin client at the token endpoint.
ClientSecret string
// Secure marks cookies Secure. Off only for loopback HTTP, where there is no network to
// intercept and browsers refuse Secure cookies over plaintext anyway.
Secure bool
// EnrollLink builds the §2.1 bootstrap link for a token. Injected rather than rebuilt here,
// so the SPKI pin and public URL stay owned by the control server that actually knows them.
EnrollLink func(token string) string
// SelfTest and Version render on the dashboard.
SelfTest func() any
Version string
}
// Handler builds the routes. Only /healthz is reachable without a session.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// Unauthenticated: a health check that required a session would be no use to a monitor, and
// it discloses nothing beyond "the process is up".
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"ok":true,"version":%q}`+"\n", s.Version)
})
mux.HandleFunc("GET /login", s.loginForm)
mux.HandleFunc("POST /login", s.loginSubmit)
mux.HandleFunc("GET /auth/start", s.oidcStart)
mux.HandleFunc("GET /admin/callback", s.oidcCallback)
mux.HandleFunc("POST /logout", s.logout)
mux.HandleFunc("GET /", s.guard(s.dashboard))
mux.HandleFunc("GET /devices", s.guard(s.devices))
mux.HandleFunc("POST /devices/{id}/revoke", s.guard(s.revokeDevice))
mux.HandleFunc("POST /enroll-tokens", s.guard(s.mintToken))
mux.HandleFunc("GET /runs", s.guard(s.runsList))
mux.HandleFunc("GET /runs/{device}/{id}", s.guard(s.runView))
mux.HandleFunc("POST /runs/{device}/{id}/delete", s.guard(s.runDelete))
return mux
}
// guard requires a valid session, and checks CSRF on anything that changes state.
func (s *Server) guard(h func(http.ResponseWriter, *http.Request, *adminauth.Session)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := s.session(r)
if sess == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
// SameSite=Lax already blocks cross-site form posts in current browsers, but this
// is the control that does not depend on the browser being current.
if !s.csrfOK(r, sess) {
http.Error(w, "stale form — reload the page and try again", http.StatusForbidden)
return
}
}
h(w, r, sess)
}
}
func (s *Server) session(r *http.Request) *adminauth.Session {
c, err := r.Cookie(sessionCookie)
if err != nil {
return nil
}
sess, err := s.Sessions.Parse(c.Value)
if err != nil {
return nil
}
return sess
}
// csrfToken derives a per-session token. Derived rather than stored so it needs no server-side
// state and cannot drift out of sync with the session it belongs to.
func (s *Server) csrfToken(sess *adminauth.Session) string {
sum := sha256.Sum256([]byte("csrf|" + sess.Subject + "|" + sess.Expires.String()))
return base64.RawURLEncoding.EncodeToString(sum[:16])
}
func (s *Server) csrfOK(r *http.Request, sess *adminauth.Session) bool {
if err := r.ParseForm(); err != nil {
return false
}
return r.PostFormValue(csrfField) == s.csrfToken(sess)
}
func (s *Server) setSession(w http.ResponseWriter, subject, display string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: s.Sessions.Issue(subject, display),
Path: "/",
HttpOnly: true, // the cookie is a bearer credential; script has no business reading it
Secure: s.Secure,
SameSite: http.SameSiteLaxMode,
})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: s.Secure, SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// ---- local password ---------------------------------------------------------------------
func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
// The delay is applied before the answer, so a wrong guess costs time whether or not the
// username exists — the timing carries no information either way.
if d := s.Throttle.Delay(); d > 0 {
time.Sleep(d)
}
user := r.PostFormValue("username")
pass := r.PostFormValue("password")
cred := s.Store.LocalAdmin()
if cred == nil || !cred.Verify(user, pass) {
s.Throttle.Failed()
slog.Info("admin login failed", "user", user, "from", clientIP(r))
s.render(w, r, "login", map[string]any{
"Error": "Incorrect username or password.",
"OIDC": s.oidcAvailable(),
})
return
}
s.Throttle.Succeeded()
slog.Info("admin login", "user", user, "method", "local", "from", clientIP(r))
s.setSession(w, "local:"+cred.Username, cred.Username)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// ---- OIDC -------------------------------------------------------------------------------
func (s *Server) oidcAvailable() bool {
return s.OIDC != nil && s.OIDC.Config().Enabled() && s.BaseURL != ""
}
// oidcStart redirects to the IdP with state and PKCE.
//
// PKCE even though this is a confidential client: it costs one hash and closes code interception
// independently of the secret, which is worth having when the redirect crosses a browser.
func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) {
if !s.oidcAvailable() {
http.Error(w, "no identity provider is configured on this server", http.StatusNotImplemented)
return
}
d, err := s.OIDC.Discover(r.Context())
if err != nil {
http.Error(w, "identity provider unreachable: "+err.Error(), http.StatusBadGateway)
return
}
state, verifier := randomToken(), randomToken()
challenge := sha256.Sum256([]byte(verifier))
// state and the PKCE verifier ride in one short-lived cookie: the callback must prove it
// belongs to the browser that started the flow, or an attacker can feed us their own code.
http.SetCookie(w, &http.Cookie{
Name: stateCookie, Value: state + "." + verifier, Path: "/",
HttpOnly: true, Secure: s.Secure, SameSite: http.SameSiteLaxMode, MaxAge: 600,
})
q := url.Values{
"response_type": {"code"},
"client_id": {s.OIDC.Config().ClientID},
"redirect_uri": {s.redirectURI()},
"scope": {"openid profile email"},
"state": {state},
"code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
"code_challenge_method": {"S256"},
}
http.Redirect(w, r, d.AuthorizationEndpoint+"?"+q.Encode(), http.StatusSeeOther)
}
func (s *Server) redirectURI() string {
return strings.TrimRight(s.BaseURL, "/") + "/admin/callback"
}
func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
if !s.oidcAvailable() {
http.Error(w, "no identity provider configured", http.StatusNotImplemented)
return
}
c, err := r.Cookie(stateCookie)
if err != nil {
http.Error(w, "sign-in did not start here — try again from the login page", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: "/", MaxAge: -1})
state, verifier, ok := strings.Cut(c.Value, ".")
if !ok || state == "" || r.URL.Query().Get("state") != state {
http.Error(w, "sign-in state did not match — start again", http.StatusBadRequest)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "no authorization code returned: "+r.URL.Query().Get("error"), http.StatusBadRequest)
return
}
idToken, err := s.exchange(r.Context(), code, verifier)
if err != nil {
slog.Info("admin oidc exchange failed", "err", err, "from", clientIP(r))
http.Error(w, "could not complete sign-in", http.StatusBadGateway)
return
}
claims, err := s.OIDC.Verify(r.Context(), idToken)
if err != nil {
slog.Info("admin oidc token rejected", "err", err, "from", clientIP(r))
http.Error(w, "the identity token was not accepted", http.StatusForbidden)
return
}
if !s.OIDC.IsAdmin(claims) {
// Named explicitly: "you signed in but you are not an admin" is a different problem from
// "your password is wrong", and the group is the thing to go and check.
slog.Info("admin access denied: not in group", "account", claims.AccountID(),
"want_group", s.OIDC.Config().AdminGroup, "have", claims.Groups)
http.Error(w, fmt.Sprintf(
"Signed in as %s, but that account is not in the %q group, so it cannot administer "+
"this server.", claims.Display(), s.OIDC.Config().AdminGroup), http.StatusForbidden)
return
}
slog.Info("admin login", "account", claims.AccountID(), "method", "oidc", "from", clientIP(r))
s.setSession(w, claims.AccountID(), claims.Display())
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// exchange trades the authorization code for tokens at the IdP.
func (s *Server) exchange(ctx context.Context, code, verifier string) (string, error) {
d, err := s.OIDC.Discover(ctx)
if err != nil {
return "", err
}
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {s.redirectURI()},
"client_id": {s.OIDC.Config().ClientID},
"code_verifier": {verifier},
}
if s.ClientSecret != "" {
form.Set("client_secret", s.ClientSecret)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.TokenEndpoint,
strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
var tok struct {
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &tok); err != nil {
return "", err
}
if tok.IDToken == "" {
return "", fmt.Errorf("token endpoint returned no id_token")
}
return tok.IDToken, nil
}
func randomToken() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// clientIP is for logs only. X-Forwarded-For is deliberately ignored: nothing is meant to sit in
// front of this listener, so a header claiming otherwise is a caller's assertion about itself.
func clientIP(r *http.Request) string {
if i := strings.LastIndex(r.RemoteAddr, ":"); i > 0 {
return r.RemoteAddr[:i]
}
return r.RemoteAddr
}
+168
View File
@@ -0,0 +1,168 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package adminui
import (
"encoding/json"
"log/slog"
"net/http"
"net/url"
"sort"
"time"
"echo-lot.app/server/internal/adminauth"
"echo-lot.app/server/internal/runs"
"echo-lot.app/server/internal/store"
)
func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
if s.session(r) != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
s.render(w, r, "login", map[string]any{
"OIDC": s.oidcAvailable(),
"LocalSet": s.Store.LocalAdmin() != nil,
"AdminUser": s.AdminUser,
})
}
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
devices := s.Store.Devices()
linked := 0
for _, d := range devices {
if d.LinkedToAccount() {
linked++
}
}
var selftest any
if s.SelfTest != nil {
selftest = s.SelfTest()
}
s.render(w, r, "dashboard", map[string]any{
"Session": sess,
"CSRF": s.csrfToken(sess),
"Devices": len(devices),
"Linked": linked,
"Runs": s.totalRuns(devices),
"SelfTest": selftest,
"Version": s.Version,
})
}
func (s *Server) totalRuns(devices []store.Device) int {
if s.Runs == nil {
return 0
}
n := 0
for _, d := range devices {
n += len(s.Runs.List(d.ID))
}
return n
}
func (s *Server) devices(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
devices := s.Store.Devices()
// Newest first: the device someone is looking for is almost always the one just enrolled.
sort.Slice(devices, func(i, j int) bool { return devices[i].Enrolled.After(devices[j].Enrolled) })
type row struct {
store.Device
Runs int
}
rows := make([]row, 0, len(devices))
for _, d := range devices {
n := 0
if s.Runs != nil {
n = len(s.Runs.List(d.ID))
}
rows = append(rows, row{Device: d, Runs: n})
}
s.render(w, r, "devices", map[string]any{
"Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows,
"Link": r.URL.Query().Get("link"),
})
}
func (s *Server) revokeDevice(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
id := r.PathValue("id")
if err := s.Store.DeleteDevice(id); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Worth a log line: revoking a device is destructive, immediate, and someone will eventually
// want to know who did it and when.
slog.Info("device revoked", "device", id, "by", sess.Subject)
http.Redirect(w, r, "/devices", http.StatusSeeOther)
}
func (s *Server) mintToken(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
tok, err := s.Store.NewEnrollToken(24*time.Hour, "admin-ui")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
slog.Info("enrolment token minted", "by", sess.Subject)
// The whole link, not the bare token: it carries the URL and the pin as well, and assembling
// those by hand is where an operator gets a pin wrong by one character.
http.Redirect(w, r, "/devices?link="+url.QueryEscape(s.EnrollLink(tok)), http.StatusSeeOther)
}
// EnrollLink is supplied by the caller so this package does not need the control server's pin.
var _ = 0
func (s *Server) runsList(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
type row struct {
runs.Meta
DeviceName string
}
var rows []row
for _, d := range s.Store.Devices() {
if s.Runs == nil {
break
}
name := d.Name
if name == "" {
name = d.ID
}
for _, m := range s.Runs.List(d.ID) {
rows = append(rows, row{Meta: m, DeviceName: name})
}
}
sort.Slice(rows, func(i, j int) bool { return rows[i].UploadedAt.After(rows[j].UploadedAt) })
if len(rows) > 200 {
rows = rows[:200] // a page, not the archive; the count is on the dashboard
}
s.render(w, r, "runs", map[string]any{"Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows})
}
func (s *Server) runView(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
body, err := s.Runs.Get(r.PathValue("device"), r.PathValue("id"))
if err != nil {
http.NotFound(w, r)
return
}
// Re-indented for reading, but otherwise exactly what was stored. An admin sees the document
// at the privacy level its uploader chose — there is nothing here that can un-redact it.
var pretty json.RawMessage = body
out, err := json.MarshalIndent(json.RawMessage(pretty), "", " ")
if err != nil {
out = body
}
s.render(w, r, "run", map[string]any{
"Session": sess, "CSRF": s.csrfToken(sess),
"Device": r.PathValue("device"), "ID": r.PathValue("id"),
"JSON": string(out),
})
}
func (s *Server) runDelete(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
device, id := r.PathValue("device"), r.PathValue("id")
if err := s.Runs.Delete(device, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
slog.Info("run deleted", "device", device, "run", id, "by", sess.Subject)
http.Redirect(w, r, "/runs", http.StatusSeeOther)
}
+170
View File
@@ -0,0 +1,170 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package adminui
import (
"bytes"
"html/template"
"log/slog"
"net/http"
)
// Templates are parsed once at start. html/template escapes by context, which is what makes it
// safe to render device names and finding text that ultimately arrived over a network.
var tpl = template.Must(template.New("base").Funcs(template.FuncMap{
"kb": func(n int64) int64 { return n / 1024 },
}).Parse(baseHTML))
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
data["Page"] = page
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil {
slog.Error("admin template", "page", page, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// There is no script here and nothing loaded from anywhere else, so a strict policy costs
// nothing and closes injected-script attacks even if an escaping bug ever slips through.
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
_, _ = buf.WriteTo(w)
}
const baseHTML = `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Echolot &mdash; {{.Page}}</title>
<style>
:root{color-scheme:dark}
body{font:15px/1.5 system-ui,sans-serif;margin:0;background:#14161a;color:#e6e6e6}
header{display:flex;gap:1.2rem;align-items:baseline;padding:.8rem 1.2rem;background:#1c1f25;border-bottom:1px solid #2b2f36}
header h1{font-size:1.1rem;margin:0;font-weight:600}
header nav a{color:#9ecbff;text-decoration:none;margin-right:1rem}
header .who{margin-left:auto;color:#9aa3ad;font-size:.9rem}
main{padding:1.2rem;max-width:70rem}
table{border-collapse:collapse;width:100%;margin:.6rem 0}
th,td{text-align:left;padding:.45rem .6rem;border-bottom:1px solid #2b2f36;vertical-align:top}
th{color:#9aa3ad;font-weight:500;font-size:.85rem}
code,pre{font-family:ui-monospace,monospace;font-size:.85rem}
pre{background:#0f1114;padding:.8rem;border-radius:6px;overflow:auto;max-height:34rem}
.card{background:#1c1f25;border:1px solid #2b2f36;border-radius:8px;padding:1rem;margin:.8rem 0}
.grid{display:flex;gap:1rem;flex-wrap:wrap}
.stat{background:#1c1f25;border:1px solid #2b2f36;border-radius:8px;padding:.8rem 1.2rem;min-width:8rem}
.stat b{display:block;font-size:1.6rem;font-weight:600}
.stat span{color:#9aa3ad;font-size:.85rem}
button{font:inherit;background:#2d6cdf;color:#fff;border:0;border-radius:6px;padding:.4rem .8rem;cursor:pointer}
button.danger{background:#8b2f2f}
button.plain{background:#3a3f47}
input{font:inherit;background:#0f1114;color:#e6e6e6;border:1px solid #2b2f36;border-radius:6px;padding:.4rem .6rem}
.err{background:#3a1f1f;border:1px solid #7a3b3b;padding:.6rem .8rem;border-radius:6px}
.muted{color:#9aa3ad}
form.inline{display:inline}
</style></head><body>
{{if ne .Page "login"}}
<header>
<h1>Echolot</h1>
<nav><a href="/">Overview</a><a href="/devices">Devices</a><a href="/runs">Runs</a></nav>
<span class="who">{{.Session.Display}}
<form method="post" action="/logout" class="inline"><button class="plain">Sign out</button></form>
</span>
</header>
{{end}}
<main>
{{if eq .Page "login"}}
<h2>Sign in</h2>
{{with .Error}}<p class="err">{{.}}</p>{{end}}
{{if .OIDC}}
<p><a href="/auth/start"><button>Sign in with your identity provider</button></a></p>
<p class="muted">or use the break-glass account:</p>
{{end}}
{{if .LocalSet}}
<form method="post" action="/login" class="card">
<p><label>Username<br><input name="username" value="{{.AdminUser}}" autocomplete="username"></label></p>
<p><label>Password<br><input name="password" type="password" autocomplete="current-password"></label></p>
<p><button>Sign in</button></p>
</form>
{{else}}
<p class="err">No break-glass admin is set. Run
<code>echolot-server --set-admin-password</code> on the host.</p>
{{end}}
{{else if eq .Page "dashboard"}}
<div class="grid">
<div class="stat"><b>{{.Devices}}</b><span>devices</span></div>
<div class="stat"><b>{{.Linked}}</b><span>signed in</span></div>
<div class="stat"><b>{{.Runs}}</b><span>stored runs</span></div>
</div>
<div class="card">
<h3>Server</h3>
<p class="muted">version {{.Version}}</p>
{{with .SelfTest}}<pre>{{printf "%+v" .}}</pre>{{end}}
</div>
{{else if eq .Page "devices"}}
<h2>Devices</h2>
{{with .Link}}
<div class="card">
<p><b>Enrolment link</b> &mdash; single use, valid 24 hours. Treat it like a password until spent.</p>
<p><code>{{.}}</code></p>
<p class="muted">On a device with adb:<br>
<code>adb shell am start -a android.intent.action.VIEW -d "{{.}}"</code></p>
</div>
{{end}}
<form method="post" action="/enroll-tokens">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button>Create enrolment link</button>
</form>
<table>
<tr><th>Device</th><th>Name</th><th>Account</th><th>Enrolled</th><th>Runs</th><th></th></tr>
{{range .Rows}}
<tr>
<td><code>{{.ID}}</code></td>
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">&mdash;</span>{{end}}</td>
<td>{{if .LinkedToAccount}}{{.AccountName}}{{else}}<span class="muted">not signed in</span>{{end}}</td>
<td>{{.Enrolled.Format "2006-01-02 15:04"}}</td>
<td>{{.Runs}}</td>
<td><form method="post" action="/devices/{{.ID}}/revoke" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="danger">Revoke</button></form></td>
</tr>
{{else}}
<tr><td colspan="6" class="muted">No devices enrolled.</td></tr>
{{end}}
</table>
{{else if eq .Page "runs"}}
<h2>Uploaded runs</h2>
<p class="muted">Shown exactly as uploaded, at the privacy level the uploader chose. Nothing
here can un-redact a run.</p>
<table>
<tr><th>Uploaded</th><th>Device</th><th>Verdict</th><th>Findings</th><th>Size</th><th>Level</th><th></th></tr>
{{range .Rows}}
<tr>
<td>{{.UploadedAt.Format "2006-01-02 15:04"}}</td>
<td>{{.DeviceName}}</td>
<td>{{if .Verdict}}{{.Verdict}}{{else}}<span class="muted">&mdash;</span>{{end}}</td>
<td>{{.FindingCount}}</td>
<td>{{kb .SizeBytes}} kB</td>
<td>{{.Anonymization}}</td>
<td><a href="/runs/{{.DeviceID}}/{{.ID}}">open</a></td>
</tr>
{{else}}
<tr><td colspan="7" class="muted">Nothing uploaded yet.</td></tr>
{{end}}
</table>
{{else if eq .Page "run"}}
<h2>Run {{.ID}}</h2>
<form method="post" action="/runs/{{.Device}}/{{.ID}}/delete" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="danger">Delete this run</button>
</form>
<pre>{{.JSON}}</pre>
{{end}}
</main></body></html>
`
+124
View File
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package certreload serves a TLS certificate that can be replaced on disk without a restart.
//
// The hard part of TLS is never termination — the stdlib does that — it is renewal. A certificate
// obtained from an ACME client expires every sixty days, and the usual arrangement is a renewal
// hook that reloads or restarts the service. That hook is the part that quietly fails: it works
// when it is written and then, months later, does not, and nobody notices until the certificate
// has already expired.
//
// So the certificate is re-read when the file changes. There is no hook to forget, no reload to
// coordinate, and a renewal that drops new files in place is picked up on the next handshake.
//
// Deliberately not used for the control plane. Clients pin that certificate's public key
// (probe-protocol.md §1), so swapping it at runtime would silently break every enrolled device —
// there the operator *should* have to think, and a restart is the least of what a key rotation
// costs. Two listeners, two different right answers.
package certreload
import (
"crypto/tls"
"fmt"
"os"
"sync"
"time"
)
// Reloader holds a certificate and refreshes it when the files on disk change.
type Reloader struct {
certPath, keyPath string
mu sync.RWMutex
cert *tls.Certificate
certMod time.Time
keyMod time.Time
checked time.Time
interval time.Duration
}
// New loads the pair once so a bad path fails at startup rather than at the first handshake,
// when the only symptom is a connection error at the far end.
func New(certPath, keyPath string) (*Reloader, error) {
r := &Reloader{certPath: certPath, keyPath: keyPath, interval: 30 * time.Second}
if err := r.load(); err != nil {
return nil, err
}
return r, nil
}
// TLSConfig returns a config that asks this reloader for the certificate on every handshake.
func (r *Reloader) TLSConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: r.getCertificate,
}
}
func (r *Reloader) getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
r.maybeReload()
r.mu.RLock()
defer r.mu.RUnlock()
if r.cert == nil {
return nil, fmt.Errorf("no certificate loaded")
}
return r.cert, nil
}
// maybeReload stats the files at most once per interval.
//
// Rate-limited because this runs on every handshake: a busy listener would otherwise stat twice
// per connection, and a certificate that is thirty seconds stale has never mattered to anyone.
func (r *Reloader) maybeReload() {
r.mu.RLock()
fresh := time.Since(r.checked) < r.interval
r.mu.RUnlock()
if fresh {
return
}
r.mu.Lock()
r.checked = time.Now()
certMod, keyMod := modTime(r.certPath), modTime(r.keyPath)
unchanged := certMod.Equal(r.certMod) && keyMod.Equal(r.keyMod)
r.mu.Unlock()
if unchanged {
return
}
// A failed reload keeps the certificate already in memory. Renewal tools write the two files
// separately, so there is a window where the pair does not match; serving the previous
// certificate through that window is strictly better than serving none.
_ = r.load()
}
func (r *Reloader) load() error {
cert, err := tls.LoadX509KeyPair(r.certPath, r.keyPath)
if err != nil {
return fmt.Errorf("loading %s / %s: %w", r.certPath, r.keyPath, err)
}
r.mu.Lock()
defer r.mu.Unlock()
r.cert = &cert
r.certMod, r.keyMod = modTime(r.certPath), modTime(r.keyPath)
return nil
}
// NotAfter is when the loaded certificate expires, for the admin UI to show and for a startup
// warning. An expiry an operator can see is one they can act on before a browser tells them.
func (r *Reloader) NotAfter() time.Time {
r.mu.RLock()
defer r.mu.RUnlock()
if r.cert == nil || r.cert.Leaf == nil {
return time.Time{}
}
return r.cert.Leaf.NotAfter
}
func modTime(path string) time.Time {
fi, err := os.Stat(path)
if err != nil {
return time.Time{}
}
return fi.ModTime()
}
@@ -0,0 +1,119 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package certreload
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
)
func writePair(t *testing.T, dir, cn string) (string, string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
certPath := filepath.Join(dir, "cert.pem")
keyPath := filepath.Join(dir, "key.pem")
cb := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
kb, _ := x509.MarshalECPrivateKey(key)
if err := os.WriteFile(certPath, cb, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}), 0o600); err != nil {
t.Fatal(err)
}
return certPath, keyPath
}
func TestBadPathsFailAtStartupNotAtHandshake(t *testing.T) {
if _, err := New("/nonexistent/cert.pem", "/nonexistent/key.pem"); err == nil {
t.Fatal("a missing certificate was accepted; the failure would surface as an " +
"unexplained connection error at the client instead")
}
}
// The whole point: a renewal that drops new files in place is picked up without a restart and
// without a reload hook that can silently stop working.
func TestANewCertificateOnDiskIsPickedUp(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := writePair(t, dir, "first")
r, err := New(certPath, keyPath)
if err != nil {
t.Fatal(err)
}
r.interval = 0 // check on every handshake, rather than waiting out the rate limit
got, err := r.getCertificate(nil)
if err != nil {
t.Fatal(err)
}
first := got.Leaf
time.Sleep(10 * time.Millisecond) // ensure a distinct mtime
writePair(t, dir, "second")
got, err = r.getCertificate(nil)
if err != nil {
t.Fatal(err)
}
if got.Leaf != nil && first != nil && got.Leaf.SerialNumber.Cmp(first.SerialNumber) == 0 {
t.Fatal("the replaced certificate was not picked up")
}
}
// Renewal tools write the certificate and the key separately, so there is a window where the two
// do not match. Serving the previous certificate through it beats serving none.
func TestAHalfWrittenPairKeepsTheOldCertificate(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := writePair(t, dir, "good")
r, err := New(certPath, keyPath)
if err != nil {
t.Fatal(err)
}
r.interval = 0
time.Sleep(10 * time.Millisecond)
if err := os.WriteFile(certPath, []byte("-----BEGIN CERTIFICATE-----\ntruncated\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := r.getCertificate(nil)
if err != nil {
t.Fatalf("a torn write took the listener down: %v", err)
}
if got == nil {
t.Fatal("no certificate served during a torn write")
}
}
func TestExpiryIsVisible(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := writePair(t, dir, "x")
r, err := New(certPath, keyPath)
if err != nil {
t.Fatal(err)
}
if got := r.NotAfter(); got.IsZero() || time.Until(got) > 48*time.Hour {
t.Fatalf("expiry not reported sensibly: %v", got)
}
}
+148 -1
View File
@@ -10,6 +10,8 @@ package config
import ( import (
"flag" "flag"
"fmt" "fmt"
"io"
"net"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -67,10 +69,63 @@ type Config struct {
// first control listen address. // first control listen address.
PublicControlURL string // ECHOLOT_PUBLIC_URL / --public-url PublicControlURL string // ECHOLOT_PUBLIC_URL / --public-url
// Identity provider. Empty issuer disables sign-in entirely; the server is a relying
// party and never stores passwords.
OIDCIssuer string // ECHOLOT_OIDC_ISSUER / --oidc-issuer
OIDCClientID string // ECHOLOT_OIDC_CLIENT_ID / --oidc-client-id (confidential, admin UI)
OIDCAppClientID string // ECHOLOT_OIDC_APP_CLIENT_ID / --oidc-app-client-id (public, the phone app)
// Issuer for the app's client, when the IdP gives each application its own.
//
// Authentik derives the issuer from the application slug, so two applications mean two
// issuers — and a token's `iss` must match the one that minted it. Empty means both clients
// share ECHOLOT_OIDC_ISSUER, which is what IdPs with a single global issuer do.
OIDCAppIssuer string // ECHOLOT_OIDC_APP_ISSUER / --oidc-app-issuer
OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group
// Break-glass admin username; the password lives hashed in the state store.
AdminUser string // ECHOLOT_ADMIN_USER / --admin-user
// Secret for the *confidential* admin client. Prefer ECHOLOT_OIDC_CLIENT_SECRET_FILE: a path
// keeps the secret out of the environment, where it is readable by anything that can see
// /proc/<pid>/environ and lands in every dump of the unit's configuration.
OIDCClientSecret string // ECHOLOT_OIDC_CLIENT_SECRET / _FILE
// Where the admin UI is reachable, used to build the OIDC redirect URI. Must match what is
// registered at the IdP exactly.
AdminBaseURL string // ECHOLOT_ADMIN_BASE_URL / --admin-base-url
// TLS for the admin listener. Without these it serves plaintext, which is only acceptable on
// loopback — see checkAdminExposure.
AdminTLSCert string // ECHOLOT_ADMIN_TLS_CERT / --admin-tls-cert
AdminTLSKey string // ECHOLOT_ADMIN_TLS_KEY / --admin-tls-key
// Deliberate override for serving the admin UI in plaintext off loopback, so that decision
// is made rather than stumbled into.
AdminInsecure bool // ECHOLOT_ADMIN_INSECURE / --admin-insecure
// Port-80 listener that answers ACME HTTP-01 challenges and redirects everything else to
// the admin UI. Empty disables it. HTTP-01 always arrives on port 80 — the CA picks the
// port — so this never collides with the admin UI on 443.
ACMEHTTPListen string // ECHOLOT_ACME_HTTP_LISTEN / --acme-http-listen
// Directory an ACME client writes challenge tokens into. Defaults to <state-dir>/acme.
ACMEWebroot string // ECHOLOT_ACME_WEBROOT / --acme-webroot
// Mode // Mode
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces) Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
} }
// secretOr reads ECHOLOT_<key>, or the contents of the file named by ECHOLOT_<key>_FILE.
//
// The file form exists because a secret in the environment is readable by anything that can see
// /proc/<pid>/environ and lands in every dump of the unit's configuration. A path costs nothing
// and keeps the value in one file whose permissions an operator can reason about.
func secretOr(key, def string) string {
if path := envOr(key+"_FILE", ""); path != "" {
if b, err := os.ReadFile(path); err == nil {
return strings.TrimSpace(string(b))
}
}
return envOr(key, def)
}
// envInt reads ECHOLOT_<key> as an integer with a fallback. // envInt reads ECHOLOT_<key> as an integer with a fallback.
func envInt(key string, def int) int { func envInt(key string, def int) int {
if v := envOr(key, ""); v != "" { if v := envOr(key, ""); v != "" {
@@ -115,6 +170,18 @@ func Load(args []string) (*Config, *Actions, error) {
fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables") fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables")
fs.IntVar(&c.UploadMaxRuns, "upload-max-runs", envInt("UPLOAD_MAX_RUNS", 200), "keep at most this many runs per device; 0 disables") fs.IntVar(&c.UploadMaxRuns, "upload-max-runs", envInt("UPLOAD_MAX_RUNS", 200), "keep at most this many runs per device; 0 disables")
fs.StringVar(&c.UploadMinAnon, "upload-min-anonymization", envOr("UPLOAD_MIN_ANONYMIZATION", "full"), "least anonymization accepted: full|balanced|strict") fs.StringVar(&c.UploadMinAnon, "upload-min-anonymization", envOr("UPLOAD_MIN_ANONYMIZATION", "full"), "least anonymization accepted: full|balanced|strict")
fs.StringVar(&c.OIDCIssuer, "oidc-issuer", envOr("OIDC_ISSUER", ""), "OpenID Connect issuer URL; empty disables sign-in")
fs.StringVar(&c.OIDCClientID, "oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "confidential OIDC client id for the admin UI")
fs.StringVar(&c.OIDCAppClientID, "oidc-app-client-id", envOr("OIDC_APP_CLIENT_ID", ""), "public OIDC client id used by the Android app (PKCE)")
fs.StringVar(&c.OIDCAppIssuer, "oidc-app-issuer", envOr("OIDC_APP_ISSUER", ""), "issuer for the app client when the IdP uses per-application issuers; empty = same as --oidc-issuer")
fs.StringVar(&c.OIDCAdminGroup, "oidc-admin-group", envOr("OIDC_ADMIN_GROUP", ""), "group claim required for admin access; empty means nobody is an admin via OIDC")
fs.StringVar(&c.OIDCClientSecret, "oidc-client-secret", secretOr("OIDC_CLIENT_SECRET", ""), "secret for the confidential admin client; prefer ECHOLOT_OIDC_CLIENT_SECRET_FILE")
fs.StringVar(&c.AdminBaseURL, "admin-base-url", envOr("ADMIN_BASE_URL", ""), "public URL of the admin UI, for the OIDC redirect (e.g. https://admin.example.net)")
fs.StringVar(&c.AdminTLSCert, "admin-tls-cert", envOr("ADMIN_TLS_CERT", ""), "TLS certificate for the admin listener")
fs.StringVar(&c.AdminTLSKey, "admin-tls-key", envOr("ADMIN_TLS_KEY", ""), "TLS key for the admin listener")
fs.BoolVar(&c.AdminInsecure, "admin-insecure", envOr("ADMIN_INSECURE", "") == "1", "allow the admin UI in plaintext off loopback (you are on your own)")
fs.StringVar(&c.ACMEHTTPListen, "acme-http-listen", envOr("ACME_HTTP_LISTEN", ""), "port-80 listener for ACME HTTP-01 challenges and http->https redirects")
fs.StringVar(&c.ACMEWebroot, "acme-webroot", envOr("ACME_WEBROOT", ""), "directory an ACME client writes challenges into (default <state-dir>/acme)")
fs.StringVar(&c.PublicControlURL, "public-url", envOr("PUBLIC_URL", ""), "public control-plane URL for enrollment links, e.g. https://probe.example.net:8443") fs.StringVar(&c.PublicControlURL, "public-url", envOr("PUBLIC_URL", ""), "public control-plane URL for enrollment links, e.g. https://probe.example.net:8443")
fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)") fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)")
fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded") fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded")
@@ -122,6 +189,12 @@ func Load(args []string) (*Config, *Actions, error) {
fs.BoolVar(&a.InstallSystemd, "install-systemd", false, "install a systemd unit for this binary and exit") fs.BoolVar(&a.InstallSystemd, "install-systemd", false, "install a systemd unit for this binary and exit")
fs.BoolVar(&a.UninstallSystemd, "uninstall-systemd", false, "remove the systemd unit and exit") fs.BoolVar(&a.UninstallSystemd, "uninstall-systemd", false, "remove the systemd unit and exit")
var daemon bool
fs.BoolVar(&a.Serve, "serve", false, "run the server (bind listeners and answer requests)")
fs.BoolVar(&daemon, "daemon", false, "alias for --serve")
fs.BoolVar(&a.SetAdminPassword, "set-admin-password", false,
"set the break-glass admin password (username as --admin-user, password read from stdin) and exit")
fs.StringVar(&c.AdminUser, "admin-user", envOr("ADMIN_USER", "admin"), "username for the break-glass admin")
fs.BoolVar(&a.SelfUpdate, "self-update", false, "check for a newer release, replace this binary, and exit") fs.BoolVar(&a.SelfUpdate, "self-update", false, "check for a newer release, replace this binary, and exit")
fs.BoolVar(&a.Version, "version", false, "print version and exit") fs.BoolVar(&a.Version, "version", false, "print version and exit")
@@ -131,12 +204,78 @@ func Load(args []string) (*Config, *Actions, error) {
if !c.Docker { if !c.Docker {
c.Docker = inContainer() c.Docker = inContainer()
} }
a.Serve = a.Serve || daemon
// No verb at all means the caller has not said what they want. Usage is the answer, and it
// is a usage error rather than success — otherwise a service manager sees a clean exit and
// concludes the server ran and finished.
if !a.Serve && !a.InstallSystemd && !a.UninstallSystemd && !a.SelfUpdate &&
!a.SetAdminPassword && !a.Version {
a.Help = true
}
if a.Serve {
if err := c.checkAdminExposure(); err != nil {
return nil, nil, err
}
}
if c.Docker && (a.InstallSystemd || a.UninstallSystemd || a.SelfUpdate) { if c.Docker && (a.InstallSystemd || a.UninstallSystemd || a.SelfUpdate) {
return nil, nil, fmt.Errorf("systemd/self-update actions are native-mode only (container detected; override with ECHOLOT_DOCKER=0 if this is wrong)") return nil, nil, fmt.Errorf("systemd/self-update actions are native-mode only (container detected; override with ECHOLOT_DOCKER=0 if this is wrong)")
} }
return c, a, nil return c, a, nil
} }
// Usage prints the verbs first and the tuning flags second, because the question someone has
// when they run this by name is "what does it do", not "what can I set".
func Usage(w io.Writer) {
fmt.Fprint(w, `echolot-server the Echolot probe server
USAGE
echolot-server --serve run the server
echolot-server --version print the version
echolot-server --install-systemd install and enable a systemd unit
echolot-server --uninstall-systemd remove it
echolot-server --self-update replace this binary with the latest release
echolot-server --set-admin-password set the break-glass admin password (stdin)
echolot-server --help full flag list
Every flag can also be set as an environment variable: --control-listen becomes
ECHOLOT_CONTROL_LISTEN. In a container, configuration comes from the environment.
Running with no verb prints this and exits non-zero: starting to serve the internet
should be something you asked for.
`)
}
// checkAdminExposure refuses to serve an unencrypted admin UI on a non-loopback address.
//
// The admin session cookie is a bearer credential for everything this server can do, and the OIDC
// authorization code arrives in a URL. In plaintext, both are readable by anyone on the path — and
// on a globally routable address "the path" means the internet. This is 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
// two safe answers are cheap: bind to loopback and tunnel, or supply a certificate.
func (c *Config) checkAdminExposure() error {
if c.AdminTLSCert != "" || c.AdminInsecure {
return nil
}
for _, addr := range Addrs(c.AdminListen) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
continue
}
ip := net.ParseIP(strings.Trim(host, "[]"))
if host == "" || ip == nil || ip.IsLoopback() {
continue // loopback, or a name we cannot judge; RFC 8252 blesses loopback plaintext
}
return fmt.Errorf(
"refusing to serve the admin UI in plaintext on %s: the session cookie and the OIDC "+
"authorization code would cross the network in the clear.\n"+
" Fix it one of three ways:\n"+
" - bind to 127.0.0.1 and reach it over an SSH tunnel (no certificate needed)\n"+
" - set ECHOLOT_ADMIN_TLS_CERT and ECHOLOT_ADMIN_TLS_KEY\n"+
" - set ECHOLOT_ADMIN_INSECURE=1 if you genuinely mean it", addr)
}
return nil
}
// Addrs splits a comma-separated listen spec into individual addresses. // Addrs splits a comma-separated listen spec into individual addresses.
// Explicit per-address binds matter on multi-IP hosts: a wildcard bind // Explicit per-address binds matter on multi-IP hosts: a wildcard bind
// (":8443") would also claim addresses reserved for other purposes (e.g. an // (":8443") would also claim addresses reserved for other purposes (e.g. an
@@ -151,11 +290,19 @@ func Addrs(spec string) []string {
return out return out
} }
// Actions are one-shot verbs that exit instead of serving. // Actions are the verbs. Serving is one of them, and it is explicit: running the binary with no
// arguments prints usage rather than binding a dozen ports and starting to answer the internet.
// Someone typing the name of an unfamiliar program on a terminal should be told what it does, not
// have it start doing it.
type Actions struct { type Actions struct {
Serve bool
// Help is set when there is nothing to do: no verb was given.
Help bool
InstallSystemd bool InstallSystemd bool
UninstallSystemd bool UninstallSystemd bool
SelfUpdate bool SelfUpdate bool
SetAdminPassword bool
Version bool Version bool
} }
+284 -8
View File
@@ -7,6 +7,7 @@
package control package control
import ( import (
"context"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"crypto/tls" "crypto/tls"
@@ -27,6 +28,7 @@ import (
"echo-lot.app/server/internal/compat" "echo-lot.app/server/internal/compat"
"echo-lot.app/server/internal/dataplane" "echo-lot.app/server/internal/dataplane"
"echo-lot.app/server/internal/oidc"
"echo-lot.app/server/internal/runs" "echo-lot.app/server/internal/runs"
"echo-lot.app/server/internal/session" "echo-lot.app/server/internal/session"
"echo-lot.app/server/internal/store" "echo-lot.app/server/internal/store"
@@ -57,8 +59,19 @@ type Server struct {
// Granted server->client sends (spec §5). Both consume an asymmetric grant. // Granted server->client sends (spec §5). Both consume an asymmetric grant.
DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error) DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error)
BigSend func(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]dataplane.BigSendResult, error) BigSend func(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]dataplane.BigSendResult, error)
// OIDC verifies ID tokens presented by the *app* (may be nil).
OIDC *oidc.Verifier
// AdminOIDC verifies tokens from the admin UI's own client. Separate because an IdP may
// give each application its own issuer — Authentik derives it from the application slug —
// and a verifier pins exactly one issuer and the clients belonging to it.
AdminOIDC *oidc.Verifier
// Runs stores uploaded measurement documents (may be nil: uploads unsupported). // Runs stores uploaded measurement documents (may be nil: uploads unsupported).
Runs *runs.Store Runs *runs.Store
// FragSend emits one datagram as hand-built IP fragments in a chosen order (may be nil:
// needs a raw socket, so it is unavailable to an unprivileged server).
FragSend func(sess *session.Session, g *session.Grant, sizeBytes int, mode dataplane.FragMode, fragSize int) (dataplane.FragResult, error)
// DownThroughput sends paced traffic toward the client for a bounded time (may be nil).
DownThroughput func(sess *session.Session, g *session.Grant, durationMs, kbps, sizeBytes int) (dataplane.ThroughputResult, error)
// EgressMTU reports the server's own measured egress path MTU (0 = unknown). With DF set // EgressMTU reports the server's own measured egress path MTU (0 = unknown). With DF set
// we cannot emit a datagram larger than this, so requested sizes above it are refused up // we cannot emit a datagram larger than this, so requested sizes above it are refused up
// front and reported as such — the client must not read that as a downstream path limit. // front and reported as such — the client must not read that as a downstream path limit.
@@ -157,6 +170,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /v1/runs", gate(s.listRuns)) mux.HandleFunc("GET /v1/runs", gate(s.listRuns))
mux.HandleFunc("GET /v1/runs/{id}", gate(s.getRun)) mux.HandleFunc("GET /v1/runs/{id}", gate(s.getRun))
mux.HandleFunc("DELETE /v1/runs/{id}", gate(s.deleteRun)) mux.HandleFunc("DELETE /v1/runs/{id}", gate(s.deleteRun))
mux.HandleFunc("POST /v1/account/link", gate(s.linkAccount))
mux.HandleFunc("DELETE /v1/account/link", gate(s.unlinkAccount))
mux.HandleFunc("GET /v1/account", gate(s.accountStatus))
// TODO(spec §5): frag_send, throughput (both build on the same grant machinery) // TODO(spec §5): frag_send, throughput (both build on the same grant machinery)
return mux return mux
} }
@@ -218,7 +234,11 @@ func (s *Server) observations(w http.ResponseWriter, r *http.Request) {
"udp": map[string]any{"packets_seen": packetsSeen, "packets": udp}, "udp": map[string]any{"packets_seen": packetsSeen, "packets": udp},
"tcp": tcp, "tcp": tcp,
"connect_back": cb, "connect_back": cb,
"dns_canary": dnsCanary, // The sender's own count, which is what makes the receiver's count mean something.
"throughput": sess.ThroughputReports(),
// The receiver's count for upstream runs — same idea, other direction.
"throughput_up": upstreamJSON(sess),
"dns_canary": dnsCanary,
// TODO(spec §6): http echo records // TODO(spec §6): http echo records
}) })
} }
@@ -241,6 +261,12 @@ func (s *Server) actions(w http.ResponseWriter, r *http.Request) {
IntervalUs int `json:"interval_us"` IntervalUs int `json:"interval_us"`
SizesBytes []int `json:"sizes_bytes"` SizesBytes []int `json:"sizes_bytes"`
DF *bool `json:"df"` DF *bool `json:"df"`
Mode string `json:"mode"`
FragBytes int `json:"frag_bytes"`
Direction string `json:"direction"`
DurationS int `json:"duration_s"`
Kbps int `json:"kbps"`
Streams int `json:"streams"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad body"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad body"})
@@ -373,6 +399,105 @@ func (s *Server) actions(w http.ResponseWriter, r *http.Request) {
"grant": map[string]any{"max_bytes": g.MaxBytes, "max_kbps": g.MaxKbps}, "grant": map[string]any{"max_bytes": g.MaxBytes, "max_kbps": g.MaxKbps},
}) })
case "frag_send":
if s.FragSend == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "frag_send needs a raw socket, which this server does not have",
})
return
}
size := clamp(req.SizeBytes, 1600, 8000) // must exceed the path MTU or nothing fragments
mode := dataplane.FragMode(req.Mode)
switch mode {
case dataplane.FragInOrder, dataplane.FragReversed, dataplane.FragFirstLast:
default:
mode = dataplane.FragInOrder
}
fragBytes := clamp(req.FragBytes, 8, 1400)
g := sess.NewGrant(actionID, int64(size), 0, session.DefaultGrantLimits)
if g == nil {
writeJSON(w, http.StatusConflict, noDataPlaneYet)
return
}
// Synchronous: the whole burst is a few kB and at most a few hundred milliseconds, and
// the caller wants to know it was actually emitted before it starts listening. An
// asynchronous send would make "nothing arrived" ambiguous between a path drop and a
// send that never happened — the one distinction this test exists to make.
result, err := s.FragSend(sess, g, size, mode, fragBytes)
slog.Info("frag_send finished", "action", actionID, "mode", mode,
"size", size, "fragments", result.Fragments, "err", err)
if err != nil {
writeJSON(w, http.StatusConflict, map[string]any{
"error": err.Error(), "action_id": actionID, "result": result,
})
return
}
writeJSON(w, http.StatusAccepted, map[string]any{
"action_id": actionID, "mode": string(mode), "size_bytes": size,
"frag_bytes": fragBytes, "fragments": result.Fragments,
"grant": map[string]any{"max_bytes": g.MaxBytes, "max_kbps": g.MaxKbps},
})
case "throughput":
if s.DownThroughput == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "throughput not wired"})
return
}
// Only the downstream direction needs the server to send. Upstream is the client
// sending and the server counting, which needs no action at all — so asking for it here
// is a client bug worth naming rather than silently doing the other thing.
if req.Direction == "up" {
// Upstream needs nothing sent from here — the client generates the traffic and the
// server counts it. The only thing an action can usefully do is zero the counter so
// the run measures itself rather than inheriting an earlier one.
sess.ResetUpstream()
writeJSON(w, http.StatusAccepted, map[string]any{
"action_id": actionID, "direction": "up", "reset": true,
"note": "send TYPE_THROUGHPUT_UP packets, then read observations.throughput_up",
})
return
}
if req.Direction != "" && req.Direction != "down" {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "direction must be up or down",
})
return
}
// Planned once, here, so the response promises exactly what the run will do. A request
// that would outlast the server's byte cap comes back with a shorter duration rather
// than being truncated halfway.
durationMs, kbps := dataplane.ThroughputPlan(
clamp(req.DurationS, 1, 30)*1000, clamp(req.Kbps, 100, 200_000))
size := clamp(req.SizeBytes, dataMinPacket, 1472)
if req.SizeBytes == 0 {
size = 1200
}
g := sess.NewGrant(actionID, 0, kbps, dataplane.ThroughputLimits(durationMs, kbps))
if g == nil {
writeJSON(w, http.StatusConflict, noDataPlaneYet)
return
}
// Answered before the run so the client can start listening, then reported through the
// observations API. Doing it the other way round would have the client miss the first
// second of a ten-second test.
writeJSON(w, http.StatusAccepted, map[string]any{
"action_id": actionID, "direction": "down",
"duration_s": durationMs / 1000, "duration_ms": durationMs,
"requested_duration_s": clamp(req.DurationS, 1, 30),
"kbps": kbps, "size_bytes": size,
"grant": map[string]any{"max_bytes": g.MaxBytes, "max_kbps": g.MaxKbps},
})
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
go func() {
result, err := s.DownThroughput(sess, g, durationMs, kbps, size)
slog.Info("throughput finished", "action", actionID, "packets", result.Packets,
"bytes", result.Bytes, "kbps", result.Kbps, "limited_by", result.LimitedBy, "err", err)
sess.RecordThroughput(actionID, result.Packets, result.Bytes, result.DurationMs,
result.Kbps, result.LimitedBy)
}()
default: default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown or unimplemented action"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown or unimplemented action"})
} }
@@ -504,6 +629,10 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
// The app needs the upload rules before it offers the switch: whether uploads are // The app needs the upload rules before it offers the switch: whether uploads are
// accepted at all, and how much identifying detail it must strip first. // accepted at all, and how much identifying detail it must strip first.
"uploads": s.uploadPolicy(), "uploads": s.uploadPolicy(),
// What a client needs to start a sign-in, without hard-coding the operator's IdP into
// the app: where to authorize, which client id to use, and whether it is worth offering
// sign-in at all on this server.
"auth": s.authInfo(r.Context()),
// What this build speaks, and which app versions it will serve. A client checks the // What this build speaks, and which app versions it will serve. A client checks the
// server side of the same question against its own bounds. // server side of the same question against its own bounds.
"compat": map[string]any{ "compat": map[string]any{
@@ -600,7 +729,7 @@ func (s *Server) uploadRun(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read failed"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read failed"})
return return
} }
meta, err := s.Runs.Put(dev.ID, body) meta, err := s.Runs.Put(dev.ID, body, dev.LinkedToAccount())
switch { switch {
case err == nil: case err == nil:
slog.Info("run uploaded", "device", dev.ID, "run", meta.ID, slog.Info("run uploaded", "device", dev.ID, "run", meta.ID,
@@ -626,11 +755,19 @@ func (s *Server) listRuns(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"}) writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return return
} }
list := s.Runs.List(dev.ID) list := s.Runs.ListFor(s.visibleDevices(dev))
if list == nil { if list == nil {
list = []runs.Meta{} list = []runs.Meta{}
} }
writeJSON(w, http.StatusOK, map[string]any{"runs": list}) writeJSON(w, http.StatusOK, map[string]any{
"runs": list,
// Says whose history this is, so a client can show "3 devices" rather than leaving the
// user to wonder why runs from another phone appeared.
"scope": map[string]any{
"account_id": dev.AccountID,
"devices": len(s.visibleDevices(dev)),
},
})
} }
func (s *Server) getRun(w http.ResponseWriter, r *http.Request) { func (s *Server) getRun(w http.ResponseWriter, r *http.Request) {
@@ -639,9 +776,14 @@ func (s *Server) getRun(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"}) writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return return
} }
// Scoped to the calling device's own directory: one device cannot read another's runs by // Resolved against the caller's own devices only, so a run id from another account is not
// guessing a run id. // found rather than being fetched from wherever it happens to live.
b, err := s.Runs.Get(dev.ID, r.PathValue("id")) owner, ok := s.Runs.OwnerOf(s.visibleDevices(dev), r.PathValue("id"))
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such run"})
return
}
b, err := s.Runs.Get(owner, r.PathValue("id"))
if err != nil { if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such run"}) writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such run"})
return return
@@ -656,7 +798,12 @@ func (s *Server) deleteRun(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"}) writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return return
} }
if err := s.Runs.Delete(dev.ID, r.PathValue("id")); err != nil { owner, ok := s.Runs.OwnerOf(s.visibleDevices(dev), r.PathValue("id"))
if !ok {
w.WriteHeader(http.StatusNoContent) // delete is idempotent; absent is the desired state
return
}
if err := s.Runs.Delete(owner, r.PathValue("id")); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
@@ -684,3 +831,132 @@ func (s *Server) EnrollmentLink(token string) string {
"&p=" + url.QueryEscape("pin-sha256:"+s.PinB64) + "&p=" + url.QueryEscape("pin-sha256:"+s.PinB64) +
"&t=" + url.QueryEscape(token) "&t=" + url.QueryEscape(token)
} }
// upstreamJSON renders the upstream tally with the derived figures already computed, so every
// consumer does not have to repeat (and risk fumbling) the same arithmetic.
func upstreamJSON(sess *session.Session) map[string]any {
u := sess.Upstream()
return map[string]any{
"packets": u.Packets, "bytes": u.Bytes,
"span_ms": u.SpanMs(), "kbps": u.Kbps(),
}
}
// authInfo advertises the sign-in configuration, so the app can present a Sign in button only
// when there is something behind it, and can drive the flow without the user typing an issuer URL.
func (s *Server) authInfo(ctx context.Context) map[string]any {
if s.OIDC == nil || !s.OIDC.Config().Enabled() {
return map[string]any{"enabled": false}
}
cfg := s.OIDC.Config()
out := map[string]any{
"enabled": true,
"issuer": cfg.Issuer,
// The app's client, not the server's: this is what a phone should authorize as.
"client_id": cfg.AppClientID,
// The app is a public client on a phone: no secret can be kept, so PKCE is what
// protects the code exchange (RFC 7636), and the redirect comes back through the
// scheme the app already registers for enrollment links.
"flow": "authorization_code+pkce",
"redirect_uri": "echolot://auth",
"scopes": "openid profile email",
}
if d, err := s.OIDC.Discover(ctx); err == nil {
out["authorization_endpoint"] = d.AuthorizationEndpoint
out["token_endpoint"] = d.TokenEndpoint
out["end_session_endpoint"] = d.EndSessionEndpoint
} else {
// Reported rather than hidden: an unreachable IdP is the operator's problem to see, and
// a client that knows the difference can say "sign-in is configured but the provider is
// not answering" instead of failing obscurely.
out["discovery_error"] = err.Error()
}
return out
}
// linkAccount ties the calling device to the person whose ID token it presents.
//
// The device credential proves *which device*; the ID token proves *which person*. Both are
// required, and neither substitutes for the other: enrollment admits a device to the server,
// signing in attributes it to someone.
func (s *Server) linkAccount(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
if s.OIDC == nil || !s.OIDC.Config().Enabled() {
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "this server has no identity provider configured, so there is nothing to sign in to",
})
return
}
var body struct {
IDToken string `json:"id_token"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.IDToken == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expected an id_token"})
return
}
claims, err := s.OIDC.Verify(r.Context(), body.IDToken)
if err != nil {
// Deliberately terse to the client and detailed in the log: a caller probing token
// handling should not be told which check it failed.
slog.Info("rejected sign-in", "device", dev.ID, "err", err)
writeJSON(w, http.StatusForbidden, map[string]string{"error": "the identity token was not accepted"})
return
}
if err := s.Store.LinkAccount(dev.ID, claims.AccountID(), claims.Display()); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
slog.Info("device linked to account", "device", dev.ID, "account", claims.AccountID(),
"admin", s.OIDC.IsAdmin(claims))
writeJSON(w, http.StatusOK, map[string]any{
"account_id": claims.AccountID(), "display_name": claims.Display(),
"admin": s.OIDC.IsAdmin(claims),
})
}
// unlinkAccount signs out on this device. The device stays enrolled: signing out should not cost
// someone their enrollment, which an operator had to grant.
func (s *Server) unlinkAccount(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
if err := s.Store.LinkAccount(dev.ID, "", ""); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) accountStatus(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"signed_in": dev.LinkedToAccount(),
"account_id": dev.AccountID,
"display_name": dev.AccountName,
"device_id": dev.ID,
})
}
// visibleDevices is the set of devices whose runs the caller may read.
//
// Signed in: every device on the same account, which is what an account is for. Not signed in:
// only itself — anonymous devices are not a group, and treating the absent account as a shared
// one would let any of them read all the others.
func (s *Server) visibleDevices(dev *store.Device) []string {
if dev.LinkedToAccount() {
if ids := s.Store.DeviceIDsForAccount(dev.AccountID); len(ids) > 0 {
return ids
}
}
return []string{dev.ID}
}
+263
View File
@@ -0,0 +1,263 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build linux
package dataplane
import (
"encoding/binary"
"fmt"
"net/netip"
"sync/atomic"
"syscall"
"time"
"echo-lot.app/server/internal/session"
)
// Crafted IPv4 fragmentation (spec §5 frag_send).
//
// Letting the kernel fragment an oversized datagram — which is what big_send with df=false does —
// answers one question: do fragments get through at all. It cannot answer the more interesting
// one, because the kernel always emits fragments in order, first one first.
//
// The classic middlebox fault is precisely 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 later fragments against. Plenty of implementations drop them. Others hold them
// briefly and reassemble; others leak. The difference is invisible to any test that sends
// fragments in order, and it shows up in the real world as "large DNS answers fail on this
// network" or "the VPN works until the MTU drops".
//
// So this builds the fragments by hand and controls their order and timing. That needs a raw
// socket (CAP_NET_RAW); when we do not have one the capability is not advertised, rather than
// advertised and failing later.
// FragMode is how a fragmented datagram is put on the wire.
type FragMode string
const (
// FragInOrder is the baseline: first fragment first, as the kernel would. A path that fails
// this fails everything, and it tells the others apart from a path that drops all fragments.
FragInOrder FragMode = "in_order"
// FragReversed sends the last fragment first. This is the one that finds stateful devices
// which need the first fragment to build state.
FragReversed FragMode = "reversed"
// FragFirstLast holds the first fragment back until the others have arrived, which tests
// whether the path buffers non-first fragments at all and for how long.
FragFirstLast FragMode = "first_last"
)
var fragIPID atomic.Uint32
// RawFragSupported reports whether crafted fragments can actually be sent here.
//
// Checked by opening the socket rather than by inspecting capabilities: the question is "will
// this work", and a permission model has more ways to say no than a capability bit has to say yes
// (user namespaces, seccomp, LSM). Advertising a capability we cannot deliver would turn a
// missing feature into a failed measurement.
func RawFragSupported() bool {
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, syscall.IPPROTO_RAW)
if err != nil {
return false
}
_ = syscall.Close(fd)
return true
}
// FragResult is what happened to one crafted fragment burst.
type FragResult struct {
Mode FragMode `json:"mode"`
SizeBytes int `json:"size_bytes"`
Fragments int `json:"fragments"`
Sent bool `json:"sent"`
Err string `json:"err,omitempty"`
}
// FragSend emits one ELT1 packet of sizeBytes as hand-built IPv4 fragments, in the given order.
//
// The datagram is assembled whole and then cut up, so what the client reassembles — if it
// reassembles — is a normal, HMAC-valid packet indistinguishable from any other. That matters:
// the client must not be able to tell a crafted fragment burst from a kernel one, or it would be
// measuring our sender rather than the path.
func (s *Server) FragSend(
sess *session.Session, g *session.Grant, sizeBytes int, mode FragMode, fragSize int,
) (FragResult, error) {
res := FragResult{Mode: mode, SizeBytes: sizeBytes}
target := sess.DataSource()
if !target.IsValid() {
return res, fmt.Errorf("no observed data-plane source")
}
if !target.Addr().Unmap().Is4() {
// IPv6 has no in-network fragmentation: only the source may fragment, via an extension
// header. Worth building, but it is a different mechanism and belongs in its own code
// path rather than pretending this one covers it.
return res, fmt.Errorf("crafted fragmentation is IPv4-only for now")
}
conn := s.connFor(target, sess.DataLocal())
if conn == nil {
return res, fmt.Errorf("no data-plane socket matches target family")
}
local := sess.DataLocal()
if !local.IsValid() {
return res, fmt.Errorf("session has no recorded local address")
}
if sizeBytes < HeaderSize+8 {
sizeBytes = HeaderSize + 8
}
if sizeBytes > 8000 {
sizeBytes = 8000
}
if !g.Allow(sizeBytes) {
return res, fmt.Errorf("grant exhausted")
}
// The ELT1 packet, signed exactly as any other, then wrapped in UDP.
payload := make([]byte, sizeBytes-HeaderSize)
binary.BigEndian.PutUint32(payload[0:4], uint32(sizeBytes))
copy(payload[4:], mode)
elt := s.buildPacket(sess, TypeFragData, 0, payload)
udp := buildUDP(local, target, elt)
// Fragment offsets are in 8-byte units, so every fragment except the last must be a multiple
// of 8. A payload that is not is not an error — it is a fragment that no host will reassemble.
if fragSize <= 0 {
fragSize = 576
}
fragSize = (fragSize / 8) * 8
if fragSize < 8 {
fragSize = 8
}
fragments := splitIPv4(local.Addr(), target.Addr(), udp, fragSize, uint16(fragIPID.Add(1)))
res.Fragments = len(fragments)
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, syscall.IPPROTO_RAW)
if err != nil {
res.Err = err.Error()
return res, err
}
defer syscall.Close(fd)
if err := syscall.SetsockoptInt(fd, syscall.IPPROTO_IP, syscall.IP_HDRINCL, 1); err != nil {
res.Err = err.Error()
return res, err
}
dst := syscall.SockaddrInet4{}
copy(dst.Addr[:], target.Addr().Unmap().AsSlice())
send := func(pkt []byte) error { return syscall.Sendto(fd, pkt, 0, &dst) }
switch mode {
case FragReversed:
for i := len(fragments) - 1; i >= 0; i-- {
if err := send(fragments[i]); err != nil {
res.Err = err.Error()
return res, err
}
time.Sleep(time.Millisecond)
}
case FragFirstLast:
for i := 1; i < len(fragments); i++ {
if err := send(fragments[i]); err != nil {
res.Err = err.Error()
return res, err
}
time.Sleep(time.Millisecond)
}
// Long enough to be a real test of whether anything holds fragments, short enough to stay
// inside the usual 30-second reassembly timeout by a wide margin.
time.Sleep(250 * time.Millisecond)
if err := send(fragments[0]); err != nil {
res.Err = err.Error()
return res, err
}
default:
for _, f := range fragments {
if err := send(f); err != nil {
res.Err = err.Error()
return res, err
}
time.Sleep(time.Millisecond)
}
}
res.Sent = true
return res, nil
}
// buildUDP wraps a payload in a UDP header with a computed checksum.
//
// The checksum is optional in IPv4 and it would be less code to send zero, but a zero-checksum
// datagram is dropped by some middleboxes — and that drop would be recorded as a fragmentation
// failure, which is exactly the wrong conclusion.
func buildUDP(src, dst netip.AddrPort, payload []byte) []byte {
out := make([]byte, 8+len(payload))
binary.BigEndian.PutUint16(out[0:2], src.Port())
binary.BigEndian.PutUint16(out[2:4], dst.Port())
binary.BigEndian.PutUint16(out[4:6], uint16(8+len(payload)))
copy(out[8:], payload)
// Pseudo-header + UDP header + data, per RFC 768.
var sum uint32
s4, d4 := src.Addr().Unmap().As4(), dst.Addr().Unmap().As4()
for _, b := range [][]byte{s4[:], d4[:]} {
sum += uint32(binary.BigEndian.Uint16(b[0:2]))
sum += uint32(binary.BigEndian.Uint16(b[2:4]))
}
sum += uint32(syscall.IPPROTO_UDP)
sum += uint32(len(out))
for i := 0; i+1 < len(out); i += 2 {
sum += uint32(binary.BigEndian.Uint16(out[i : i+2]))
}
if len(out)%2 == 1 {
sum += uint32(out[len(out)-1]) << 8
}
for sum>>16 != 0 {
sum = (sum & 0xFFFF) + (sum >> 16)
}
ck := ^uint16(sum)
if ck == 0 {
ck = 0xFFFF // 0 means "no checksum" in IPv4; the all-ones form is the same value
}
binary.BigEndian.PutUint16(out[6:8], ck)
return out
}
// splitIPv4 cuts a UDP datagram into IPv4 fragments of at most fragSize payload bytes each.
//
// Every fragment carries the same IP ID — that is what marks them as one datagram — and every one
// but the last sets MF. The kernel fills in the header checksum and total length for us under
// IP_HDRINCL (raw(7)); the ID it only fills when zero, which is why it is set explicitly here.
func splitIPv4(src, dst netip.Addr, udp []byte, fragSize int, id uint16) [][]byte {
s4, d4 := src.Unmap().As4(), dst.Unmap().As4()
var out [][]byte
for off := 0; off < len(udp); off += fragSize {
end := off + fragSize
if end > len(udp) {
end = len(udp)
}
chunk := udp[off:end]
more := end < len(udp)
hdr := make([]byte, 20, 20+len(chunk))
hdr[0] = 0x45 // IPv4, 5 words of header
hdr[1] = 0 // DSCP/ECN
binary.BigEndian.PutUint16(hdr[2:4], uint16(20+len(chunk)))
binary.BigEndian.PutUint16(hdr[4:6], id)
flagsOff := uint16(off / 8)
if more {
flagsOff |= 0x2000 // MF
}
binary.BigEndian.PutUint16(hdr[6:8], flagsOff)
hdr[8] = 64 // TTL
hdr[9] = syscall.IPPROTO_UDP
// hdr[10:12] checksum left zero: the kernel computes it under IP_HDRINCL.
copy(hdr[12:16], s4[:])
copy(hdr[16:20], d4[:])
out = append(out, append(hdr, chunk...))
}
return out
}
@@ -0,0 +1,160 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build linux
package dataplane
import (
"encoding/binary"
"net/netip"
"testing"
)
// Fragment headers are the kind of thing that is either exactly right or silently useless: a
// wrong offset unit, a missing MF bit or a bad checksum produces packets that leave the machine
// and are dropped by the receiver's IP stack without a word. Nothing downstream would notice —
// the client would simply record "fragments do not get through", which is a wrong answer rather
// than a missing one. Hence these check the bytes.
func testAddrs() (netip.AddrPort, netip.AddrPort) {
return netip.MustParseAddrPort("192.0.2.1:8442"), netip.MustParseAddrPort("198.51.100.9:41000")
}
func TestSplitCoversThePayloadExactlyOnce(t *testing.T) {
src, dst := testAddrs()
udp := buildUDP(src, dst, make([]byte, 2000))
frags := splitIPv4(src.Addr(), dst.Addr(), udp, 576, 0x1234)
if len(frags) < 3 {
t.Fatalf("expected several fragments for %d bytes, got %d", len(udp), len(frags))
}
// Reassemble the way a receiver would: place each fragment's payload at its offset.
rebuilt := make([]byte, len(udp))
covered := make([]bool, len(udp))
for _, f := range frags {
flagsOff := binary.BigEndian.Uint16(f[6:8])
off := int(flagsOff&0x1FFF) * 8
body := f[20:]
if off+len(body) > len(udp) {
t.Fatalf("fragment at offset %d overruns the datagram", off)
}
for i, b := range body {
if covered[off+i] {
t.Fatalf("byte %d delivered twice", off+i)
}
covered[off+i] = true
rebuilt[off+i] = b
}
}
for i, c := range covered {
if !c {
t.Fatalf("byte %d was never sent", i)
}
}
for i := range udp {
if rebuilt[i] != udp[i] {
t.Fatalf("reassembled byte %d differs", i)
}
}
}
func TestFragmentHeadersAreWellFormed(t *testing.T) {
src, dst := testAddrs()
udp := buildUDP(src, dst, make([]byte, 3000))
frags := splitIPv4(src.Addr(), dst.Addr(), udp, 800, 0xBEEF)
for i, f := range frags {
if got := f[0]; got != 0x45 {
t.Errorf("fragment %d: version/IHL = %#x, want 0x45", i, got)
}
if got := f[9]; got != 17 {
t.Errorf("fragment %d: protocol = %d, want 17 (UDP)", i, got)
}
if got := binary.BigEndian.Uint16(f[4:6]); got != 0xBEEF {
t.Errorf("fragment %d: IP ID = %#x — all fragments of one datagram must share it", i, got)
}
if got := binary.BigEndian.Uint16(f[2:4]); int(got) != len(f) {
t.Errorf("fragment %d: total length = %d, actual %d", i, got, len(f))
}
flagsOff := binary.BigEndian.Uint16(f[6:8])
mf := flagsOff&0x2000 != 0
wantMF := i < len(frags)-1
if mf != wantMF {
t.Errorf("fragment %d: MF = %v, want %v", i, mf, wantMF)
}
}
}
// Offsets are counted in 8-byte units, so every fragment but the last must be a multiple of 8.
// A 100-byte "fragment size" that silently becomes 100 bytes on the wire produces a datagram no
// host will ever reassemble.
func TestNonFinalFragmentsAreEightByteMultiples(t *testing.T) {
src, dst := testAddrs()
udp := buildUDP(src, dst, make([]byte, 2500))
for _, size := range []int{8, 100, 576, 999, 1400} {
frags := splitIPv4(src.Addr(), dst.Addr(), udp, (size/8)*8, 1)
for i, f := range frags[:len(frags)-1] {
if body := len(f) - 20; body%8 != 0 {
t.Errorf("size %d: non-final fragment %d carries %d bytes, not a multiple of 8",
size, i, body)
}
}
}
}
// The UDP checksum is optional in IPv4, and sending zero would be less code — but a
// zero-checksum datagram is dropped by some middleboxes, and that drop would be recorded as a
// fragmentation failure. So it must be present and correct.
func TestUDPChecksumVerifies(t *testing.T) {
src, dst := testAddrs()
for _, n := range []int{0, 1, 7, 8, 100, 1001} { // odd lengths exercise the tail-byte path
udp := buildUDP(src, dst, make([]byte, n))
if got := binary.BigEndian.Uint16(udp[6:8]); got == 0 {
t.Fatalf("payload %d: checksum is zero, which means 'not computed'", n)
}
if sum := verifyUDPChecksum(src.Addr(), dst.Addr(), udp); sum != 0xFFFF {
t.Errorf("payload %d: checksum does not verify (one's complement sum %#x)", n, sum)
}
if got := binary.BigEndian.Uint16(udp[4:6]); int(got) != len(udp) {
t.Errorf("payload %d: UDP length field %d, actual %d", n, got, len(udp))
}
}
}
func TestUDPPortsComeFromTheSessionAddresses(t *testing.T) {
src, dst := testAddrs()
udp := buildUDP(src, dst, []byte("x"))
if got := binary.BigEndian.Uint16(udp[0:2]); got != src.Port() {
t.Errorf("source port = %d, want %d", got, src.Port())
}
// The destination port must be the client's observed source port, or the datagram arrives
// at the machine and is discarded before any socket sees it.
if got := binary.BigEndian.Uint16(udp[2:4]); got != dst.Port() {
t.Errorf("destination port = %d, want %d", got, dst.Port())
}
}
// Recomputes the one's complement sum over the pseudo-header and datagram; a correct checksum
// makes the total 0xFFFF.
func verifyUDPChecksum(src, dst netip.Addr, udp []byte) uint16 {
var sum uint32
s4, d4 := src.Unmap().As4(), dst.Unmap().As4()
for _, b := range [][]byte{s4[:], d4[:]} {
sum += uint32(binary.BigEndian.Uint16(b[0:2]))
sum += uint32(binary.BigEndian.Uint16(b[2:4]))
}
sum += 17
sum += uint32(len(udp))
for i := 0; i+1 < len(udp); i += 2 {
sum += uint32(binary.BigEndian.Uint16(udp[i : i+2]))
}
if len(udp)%2 == 1 {
sum += uint32(udp[len(udp)-1]) << 8
}
for sum>>16 != 0 {
sum = (sum & 0xFFFF) + (sum >> 16)
}
return uint16(sum)
}
+41
View File
@@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build !linux
package dataplane
import (
"fmt"
"echo-lot.app/server/internal/session"
)
// Crafting IP fragments needs a raw socket and Linux's IP_HDRINCL semantics. Off Linux the
// capability is simply not advertised, so a client never asks for it — better than answering
// with a measurement we cannot actually make.
type FragMode string
const (
FragInOrder FragMode = "in_order"
FragReversed FragMode = "reversed"
FragFirstLast FragMode = "first_last"
)
type FragResult struct {
Mode FragMode `json:"mode"`
SizeBytes int `json:"size_bytes"`
Fragments int `json:"fragments"`
Sent bool `json:"sent"`
Err string `json:"err,omitempty"`
}
func RawFragSupported() bool { return false }
func (s *Server) FragSend(
sess *session.Session, g *session.Grant, sizeBytes int, mode FragMode, fragSize int,
) (FragResult, error) {
return FragResult{Mode: mode, SizeBytes: sizeBytes},
fmt.Errorf("crafted fragmentation is only implemented on Linux")
}
+187
View File
@@ -0,0 +1,187 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package dataplane
import (
"encoding/binary"
"fmt"
"time"
"echo-lot.app/server/internal/session"
)
// Sustained-rate sending (spec §5 throughput).
//
// This is the most expensive thing the server will do on a client's say-so, so it is also the
// action where the §3.4 anti-amplification rules matter most. Three bounds apply, and all three
// are enforced here rather than trusted to the caller:
//
// - the destination is the session's *observed* data-plane source, verified by an HMAC-signed
// ECHO that arrived from that address, so this cannot be aimed at a third party;
// - the grant carries a byte budget and an average-rate ceiling, and the send stops the moment
// either is reached;
// - the duration is hard-capped, so a client that vanishes mid-test costs a bounded amount of
// traffic rather than an open-ended one.
//
// The measurement this produces is honest only if the client is told which limit it hit. A run
// that saturates the grant ceiling has measured *us*, not the network, and reporting that as
// throughput would be worse than not measuring at all — see ThroughputResult.LimitedBy.
// ThroughputResult is what the server actually managed to send.
type ThroughputResult struct {
Packets int `json:"packets"`
Bytes int64 `json:"bytes"`
DurationMs int64 `json:"duration_ms"`
Kbps int `json:"kbps"`
// LimitedBy says what stopped it: "duration" (ran the full time, so the rate is the path's
// or ours to give), "budget" (hit the grant's byte ceiling), or "rate" (the pacing ceiling
// held it back). Only "duration" makes the number a property of the network.
LimitedBy string `json:"limited_by"`
}
// ThroughputLimits derives a grant sized for one throughput run.
//
// The default 8 MiB action budget is deliberately far too small for this — ten seconds at
// 50 Mbps is 62 MB — so throughput gets its own budget computed from what it asked for, still
// clamped to a ceiling. Sizing the budget to the request (rather than raising the global default)
// keeps every *other* action bounded at 8 MiB.
func ThroughputLimits(durationMs, kbps int) session.GrantLimits {
durationMs, kbps = ThroughputPlan(durationMs, kbps)
// bytes = kbps * 1000 / 8 * seconds, with a little headroom so the byte budget is not what
// stops a run that was meant to be stopped by the clock.
budget := int64(kbps) * 1000 / 8 * int64(durationMs) / 1000
budget = budget * 11 / 10
if budget > maxThroughputBytes {
budget = maxThroughputBytes
}
return session.GrantLimits{
MaxBytes: budget,
// A little above the pacing target on purpose: the pacer should be what controls the
// rate, and the grant should be the safety net. If they are equal, ordinary scheduling
// jitter trips the grant and the run is cut short for no real reason.
MaxKbps: kbps * 12 / 10,
MaxHold: time.Duration(durationMs)*time.Millisecond + 5*time.Second,
}
}
// ThroughputPlan reduces a request to what this server will actually run, and is the single
// place that decides it.
//
// When the byte cap binds before the clock does, the *duration* is shortened rather than the run
// being cut off partway. Truncating mid-run is not wrong exactly — the rate is still computed
// over the elapsed time and limited_by says "budget" — but it means promising a client thirty
// seconds and giving it twenty-one. Saying "twenty-one seconds" up front is the same information
// without the surprise, and it keeps "the clock ended the run" as the normal case, which is the
// only case where the number is a clean property of the network.
func ThroughputPlan(durationMs, kbps int) (effectiveMs, effectiveKbps int) {
if durationMs <= 0 {
durationMs = 10_000
}
if durationMs > maxThroughputMs {
durationMs = maxThroughputMs
}
if kbps <= 0 || kbps > maxThroughputKbps {
kbps = maxThroughputKbps
}
bytesPerMs := int64(kbps) * 1000 / 8 / 1000
if bytesPerMs > 0 {
if maxMs := maxThroughputBytes / bytesPerMs; int64(durationMs) > maxMs {
durationMs = int(maxMs)
}
}
return durationMs, kbps
}
const (
maxThroughputMs = 30_000
maxThroughputKbps = 200_000
maxThroughputBytes = 256 << 20
)
// DownThroughput sends paced traffic toward the client for up to durationMs.
//
// Pacing is deliberate rather than "send as fast as possible": an unpaced burst measures the
// server's NIC and the first queue it meets, then collapses into loss that looks like a network
// fault. Spacing packets at the target rate makes loss mean what a reader will assume it means.
func (s *Server) DownThroughput(
sess *session.Session, g *session.Grant, durationMs, kbps, sizeBytes int,
) (ThroughputResult, error) {
res := ThroughputResult{}
target := sess.DataSource()
if !target.IsValid() {
return res, fmt.Errorf("no observed data-plane source")
}
conn := s.connFor(target, sess.DataLocal())
if conn == nil {
return res, fmt.Errorf("no data-plane socket matches target family")
}
// Same plan the grant was sized from, so the two cannot disagree.
durationMs, kbps = ThroughputPlan(durationMs, kbps)
if sizeBytes < HeaderSize+16 {
sizeBytes = 1200 // a size that survives every common path unfragmented
}
if sizeBytes > 1472 {
sizeBytes = 1472
}
// Nanoseconds between packets to hit the target rate.
perPacketNs := int64(sizeBytes) * 8 * 1_000_000 / int64(kbps)
if perPacketNs < 1_000 {
perPacketNs = 1_000
}
payload := make([]byte, sizeBytes-HeaderSize)
deadline := time.Now().Add(time.Duration(durationMs) * time.Millisecond)
start := time.Now()
next := start
var seq uint32
for time.Now().Before(deadline) {
ok, why := g.TryAllow(sizeBytes)
if !ok {
if why == session.RefusalRate {
// Transient: the bucket is momentarily empty. Wait for the next slot and carry
// on. Ending the run here would report a rate measured over a fraction of a
// second, which is worse than reporting no rate at all.
res.LimitedBy = "rate"
time.Sleep(time.Duration(perPacketNs))
continue
}
// Terminal: the budget is spent, or the grant expired.
res.LimitedBy = why
break
}
// Reaching here means the run is progressing normally; the clock will end it.
res.LimitedBy = "duration"
binary.BigEndian.PutUint32(payload[0:4], seq)
binary.BigEndian.PutUint64(payload[4:12], uint64(time.Since(s.start).Nanoseconds()))
if err := s.sendErr(conn, target, sess, TypeThroughputData, seq, payload); err != nil {
// A send error mid-run is a local condition (buffer full, route gone). Stop and
// report what got out rather than pretending the rest was lost on the path.
res.LimitedBy = "send_error"
break
}
res.Packets++
res.Bytes += int64(sizeBytes)
seq++
// Absolute schedule, not sleep-per-packet: sleeping a fixed interval accumulates the
// scheduler's error and drifts the achieved rate below the target over a 10-second run.
next = next.Add(time.Duration(perPacketNs))
if d := time.Until(next); d > 0 {
time.Sleep(d)
}
}
elapsed := time.Since(start)
res.DurationMs = elapsed.Milliseconds()
// bits per millisecond is kilobits per second, so no scaling constant is needed - and none
// can be got wrong. Guarded because a run that ends inside a millisecond has no rate.
if res.DurationMs > 0 {
res.Kbps = int(res.Bytes * 8 / res.DurationMs)
}
return res, nil
}
@@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package dataplane
import (
"testing"
"time"
)
// The grant has to be big enough that the *clock* ends a throughput run, not the byte budget. Get
// this wrong and the test still "works": it stops early, reports a rate computed over a truncated
// window, and nothing anywhere says the number is meaningless. So the sizing is pinned.
func TestThroughputBudgetOutlastsTheRequestedRun(t *testing.T) {
cases := []struct{ durationMs, kbps int }{
{1_000, 1_000},
{10_000, 50_000},
{10_000, 200_000},
{30_000, 100_000},
}
for _, c := range cases {
// Against the *planned* duration, which is what will actually be run: a request the
// server shortens is answered with the shorter number, not truncated halfway.
planMs, planKbps := ThroughputPlan(c.durationMs, c.kbps)
lim := ThroughputLimits(c.durationMs, c.kbps)
needed := int64(planKbps) * 1000 / 8 * int64(planMs) / 1000
if lim.MaxBytes < needed {
t.Errorf("%d ms at %d kbps (planned %d ms) needs %d bytes, budget is %d - the run "+
"would stop early and report a rate over a truncated window",
c.durationMs, c.kbps, planMs, needed, lim.MaxBytes)
}
}
}
// The pacer should control the rate and the grant should be the safety net. If the grant's
// ceiling equals the pacing target, ordinary scheduling jitter trips it and cuts the run short
// for no real reason.
func TestGrantRateCeilingSitsAboveThePacingTarget(t *testing.T) {
lim := ThroughputLimits(10_000, 50_000)
if lim.MaxKbps <= 50_000 {
t.Fatalf("grant ceiling %d kbps is not above the 50000 kbps pacing target", lim.MaxKbps)
}
}
// A client asking for more than the server will do must get the server's number, not its own.
func TestThroughputRequestsAreClamped(t *testing.T) {
lim := ThroughputLimits(10*60*1000, 10_000_000) // ten minutes at 10 Gbps
if lim.MaxBytes > maxThroughputBytes {
t.Errorf("byte budget %d exceeds the hard cap %d", lim.MaxBytes, maxThroughputBytes)
}
if lim.MaxKbps > maxThroughputKbps*12/10 {
t.Errorf("rate ceiling %d exceeds the hard cap", lim.MaxKbps)
}
// The hold has to outlast the planned run, or the grant expires mid-send and the run is
// reported as rate-limited when it was really time-limited.
planMs, _ := ThroughputPlan(10*60*1000, 10_000_000)
if lim.MaxHold < time.Duration(planMs)*time.Millisecond {
t.Errorf("hold %v is shorter than the planned run of %d ms", lim.MaxHold, planMs)
}
}
// When the byte cap binds before the clock does, the server shortens the run and says so, rather
// than accepting thirty seconds and delivering twenty-one. Same information, no surprise - and it
// keeps "the clock ended the run" as the normal case, which is the only case where the resulting
// rate is a clean property of the network.
func TestAnOversizedRequestComesBackShorterRatherThanTruncated(t *testing.T) {
const kbps = 200_000
askedMs := 30_000
planMs, planKbps := ThroughputPlan(askedMs, kbps)
if planKbps != kbps {
t.Errorf("rate was reduced to %d; the duration should absorb the cap, not the rate", planKbps)
}
if planMs >= askedMs {
t.Fatalf("plan kept the full %d ms at %d kbps, which exceeds the %d byte cap",
askedMs, kbps, maxThroughputBytes)
}
// And what it does promise must fit.
if got := int64(planKbps) * 1000 / 8 * int64(planMs) / 1000; got > maxThroughputBytes {
t.Errorf("planned run needs %d bytes, over the %d cap", got, maxThroughputBytes)
}
}
// A short, ordinary request must come back untouched - the clamping only exists for the extremes.
func TestAnOrdinaryRequestIsNotRewritten(t *testing.T) {
planMs, planKbps := ThroughputPlan(10_000, 50_000)
if planMs != 10_000 || planKbps != 50_000 {
t.Errorf("10 s at 50 Mbps was rewritten to %d ms at %d kbps", planMs, planKbps)
}
}
// Every action other than throughput stays on the small default budget. Throughput needs a big
// one; raising the global default to suit it would quietly unbound everything else.
func TestOnlyThroughputGetsTheLargeBudget(t *testing.T) {
big := ThroughputLimits(10_000, 50_000)
if big.MaxBytes <= 8<<20 {
t.Fatalf("throughput budget %d is no larger than the default action budget", big.MaxBytes)
}
}
+28 -2
View File
@@ -35,6 +35,14 @@ const (
// Server->client under an asymmetric grant (spec §3.4/§5). // Server->client under an asymmetric grant (spec §3.4/§5).
TypeDownTrainData = 0x06 TypeDownTrainData = 0x06
TypeBigSend = 0x0C TypeBigSend = 0x0C
// TypeFragData is delivered only after IP reassembly, so its arrival IS the measurement.
TypeFragData = 0x0D
// TypeThroughputData is one packet of a sustained-rate downstream run.
TypeThroughputData = 0x0E
// TypeThroughputUp is one packet of a client-driven upstream run. The server counts it and
// deliberately does not answer: a reply would double the traffic and measure the return
// path at the same time, which is the one thing this test is trying not to do.
TypeThroughputUp = 0x0F
) )
type Server struct { type Server struct {
@@ -148,6 +156,14 @@ func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRx
if la, ok := conn.LocalAddr().(*net.UDPAddr); ok { if la, ok := conn.LocalAddr().(*net.UDPAddr); ok {
sess.NoteDataLocal(la.AddrPort()) sess.NoteDataLocal(la.AddrPort())
} }
// Upstream throughput short-circuits before the observation log. Recording one struct per
// packet here would mean tens of thousands of allocations for a single run; the counter is
// all anyone needs, since the client holds the send-side record.
if typ == TypeThroughputUp {
sess.CountUpstream(len(pkt), tRxNs)
return
}
sess.RecordUDP(session.UDPObservation{ sess.RecordUDP(session.UDPObservation{
Seq: seq, TRxNs: tRxNs, TTxNs: time.Since(s.start).Nanoseconds(), Seq: seq, TRxNs: tRxNs, TTxNs: time.Since(s.start).Nanoseconds(),
Src: raddr.String(), Size: len(pkt), Type: typ, Src: raddr.String(), Size: len(pkt), Type: typ,
@@ -232,6 +248,17 @@ func (s *Server) send(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Ses
// EMSGSIZE means our own egress MTU refused the datagram, which is a different fact from the // EMSGSIZE means our own egress MTU refused the datagram, which is a different fact from the
// client not receiving it. // client not receiving it.
func (s *Server) sendErr(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, typ byte, seq uint32, payload []byte) error { func (s *Server) sendErr(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, typ byte, seq uint32, payload []byte) error {
pkt := s.buildPacket(sess, typ, seq, payload)
_, err := conn.WriteToUDPAddrPort(pkt, raddr)
return err
}
// buildPacket assembles and signs an ELT1 packet without sending it.
//
// Split out for the crafted-fragment path, which needs the bytes so it can cut them up itself.
// What arrives after reassembly must be indistinguishable from an ordinary packet, or the client
// would be measuring our sender rather than the path — so it goes through exactly this function.
func (s *Server) buildPacket(sess *session.Session, typ byte, seq uint32, payload []byte) []byte {
pkt := make([]byte, HeaderSize+len(payload)) pkt := make([]byte, HeaderSize+len(payload))
copy(pkt[0:4], Magic) copy(pkt[0:4], Magic)
pkt[4] = typ pkt[4] = typ
@@ -247,8 +274,7 @@ func (s *Server) sendErr(conn *net.UDPConn, raddr netip.AddrPort, sess *session.
mac.Write(pkt[0:28]) mac.Write(pkt[0:28])
mac.Write(payload) mac.Write(payload)
copy(pkt[28:32], mac.Sum(nil)[:4]) copy(pkt[28:32], mac.Sum(nil)[:4])
_, err := conn.WriteToUDPAddrPort(pkt, raddr) return pkt
return err
} }
func hexByte(hi, lo byte) byte { func hexByte(hi, lo byte) byte {
+504
View File
@@ -0,0 +1,504 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package oidc verifies OpenID Connect ID tokens against a configured issuer.
//
// Echolot is a *relying party*, never an identity provider. It delegates to whatever IdP the
// operator already runs and stores no passwords — no hashing, no reset flow, no lockout policy,
// and no credential database to leak. For a tool people self-host on a box they also use for
// other things, that is the difference between "one more service" and "one more thing that can
// lose your users' passwords".
//
// Verification is written against the stdlib rather than a JWT library, because the server has no
// external dependencies by design. That is a real constraint and it cuts both ways: the code below
// is longer than `jwt.Parse`, but it is also auditable in one sitting and cannot be broken by
// somebody else's release. The algorithm allow-list is the part that matters — accepting `alg`
// from the token itself is the classic JWT forgery, so it is fixed here and `none` can never
// appear.
package oidc
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"time"
)
// Claims are the parts of an ID token Echolot acts on.
type Claims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience audience `json:"aud"`
Expiry int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
Nonce string `json:"nonce"`
Email string `json:"email"`
Name string `json:"name"`
Username string `json:"preferred_username"`
Groups []string `json:"groups"`
}
// AccountID is the stable identity of a person: issuer plus subject.
//
// Subject alone is not enough — it is only unique within an issuer — and email is not stable,
// since people change them and IdPs allow reuse. Keying on iss+sub means an operator can switch
// IdPs and know that the accounts did not silently merge.
func (c Claims) AccountID() string { return c.Issuer + "#" + c.Subject }
// Display is the friendliest name available, for the admin UI.
func (c Claims) Display() string {
for _, s := range []string{c.Name, c.Username, c.Email} {
if s != "" {
return s
}
}
return c.Subject
}
// audience tolerates the spec's two shapes: a string or an array of strings.
type audience []string
func (a *audience) UnmarshalJSON(b []byte) error {
var one string
if err := json.Unmarshal(b, &one); err == nil {
*a = audience{one}
return nil
}
var many []string
if err := json.Unmarshal(b, &many); err != nil {
return err
}
*a = many
return nil
}
func (a audience) contains(s string) bool {
for _, v := range a {
if v == s {
return true
}
}
return false
}
// Config is what the operator supplies.
type Config struct {
// Issuer is the IdP's base URL, e.g. https://auth.example.net/application/o/echolot/
Issuer string
// ClientID is this server's own registered client — confidential, used for the admin UI's
// browser login, where a secret can genuinely be kept in the host's config.
ClientID string
// AppClientID is the mobile app's registered client. It is a separate, *public* client
// because an APK cannot keep a secret, so it uses PKCE instead.
//
// Both are accepted as audiences, and they must be listed rather than merged: a token is
// addressed to a specific client, and accepting "any client of this issuer" would let every
// other application registered with the same IdP authenticate here.
AppClientID string
// AdminGroup, when set, is the group claim a person must hold to reach the admin UI.
// Empty means no one is an admin via OIDC, which is the safe default: an operator who has
// not said who may administer the server has not said "everyone".
AdminGroup string
// Skew tolerated on exp/iat, for ordinary clock drift between the IdP and this server.
Skew time.Duration
}
func (c Config) Enabled() bool { return c.Issuer != "" && (c.ClientID != "" || c.AppClientID != "") }
// acceptedAudiences is every client id this server answers for.
func (v *Verifier) acceptedAudiences() []string {
out := make([]string, 0, 2)
for _, id := range []string{v.cfg.ClientID, v.cfg.AppClientID} {
if id != "" {
out = append(out, id)
}
}
return out
}
func (v *Verifier) audienceAccepted(aud audience) bool {
for _, id := range v.acceptedAudiences() {
if aud.contains(id) {
return true
}
}
return false
}
// Discovery is the subset of the provider metadata document that is used.
type Discovery struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSURI string `json:"jwks_uri"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
EndSessionEndpoint string `json:"end_session_endpoint"`
}
// Verifier fetches provider metadata and keys, and checks tokens against them.
type Verifier struct {
cfg Config
client *http.Client
mu sync.RWMutex
discovery *Discovery
keys map[string]crypto.PublicKey
keysAt time.Time
}
func New(cfg Config, client *http.Client) *Verifier {
if cfg.Skew == 0 {
cfg.Skew = 2 * time.Minute
}
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
return &Verifier{cfg: cfg, client: client, keys: map[string]crypto.PublicKey{}}
}
func (v *Verifier) Config() Config { return v.cfg }
var (
ErrDisabled = errors.New("no OIDC issuer is configured on this server")
ErrMalformed = errors.New("token is not a well-formed JWT")
ErrSignature = errors.New("token signature does not verify")
ErrClaims = errors.New("token claims are not acceptable")
)
// Discover fetches (and caches) the provider metadata.
func (v *Verifier) Discover(ctx context.Context) (*Discovery, error) {
if !v.cfg.Enabled() {
return nil, ErrDisabled
}
v.mu.RLock()
d := v.discovery
v.mu.RUnlock()
if d != nil {
return d, nil
}
url := strings.TrimRight(v.cfg.Issuer, "/") + "/.well-known/openid-configuration"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := v.client.Do(req)
if err != nil {
return nil, fmt.Errorf("discovery: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("discovery: %s returned %d", url, resp.StatusCode)
}
var got Discovery
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
return nil, fmt.Errorf("discovery: %w", err)
}
// The issuer in the document must match the one configured, or a redirect could point us at
// somebody else's keys while we keep believing we are talking to the configured provider.
if strings.TrimRight(got.Issuer, "/") != strings.TrimRight(v.cfg.Issuer, "/") {
return nil, fmt.Errorf("discovery: document says issuer %q, configured %q", got.Issuer, v.cfg.Issuer)
}
v.mu.Lock()
v.discovery = &got
v.mu.Unlock()
return &got, nil
}
// jwksTTL is how long keys are trusted before refetching. Short enough to pick up a rotation
// without an operator restarting anything; long enough that token checks are not IdP round trips.
const jwksTTL = 15 * time.Minute
func (v *Verifier) keyFor(ctx context.Context, kid string) (crypto.PublicKey, error) {
v.mu.RLock()
k, ok := v.keys[kid]
fresh := time.Since(v.keysAt) < jwksTTL
v.mu.RUnlock()
if ok && fresh {
return k, nil
}
if err := v.refreshKeys(ctx); err != nil {
return nil, err
}
v.mu.RLock()
defer v.mu.RUnlock()
if k, ok := v.keys[kid]; ok {
return k, nil
}
// A kid we have never seen, after a refresh, is a token from somewhere else.
return nil, fmt.Errorf("%w: no key %q at the issuer", ErrSignature, kid)
}
func (v *Verifier) refreshKeys(ctx context.Context) error {
d, err := v.Discover(ctx)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.JWKSURI, nil)
if err != nil {
return err
}
resp, err := v.client.Do(req)
if err != nil {
return fmt.Errorf("jwks: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("jwks: %s returned %d", d.JWKSURI, resp.StatusCode)
}
var set struct {
Keys []jwk `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&set); err != nil {
return fmt.Errorf("jwks: %w", err)
}
parsed := make(map[string]crypto.PublicKey, len(set.Keys))
for _, k := range set.Keys {
if pub, err := k.publicKey(); err == nil {
parsed[k.Kid] = pub
}
}
if len(parsed) == 0 {
return errors.New("jwks: no usable keys")
}
v.mu.Lock()
v.keys = parsed
v.keysAt = time.Now()
v.mu.Unlock()
return nil
}
type jwk struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
Alg string `json:"alg"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
}
func (k jwk) publicKey() (crypto.PublicKey, error) {
switch k.Kty {
case "RSA":
n, err := b64uint(k.N)
if err != nil {
return nil, err
}
e, err := b64uint(k.E)
if err != nil {
return nil, err
}
if !e.IsInt64() || e.Int64() > 1<<31 {
return nil, errors.New("implausible RSA exponent")
}
return &rsa.PublicKey{N: n, E: int(e.Int64())}, nil
case "EC":
curve, err := curveFor(k.Crv)
if err != nil {
return nil, err
}
x, err := b64uint(k.X)
if err != nil {
return nil, err
}
y, err := b64uint(k.Y)
if err != nil {
return nil, err
}
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
}
return nil, fmt.Errorf("unsupported key type %q", k.Kty)
}
// Verify checks a serialized ID token and returns its claims.
//
// The order is deliberate: structure, then algorithm, then signature, then claims. Nothing about
// the token's contents is believed before its signature has been checked — reading `iss` or `aud`
// out of an unverified token and acting on it is how "verified" tokens turn out not to be.
func (v *Verifier) Verify(ctx context.Context, token string) (*Claims, error) {
if !v.cfg.Enabled() {
return nil, ErrDisabled
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, ErrMalformed
}
headerJSON, err := b64(parts[0])
if err != nil {
return nil, ErrMalformed
}
var hdr struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Typ string `json:"typ"`
}
if err := json.Unmarshal(headerJSON, &hdr); err != nil {
return nil, ErrMalformed
}
// The allow-list is fixed here rather than taken from the token. Trusting the token's own
// `alg` is the classic JWT forgery: "none" turns any token into a valid one, and swapping RS256
// for HS256 lets an attacker sign with the public key. Neither is reachable from here.
if _, ok := allowedAlgs[hdr.Alg]; !ok {
return nil, fmt.Errorf("%w: algorithm %q is not accepted", ErrSignature, hdr.Alg)
}
pub, err := v.keyFor(ctx, hdr.Kid)
if err != nil {
return nil, err
}
sig, err := b64(parts[2])
if err != nil {
return nil, ErrMalformed
}
signed := parts[0] + "." + parts[1]
if err := verifySignature(hdr.Alg, pub, []byte(signed), sig); err != nil {
return nil, err
}
payload, err := b64(parts[1])
if err != nil {
return nil, ErrMalformed
}
var claims Claims
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, ErrMalformed
}
if err := v.checkClaims(claims); err != nil {
return nil, err
}
return &claims, nil
}
func (v *Verifier) checkClaims(c Claims) error {
if strings.TrimRight(c.Issuer, "/") != strings.TrimRight(v.cfg.Issuer, "/") {
return fmt.Errorf("%w: issued by %q, expected %q", ErrClaims, c.Issuer, v.cfg.Issuer)
}
// A token addressed to a different client is a valid token that was not meant for us —
// accepting it lets any other client of the same IdP authenticate here.
if !v.audienceAccepted(c.Audience) {
return fmt.Errorf("%w: addressed to %v, not to %v", ErrClaims,
[]string(c.Audience), v.acceptedAudiences())
}
if c.Subject == "" {
return fmt.Errorf("%w: no subject", ErrClaims)
}
now := time.Now()
if c.Expiry == 0 || now.After(time.Unix(c.Expiry, 0).Add(v.cfg.Skew)) {
return fmt.Errorf("%w: expired", ErrClaims)
}
if c.IssuedAt != 0 && now.Add(v.cfg.Skew).Before(time.Unix(c.IssuedAt, 0)) {
return fmt.Errorf("%w: issued in the future", ErrClaims)
}
return nil
}
// IsAdmin reports whether these claims carry the configured admin group.
//
// With no 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".
func (v *Verifier) IsAdmin(c *Claims) bool {
if c == nil || v.cfg.AdminGroup == "" {
return false
}
for _, g := range c.Groups {
if g == v.cfg.AdminGroup {
return true
}
}
return false
}
var allowedAlgs = map[string]crypto.Hash{
"RS256": crypto.SHA256, "RS384": crypto.SHA384, "RS512": crypto.SHA512,
"ES256": crypto.SHA256, "ES384": crypto.SHA384, "ES512": crypto.SHA512,
}
func verifySignature(alg string, pub crypto.PublicKey, signed, sig []byte) error {
h := allowedAlgs[alg]
digest := hashOf(h, signed)
switch {
case strings.HasPrefix(alg, "RS"):
k, ok := pub.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("%w: %s token against a non-RSA key", ErrSignature, alg)
}
if err := rsa.VerifyPKCS1v15(k, h, digest, sig); err != nil {
return ErrSignature
}
return nil
case strings.HasPrefix(alg, "ES"):
k, ok := pub.(*ecdsa.PublicKey)
if !ok {
return fmt.Errorf("%w: %s token against a non-EC key", ErrSignature, alg)
}
// JWS packs ECDSA signatures as r||s, fixed width — not the ASN.1 form ecdsa.Verify
// would otherwise expect.
if len(sig)%2 != 0 {
return ErrSignature
}
half := len(sig) / 2
r := new(big.Int).SetBytes(sig[:half])
s := new(big.Int).SetBytes(sig[half:])
if !ecdsa.Verify(k, digest, r, s) {
return ErrSignature
}
return nil
}
return ErrSignature
}
func hashOf(h crypto.Hash, b []byte) []byte {
switch h {
case crypto.SHA384:
d := sha512.Sum384(b)
return d[:]
case crypto.SHA512:
d := sha512.Sum512(b)
return d[:]
default:
d := sha256.Sum256(b)
return d[:]
}
}
func curveFor(crv string) (elliptic.Curve, error) {
switch crv {
case "P-256":
return elliptic.P256(), nil
case "P-384":
return elliptic.P384(), nil
case "P-521":
return elliptic.P521(), nil
}
return nil, fmt.Errorf("unsupported curve %q", crv)
}
// b64 decodes JWT base64url, which omits padding.
func b64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
func b64uint(s string) (*big.Int, error) {
b, err := b64(s)
if err != nil {
return nil, err
}
if len(b) == 0 {
return nil, errors.New("empty value")
}
return new(big.Int).SetBytes(b), nil
}
+325
View File
@@ -0,0 +1,325 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package oidc
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// A self-contained IdP: real keys, real signatures, real discovery and JWKS documents. Testing
// token verification against anything less than a genuine signer proves nothing — the failure
// modes that matter here (accepting `none`, accepting another client's token, accepting an
// expired one) all look fine to a mock that just returns success.
type testIdP struct {
*httptest.Server
rsaKey *rsa.PrivateKey
ecKey *ecdsa.PrivateKey
}
func newIdP(t *testing.T) *testIdP {
t.Helper()
rk, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
ek, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
idp := &testIdP{rsaKey: rk, ecKey: ek}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(Discovery{
Issuer: idp.URL,
AuthorizationEndpoint: idp.URL + "/auth",
TokenEndpoint: idp.URL + "/token",
JWKSURI: idp.URL + "/jwks",
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{
{
"kty": "RSA", "kid": "rsa-1", "alg": "RS256", "use": "sig",
"n": raw(rk.N.Bytes()),
"e": raw(big.NewInt(int64(rk.E)).Bytes()),
},
{
"kty": "EC", "kid": "ec-1", "alg": "ES256", "use": "sig", "crv": "P-256",
"x": raw(ek.X.Bytes()), "y": raw(ek.Y.Bytes()),
},
}})
})
idp.Server = httptest.NewServer(mux)
t.Cleanup(idp.Close)
return idp
}
func raw(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func (i *testIdP) sign(t *testing.T, alg, kid string, claims map[string]any) string {
t.Helper()
h, _ := json.Marshal(map[string]string{"alg": alg, "kid": kid, "typ": "JWT"})
p, _ := json.Marshal(claims)
signing := raw(h) + "." + raw(p)
digest := sha256.Sum256([]byte(signing))
var sig []byte
switch alg {
case "RS256":
s, err := rsa.SignPKCS1v15(rand.Reader, i.rsaKey, crypto.SHA256, digest[:])
if err != nil {
t.Fatal(err)
}
sig = s
case "ES256":
r, s, err := ecdsa.Sign(rand.Reader, i.ecKey, digest[:])
if err != nil {
t.Fatal(err)
}
// JWS wants fixed-width r||s, not ASN.1.
sig = make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
default:
t.Fatalf("unsupported test alg %q", alg)
}
return signing + "." + raw(sig)
}
func (i *testIdP) claims(extra map[string]any) map[string]any {
c := map[string]any{
"iss": i.URL, "sub": "user-1", "aud": "echolot",
"exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(),
"email": "someone@example.net", "groups": []string{"users"},
}
for k, v := range extra {
c[k] = v
}
return c
}
func verifier(i *testIdP, adminGroup string) *Verifier {
return New(Config{
Issuer: i.URL, ClientID: "echolot", AppClientID: "echolot-app", AdminGroup: adminGroup,
}, i.Client())
}
func TestAcceptsAGenuineToken(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
for _, tc := range []struct{ alg, kid string }{{"RS256", "rsa-1"}, {"ES256", "ec-1"}} {
got, err := v.Verify(context.Background(), idp.sign(t, tc.alg, tc.kid, idp.claims(nil)))
if err != nil {
t.Fatalf("%s: %v", tc.alg, err)
}
if got.Subject != "user-1" || got.Email != "someone@example.net" {
t.Fatalf("%s: claims not parsed: %+v", tc.alg, got)
}
if want := idp.URL + "#user-1"; got.AccountID() != want {
t.Errorf("AccountID = %q, want %q", got.AccountID(), want)
}
}
}
// "alg": "none" is the oldest JWT forgery there is: strip the signature, declare no algorithm,
// and a naive verifier accepts anything. It must not even reach the key lookup.
func TestRejectsAlgNone(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
h, _ := json.Marshal(map[string]string{"alg": "none", "kid": "rsa-1", "typ": "JWT"})
p, _ := json.Marshal(idp.claims(nil))
token := raw(h) + "." + raw(p) + "."
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrSignature) {
t.Fatalf("alg=none was not refused as a signature failure: %v", err)
}
}
// The other classic: declare HS256 so the verifier treats the RSA *public* key as an HMAC secret,
// which the attacker also has. The allow-list has no symmetric algorithms at all.
func TestRejectsSymmetricAlgorithmConfusion(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
h, _ := json.Marshal(map[string]string{"alg": "HS256", "kid": "rsa-1", "typ": "JWT"})
p, _ := json.Marshal(idp.claims(nil))
token := raw(h) + "." + raw(p) + "." + raw([]byte("whatever"))
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrSignature) {
t.Fatalf("HS256 confusion was not refused: %v", err)
}
}
func TestRejectsATamperedPayload(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
good := idp.sign(t, "RS256", "rsa-1", idp.claims(nil))
// Swap the payload for one claiming to be somebody else, keeping the valid signature.
forged, _ := json.Marshal(idp.claims(map[string]any{"sub": "admin"}))
parts := []byte(good)
dot1, dot2 := 0, 0
for i, c := range parts {
if c == '.' {
if dot1 == 0 {
dot1 = i
} else {
dot2 = i
}
}
}
token := string(parts[:dot1+1]) + raw(forged) + string(parts[dot2:])
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrSignature) {
t.Fatalf("a swapped payload was not refused: %v", err)
}
}
// A token from the same IdP but issued to a different client is perfectly valid — just not for
// us. Accepting it would let any other client of the same provider authenticate here.
func TestRejectsAnotherClientsToken(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
tok := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{"aud": "some-other-app"}))
if _, err := v.Verify(context.Background(), tok); !errors.Is(err, ErrClaims) {
t.Fatalf("another client's token was accepted: %v", err)
}
}
func TestAcceptsAudienceArrayContainingUs(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
tok := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{"aud": []string{"other", "echolot"}}))
if _, err := v.Verify(context.Background(), tok); err != nil {
t.Fatalf("an audience array including us was refused: %v", err)
}
}
func TestRejectsExpiredAndFutureTokens(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
expired := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{
"exp": time.Now().Add(-time.Hour).Unix(),
}))
if _, err := v.Verify(context.Background(), expired); !errors.Is(err, ErrClaims) {
t.Errorf("expired token accepted: %v", err)
}
future := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{
"iat": time.Now().Add(time.Hour).Unix(),
}))
if _, err := v.Verify(context.Background(), future); !errors.Is(err, ErrClaims) {
t.Errorf("token issued in the future accepted: %v", err)
}
}
// A token signed by a completely different provider, with its own keys and its own kid.
func TestRejectsATokenFromAnotherIssuer(t *testing.T) {
ours, theirs := newIdP(t), newIdP(t)
v := verifier(ours, "")
tok := theirs.sign(t, "RS256", "rsa-1", theirs.claims(nil))
if _, err := v.Verify(context.Background(), tok); err == nil {
t.Fatal("a token from another issuer was accepted")
}
}
func TestRejectsMalformedTokens(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
for _, bad := range []string{"", "not-a-token", "a.b", "a.b.c.d", "...", "!!!.???.***"} {
if _, err := v.Verify(context.Background(), bad); err == nil {
t.Errorf("%q was accepted", bad)
}
}
}
// 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".
func TestNobodyIsAdminUntilAGroupIsConfigured(t *testing.T) {
idp := newIdP(t)
claims := &Claims{Groups: []string{"users", "echolot-admins"}}
if verifier(idp, "").IsAdmin(claims) {
t.Error("someone was an admin with no admin group configured")
}
if !verifier(idp, "echolot-admins").IsAdmin(claims) {
t.Error("a member of the configured group was not an admin")
}
if verifier(idp, "other-group").IsAdmin(claims) {
t.Error("a non-member was an admin")
}
if verifier(idp, "echolot-admins").IsAdmin(nil) {
t.Error("an absent identity was an admin")
}
}
// A discovery document whose issuer disagrees with the configured one means we were redirected
// somewhere — and would otherwise have fetched that somewhere's signing keys while believing
// they belonged to the configured provider.
func TestRefusesDiscoveryThatRenamesTheIssuer(t *testing.T) {
mux := http.NewServeMux()
srv := httptest.NewServer(mux)
defer srv.Close()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(Discovery{Issuer: "https://somewhere.else", JWKSURI: srv.URL + "/jwks"})
})
v := New(Config{Issuer: srv.URL, ClientID: "echolot"}, srv.Client())
if _, err := v.Discover(context.Background()); err == nil {
t.Fatal("discovery accepted a document for a different issuer")
}
}
func TestDisabledWithoutConfiguration(t *testing.T) {
v := New(Config{}, nil)
if v.Config().Enabled() {
t.Fatal("an unconfigured verifier reports itself enabled")
}
if _, err := v.Verify(context.Background(), "x.y.z"); !errors.Is(err, ErrDisabled) {
t.Fatalf("want ErrDisabled, got %v", err)
}
}
// Two clients, because the phone and the admin UI have different properties: an APK cannot keep a
// secret (public + PKCE) while the server can (confidential). Both must be accepted — but only
// those two. "Any client of this issuer" would let every other application registered with the
// same IdP authenticate here, which is the whole reason the audience check exists.
func TestBothRegisteredClientsAreAccepted(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
for _, aud := range []any{"echolot", "echolot-app", []string{"echolot-app", "other"}} {
tok := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{"aud": aud}))
if _, err := v.Verify(context.Background(), tok); err != nil {
t.Errorf("aud %v was refused: %v", aud, err)
}
}
// A third application at the same issuer is still not us.
tok := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{"aud": "someone-elses-app"}))
if _, err := v.Verify(context.Background(), tok); !errors.Is(err, ErrClaims) {
t.Fatalf("a third client's token was accepted: %v", err)
}
}
// Either client id alone is enough to make sign-in usable: an operator may register only the app
// (no admin UI login) or only the server.
func TestEitherClientIDAloneEnablesSignIn(t *testing.T) {
if !(Config{Issuer: "https://i", ClientID: "a"}).Enabled() {
t.Error("a server-only configuration was reported disabled")
}
if !(Config{Issuer: "https://i", AppClientID: "b"}).Enabled() {
t.Error("an app-only configuration was reported disabled")
}
if (Config{Issuer: "https://i"}).Enabled() {
t.Error("an issuer with no client at all was reported enabled")
}
}
+78
View File
@@ -0,0 +1,78 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package runs
import (
"testing"
"time"
)
// Account scoping widens what a caller can read, so the test that matters is the one about what
// it must NOT widen: a run id from another account has to be invisible, not merely unlisted.
func TestAccountScopingDoesNotReachOtherAccounts(t *testing.T) {
s, _ := open(t, DefaultPolicy())
// Two devices on one account, one device belonging to somebody else.
mine := []string{"phone-a", "tablet-a"}
for i, d := range mine {
if _, err := s.Put(d, doc("run-"+d, AnonFull), true); err != nil {
t.Fatal(err)
}
_ = i
time.Sleep(2 * time.Millisecond)
}
if _, err := s.Put("phone-b", doc("run-secret", AnonFull), true); err != nil {
t.Fatal(err)
}
got := s.ListFor(mine)
if len(got) != 2 {
t.Fatalf("account history has %d runs, want 2", len(got))
}
for _, m := range got {
if m.ID == "run-secret" {
t.Fatal("another account's run appeared in the history")
}
}
// The decisive one: knowing the id is not enough.
if _, ok := s.OwnerOf(mine, "run-secret"); ok {
t.Fatal("a run id from another account resolved against this account's devices")
}
if owner, ok := s.OwnerOf(mine, "run-phone-a"); !ok || owner != "phone-a" {
t.Fatalf("own run did not resolve: owner=%q ok=%v", owner, ok)
}
// A sibling device's run must resolve — that is the point of the feature.
if owner, ok := s.OwnerOf(mine, "run-tablet-a"); !ok || owner != "tablet-a" {
t.Fatalf("sibling device's run did not resolve: owner=%q ok=%v", owner, ok)
}
}
func TestAccountHistoryIsNewestFirstAcrossDevices(t *testing.T) {
s, _ := open(t, DefaultPolicy())
if _, err := s.Put("phone", doc("older", AnonFull), true); err != nil {
t.Fatal(err)
}
time.Sleep(5 * time.Millisecond)
if _, err := s.Put("tablet", doc("newer", AnonFull), true); err != nil {
t.Fatal(err)
}
got := s.ListFor([]string{"phone", "tablet"})
if len(got) != 2 || got[0].ID != "newer" {
t.Fatalf("not merged newest-first: %+v", got)
}
}
func TestEmptyDeviceSetSeesNothing(t *testing.T) {
s, _ := open(t, DefaultPolicy())
if _, err := s.Put("someone", doc("run-1", AnonFull), true); err != nil {
t.Fatal(err)
}
if got := s.ListFor(nil); len(got) != 0 {
t.Fatalf("an empty device set returned %d runs", len(got))
}
if _, ok := s.OwnerOf(nil, "run-1"); ok {
t.Fatal("a run resolved against an empty device set")
}
}
+46 -10
View File
@@ -38,10 +38,9 @@ const (
// ModeAnonymous accepts uploads from any enrolled device. The default: enrollment already // ModeAnonymous accepts uploads from any enrolled device. The default: enrollment already
// required an admin-minted token, so "anyone enrolled" is not "anyone". // required an admin-minted token, so "anyone enrolled" is not "anyone".
ModeAnonymous Mode = "anonymous" ModeAnonymous Mode = "anonymous"
// ModeAccount accepts uploads only from a device tied to a signed-in account. The account // ModeAccount accepts uploads only from a device where somebody has signed in (see
// system (OIDC) is not built yet, so today this refuses everything with a distinct reason — // /v1/account/link). Enrollment alone is not enough: the operator's token admits a device,
// it exists so operators can pick the strict setting now and have it mean the right thing // an account attributes it to a person.
// when accounts land, rather than silently loosening on upgrade.
ModeAccount Mode = "account" ModeAccount Mode = "account"
) )
@@ -120,22 +119,27 @@ func Open(stateDir string, p Policy) (*Store, error) {
func (s *Store) Policy() Policy { return s.policy } func (s *Store) Policy() Policy { return s.policy }
// Accepts reports whether an upload would be allowed at all, so callers can answer the // Accepts reports whether an upload from this caller would be allowed at all, so callers can
// capability question without a body. // answer the capability question without a body.
func (s *Store) Accepts() error { //
// linked says whether a person has signed in on the uploading device. It is the only thing that
// distinguishes ModeAccount from ModeOff — and the reason the check takes an argument at all.
func (s *Store) Accepts(linked bool) error {
switch s.policy.Mode { switch s.policy.Mode {
case ModeOff: case ModeOff:
return ErrDisabled return ErrDisabled
case ModeAccount: case ModeAccount:
return ErrNeedAccount if !linked {
return ErrNeedAccount
}
} }
return nil return nil
} }
// Put validates and stores one uploaded document. body is the raw JSON as received: it is stored // Put validates and stores one uploaded document. body is the raw JSON as received: it is stored
// byte-for-byte so what the device signed off on is what sits on disk. // byte-for-byte so what the device signed off on is what sits on disk.
func (s *Store) Put(deviceID string, body []byte) (Meta, error) { func (s *Store) Put(deviceID string, body []byte, linked bool) (Meta, error) {
if err := s.Accepts(); err != nil { if err := s.Accepts(linked); err != nil {
return Meta{}, err return Meta{}, err
} }
if s.policy.MaxBytes > 0 && int64(len(body)) > s.policy.MaxBytes { if s.policy.MaxBytes > 0 && int64(len(body)) > s.policy.MaxBytes {
@@ -205,6 +209,38 @@ func (s *Store) List(deviceID string) []Meta {
return s.listLocked(filepath.Join(s.dir, sanitizeID(deviceID))) return s.listLocked(filepath.Join(s.dir, sanitizeID(deviceID)))
} }
// ListFor returns the runs of several devices at once, newest first.
//
// This is what makes an account mean something: three phones signed in to one account produce one
// history, which is the main reason to have accounts beyond upload permission.
func (s *Store) ListFor(deviceIDs []string) []Meta {
s.mu.Lock()
defer s.mu.Unlock()
var out []Meta
for _, id := range deviceIDs {
out = append(out, s.listLocked(filepath.Join(s.dir, sanitizeID(id)))...)
}
sort.Slice(out, func(i, j int) bool { return out[i].UploadedAt.After(out[j].UploadedAt) })
return out
}
// OwnerOf reports which of these devices holds runID, so a caller can be granted access to a run
// belonging to a sibling device without being able to name an arbitrary device.
//
// The search is over an allow-list the caller never supplies directly — it comes from the account
// — so a run id from another account simply is not found.
func (s *Store) OwnerOf(deviceIDs []string, runID string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, id := range deviceIDs {
p := filepath.Join(s.dir, sanitizeID(id), sanitizeID(runID)+".json")
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
return id, true
}
}
return "", false
}
// Get returns the stored document bytes for one run. // Get returns the stored document bytes for one run.
func (s *Store) Get(deviceID, runID string) ([]byte, error) { func (s *Store) Get(deviceID, runID string) ([]byte, error) {
s.mu.Lock() s.mu.Lock()
+32 -18
View File
@@ -34,19 +34,33 @@ func TestModeOffRefusesEverything(t *testing.T) {
p := DefaultPolicy() p := DefaultPolicy()
p.Mode = ModeOff p.Mode = ModeOff
s, _ := open(t, p) s, _ := open(t, p)
if _, err := s.Put("dev1", doc("run-1", AnonFull)); !errors.Is(err, ErrDisabled) { if _, err := s.Put("dev1", doc("run-1", AnonFull), false); !errors.Is(err, ErrDisabled) {
t.Fatalf("want ErrDisabled, got %v", err) t.Fatalf("want ErrDisabled, got %v", err)
} }
} }
// ModeAccount must refuse today rather than fall back to anonymous: an operator who selects the // ModeAccount turns on whether the *caller* has signed in, and nothing else. A device that has
// strict setting before accounts exist must not be silently running the permissive one. // not is refused with a reason it can act on; one that has is treated exactly like anonymous mode.
func TestModeAccountRefusesUntilAccountsExist(t *testing.T) { func TestModeAccountTurnsOnWhetherTheCallerSignedIn(t *testing.T) {
p := DefaultPolicy() p := DefaultPolicy()
p.Mode = ModeAccount p.Mode = ModeAccount
s, _ := open(t, p) s, _ := open(t, p)
if _, err := s.Put("dev1", doc("run-1", AnonFull)); !errors.Is(err, ErrNeedAccount) {
t.Fatalf("want ErrNeedAccount, got %v", err) if _, err := s.Put("dev1", doc("run-1", AnonFull), false); !errors.Is(err, ErrNeedAccount) {
t.Fatalf("an un-signed-in device was not refused: %v", err)
}
if _, err := s.Put("dev1", doc("run-2", AnonFull), true); err != nil {
t.Fatalf("a signed-in device was refused: %v", err)
}
}
// Signing in must not open a door that the operator closed outright: mode=off means off.
func TestSigningInDoesNotOverrideModeOff(t *testing.T) {
p := DefaultPolicy()
p.Mode = ModeOff
s, _ := open(t, p)
if _, err := s.Put("dev1", doc("run-1", AnonFull), true); !errors.Is(err, ErrDisabled) {
t.Fatalf("a signed-in device uploaded to a server with uploads off: %v", err)
} }
} }
@@ -55,15 +69,15 @@ func TestMinAnonymizationEnforced(t *testing.T) {
p.MinAnonymization = AnonBalanced p.MinAnonymization = AnonBalanced
s, _ := open(t, p) s, _ := open(t, p)
if _, err := s.Put("dev1", doc("run-full", AnonFull)); !errors.Is(err, ErrNotAnonEnough) { if _, err := s.Put("dev1", doc("run-full", AnonFull), false); !errors.Is(err, ErrNotAnonEnough) {
t.Fatalf("full should be refused when balanced is required, got %v", err) t.Fatalf("full should be refused when balanced is required, got %v", err)
} }
// An undeclared level means nothing was stripped, so it must be treated as "full". // An undeclared level means nothing was stripped, so it must be treated as "full".
if _, err := s.Put("dev1", []byte(`{"run":{"id":"run-bare"},"summary":{}}`)); !errors.Is(err, ErrNotAnonEnough) { if _, err := s.Put("dev1", []byte(`{"run":{"id":"run-bare"},"summary":{}}`), false); !errors.Is(err, ErrNotAnonEnough) {
t.Fatalf("undeclared level should be treated as full, got %v", err) t.Fatalf("undeclared level should be treated as full, got %v", err)
} }
for _, lvl := range []string{AnonBalanced, AnonStrict} { for _, lvl := range []string{AnonBalanced, AnonStrict} {
if _, err := s.Put("dev1", doc("run-"+lvl, lvl)); err != nil { if _, err := s.Put("dev1", doc("run-"+lvl, lvl), false); err != nil {
t.Fatalf("%s should be accepted: %v", lvl, err) t.Fatalf("%s should be accepted: %v", lvl, err)
} }
} }
@@ -74,7 +88,7 @@ func TestSizeLimit(t *testing.T) {
p.MaxBytes = 200 p.MaxBytes = 200
s, _ := open(t, p) s, _ := open(t, p)
big := append(doc("run-1", AnonFull), make([]byte, 400)...) big := append(doc("run-1", AnonFull), make([]byte, 400)...)
if _, err := s.Put("dev1", big); !errors.Is(err, ErrTooLarge) { if _, err := s.Put("dev1", big, false); !errors.Is(err, ErrTooLarge) {
t.Fatalf("want ErrTooLarge, got %v", err) t.Fatalf("want ErrTooLarge, got %v", err)
} }
} }
@@ -84,7 +98,7 @@ func TestRetentionByCountKeepsNewest(t *testing.T) {
p.MaxRunsPerDevice = 3 p.MaxRunsPerDevice = 3
s, _ := open(t, p) s, _ := open(t, p)
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
if _, err := s.Put("dev1", doc(fmt.Sprintf("run-%d", i), AnonFull)); err != nil { if _, err := s.Put("dev1", doc(fmt.Sprintf("run-%d", i), AnonFull), false); err != nil {
t.Fatalf("put %d: %v", i, err) t.Fatalf("put %d: %v", i, err)
} }
time.Sleep(2 * time.Millisecond) // distinct UploadedAt so "newest" is well defined time.Sleep(2 * time.Millisecond) // distinct UploadedAt so "newest" is well defined
@@ -109,7 +123,7 @@ func TestRetentionByAge(t *testing.T) {
p.RetentionDays = 7 p.RetentionDays = 7
p.MaxRunsPerDevice = 0 p.MaxRunsPerDevice = 0
s, dir := open(t, p) s, dir := open(t, p)
if _, err := s.Put("dev1", doc("run-old", AnonFull)); err != nil { if _, err := s.Put("dev1", doc("run-old", AnonFull), false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Backdate the index entry past the retention window. // Backdate the index entry past the retention window.
@@ -123,7 +137,7 @@ func TestRetentionByAge(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if _, err := s.Put("dev1", doc("run-new", AnonFull)); err != nil { if _, err := s.Put("dev1", doc("run-new", AnonFull), false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
got := s.List("dev1") got := s.List("dev1")
@@ -136,7 +150,7 @@ func TestRetentionByAge(t *testing.T) {
// store directory or overwrite another device's data. // store directory or overwrite another device's data.
func TestIDsCannotEscapeTheStoreDirectory(t *testing.T) { func TestIDsCannotEscapeTheStoreDirectory(t *testing.T) {
s, dir := open(t, DefaultPolicy()) s, dir := open(t, DefaultPolicy())
if _, err := s.Put("../../etc", doc("../../../passwd", AnonFull)); err != nil { if _, err := s.Put("../../etc", doc("../../../passwd", AnonFull), false); err != nil {
t.Fatalf("put: %v", err) t.Fatalf("put: %v", err)
} }
var found []string var found []string
@@ -159,10 +173,10 @@ func TestIDsCannotEscapeTheStoreDirectory(t *testing.T) {
func TestListIsPerDevice(t *testing.T) { func TestListIsPerDevice(t *testing.T) {
s, _ := open(t, DefaultPolicy()) s, _ := open(t, DefaultPolicy())
if _, err := s.Put("devA", doc("run-a", AnonFull)); err != nil { if _, err := s.Put("devA", doc("run-a", AnonFull), false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := s.Put("devB", doc("run-b", AnonFull)); err != nil { if _, err := s.Put("devB", doc("run-b", AnonFull), false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := s.List("devA"); len(got) != 1 || got[0].ID != "run-a" { if got := s.List("devA"); len(got) != 1 || got[0].ID != "run-a" {
@@ -175,7 +189,7 @@ func TestListIsPerDevice(t *testing.T) {
func TestMetaSummarisesTheDocument(t *testing.T) { func TestMetaSummarisesTheDocument(t *testing.T) {
s, _ := open(t, DefaultPolicy()) s, _ := open(t, DefaultPolicy())
m, err := s.Put("dev1", doc("run-1", AnonBalanced)) m, err := s.Put("dev1", doc("run-1", AnonBalanced), false)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -190,7 +204,7 @@ func TestMetaSummarisesTheDocument(t *testing.T) {
func TestMalformedRejected(t *testing.T) { func TestMalformedRejected(t *testing.T) {
s, _ := open(t, DefaultPolicy()) s, _ := open(t, DefaultPolicy())
for _, body := range [][]byte{[]byte("not json"), []byte(`{"run":{}}`), []byte(`{}`)} { for _, body := range [][]byte{[]byte("not json"), []byte(`{"run":{}}`), []byte(`{}`)} {
if _, err := s.Put("dev1", body); !errors.Is(err, ErrMalformed) { if _, err := s.Put("dev1", body, false); !errors.Is(err, ErrMalformed) {
t.Fatalf("body %q: want ErrMalformed, got %v", body, err) t.Fatalf("body %q: want ErrMalformed, got %v", body, err)
} }
} }
+10
View File
@@ -9,6 +9,7 @@ package selfupdate
import ( import (
"crypto/sha256" "crypto/sha256"
"echo-lot.app/server/internal/system"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -132,6 +133,15 @@ func Run(api, currentVersion string) error {
os.Remove(tmp) os.Remove(tmp)
return fmt.Errorf("atomic replace failed (filesystem boundaries?): %w", err) return fmt.Errorf("atomic replace failed (filesystem boundaries?): %w", err)
} }
// Serving became an explicit verb, and a unit written before that change starts this binary
// with no arguments - which now prints usage and exits non-zero. The unit is not part of what
// an update replaces, so it is repaired here rather than left to fail at the next restart,
// which might be a reboot months from now.
if repaired, err := system.RepairExecStart(); err != nil {
fmt.Println("WARNING: could not update the systemd unit for --serve:", err)
} else if repaired {
fmt.Println("updated the systemd unit to pass --serve (serving is now an explicit verb)")
}
fmt.Printf("updated %s -> %s (%s); restart to run it\n", currentVersion, rel.TagName, self) fmt.Printf("updated %s -> %s (%s); restart to run it\n", currentVersion, rel.TagName, self)
return nil return nil
} }
+49 -7
View File
@@ -74,22 +74,64 @@ func (s *Session) NewGrant(actionID string, wantBytes int64, wantKbps int, lim G
// enforces the byte ceiling, the expiry, and the average rate (by refusing early sends rather // enforces the byte ceiling, the expiry, and the average rate (by refusing early sends rather
// than sleeping, so callers stay in control of pacing). // than sleeping, so callers stay in control of pacing).
func (g *Grant) Allow(n int) bool { func (g *Grant) Allow(n int) bool {
ok, _ := g.TryAllow(n)
return ok
}
// Refusal reasons from TryAllow. The distinction is not cosmetic: "too fast just now" is
// transient and a caller should pace and carry on, while "budget" and "expired" are terminal and
// a caller that keeps trying is only wasting its own run.
const (
RefusalNone = ""
RefusalBudget = "budget"
RefusalExpired = "expired"
RefusalRate = "rate"
)
// TryAllow reports whether n more bytes may be sent now, consuming the budget when they may, and
// says why not when they may not.
//
// The rate limit is a token bucket: allowance = burst + rate x elapsed. An earlier version
// exempted the first 50 ms from the check entirely, meaning to be lenient at startup. The effect
// was the opposite - a sender could dump an unbounded burst into that window, and the moment the
// check switched on it compared those bytes against 50 ms worth of allowance and refused
// everything until real time caught up. A sustained send died about fifty milliseconds in, having
// looked perfectly fine in every short test. A bucket has no such cliff: it is smooth from t=0.
func (g *Grant) TryAllow(n int) (bool, string) {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() defer g.mu.Unlock()
if time.Now().After(g.ExpiresAt) { if time.Now().After(g.ExpiresAt) {
return false return false, RefusalExpired
} }
if g.sentBytes+int64(n) > g.MaxBytes { if g.sentBytes+int64(n) > g.MaxBytes {
return false return false, RefusalBudget
} }
// Average-rate check: bytes allowed so far = kbps/8 * elapsed_seconds. // kbps -> bytes/s is kbps*1000/8 = kbps*125.
bytesPerSec := float64(g.MaxKbps) * 125
elapsed := time.Since(g.started).Seconds() elapsed := time.Since(g.started).Seconds()
allowed := float64(g.MaxKbps) * 125 * elapsed // kbps -> bytes/s is kbps*1000/8 = kbps*125 allowed := burstBytes(bytesPerSec) + bytesPerSec*elapsed
if elapsed > 0.05 && float64(g.sentBytes+int64(n)) > allowed { if float64(g.sentBytes+int64(n)) > allowed {
return false return false, RefusalRate
} }
g.sentBytes += int64(n) g.sentBytes += int64(n)
return true return true, RefusalNone
}
// burstBytes is the bucket's depth: 100 ms of the allowed rate, floored at a single ordinary
// datagram.
//
// The floor exists only so that one packet is never refused outright by a very slow grant — it is
// deliberately one datagram and not more. A generous floor would undo the rate ceiling at low
// rates: at 8 kbps a 64 KB burst is sixty-four seconds' worth, which is exactly the instant dump
// the ceiling is there to prevent. One datagram is 1.5 seconds' worth at that rate and nothing at
// any realistic one.
func burstBytes(bytesPerSec float64) float64 {
const oneDatagram = 1500
b := bytesPerSec * 0.1
if b < oneDatagram {
b = oneDatagram
}
return b
} }
// Sent returns how many bytes this grant has consumed. // Sent returns how many bytes this grant has consumed.
+64
View File
@@ -92,3 +92,67 @@ func TestGrantEnforcesRate(t *testing.T) {
t.Fatalf("rate limit let %d bytes through in ~60ms at 8kbps", sent) t.Fatalf("rate limit let %d bytes through in ~60ms at 8kbps", sent)
} }
} }
// The bug this pins: the rate check used to exempt the first 50 ms entirely, so a sender could
// dump an unbounded burst into that window and then be refused for as long as it took real time
// to catch up. Every short test passed; a sustained send died about fifty milliseconds in. A
// token bucket has no such cliff, and the property that matters is that a sender pacing *at* the
// allowed rate is never refused for long.
func TestSustainedSendAtTheAllowedRateIsNotCutOff(t *testing.T) {
s := sessionWithSource(t)
const kbps = 8000 // 1 MB/s
const packet = 1200 // bytes
g := s.NewGrant("a1", 8<<20, kbps, DefaultGrantLimits)
// Pace at the allowed rate for a short run and count how much got through. A correct
// limiter passes essentially all of it; the old one stopped almost immediately.
perPacket := time.Duration(float64(packet) / (float64(kbps) * 125) * float64(time.Second))
deadline := time.Now().Add(300 * time.Millisecond)
sent, refusals := 0, 0
for time.Now().Before(deadline) {
if ok, why := g.TryAllow(packet); ok {
sent += packet
} else if why == RefusalRate {
refusals++
} else {
t.Fatalf("unexpected terminal refusal %q after %d bytes", why, sent)
}
time.Sleep(perPacket)
}
// 300 ms at 1 MB/s is ~300 KB. Allow generous slack for scheduler granularity, but a run
// that delivered only a few packets means the limiter cut it off.
if sent < 100_000 {
t.Fatalf("a sender pacing at the allowed rate got only %d bytes through in 300ms "+
"(%d rate refusals) — the limiter is cutting off sustained sends", sent, refusals)
}
}
// The other half: a rate refusal must be distinguishable from a spent budget, because one is
// transient and one is terminal, and a caller that cannot tell them apart either gives up early
// or spins forever.
func TestRefusalReasonsAreDistinguishable(t *testing.T) {
s := sessionWithSource(t)
// Budget: tiny ceiling, plenty of rate.
g := s.NewGrant("a1", 1000, 100_000, DefaultGrantLimits)
for i := 0; i < 20; i++ {
g.TryAllow(100)
}
if ok, why := g.TryAllow(100); ok || why != RefusalBudget {
t.Errorf("spent budget reported as ok=%v why=%q, want %q", ok, why, RefusalBudget)
}
// Rate: huge ceiling, minimal rate, so only the bucket can refuse.
g2 := s.NewGrant("a2", 1<<20, 8, DefaultGrantLimits)
sawRate := false
for i := 0; i < 100; i++ {
if ok, why := g2.TryAllow(1000); !ok && why == RefusalRate {
sawRate = true
break
}
}
if !sawRate {
t.Error("a sender far above the rate ceiling never got a rate refusal")
}
}
+95
View File
@@ -40,6 +40,8 @@ type Session struct {
packetsSeen uint64 packetsSeen uint64
udpObs []UDPObservation // ring, newest last, cap obsCap udpObs []UDPObservation // ring, newest last, cap obsCap
connectBack []ConnectBackResult connectBack []ConnectBackResult
throughput []ThroughputReport
upstream UpstreamCounter
} }
const obsCap = 4096 const obsCap = 4096
@@ -54,6 +56,48 @@ type UDPObservation struct {
Type uint8 `json:"type"` Type uint8 `json:"type"`
} }
// ThroughputReport is the server's own account of a sustained send: what it managed to put on
// the wire, and what stopped it. The client needs this to interpret its own count — the gap
// between the two IS the loss, and without the sender's number a receiver can only guess.
type ThroughputReport struct {
ActionID string `json:"action_id"`
Packets int `json:"packets"`
Bytes int64 `json:"bytes"`
DurationMs int64 `json:"duration_ms"`
Kbps int `json:"kbps"`
LimitedBy string `json:"limited_by"`
}
// UpstreamCounter is the server's tally of a client-driven throughput run.
//
// Deliberately a counter and not a list. A five-second upstream run at 20 Mbps is around ten
// thousand packets; one observation struct each would turn a measurement into an allocation
// storm on a shared server, and nothing downstream needs the per-packet detail - the client
// already has its own send record. The gap between the two counts IS the loss.
type UpstreamCounter struct {
Packets int `json:"packets"`
Bytes int64 `json:"bytes"`
FirstRxNs int64 `json:"first_rx_ns"`
LastRxNs int64 `json:"last_rx_ns"`
}
// SpanMs is the time between the first and last packet, which is the interval the rate should be
// computed over - not the client's requested duration, which includes ramp-up and the tail.
func (u UpstreamCounter) SpanMs() int64 {
if u.Packets < 2 || u.LastRxNs <= u.FirstRxNs {
return 0
}
return (u.LastRxNs - u.FirstRxNs) / 1_000_000
}
// Kbps is bits per millisecond, which is kilobits per second - no scaling constant to get wrong.
func (u UpstreamCounter) Kbps() int {
if ms := u.SpanMs(); ms > 0 {
return int(u.Bytes * 8 / ms)
}
return 0
}
// ConnectBackResult records one connect-back action outcome. // ConnectBackResult records one connect-back action outcome.
type ConnectBackResult struct { type ConnectBackResult struct {
ActionID string `json:"action_id"` ActionID string `json:"action_id"`
@@ -87,6 +131,57 @@ func (s *Session) Observations() (packetsSeen uint64, udp []UDPObservation, cb [
append([]ConnectBackResult(nil), s.connectBack...) append([]ConnectBackResult(nil), s.connectBack...)
} }
// CountUpstream tallies one client-sent throughput packet.
//
// Called on the hot path for every packet of an upstream run, so it does exactly two additions
// and two comparisons under the lock and allocates nothing.
func (s *Session) CountUpstream(sizeBytes int, tRxNs int64) {
s.mu.Lock()
defer s.mu.Unlock()
if s.upstream.Packets == 0 {
s.upstream.FirstRxNs = tRxNs
}
s.upstream.Packets++
s.upstream.Bytes += int64(sizeBytes)
s.upstream.LastRxNs = tRxNs
}
// Upstream returns the tally so far.
func (s *Session) Upstream() UpstreamCounter {
s.mu.Lock()
defer s.mu.Unlock()
return s.upstream
}
// ResetUpstream clears the tally, so a second run in one session measures itself rather than
// inheriting the first one's packets.
func (s *Session) ResetUpstream() {
s.mu.Lock()
defer s.mu.Unlock()
s.upstream = UpstreamCounter{}
}
// RecordThroughput stores the server's account of one sustained send.
//
// Kept as a per-action summary rather than per-packet records: a ten-second run at 50 Mbps is
// half a million packets, and holding one struct each would turn a measurement into a memory
// exhaustion. The client has the per-packet view; the server only needs to say how many it sent.
func (s *Session) RecordThroughput(actionID string, packets int, bytes, durationMs int64, kbps int, limitedBy string) {
s.mu.Lock()
defer s.mu.Unlock()
s.throughput = append(s.throughput, ThroughputReport{
ActionID: actionID, Packets: packets, Bytes: bytes,
DurationMs: durationMs, Kbps: kbps, LimitedBy: limitedBy,
})
}
// ThroughputReports returns the server's account of every sustained send in this session.
func (s *Session) ThroughputReports() []ThroughputReport {
s.mu.Lock()
defer s.mu.Unlock()
return append([]ThroughputReport(nil), s.throughput...)
}
// DataSource returns the last verified data-plane source (invalid when the // DataSource returns the last verified data-plane source (invalid when the
// session has not sent data-plane traffic yet). // session has not sent data-plane traffic yet).
func (s *Session) DataSource() netip.AddrPort { func (s *Session) DataSource() netip.AddrPort {
+33
View File
@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package store
import "testing"
// The empty account must never match. Devices nobody has signed in on are not a group — they are
// unrelated devices that share the absence of an owner — and treating that as an account would
// let any anonymous device read every other anonymous device's runs.
func TestTheEmptyAccountIsNotAGroup(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
for _, id := range []string{"anon-1", "anon-2"} {
s.data.Devices = append(s.data.Devices, Device{ID: id})
}
s.data.Devices = append(s.data.Devices,
Device{ID: "mine-1", AccountID: "iss#me"},
Device{ID: "mine-2", AccountID: "iss#me"},
Device{ID: "theirs", AccountID: "iss#them"})
if got := s.DeviceIDsForAccount(""); len(got) != 0 {
t.Fatalf("the empty account matched %v", got)
}
if got := s.DeviceIDsForAccount("iss#me"); len(got) != 2 {
t.Fatalf("account has %v, want both of its devices", got)
}
if got := s.DeviceIDsForAccount("iss#them"); len(got) != 1 || got[0] != "theirs" {
t.Fatalf("wrong devices for the other account: %v", got)
}
}
+123
View File
@@ -16,6 +16,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"echo-lot.app/server/internal/adminauth"
"time" "time"
) )
@@ -37,8 +39,19 @@ type Device struct {
Credential string `json:"credential"` Credential string `json:"credential"`
Enrolled time.Time `json:"enrolled"` Enrolled time.Time `json:"enrolled"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
// The account this device belongs to, as issuer#subject — empty when nobody has signed in
// on it. Enrollment and sign-in are deliberately separate: a device is admitted by an
// operator's token, and only later (if ever) associated with a person. Servers that accept
// anonymous uploads never need the second step.
AccountID string `json:"account_id,omitempty"`
AccountName string `json:"account_name,omitempty"`
LinkedAt time.Time `json:"linked_at,omitempty"`
} }
// LinkedToAccount reports whether a person has signed in on this device.
func (d Device) LinkedToAccount() bool { return d.AccountID != "" }
type Store struct { type Store struct {
mu sync.Mutex mu sync.Mutex
path string path string
@@ -48,6 +61,12 @@ type Store struct {
type fileData struct { type fileData struct {
Tokens []EnrollToken `json:"tokens"` Tokens []EnrollToken `json:"tokens"`
Devices []Device `json:"devices"` Devices []Device `json:"devices"`
// The break-glass admin. Absent until an operator sets one.
LocalAdmin *adminauth.Credential `json:"local_admin,omitempty"`
// Signing secret for admin session cookies. Persisted so sessions survive a restart;
// deleting it from the state file invalidates every session at once, which is how an
// operator revokes them.
SessionSecret string `json:"session_secret,omitempty"`
} }
func Open(stateDir string) (*Store, error) { func Open(stateDir string) (*Store, error) {
@@ -126,6 +145,110 @@ func (s *Store) Redeem(token, name string) (*Device, error) {
} }
// DeviceByCredential authenticates a bearer credential. // DeviceByCredential authenticates a bearer credential.
// LinkAccount ties a device to a signed-in identity, or clears it when accountID is empty.
func (s *Store) LinkAccount(deviceID, accountID, displayName string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.data.Devices {
if s.data.Devices[i].ID != deviceID {
continue
}
s.data.Devices[i].AccountID = accountID
s.data.Devices[i].AccountName = displayName
if accountID == "" {
s.data.Devices[i].LinkedAt = time.Time{}
} else {
s.data.Devices[i].LinkedAt = time.Now().UTC()
}
return s.save()
}
return errors.New("no such device")
}
// DeviceIDsForAccount returns every device signed in to the same account.
//
// The empty account is never matched: devices that nobody has signed in on are not a group, they
// are unrelated devices that happen to share the absence of an owner. Treating them as an account
// would let any anonymous device read every other anonymous device's runs.
func (s *Store) DeviceIDsForAccount(accountID string) []string {
if accountID == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
var out []string
for _, d := range s.data.Devices {
if d.AccountID == accountID {
out = append(out, d.ID)
}
}
return out
}
// Devices returns a copy of the device list, for the admin UI.
func (s *Store) Devices() []Device {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Device(nil), s.data.Devices...)
}
// DeleteDevice revokes a device: its credential stops working immediately.
func (s *Store) DeleteDevice(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.data.Devices {
if s.data.Devices[i].ID == id {
s.data.Devices = append(s.data.Devices[:i], s.data.Devices[i+1:]...)
return s.save()
}
}
return errors.New("no such device")
}
// SetLocalAdmin stores (or replaces) the break-glass admin password.
func (s *Store) SetLocalAdmin(c adminauth.Credential) error {
s.mu.Lock()
defer s.mu.Unlock()
s.data.LocalAdmin = &c
return s.save()
}
// LocalAdmin returns the configured break-glass admin, or nil.
func (s *Store) LocalAdmin() *adminauth.Credential {
s.mu.Lock()
defer s.mu.Unlock()
if s.data.LocalAdmin == nil {
return nil
}
c := *s.data.LocalAdmin
return &c
}
// ClearLocalAdmin removes the break-glass admin.
func (s *Store) ClearLocalAdmin() error {
s.mu.Lock()
defer s.mu.Unlock()
s.data.LocalAdmin = nil
return s.save()
}
// SessionSecret returns the admin session signing secret, creating one on first use.
func (s *Store) SessionSecret() ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.data.SessionSecret != "" {
if b, err := hex.DecodeString(s.data.SessionSecret); err == nil && len(b) >= 32 {
return b, nil
}
}
b, err := adminauth.NewSecret()
if err != nil {
return nil, err
}
s.data.SessionSecret = hex.EncodeToString(b)
return b, s.save()
}
func (s *Store) DeviceByCredential(cred string) *Device { func (s *Store) DeviceByCredential(cred string) *Device {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
+34
View File
@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build linux
package system
import (
"os"
"syscall"
"unsafe"
)
// DisableEcho turns off terminal echo while a password is typed, returning a function that puts
// the terminal back. Both are best-effort: when stdin is a pipe (the automation case) there is
// no terminal to change and nothing to restore.
func DisableEcho(f *os.File) (func(), error) {
fd := f.Fd()
var t syscall.Termios
if _, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, fd,
syscall.TCGETS, uintptr(unsafe.Pointer(&t)), 0, 0, 0); errno != 0 {
return nil, errno // not a terminal; nothing to do
}
original := t
t.Lflag &^= syscall.ECHO
if _, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, fd,
syscall.TCSETS, uintptr(unsafe.Pointer(&t)), 0, 0, 0); errno != 0 {
return nil, errno
}
return func() {
_, _, _ = syscall.Syscall6(syscall.SYS_IOCTL, fd,
syscall.TCSETS, uintptr(unsafe.Pointer(&original)), 0, 0, 0)
}, nil
}
+12
View File
@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build !linux
package system
import "os"
// DisableEcho is a no-op off Linux: the password is still read, just echoed. Better than
// refusing to run — an operator on a Mac still needs to set the break-glass password.
func DisableEcho(*os.File) (func(), error) { return nil, nil }
+40 -1
View File
@@ -12,6 +12,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
) )
const ( const (
@@ -29,7 +30,7 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=%s ExecStart=%s --serve
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
StateDirectory=echolot-server StateDirectory=echolot-server
@@ -144,3 +145,41 @@ func UninstallSystemd() error {
fmt.Println("removed echolot-server units (state dir and env file left in place)") fmt.Println("removed echolot-server units (state dir and env file left in place)")
return nil return nil
} }
// RepairExecStart brings an already-installed unit up to date with the current invocation.
//
// Serving became an explicit verb (--serve), which means every unit written before that change
// would start the binary with no arguments — and the binary now answers that with usage and a
// non-zero exit. A self-update replaces the binary but never the unit, so without this a routine
// update would leave a service that cannot start, discovered whenever the host next reboots.
//
// Only a unit this program wrote is touched, identified by its description line. Editing an
// operator's hand-written unit would be overreach; leaving ours broken would be negligence.
func RepairExecStart() (repaired bool, err error) {
b, err := os.ReadFile(unitPath)
if err != nil {
return false, nil // no unit installed: nothing to repair, and not an error
}
text := string(b)
if !strings.Contains(text, "Echolot probe server") {
return false, nil // somebody else's unit
}
lines := strings.Split(text, "\n")
changed := false
for i, ln := range lines {
t := strings.TrimSpace(ln)
// Only the serving unit's ExecStart; the timer's own line already carries its verb.
if strings.HasPrefix(t, "ExecStart=") && !strings.Contains(t, "--") {
lines[i] = ln + " --serve"
changed = true
}
}
if !changed {
return false, nil
}
if err := os.WriteFile(unitPath, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
return false, fmt.Errorf("updating %s: %w", unitPath, err)
}
_ = exec.Command("systemctl", "daemon-reload").Run()
return true, nil
}