Files
echolot/server
mrambossekandClaude Opus 5 1472a86508
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 28s
server-release / release (push) Successful in 28s
server: tls-echo — ClientHello capture + JA4 on the TCP-echo port (§4 complete)
A connection opening with a TLS handshake (first byte 0x16) and ALPN
elt-echo gets the ClientHello it sent back raw (b64) and as a JA4
fingerprint (sec.clienthello_echo), then a TLS byte-echo; plain
connections are unchanged. One port, multiplexed by a timed peek:
plain echo is server-speaks-first, so a silent client (peek timeout) is
greeted, while a TLS client's immediate ClientHello (0x16) routes to the
TLS path — 500ms tolerates ~1s RTT before misdetection.

JA4 (FoxIO): full ClientHello parser (ciphers, extensions, ALPN,
supported_versions, sig algs) with GREASE exclusion; a_b_c fingerprint,
unit-tested for structure + GREASE invariance. Live-verified: elt-echo
negotiated, JA4 t13d1712eo computed, 1530-byte ClientHello returned.
Capability tls-echo. This completes spec §4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:59:44 +02:00
..

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:

  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 (for canary-dns): set ECHOLOT_DNS_LISTEN (udp+tcp/53 on the service IPs) and ECHOLOT_CANARY_ZONE (e.g. c.echo-lot.app), then delegate the zone to this host in your DNS provider:
    c.echo-lot.app.  NS  fmr-1.echo-lot.app.
    c.echo-lot.app.  NS  fmr-2.echo-lot.app.
    
    The server is authoritative for that zone only, serving the spec §6.1 reference records (frozen in 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.
  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.

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 exposes server_selftest.mtu_ok and 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-listenECHOLOT_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.