API

Three JSON endpoints wrapping echtool's dech, greasy and conn commands. One domain per request. All request/response bodies are application/json. The test command (longitudinal, multi-domain experiments) is out of scope.

POST /api/dech

Decode a base64 ECHConfigList. Pure computation: no target is dialed, and the result is not cached.

Request

fieldtyperequirednotes
ech_configstringyes bare base64, or input containing an ech="..." token (as produced by dig / SVCB presentation format)

Response 200

fieldtypenotes
ech_config_list_parsedarray of ECH config object
ech_config_liststringthe decoded list, re-encoded to its base64 wire form
curl -X POST http://localhost:8111/api/dech \
  -H 'Content-Type: application/json' \
  -d '{"ech_config":"AEX+DQBB2gAgACDdGXq0rJF+bd5LM3CRD016rLlGsafUbGoQHXnM8UVuQQAEAAEAAQASY2xvdWRmbGFyZS1lY2guY29tAAA="}'

POST /api/greasy

Offers a GREASE ECHConfig to target, or offer a caller-supplied ech_config instead if given. Reports whether ECH was accepted, rejected with retry configs, or the handshake failed outright.

POST /api/conn

Same request/response shape as /api/greasy, but ech_config is required and no GREASE fallback is generated. Connect using a specific, real ECH config (e.g. one obtained from a prior greasy call's retry_config_list, or from DNS), with individual fields optionally overridden.

Request (greasy & conn)

fieldtypenotes
targetstring, requiredhost or host:port; a bare host dials port 443
transportstring"tcp" (default) or "quic"
ech_configstringbase64 ECHConfigList to offer instead of a fresh GREASE config. Required for /api/conn
pub_namestringoverride the ECH public name / SNI (defaults to the target host)
config_idinteger 0-255override/pin the ECHConfig's config_id
ech_versionintegeroverride the ECHConfig version
max_name_lengthinteger 0-255override maximum_name_length
cipher_suitesarray of stringeach entry "KDF/AEAD", e.g. "SHA256/AES128GCM"
alpnarray of stringALPN protocol(s) to offer; quic defaults to ["h3"] if empty
insecurebooleanskip TLS certificate verification
dns_serverstringhost:port resolver for the retry-vs-DNS comparison (default: system resolver)
validate_configbooleanreplay the handshake with the first retry config, to confirm the server actually accepts it
validate_all_configsbooleanreplay the handshake with every retry config (capped at 8)
show_configintegerlimit the retry config list to a single index (0-based); ignored when the server returned none. The offered list is never narrowed, since it's the config you chose. Rendering only: doesn't affect the probe or the cache key

Response 200

status is the verdict and the field to branch on; ech_accepted and rejected are the same answer as booleans. The vocabulary is echtool's own, so a response reads like echtool greasy --format json with the request echo and cache flag added.

fieldtypenotes
target, address, transportstringechoes the resolved request
statusstringwhat happened: accepted, rejected, not_accepted (the handshake completed, but without ECH), or failed (the handshake failed or never started, so nothing was learned about ECH either way)
error_kindstringmachine-readable category for anything but an acceptance: tls_alert, certificate_error, dns_error, timeout, network_error, other, or the ECH-specific ech_rejected, ech_not_accepted, retry_configs_invalid
detailstringthe same verdict in prose, e.g. timed out after 15s: dial tcp 93.184.216.34:444: i/o timeout
ech_acceptedbooleantrue if the handshake completed with the server accepting ECH
offered_config_list_parsedarray of ECH config objectwhat was offered (GREASE-generated or supplied)
offered_config_liststringthe same list in its base64 wire form. Throughout, a plain key holds the base64 blob and its _parsed sibling the decoded view
peerobject{address, negotiated_alpn, tls_version}: the TLS connection that was established. tls_version only when the handshake completed (not rejected, not failed)
rejectedbooleantrue if the server rejected the offered config
retry_config_list_parsed, retry_config_listarray / stringwhat the server handed back after rejecting. The base64 form is present only when the server actually returned a non-empty list. Many non-ECH-supporting servers reject with an empty list, which shows as [] and omits the base64 rather than showing meaningless data. A list the server did send but that failed to parse arrives as [] too, and error_kind retry_configs_invalid is what tells the two apart
dnsobjectpresent when rejected: {found, matches, error, ech_config_list_parsed, ech_config_list}: whether the domain publishes an ECH config in its DNS HTTPS record, and whether it matches the retry config. error is set when the lookup itself failed: that leaves found false without meaning the domain publishes none, since the comparison was never made
retry_config_validationsarraypresent when rejected and validate_config/validate_all_configs was requested and there was a retry config to test: [{index, config_id, accepted, error_kind, error}]. A server refusing its own retry config (ech_rejected) is a different finding from one that became unreachable midway (timeout, network_error)
cachedbooleantrue if this response came from the 60-second cache instead of a fresh probe
curl -X POST http://localhost:8111/api/greasy \
  -H 'Content-Type: application/json' \
  -d '{"target":"cloudflare.com","transport":"tcp","validate_config":true}'

ECH config object

fieldtype
ech_versioninteger
ech_config_idinteger
ech_public_namestring
ech_max_name_lengthinteger
ech_raw_extensionsstring (hex)
ech_kemstring
ech_cipher_suitesarray of {kdf, kdf_name, aead, aead_name}
ech_public_keystring (hex)

Errors

Non-2xx responses are {"error": "message"}.

Caching

Identical requests (same command, target, transport, and every field that affects the probe; show_config is the one exception, since it only changes rendering) are cached in-memory for 60 seconds and coalesced across concurrent callers, so repeated or simultaneous requests don't hammer one target deployment with duplicate probes.