# Echolot Probe Protocol — Spec v1 (draft 1) Project name: **Echolot** (decided 2026-07-29; domain: echo-lot.app). Status: draft for review, 2026-07-29. Companion to `measurement-schema.md` (evidence fields reference observations defined here). Two planes: a **control plane** (HTTPS + JSON, key-pinned) and a **data plane** (binary UDP probe protocol, TCP/TLS/HTTP echo endpoints, standard STUN, standard DNS on the canary zone). ## 1. Transport & trust - Control plane: HTTPS. The client validates the server certificate **only** against the SPKI pin from enrollment (`pin-sha256`, RFC 7469 computation). CA validation is not required; self-signed is first-class. Key rotation: profile may list `next_pins[]` ahead of rotation. - All control endpoints are under `/v1/`. Version negotiation is by capability list (§2.3), never by sniffing version numbers. - Data-plane authenticity: per-session HMAC key (§3), truncated HMAC on every UDP probe packet. Default ports (all configurable): control HTTPS **8443**; UDP probe **8442** (same number on v4 and v6); STUN **3478** (+ alternate address per RFC 5780 — requires a second IP); TCP echo **8441**; canary DNS: standard 53 on the delegated zone. ## 2. Control plane ### 2.1 Enrollment Bootstrap payload (QR / paste string): ``` echolot://enroll?v=1&u=&p=pin-sha256:&t= ``` ``` POST /v1/enroll Authorization: Bearer → 201 { "device_credential": "", "device_id": "uuid" } ``` 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. ### 2.2 Profile ``` GET /v1/profile Authorization: Bearer ``` ```json { "profile_version": 7, "name": "homelab", "server_version": "0.4.0", "capabilities": ["udp-probe", "stun-5780", "canary-dns", "connect-back", "delayed-echo", "big-send", "tls-echo", "throughput", "ntp"], "targets": [ { "id": "vie-1", "location": "Vienna", "ip4": "203.0.113.10", "ip6": "2001:db8::10", "udp_port": 8442, "tcp_port": 8441, "stun_port": 3478, "stun_alt": { "ip4": "203.0.113.11", "port": 3478 } } ], "canary_zone": "c.probe.example.net", "recursive_resolver": { "ip4": "203.0.113.10", "dot": true, "doh_url": "https://..." }, "pins": ["pin-sha256:..."], "next_pins": [], "limits": { "max_kbps": 50000, "max_session_s": 900 } } ``` The app re-fetches the profile at the start of every run (falling back to the cached copy offline) and records `profile_version` in the measurement. ### 2.3 Capabilities (v1 registry) `udp-probe`, `stun-basic`, `stun-5780`, `canary-dns`, `recursive-dns`, `connect-back`, `delayed-echo`, `big-send`, `frag-send`, `tls-echo`, `http-echo`, `throughput`, `ntp`. A server omits what it can't offer (e.g. `stun-5780` without a second IP degrades to `stun-basic`). Clients must skip, and record as `unsupported`, any test whose capability is absent. Unknown capability strings are ignored. ### 2.4 Sessions Every measurement run opens one session per target: ``` POST /v1/sessions Authorization: Bearer { "target": "vie-1" } → 201 { "session_id": "", "key_salt": "", "epoch": "server wall-clock RFC3339 at session start", "expires_s": 900 } ``` Session key: `HKDF-SHA256(ikm = device_credential, salt = key_salt, info = "echolot-v1/" + session_id)` → 32 bytes. Both sides derive it; it never crosses the wire. ``` GET /v1/sessions/{id}/observations → §6 POST /v1/sessions/{id}/actions → §5 (connect-back, delayed echo, big/frag send) DELETE /v1/sessions/{id} ``` ### 2.5 Rate limiting & abuse Per-credential and per-source-IP token buckets on: session creation, actions, UDP packets, bytes. `429` on control plane; silent drop on data plane (probes must tolerate loss anyway). All reflected/generated traffic goes **only** to the session's observed source address (or, for connect-back, the source address of the session-creating request). Data-plane responses to unauthenticated packets are never larger than the request (§3.4). ### 2.2 `GET /v1/discover` — where the control plane lives Unauthenticated, and says almost nothing: the control-plane URL and the server's display name. ```json { "control_url": "https://probe.example.net", "name": "example" } ``` It exists so an enrollment link can carry the name a person recognises while the app still connects to the name that selects the pinned certificate. When a server shares port 443 between its admin UI and its control plane, those must be different hostnames — one port and one name is one certificate, and the two need different ones (a browser-trusted certificate, and a long-lived self-signed one the client pins). Without discovery, the difference leaks into every enrollment link an operator hands out. **It hands out an address, never a pin.** The pin travels in the link itself. Serving it here would reduce pinning to whatever the certificate authorities are worth, and pinning exists precisely to survive one the operator does not control — a root injected by corporate device management, for instance, which is unremarkable on the networks this tool is pointed at. Because the pin is pre-shared, an intercepted discovery response can only send a device to the wrong host, where the pin will not match: an outage, not a compromise. Clients treat it as optional. A server that does not answer, or a link that already names the control endpoint, works unchanged — enrollment must not begin failing because a lookup did. ## 3. UDP probe protocol ### 3.1 Packet header (fixed 32 bytes, network byte order) ``` offset size field 0 4 magic "ELT1" 4 1 type 5 1 flags 6 2 payload_len 8 8 session_prefix (first 8 bytes of session_id) 16 4 seq 20 8 t_ns (sender timestamp, ns since session epoch, sender's clock) 28 4 hmac32 (first 4 bytes of HMAC-SHA256(session_key, header[0..28] || payload)) ``` Padding payload is arbitrary bytes counted by `payload_len`; total datagram size is what MTU/train tests vary. `hmac32` is an anti-abuse gate, not cryptographic integrity for hostile networks — 4 bytes is enough to make blind reflection/replay impractical at the allowed rates; on-path attackers are out of scope for the data plane (tampering is what the *tests* detect). Server drops packets with unknown session prefix, bad HMAC, expired session, or seq replay outside a 1024-wide window. ### 3.2 Types | type | dir | purpose | |---|---|---| | 0x01 ECHO_REQ / 0x02 ECHO_RESP | c→s / s→c | RTT, loss, reordering; RESP carries observation block (§3.3) | | 0x03 TRAIN_DATA | c→s | upstream train, no per-packet response | | 0x04 TRAIN_REPORT_REQ / 0x05 TRAIN_REPORT | c→s / s→c | server's received-view of an upstream train (columnar, may span multiple RESP datagrams) | | 0x06 DOWNTRAIN_DATA | s→c | downstream train (scheduled via §5 action) | | 0x07 TIMESYNC_REQ / 0x08 TIMESYNC_RESP | c→s / s→c | 4-timestamp exchange: client t1 in REQ; RESP carries t2 (rx) and t3 (tx); t4 on receipt. Feeds `time.server_offset` | | 0x09 MTU_PROBE / 0x0A MTU_ACK | c→s / s→c | client sends DF-flagged sizes; ACK reports size received (ACK is small — never amplifies) | | 0x0B DELAYED_ECHO | s→c | single packet sent T seconds after the action request (NAT mapping lifetime) | ### 3.3 Observation block (in ECHO_RESP, appended per-packet) Server reports what it saw on the corresponding request: `t_rx_ns`, `t_tx_ns` (server clock, session epoch), observed source IP + port (detects NAT rebinding mid-flow), received TTL/hop-limit, DSCP, ECN bits, received size. This is the raw material for `train.udp_updown`, `sec.dscp_ecn_survival`, and TTL-based path-length evidence in the measurement schema. ### 3.4 Anti-amplification rule (normative) For any datagram whose HMAC does not verify: no response ever. For verified packets: ECHO_RESP/MTU_ACK/TRAIN_REPORT responses are ≤ request size unless the session is in an **asymmetric grant** created via an authenticated control-plane action (§5), which sets an explicit byte budget and rate. DOWNTRAIN and big/frag sends exist only under such grants. ## 4. TCP, TLS and HTTP endpoints - **TCP echo (8441):** after connect, server sends one JSON line: observed source IP/port, negotiated MSS (from `TCP_INFO`), timestamps/window-scale options seen — this is the `mtu.mss_observed` evidence. Then byte-echo until FIN. TLS variant on the same port via ALPN `elt-echo` (capability `tls-echo`): server additionally returns the ClientHello it received, raw + JA4, before echoing — the `sec.clienthello_echo` evidence. - **HTTP echo:** `POST /v1/echo` on the control listener: returns exact received request bytes (headers + body) base64-wrapped in JSON, plus observed TLS parameters. Detects header injection/stripping/proxying (`sec.http_echo`). A plain-HTTP variant on a configurable port (default off) tests plaintext-path tampering. - **TLS reference:** `GET /v1/tls-reference?host=` returns the full cert chain the server itself serves, DER+base64, so the app can compare an out-of-band copy against what a direct handshake yielded (`sec.tls_reference`). - **STUN:** unmodified RFC 5389/5780 on 3478; second address enables full behavior discovery. No custom framing — interop with existing tooling is a feature. ## 5. Actions (authenticated asymmetric operations) `POST /v1/sessions/{id}/actions` with one of: ```json { "action": "downtrain", "count": 1000, "size_bytes": 64, "interval_us": 20000, "dscp": 46 } { "action": "big_send", "sizes_bytes": [1400, 1472, 1500, 1600, 2000], "df": true } { "action": "frag_send", "size_bytes": 2000, "family": "4 | 6" } { "action": "delayed_echo", "delay_s": 30 } { "action": "connect_back", "protocol": "tcp | udp", "port": 40123 } { "action": "throughput", "direction": "up | down", "streams": 4, "duration_s": 10 } ``` Rules: destination is always the session's observed source address; every action is bounded by `limits` from the profile; the response includes an `action_id` echoed in resulting data-plane packets (in payload) so evidence can be correlated. `delayed_echo` is the NAT-mapping-lifetime primitive: client binary-searches `delay_s` over repeated actions on fresh sockets. ## 6. Observations API ``` GET /v1/sessions/{id}/observations ``` Returns everything the server witnessed for this session, merged by the app into measurement evidence: ```json { "udp": { "packets_seen": ..., "trains": [ { columnar per-seq view } ] }, "tcp": [ { "connected_at", "src", "mss", "options": [...] } ], "http": [ { "raw_request_b64", "tls": {...}, "ja4": "..." } ], "dns_canary": [ { "qname": "x7f3a.sess1.c.probe.example.net", "at": "...", "resolver_ip": "198.51.100.7", "resolver_asn": 64500, "transport": "udp", "edns": { "present": true, "bufsize": 1232, "flags": ["do"] }, "case_preserved": true, "qname_minimized": false } ], "connect_back": [ { "action_id", "result": "connected | refused | timeout", "rtt_ms" } ] } ``` Canary DNS: the app generates `..` names and resolves them through the resolver under test (system/provider resolver, user-configured override resolvers, or the server's reference recursive); the authoritative server records who actually asked and how. Query-log retention is a server-config value surfaced in the admin UI (privacy default: 24 h). The `ecs` field in canary observations records any EDNS Client Subnet option the resolver forwarded. ### 6.1 Reference records (normative, capability `canary-dns`) The server MUST serve these fixed records under the canary zone; TTLs and RDATA are defined by this spec (not configurable), so clients have ground truth without a side channel. Used by `dns.ttl_integrity` and `dns.answer_integrity` (measurement schema §6.4). | name | type | TTL | RDATA | |---|---|---|---| | `ttl-5.` | A / AAAA / TXT | 5 | `192.0.2.5` / `2001:db8::5` / `"echolot-ref ttl=5"` | | `ttl-60.` | A / AAAA / TXT | 60 | `192.0.2.60` / `2001:db8::60` / `"echolot-ref ttl=60"` | | `ttl-3600.` | A / AAAA / TXT | 3600 | `192.0.2.36` / `2001:db8::3600` / `"echolot-ref ttl=3600"` | | `ttl-86400.` | A / AAAA / TXT | 86400 | `192.0.2.86` / `2001:db8::8640` / `"echolot-ref ttl=86400"` | | `many-rr.` | A | 300 | exactly 8 A records in defined order (order/stripping check) | | `big-txt.` | TXT | 300 | ~1800 bytes (EDNS bufsize / TCP-fallback check) | Note: exact RDATA constants to be frozen in the implementation's `dns_reference.go` and mirrored in the app; the table above fixes the *names and TTLs*; RDATA must be deterministic, documentation-range addresses. Cache-miss variants: `.miss.` wildcard answers with TTL 3600 and RDATA encoding the nonce (per-query ground truth that can never be pre-cached). ## 7. Server admin UI (scope note) Same daemon, separate listener (default localhost-only): health + self-test (are both IPs live, is the canary zone delegated correctly, is UDP reachable from outside — tested via a public echolot "mirror" if configured), enrollment token management (create/expire/scope), device list + revocation, retention settings, QR rendering (client-side JS). Out of scope for this spec beyond the endpoints above. ## 8. Version compatibility Both artifacts are versioned with **SemVer**: the Go server (`server-vX.Y.Z` tags) and the Android app (`versionName`; `versionCode` is derived from it, never maintained separately). Two independent things are checked, and conflating them is the mistake this section exists to prevent. ### 8.1 Protocol version — *can* these builds talk? `protocol_version` is the version of **this document**. It is advertised in the profile (`compat.protocol_version`) and is the correctness axis: a peer in a different breaking series cannot be talked to, whatever its release version says. Below `1.0.0` the **minor** is the breaking axis (SemVer §4); at and above it, the major is. A patch bump of the protocol never splits a fleet. ### 8.2 Release-version window — *should* they, per policy? Each side declares the range of peer release versions it will work with, as `[min, max)` — **minimum inclusive, maximum exclusive**, because the useful bound is always "the version that broke it" and writing that literally is unambiguous. An empty maximum means unbounded. The server advertises its window and enforces it: ```jsonc "compat": { "protocol_version": "1.0.0", "schema_version": "1.0.0", "app_min": "0.2.0", "app_max": "1.0.0" // exclusive; "" = no upper bound } ``` Operators override it with `ECHOLOT_MIN_APP_VERSION` / `ECHOLOT_MAX_APP_VERSION` (or `--min-app-version` / `--max-app-version`). A malformed bound is **fatal at startup**, not ignored: a typo must not silently disable a restriction the operator meant to set. The app sends its version on every control-plane request: ``` X-Echolot-App-Version: 0.2.0 ``` and carries its own bounds for the server (`MIN_SERVER` / `MAX_SERVER` in `Compat.kt`). It checks the profile in **both** directions — is the server in our range, and are we in the server's — so a mismatch is reported before a run starts rather than discovered halfway through one. ### 8.3 Rules 1. **`GET /v1/profile` is never gated.** It is where a refused client learns which version it needs. Gating it leaves the user with a network error instead of an answer, defeating the check. 2. **Refusal is `426 Upgrade Required`**, with a body naming both versions and the accepted window: ```json { "error": "app 0.1.0 is older than this build supports (needs >= 0.2.0, < 1.0.0). Update the app.", "app_version": "0.1.0", "accepts_app": ">= 0.2.0, < 1.0.0", "server_version": "0.5.0", "protocol_version": "1.0.0" } ``` 3. **An unparseable or absent version is `unknown`, and is allowed.** Development builds report `dev`, and a client too old to send the header cannot be identified anyway. The check exists to turn confusing failures into clear ones; refusing what it cannot identify does the opposite. 4. **Bounds move at breaking boundaries, not at releases.** Shipping a patch must never require editing a range. A minimum is raised only when older peers are actually harmful — e.g. the app requires server `>= 0.4.2` because earlier multi-homed servers sent granted traffic from an address the session never used, which the client measured as 100 % downstream loss. A confidently wrong measurement is worse than a refused one. ## 9. Cross-references to the measurement schema - Observation-block fields (§3.3) appear as `*_seen_by_server` columns in `train` evidence (schema §6.2). - `TIMESYNC` (§3.2) produces the `time.server_offset` test; without it, cross-clock fields must not be compared (schema §6.2 note). - Capability strings (§2.3) are copied verbatim into `server_sessions[].capabilities` (schema §5); tests skipped for missing capability get `status: "unsupported"`. - `session_id` maps to `server_sessions[].session_id`; `action_id`s appear in test `params`. ## 10. Open items 1. Whether TRAIN_REPORT should also stream during long trains (partial reports every N packets) for live UI feedback — leaning yes, same type with a `flags` bit. 2. Throughput methodology (fixed streams vs BBR-style ramp) — decide with `perf.*` test design. 3. Server mirror/self-test protocol between two echolot servers (nice for operators; postpone). 4. IPv6 flow-label control for ECMP-variance tracing — needs `IPV6_FLOWINFO` sockopt verification on Android first (capability-prober item).