The screen was gated on raw-mode availability alone, so `-t 7` with stdout
redirected (`> out.txt`, `| tee`) still painted it and then waited forever for a
keypress nobody could send. It now requires a terminal on both ends — stdin to
answer it and stdout to draw it — and falls back to the CLI flags otherwise,
which is what `--burst`/`--burst-timeout`/`--burst-profiles` are for.
Found by the release smoke test: the piped run hung until the harness deadline.
Test 7 gets its own settings screen (digit 7 in the menu, or `-t 7` on a
terminal): simultaneous requests and per-attempt timeout on `←→`, a grey input
box for the host that goes active on `→` (caret, letters) and back to grey on
`←` (so `w/a/s/d/q` are host characters while typing, and arrow keys keep
navigating), a profile cycler shaped like the menu's fingerprint row
(`all` → RUSTLS → FIREFOX → CHROME → SAFARI), and the default the empty field
falls back to printed under it. `Q`/`Esc` cancels and skips the test. The
screen's rows are built by a pure function, so its layout (fixed box width, the
prompt under the input box, the footer inside 80 columns) is covered by tests.
The report gains the burst table (`answered/total` per profile, the dominant
failure in the detail column, Latin tokens per rule 4), a Stress row in the
summary, and an additive `results.fingerprint_burst` payload; the machine
schema is otherwise untouched.
The human header names the active profile; long summary values wrap inside the
panel instead of pushing the border out (the resolver-hijack list is the one
that did), and every probe detail is rendered through the i18n detail map.
Windows localizes socket errors, so a reset arriving after the ClientHello was
classified from its English text alone: it fell through to UNKNOWN and the cell
showed raw OS prose ("connection error | Удаленный хост принудительно разорвал
существующее подключение") instead of TLS RST. `hyper_err_info` (domains.rs) and
`hyper_info` (tcp16.rs) now return the `raw_os_error`/`ErrorKind` alongside the
message, and `classify_read_error` takes them, so the reset is recognised from
WSAECONNRESET/104 exactly like the Python prototype's `get_errno_from_chain`.
The redirect rule now matches the prototype in both phases: a redirect that
stays on the same host or a subdomain is a plain OK, a foreign one is a red
REDIR (`RedirSuspect`, `is_ok_status()` false). `resolve_location` follows
`urljoin` semantics — a protocol-relative `//host/x` belongs to the host it
names, a relative Location resolves against the phase's base — where the old
hand-rolled version treated `//evil.com/x` as a local path (green OK) and
claimed "→ https" for relative redirects in the HTTP phase.
The 17-case matrix in `domains::tests` pins the Python behaviour; the JSON token
for a suspect redirect stays the frozen `redir`.
Test 7 fires N handshakes from one fingerprint at one host and reports how many
were answered — the measurement behind "a few parallel handshakes with this
shape and the site goes dark", which no other test can express: test 2 makes two
handshakes per host, sequentially, and `--concurrency` spreads work across
*different* hosts.
`probe/burst.rs` connects every attempt in parallel, then releases the
handshakes together through a barrier, so they are simultaneous rather than
merely concurrent; each attempt is its own TCP connection with a fresh session
(no resumption) and sends no HTTP request on top. Verdicts come from the shared
classifiers (`DpiProbeStream` + the connect/TLS error classifiers), so a lost
handshake keeps the vocabulary of the report — TLS RST, TLS DROP, SYN DROP.
`BurstSettings::clamped` keeps interactive input inside the range the test stays
meaningful in (2-16 requests, 1-60 s per attempt), and the report exposes
answered/lost per profile plus the dominant failure, which is what the table
shows.
Test 4's baseline no longer requires a 16-20 KB transfer-window kill: a
TLS-stage kill of the ClientHello (TLS RST, TLS DROP, TLS ABORT) also proves the
target is DPI-filtered and worth an SNI search, while plain connectivity
failures (SYN timeout, refused, unreachable) stay out — those are the
ban/rate-limit signal the batch loop aborts on.
The update check now compares semver properly (numeric vs alphanumeric
prerelease identifiers, derived order) and picks a channel: a prerelease build
is offered every published release, a stable build only stable ones.
Smaller cleanups: the Telegram probe uses the shared `DET_SYN_TIMEOUT_SHORT`
token and reports its phase through the progress callback; the public-IP fetch
applies its timeout to the whole redirect chain instead of once per hop (four
hops could spend four times the caller's deadline); the unused
`RegionProfile::display_name` is gone.
Test 1 now runs its blocks (DoH, DoT, UDP, egress) concurrently and reports all
of them on one live line: `ProgressBlock` in lib.rs names each counter with a
canonical token (rule 4), and the availability loop drives them through one
semaphore sized by MAX_CONCURRENT — the separate DNS_PROBE_CONCURRENCY cap is
gone (config.yml documents the change).
Two robustness fixes came out of that work: a DoH warmup window bounds the
throwaway query that only opens the HTTP/2 stream, and a budget check stops
starting new queries when the outer probe cap would cancel the whole future and
take the collected answers with it.
`dns/resolve.rs` pins resolved hosts (1 h, 256 entries, failures never cached):
the same DoH/DoT endpoint name is re-resolved for every retry, and each miss
costs a system-resolver timeout plus the bootstrap chain. `dns/doh.rs` exposes
`is_h2()` and the connection-poisoning state, because on HTTP/1.1 an abandoned
request leaves hyper unable to resynchronise the stream.
Four shapes a TLS probe can present, selectable per run with `--fingerprint`
(config `TLS_FINGERPRINT`, menu row, and the token in `--json`):
* `rustls` — the untouched baseline every earlier measurement was taken with;
* `custom` (shown as FIREFOX) — uTLS `HelloFirefox_148`, the shape Xray/REALITY
clients present, including the hybrid X25519MLKEM768 key share;
* `chrome` / `safari` — the JA3s of the curl-impersonate profiles the reported
TSPU trigger list names (chrome 99-116 / edge 99-101, safari 15.5-18.4).
`net/fingerprint.rs` holds the profile data, `net/ja3.rs` extracts JA3 from the
wire bytes so the tests can pin each profile to the JA3 of the client it
reproduces, and `net/pq_kx.rs` supplies the pure-Rust hybrid group (rustls ships
it only in the aws-lc-rs provider, which links C code).
Deliberate deviations, each measured: no ECH (a synthesized body makes
Cloudflare, Google and dns.google answer `fatal alert: DecodeError`), ALPN is
`http/1.1` only (the probes speak HTTP/1.1; JA3 hashes extension types, so the
extension-16 entry still matches), and no HTTP/2 fingerprint. The default
profile keeps `cert_decompressors` cleared, so enabling the compression
features here does not change what the baseline sends.
`cargo run --example tls_fingerprint dump|live <profile>` prints the wire shape
and runs real handshakes; the unit tests pin both curl JA3s and the Firefox
extension set.
The `clap` command is now built at runtime from `Messages`, so `--help` and
every value name follow `--lang` on the same command line (`--fingerprint`,
`--burst`, `--burst-timeout`, `--burst-profiles` included).
`i18n/details.rs` maps the canonical `DET_*` probe details to display text in
all four languages (Russian stays the source language and passes through), with
the new `DET_*` constants in `classify/constants.rs`; machine JSON keeps
emitting the identifiers verbatim (rule 5).
config.yml documents TLS_FINGERPRINT and the concurrency change (MAX_CONCURRENT
is now the cap for every test, DNS_PROBE_CONCURRENCY is gone), plus the DNS
truth-fallback keys; the README lists the new options and test 7.
`vendor/rustls` is upstream rustls 0.23.43 plus one hook: a ClientHello
profile override (`src/client/hello_profile.rs`) wired in through
`[patch.crates-io]`, so tokio-rustls, hyper and rustls-rustcrypto all
resolve to the same instance. Upstream gives no control over the ClientHello
(cipher list comes from the provider, extension order is randomized, browser
extensions are never emitted), and the ready-made impersonation crates are
either BoringSSL (C code, rules out the router targets) or pinned to an older
rustls. `exclude = ["vendor/rustls"]` keeps its upstream tests out of the
workspace; `PATCH.diff` reproduces the tree.
`brotli` + `zlib` let the profile advertise certificate compression and decode
what a server then sends; `ml-kem` + `x25519-dalek` back the pure-Rust
X25519MLKEM768 group the Firefox profile offers.
`.cargo/config.toml` pins rust-lld for x86_64-pc-windows-msvc (112 s -> 56 s
per fat-LTO relink here), and `[profile.release-local]` is the release knobs
minus whole-program LTO: 1.3 s per edit instead of 52 s, for iteration only
(it is not shippable, releases still build with `--release`).