17 KiB
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 listnext_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,pandtare 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>
{
"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).
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 themtu.mss_observedevidence. Then byte-echo until FIN. TLS variant on the same port via ALPNelt-echo(capabilitytls-echo): server additionally returns the ClientHello it received, raw + JA4, before echoing — thesec.clienthello_echoevidence. - HTTP echo:
POST /v1/echoon 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:
{ "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:
{
"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:
"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
GET /v1/profileis 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.- Refusal is
426 Upgrade Required, with a body naming both versions and the accepted window:{ "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" } - An unparseable or absent version is
unknown, and is allowed. Development builds reportdev, 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. - 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.2because 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_servercolumns intrainevidence (schema §6.2). TIMESYNC(§3.2) produces thetime.server_offsettest; 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 getstatus: "unsupported". session_idmaps toserver_sessions[].session_id;action_ids appear in testparams.
10. Open items
- Whether TRAIN_REPORT should also stream during long trains (partial reports every N packets) for live UI feedback — leaning yes, same type with a
flagsbit. - Throughput methodology (fixed streams vs BBR-style ramp) — decide with
perf.*test design. - Server mirror/self-test protocol between two echolot servers (nice for operators; postpone).
- IPv6 flow-label control for ECMP-variance tracing — needs
IPV6_FLOWINFOsockopt verification on Android first (capability-prober item).