# echolot-server The probe server ([spec](../docs/probe-protocol.md)). 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: 1. **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. 2. **Second IP (optional):** full RFC 5780 NAT-behavior discovery (`stun-5780`) needs an alternate reply address; without it the profile advertises `stun-basic` and clients degrade gracefully. 3. **Delegated DNS subzone (optional, later):** the `canary-dns` capability needs port 53 on some IP + an NS delegation (mind systemd-resolved on 127.0.0.53). Absent → capability simply not advertised. 4. **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) ```sh 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) ```sh 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: ```sh 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) ```sh 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 ```sh # 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://:8443/v1/enroll -H 'Authorization: Bearer ' # 3. device fetches its profile curl -sk https://:8443/v1/profile -H 'Authorization: Bearer ' ``` The SPKI pin clients must verify is logged at startup (`pin-sha256`). ## Development ```sh 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.