Files
echolot/docs/probe-protocol.md
T
mrambossekandClaude Opus 5 082a2314ef protocol: enrollment links carry the public name, not the endpoint
Sharing port 443 between the admin UI and the control plane forces two
hostnames — one port and one name is one certificate, and the two need
different ones. That difference had been leaking into every enrollment
link, so an operator handed out fmr-1.echo-lot.app when the thing they
and their users know is fmr.echo-lot.app.

The link now carries the public name and the app asks GET /v1/discover
where to actually connect. The endpoint is plumbing: it exists to select
a certificate, and nobody needs to see it.

Discovery hands out an address and never a pin. The pin stays in the
link. Fetching it over an ordinary TLS connection would make pinning
worth exactly what the certificate authorities are worth, and pinning is
there to survive one the operator does not control — a root injected by
corporate device management, say, which is unremarkable on the networks
this tool gets pointed at. With the pin pre-shared, an intercepted
discovery can only send a device somewhere the pin will not match: an
outage, not a compromise.

Optional on both sides. A server that does not answer, or a link that
already names the control endpoint, works unchanged — enrollment must not
start failing because a lookup did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 00:13:27 +02:00

319 lines
19 KiB
Markdown

# 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=<control-URL, urlencoded>&p=pin-sha256:<b64 SPKI hash>&t=<enrollment token>
```
```
POST /v1/enroll Authorization: Bearer <enrollment-token>
→ 201 { "device_credential": "<random 256-bit, b64url>",
"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 <device-credential>
```
```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 <device-credential>
{ "target": "vie-1" }
→ 201 { "session_id": "<opaque, 8-byte hex prefix used on the wire>",
"key_salt": "<b64, 16 bytes>",
"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=<name>` 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 `<nonce>.<session-prefix>.<canary_zone>` 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.<zone>` | A / AAAA / TXT | 5 | `192.0.2.5` / `2001:db8::5` / `"echolot-ref ttl=5"` |
| `ttl-60.<zone>` | A / AAAA / TXT | 60 | `192.0.2.60` / `2001:db8::60` / `"echolot-ref ttl=60"` |
| `ttl-3600.<zone>` | A / AAAA / TXT | 3600 | `192.0.2.36` / `2001:db8::3600` / `"echolot-ref ttl=3600"` |
| `ttl-86400.<zone>` | A / AAAA / TXT | 86400 | `192.0.2.86` / `2001:db8::8640` / `"echolot-ref ttl=86400"` |
| `many-rr.<zone>` | A | 300 | exactly 8 A records in defined order (order/stripping check) |
| `big-txt.<zone>` | 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: `<nonce>.miss.<zone>` 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).