Both sides now declare what they will talk to, and enforce it. Two axes kept
deliberately separate, because conflating them is the trap:
protocol_version — CAN these builds talk. The correctness axis. Below 1.0.0
the minor is the breaking axis, per SemVer §4.
release window — MAY they, per policy. [min, max), advertised in the
profile, overridable by the operator.
The server refuses out-of-window apps with 426 and a body naming both versions
and the accepted range; the app checks the profile in both directions before a
run rather than discovering mid-measurement that it will be refused.
Three rules that shape the rest:
- 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, which is precisely the confusion this exists to remove.
- 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.
- Bounds sit at breaking boundaries, not at releases, so shipping a patch
never requires editing a range. The app's server minimum is 0.4.2 for a
stated reason: earlier multi-homed servers mis-addressed granted sends and
the client measured 100% downstream loss that never happened.
The app's versionCode is now derived from its SemVer instead of being a second
number someone has to remember to bump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
big_send now forces the Don't-Fragment bit for the whole burst by default, so
the largest size that arrives IS the downstream path MTU rather than "fragments
got through" — two different measurements the schema already separates. Sizes
above our own egress MTU (from the startup self-test) are refused up front and
reported as max_df_bytes, because absence caused by our kernel must not be read
as a limit of the client's path.
Uploads: one JSON file per run under the state dir, with the policy the operator
actually cares about — who may upload (off / anonymous / account), how large,
how long to keep, and the least anonymization accepted. The profile advertises
all of it so the app can present the switch honestly instead of discovering the
rules by failing. `account` refuses today rather than falling back to anonymous:
picking the strict setting before OIDC lands must not silently mean the loose one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The grant is the keystone that makes server->client sends safe: created
only by an authenticated control-plane action, bound at creation to the
session's OBSERVED data-plane source (so it can never be aimed at a third
party), and bounded by bytes, average rate and expiry. Sends stop the
moment the budget runs out, so a buggy action cannot become a flood.
Two granted actions on top of it:
- downtrain: N packets at a given size/interval toward the client, with
seq + send-timestamp in the payload — downstream loss/reorder/jitter,
which an upstream-only train cannot measure.
- big_send: one datagram per requested size, echoing the intended size in
the payload — downstream MTU / black-hole evidence the client cannot
produce for itself (only the far end can emit a large packet toward it).
Tests cover the security properties: no grant without a verified
destination, client requests clamped to server limits, byte budget stops
sending exactly, expiry refuses, and the rate ceiling throttles a burst.
Capabilities gain downtrain + big-send.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
A measurement server must prove its own host isn't distorting results:
- sysctl audit (/proc/sys): flags accept_ra on a static host, ICMP
redirects, ICMP rate-limiting of the server's own errors, and disabled
TCP options — each a measurement-fidelity hazard, with the "why".
- egress-MTU self-proof: DF PMTUD probe (IP_MTU_DISCOVER + getsockopt
IP_MTU, no root — Linux-only, stub elsewhere) to external anchors. If the
server's own uplink is below 1500, client MTU tests measure THIS server,
so we say so.
Exposed at GET /admin/selftest (full report) and as server_selftest
{mtu_ok, sysctl_ok} in the profile so clients can trust or skip MTU tests.
Recommended deploy/99-echolot-sysctl.conf + README section.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- POST /v1/echo: returns the received request head + body (base64) and the
observed TLS parameters (version, cipher, SNI, ALPN, resumed). The client
diffs against what it sent to detect header injection/stripping,
transparent proxying, or TLS interception (sec.http_echo). http-echo
added to the capability set.
- GET /v1/tls-reference: the served leaf-first DER chain + pin, so the app
can compare an out-of-band copy against its own handshake (sec.tls_reference).
Always available, no auth — public handshake info.
- Optional CLEARTEXT http-echo listener (ECHOLOT_HTTP_ECHO_LISTEN, default
off) exposing only /v1/echo for the plaintext-path tampering test.
Live-smoke-tested (HTTPS echo reflected an injected header + observed
TLS1.3; cleartext variant reports tls:none); httptest unit tests added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stdlib DNS responder (no external deps): parses single-question queries
with EDNS OPT (bufsize, DO, ECS), serves the spec's frozen reference
records (ttl-{5,60,3600,86400} A/AAAA/TXT, many-rr 8×A in order, big-txt
~1800B), and per-query <nonce>.<session>.<zone> answers in 192.0.2.0/24.
UDP truncation sets TC past 512 (or the EDNS bufsize); TCP never
truncates — the EDNS-bufsize / TCP-fallback test. Every query is logged
(qname, resolver, transport, EDNS, ECS, case) and surfaced per session
prefix in GET /v1/sessions/{id}/observations as dns_canary. Profile gains
canary_zone + the canary-dns capability when configured.
Wire format validated against an independent client (correct rcodes,
answer counts, TC behavior, full EDNS response); unit tests cover
references, truncation-vs-EDNS, logging, NXDOMAIN.
Versioning: patch-first convention recorded in CLAUDE.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- stun: RFC 5389 binding responder + RFC 5780 attributes (OTHER-ADDRESS,
RESPONSE-ORIGIN, CHANGE-REQUEST) on a primary/alt-port socket grid per
address; advertises stun-5780 with >=2 same-family addrs, else
stun-basic. Unmodified framing for tooling interop. Tested.
- tcpecho: JSON greeting with observed src + TCP_INFO MSS/options
(Linux getsockopt; zeroed elsewhere via build tags), then byte echo.
- session: per-packet UDP observations + connect-back results, ByID lookup.
- control: GET /v1/sessions/{id}/observations, POST .../actions
(delayed_echo → DELAYED_ECHO at the observed data-plane source;
connect_back → dial the control-plane source, record connected/refused/
timeout+rtt). Capabilities computed from what is actually wired.
- config/main: comma-separated STUN listeners; all planes bind explicit
addresses; graceful shutdown of the new listeners.
Full flow smoke-tested; go test green (stun binding/change-port,
dataplane wire format).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Comma-separated ECHOLOT_{CONTROL,UDP,TCP}_LISTEN; one listener/socket per
address. Explicit binds matter on multi-IP hosts (a wildcard would also
claim the SSH-only management address) and per-address UDP sockets are
the substrate stun-5780 needs.
- systemd unit reads /etc/echolot-server.env (seeded once, never
overwritten); --install-systemd with --self-update-api also installs a
daily randomized update timer that try-restarts the service.
- selfupdate: SHA256SUMS verification is now mandatory before the atomic
replace (integrity, not authenticity — signing still TODO).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure stdlib. Implements the spec's core: enrollment (single-use tokens),
profile (SPKI pin, only real capabilities advertised), sessions with the
§2.4 HKDF-SHA256 key schedule; UDP data plane with the 32-byte ELT1
header, 4-byte HMAC gate, 1024-wide anti-replay window, ECHO_RESP with
observation block, TIMESYNC, and the §3.4 anti-amplification cap. Wire
format has tests (roundtrip + silent-drop cases); enroll→profile→session
smoke-tested live.
Modes: container (autodetect /.dockerenv|/run/.containerenv|cgroup, or
--docker/ECHOLOT_DOCKER=1; config via ECHOLOT_* env; distroless image;
network_mode host required — Docker NAT would falsify observed sources)
and native (--install-systemd/--uninstall-systemd with a hardened unit,
opt-in --self-update from Gitea releases; refused in containers).
CI: tests on any server/ push; server-v* tags build+push the image to the
Gitea registry and attach linux amd64/arm64 binaries + SHA256SUMS to a
release — the artifact self-update consumes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>