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
| field | type | required | notes |
|---|---|---|---|
ech_config | string | yes | bare base64, or input containing an ech="..." token (as produced by dig / SVCB presentation format) |
Response 200
| field | type | notes |
|---|---|---|
ech_config_list_parsed | array of ECH config object | |
ech_config_list | string | the 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)
| field | type | notes |
|---|---|---|
target | string, required | host or host:port; a bare host dials port 443 |
transport | string | "tcp" (default) or "quic" |
ech_config | string | base64 ECHConfigList to offer instead of a fresh GREASE config. Required for /api/conn |
pub_name | string | override the ECH public name / SNI (defaults to the target host) |
config_id | integer 0-255 | override/pin the ECHConfig's config_id |
ech_version | integer | override the ECHConfig version |
max_name_length | integer 0-255 | override maximum_name_length |
cipher_suites | array of string | each entry "KDF/AEAD", e.g. "SHA256/AES128GCM" |
alpn | array of string | ALPN protocol(s) to offer; quic defaults to ["h3"] if empty |
insecure | boolean | skip TLS certificate verification |
dns_server | string | host:port resolver for the retry-vs-DNS comparison (default: system resolver) |
validate_config | boolean | replay the handshake with the first retry config, to confirm the server actually accepts it |
validate_all_configs | boolean | replay the handshake with every retry config (capped at 8) |
show_config | integer | limit 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.
| field | type | notes |
|---|---|---|
target, address, transport | string | echoes the resolved request |
status | string | what 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_kind | string | machine-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 |
detail | string | the same verdict in prose, e.g. timed out after 15s: dial tcp 93.184.216.34:444: i/o timeout |
ech_accepted | boolean | true if the handshake completed with the server accepting ECH |
offered_config_list_parsed | array of ECH config object | what was offered (GREASE-generated or supplied) |
offered_config_list | string | the same list in its base64 wire form. Throughout, a plain key holds the base64 blob and its _parsed sibling the decoded view |
peer | object | {address, negotiated_alpn, tls_version}: the TLS connection that was established. tls_version only when the handshake completed (not rejected, not failed) |
rejected | boolean | true if the server rejected the offered config |
retry_config_list_parsed, retry_config_list | array / string | what 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 |
dns | object | present 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_validations | array | present 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) |
cached | boolean | true 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
| field | type |
|---|---|
ech_version | integer |
ech_config_id | integer |
ech_public_name | string |
ech_max_name_length | integer |
ech_raw_extensions | string (hex) |
ech_kem | string |
ech_cipher_suites | array of {kdf, kdf_name, aead, aead_name} |
ech_public_key | string (hex) |
Errors
Non-2xx responses are {"error": "message"}.
- 400: bad request shape: missing/invalid fields, unknown JSON fields, an unparsable
cipher_suitesentry, or atargetthat resolves to a private/internal/loopback/link-local/metadata address (blocked before any dial is attempted, so this service can't be used as an SSRF pivot). - 502: the probe could not be run to a verdict at all. Server behaviour that is a finding never lands here: a failed handshake, and retry configs that couldn't be parsed, are both a 200 carrying the
statusthat says so.
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.