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>
echolot-server
The probe server (spec). Pure Go, stdlib only, GPL-3.0-or-later.
Skeleton status: control plane (enroll / profile / sessions with the spec's HKDF key schedule), UDP data plane (ECHO with observation block, TIMESYNC, HMAC gate, anti-replay, anti-amplification — wire format covered by tests). Not yet: TCP/TLS echo, STUN, canary DNS, actions, observations API, admin UI beyond token minting.
Deployment requirements
No reverse proxy, no 80/443 — by design. Traefik/nginx on the same host are fine; this server never touches their ports, and putting it behind them would break two protocol properties:
- Clients trust the control plane only via the SPKI pin from enrollment (self-signed is first-class). A proxy terminates TLS with its own rotating ACME cert → pins break. The pin model exists so no real certificate is ever needed.
- On the data plane, the observed source address/port/TTL/DSCP is the measurement. Any proxy or NAT layer (including Docker's) substitutes its own — hence host networking.
What a target host actually needs:
| Port | Proto | Purpose | Notes |
|---|---|---|---|
| 8443 | tcp | control plane (pinned HTTPS) | any port — it travels in the enrollment QR + profile |
| 8442 | udp | UDP probe data plane | any port, profile-driven |
| 8441 | tcp | TCP/TLS echo | any port (not yet implemented) |
| 3478 | udp | STUN | keep standard: vanilla RFC 5389 for tool interop; rarely contended |
| 8444 | tcp | admin | loopback-only by design — reach via SSH tunnel |
All configurable via ECHOLOT_*_LISTEN. Plus:
- A public IP on the host (v4, ideally also v6 — v6 topology issues are half of what clients want to measure). Behind NAT, plain port-forwards work.
- Second IP (optional): full RFC 5780 NAT-behavior discovery (
stun-5780) needs an alternate reply address; without it the profile advertisesstun-basicand clients degrade gracefully. - Delegated DNS subzone (for
canary-dns): setECHOLOT_DNS_LISTEN(udp+tcp/53 on the service IPs) andECHOLOT_CANARY_ZONE(e.g.c.echo-lot.app), then delegate the zone to this host in your DNS provider:The server is authoritative for that zone only, serving the spec §6.1 reference records (frozen inc.echo-lot.app. NS fmr-1.echo-lot.app. c.echo-lot.app. NS fmr-2.echo-lot.app.internal/canarydns/dns_reference.go) plus per-query<nonce>.<session>.<zone>lookups it logs. Binding :53 on the public IPs is fine even with systemd-resolved (it only claims 127.0.0.53). Absent config → capability simply not advertised. - Outbound freedom for connect-back / delayed-echo actions — no extra inbound ports; generated traffic goes only to the session's observed source.
Deliberately out of scope here: an echo listener on 443 (to detect port-based egress filtering) — that genuinely needs 443 and belongs on a dedicated IP, not on a host running a reverse proxy.
Host tuning (measurement fidelity)
A measurement server must not let the kernel distort what clients observe. Apply the recommended sysctls and the daemon will confirm the host is clean:
sudo cp deploy/99-echolot-sysctl.conf /etc/sysctl.d/ && sudo sysctl --system
The daemon self-tests at startup and via GET /admin/selftest (localhost):
- sysctl audit — flags settings that would distort results (RA acceptance on a static host, ICMP redirects, ICMP rate-limiting of the server's own errors, disabled TCP options).
- egress-MTU self-proof — DF-probes external anchors (
ECHOLOT_MTU_PROBE_TARGETS, default 1.1.1.1 + a v6 anchor) and reads the discovered path MTU. If the server's own uplink can't carry 1500, client MTU results would measure this server, not the client — so the profile exposesserver_selftest.mtu_okand the log warns loudly.
Both signals ride in GET /v1/profile as server_selftest so a client can trust — or skip —
MTU testing accordingly.
Run in Docker (config via env)
docker compose up -d # see compose.yaml — network_mode: host is required
Host networking is not negotiable: behind Docker NAT the server would observe the proxy's
source addresses and TTLs instead of the client's — falsifying exactly what it measures.
Container mode is autodetected (/.dockerenv etc.); --docker / ECHOLOT_DOCKER=1 forces it.
In this mode systemd install and self-update are refused — update by pulling a new image tag.
Run native (systemd)
go build -o /usr/local/bin/echolot-server ./cmd/echolot-server
sudo /usr/local/bin/echolot-server --install-systemd # writes unit, enables, starts
sudo /usr/local/bin/echolot-server --uninstall-systemd
Config precedence: flags > ECHOLOT_* env > defaults. Every flag has an env twin
(--udp-listen ↔ ECHOLOT_UDP_LISTEN). Host config lives in /etc/echolot-server.env
(seeded by --install-systemd, never overwritten).
Multi-IP hosts: listen specs are comma-separated, and you should bind explicit addresses — a wildcard bind would also claim management-only IPs:
ECHOLOT_CONTROL_LISTEN=203.0.113.10:8443,[2001:db8::10]:8443
ECHOLOT_UDP_LISTEN=203.0.113.10:8442,203.0.113.11:8442,[2001:db8::10]:8442,[2001:db8::11]:8442
Passing --self-update-api to --install-systemd additionally installs a daily randomized
self-update timer (echolot-server-update.timer) that restarts the service after a successful
update. Updates are checksum-verified against the release's SHA256SUMS (integrity, not
authenticity — signature verification remains TODO before treating the update source as
untrusted).
Self-update (opt-in, native only)
echolot-server --self-update \
--self-update-api https://git.example.net/api/v1/repos/owner/repo
Fetches the newest server-v* release asset for this OS/arch and atomically replaces the
binary; systemd's Restart= brings up the new version. Run it from a systemd timer for
unattended updates. TODO before enabling anywhere untrusted: signature verification of the
downloaded asset.
First contact
# 1. mint an enrollment token (admin listener is loopback-only)
curl -s -X POST 'http://127.0.0.1:8444/admin/enroll-tokens?note=phone'
# 2. device enrolls with it (normally via the echolot:// QR code)
curl -sk -X POST https://<host>:8443/v1/enroll -H 'Authorization: Bearer <token>'
# 3. device fetches its profile
curl -sk https://<host>:8443/v1/profile -H 'Authorization: Bearer <credential>'
The SPKI pin clients must verify is logged at startup (pin-sha256).
Development
go test ./... # includes wire-format tests for the UDP data plane
go vet ./...
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
attaches static linux amd64/arm64 binaries (+ SHA256SUMS) to a release — the same
artifacts --self-update consumes.