- Comma-separated ECHOLOT_{CONTROL,UDP,TCP}_LISTEN; one listener/socket per
address. Explicit binds matter on multi-IP hosts (a wildcard would also
claim the SSH-only management address) and per-address UDP sockets are
the substrate stun-5780 needs.
- systemd unit reads /etc/echolot-server.env (seeded once, never
overwritten); --install-systemd with --self-update-api also installs a
daily randomized update timer that try-restarts the service.
- selfupdate: SHA256SUMS verification is now mandatory before the atomic
replace (integrity, not authenticity — signing still TODO).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5.4 KiB
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 (optional, later): the
canary-dnscapability needs port 53 on some IP + an NS delegation (mind systemd-resolved on 127.0.0.53). Absent → 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.
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.