feat(classify,cli): name TLS-stack failures, fix the site-family redirect rule, trace test 6

The detail vocabulary had one hole: a rustls failure that is not a peer alert
reached the table as its own English sentence (`connection error | cannot decrypt
peer's message`) — the only `Detail` whose code is prose, so `--json` carried
that sentence and no test could pin it. `Detail::StackFailure(StackKind)` names
the six rustls failures a middlebox leaves behind (decrypt error, invalid /
inappropriate / inappropriate-handshake message, peer misbehaved, no application
protocol) through `classify/stack.rs`, the table that mirrors `alert.rs`. The
status stays `Unknown` on purpose: a decrypt error fits an injected record and a
buggy server alike, and the classifier still refuses to read "our stack lacks
this feature" as a peer failure.

Redirect details became values as well (`Redirect { host }`, `UpgradeHttps
{ status }`), which retires the last two `Other` sites that built display prose
inside the core, and the same-site rule is symmetric now: `m.holod.media` →
`holod.media` was a red REDIR while `www.holod.media` → `holod.media` was OK, so
a subdomain naming its main domain read as a redirection away from the site.

Test 6 gained `--trace [<PATH>]`: one line per attempt — UTC, round, shape, host,
attempt, status, detail code, milliseconds — to stderr or to a file. The file
form is what works under the TUI, and it is where a "whole column is red"
question gets answered. `BurstObserver` grew a defaulted `host_probed`, so the
live line and the trace are two observers of one run and neither knows about the
other; an observer that wants neither implements nothing.

README covers all three: the flag, the symmetric redirect rule, the grouped
detail column, and the HTTP/2 rows the fingerprint profiles no longer deviate on.
This commit is contained in:
Kirill Minovsky 2026-09-14 16:22:40 +03:00
parent d1f97abb51
commit 2f2c76f088
16 changed files with 591 additions and 79 deletions

View file

@ -109,6 +109,7 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
--burst-profiles <LIST> Fingerprint для теста 6: all|rustls,custom(firefox133),chrome(chrome107),safari(safari155) [по умолчанию: all]
--burst-tls <VERSION> Версия TLS для теста 6: 1.2|1.3 [по умолчанию: 1.3]
--burst-alpn <PROTOCOL> ALPN для теста 6: h2 (предлагает h2 с откатом на http/1.1)|http/1.1 (только http/1.1) [по умолчанию: h2]
--trace [<PATH>] Тест 6: трасса по строке на попытку (раунд, отпечаток, домен, статус, деталь, мс); без пути — в stderr, с путём — в указанный файл [по умолчанию: выключено]
-h, --help Показать справку
-V, --version Показать версию
```
@ -118,7 +119,7 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
### Локализация (`crates/dpi-core/src/i18n`)
* `Messages` - по одному полю на строку, четыре блока (`En`, `Ru`, `Zh`, `Fa`); шаблоны используют `{}`.
* `legend_sections*()` - таблицы `--legend`; `details.rs` - текст для деталей `DET_*` (в русском блоке строка выводится как есть).
* `legend_sections*()` - таблицы `--legend`; `details.rs` - текст для каждой детали `Detail` (в русском блоке строка выводится как есть).
* Язык Farsi рендерится **Finglish** (латиница, без персидской графики и диакритики).
* Статусные бейджи и имена протоколов (`OK`, `BLOCKED`, `TLS RST`, `SNI`, `ClientHello`) остаются латиницей в любом языке — так цифры, IP-адреса и строки логов читаются одинаково во всех локалях.
@ -136,14 +137,23 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
При выборе теста `6` открывается свой экран настроек: число одновременных рукопожатий, таймаут одного рукопожатия **в секундах**, **версия TLS** (`1.2`/`1.3`), **протокол HTTP в ALPN** (`h2` — как у браузеров, с откатом на `http/1.1`, или `http/1.1` — только он), домен (в поле показан хост, который проверял прошлый прогон; первый набранный символ заменяет его, а пустое поле — весь список, как в тесте `2`; вставленный URL приводится к имени хоста тем же очистителем, что и список из конфига) и **Fingerprint** (по умолчанию все). Поле домена подсвечивается: серый фон — строка выбрана, синий — в поле идёт ввод. `Q`/`Esc` отменяет тест.
На каждый домен и отпечаток выполняется N одновременных попыток — отдельные TCP-соединения, свежие сессии без resumption. Попытка не заканчивается рукопожатием: сразу после него тем же профилем (и тем же `User-Agent`/заголовками) уходит `GET /` по тому протоколу, который выбрал ALPN, и ответ классифицируется так же, как в тесте 2. Рукопожатие ограничено таймаутом из настроек, ответ ждёт `read_timeout` из `config.yml`; попытка считается удачной, только если вернулся ответ — обрыв на запросе, редирект на чужой хост, `HTTP BLOCK` или таймаут чтения видны как своя строка, а не как «ответили». Две оси дают 4 комбинации, и они измеряют разные вещи: `1.3` против `1.2` — реакцию на версию протокола, `h2` против `http/1.1` — реакцию на ALPN (некоторые триггеры срабатывают именно на h2-хелло; JA4 при этом меняет поле ALPN, `h2` → `h1`).
На каждый домен и отпечаток выполняется N одновременных попыток — отдельные TCP-соединения, свежие сессии без resumption. Попытка не заканчивается рукопожатием: сразу после него тем же профилем (и тем же `User-Agent`/заголовками) уходит `GET /` по тому протоколу, который выбрал ALPN, и ответ классифицируется так же, как в тесте 2. Рукопожатие ограничено таймаутом из настроек, ответ ждёт `read_timeout` из `config.yml`; попытка считается удачной, только если вернулся ответ — обрыв на запросе, редирект на чужой хост, `HTTP BLOCK` или таймаут чтения видны как своя строка, а не как «ответили». Редирект внутри своего сайта вердикта не меняет: поддомен и основной домен — это одна и та же площадка, поэтому `holod.media` → `www.holod.media` и `m.holod.media` → `holod.media` читаются как `OK`, а на чужой домен (`www.instagram.com` → `www.facebook.com`) — как `REDIR` с именем хоста в детали. Две оси дают 4 комбинации, и они измеряют разные вещи: `1.3` против `1.2` — реакцию на версию протокола, `h2` против `http/1.1` — реакцию на ALPN (некоторые триггеры срабатывают именно на h2-хелло; JA4 при этом меняет поле ALPN, `h2` → `h1`).
Ось `1.3` шлёт **браузерный** оффер — 1.2 и 1.3 вместе, как Chrome, Safari и Firefox, — потому что тест спрашивает, блокируется ли *форма браузера*, а хелло, пришпиленный к 1.3, не шлёт ни один браузер. Ответ требуется именно 1.3: если пир выбрал 1.2, строка получает `NO TLS 1.3`. Ось `1.2` — намеренно синтетический клиент, предлагающий только 1.2: хелло с обоими протоколами никогда не получает ответ 1.2, и иначе проверить выживание 1.2-рукопожатия нельзя. Тот же компромисс, что у двух колонок теста 2. В таблице `M/N` по каждому отпечатку (зелёный — все попытки прошли целиком (рукопожатие и запрос), жёлтый — часть, красный — никто), в колонке «Детали» — самый частый вердикт отказа (`TLS RST`, `TLS DROP`, `SYN DROP`, …). Тест идёт последним: серия рукопожатий может «сжечь» цель на время, и измерения после неё недействительны.
Ось `1.3` шлёт **браузерный** оффер — 1.2 и 1.3 вместе, как Chrome, Safari и Firefox, — потому что тест спрашивает, блокируется ли *форма браузера*, а хелло, пришпиленный к 1.3, не шлёт ни один браузер. Ответ требуется именно 1.3: если пир выбрал 1.2, строка получает `NO TLS 1.3`. Ось `1.2` — намеренно синтетический клиент, предлагающий только 1.2: хелло с обоими протоколами никогда не получает ответ 1.2, и иначе проверить выживание 1.2-рукопожатия нельзя. Тот же компромисс, что у двух колонок теста 2. В таблице `M/N` по каждому отпечатку (зелёный — все попытки прошли целиком (рукопожатие и запрос), жёлтый — часть, красный — никто), в колонке «Детали» — **все** вердикты строки, сгруппированные по статусу: частые первыми, при равенстве — в порядке появления (`TLS ALERT ×8 · TLS RST ×4`), так что двенадцать потерянных рукопожатий не схлопываются в одну «победившую» ошибку. Группировка идёт по статусу, а не по детали: разные алерты — одна группа `TLS ALERT`, а конкретная деталь каждой попытки остаётся в `--json` и в трассе. Тест идёт последним: серия рукопожатий может «сжечь» цель на время, и измерения после неё недействительны.
Во время прогона печатается одна живая строка `Тестируем: CHROME 107 3/4 12/35 · 00:04` — какая форма сейчас в эфире, который это раунд из скольких, сколько хостов раунда готово и сколько идёт время. Отдельной шапки с настройками и блок «Итог» тест не выводит: отпечаток с версией и так виден в колонках таблицы, а пустой итог не рисуется.
Из неинтерактивного запуска настройки задаются флагами `--burst`, `--burst-timeout`, `--burst-profiles`, `--burst-tls`, `--burst-alpn`; в `--json` результат лежит в ключе `results.fingerprint_burst` (вместе с `tls` и `alpn` этого прогона).
Флаг `--trace [<PATH>]` пишет по строке на каждую попытку — время UTC, раунд, форма, домен, номер попытки, статус, код детали и миллисекунды:
```text
13:19:30Z r1/1 chrome www.google.com #1 tls_alert alert_unexpected_message 719 ms
13:19:30Z r1/1 chrome standby-rezka.tv #1 tls_alert alert_illegal_parameter 1031 ms
```
Без пути трасса идёт в stderr, с путём — в указанный файл, что и делает её пригодной для интерактивного экрана: `--trace C:\temp\trace.log` плюс `Get-Content -Wait C:\temp\trace.log` во втором окне, пока TUI перерисовывает кадр. Строки не переводятся — это машинный журнал: видно, какая именно из N попыток, с какой задержкой и по какой причине не прошла (тишина до нашего таймаута, сброс за десятки миллисекунд или алерт от пира), и `grep` по форме или домену отвечает на вопрос «почему эта колонка красная».
### Профили отпечатка (`--fingerprint`)
Профиль задаёт три слоя сразу, и все три взяты из одного и того же набора `curl-impersonate v2.2.2`, поэтому клиент, выглядящий как `curl_chrome107` в одном слое, выглядит так же и в остальных:
@ -152,11 +162,11 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
| --- | --- | --- |
| TLS | список шифров и их порядок, группы, `signature_algorithms`, ALPN, порядок расширений, GREASE, ALPS, padding до 512, сжатие сертификата, а у Safari ещё и фолбэки `0x0302`/`0x0301` в `supported_versions`; тесты пинуют JA3 и JA4 против бандла | ECH (синтез ломает рукопожатие с Google и Cloudflare, поэтому у Firefox форма на одно расширение и на 357 байт короче оригинала); там, где версия пришпилена ради изоляции (две колонки теста 2 и ось `1.2` теста 6), в `supported_versions` ровно одна версия — JA3/JA4 этого не видят, читающий тело middlebox видит |
| HTTP | `User-Agent` и набор заголовков клиента в его порядке (`sec-ch-ua*`, `accept`, `sec-fetch-*`, `accept-language`; у Firefox ещё `priority` и `te`) | `accept-encoding` всегда `identity`: тесты 2–4 считают байты до обрыва, а согласованное сжатие сделало бы эти числа зависящими от сжимаемости ответа |
| HTTP/2 | значения `SETTINGS` и то, какие из них отправляются, плюс оконный `WINDOW_UPDATE` | порядок псевдозаголовков (`m,s,a,p` — порядок hyper; Chrome шлёт `m,a,s,p`, Firefox `m,p,a,s`, Safari `m,s,p,a`) и приоритет на `HEADERS` (h2 0.4 не умеет приоритеты) |
| HTTP/2 | значения и **состав** `SETTINGS`, **порядок** `SETTINGS` (у Safari `4:4194304` идёт перед `3:100`), оконный `WINDOW_UPDATE`, порядок псевдозаголовков (`m,a,s,p` — Chrome, `m,p,a,s` — Firefox, `m,s,p,a` — Safari) и приоритет на `HEADERS` с весом и зависимостью (Chrome 256, Firefox 42, Safari 255) | — |
Свой `user_agent` в `config.yml` перебивает профиль: если поле отличается от встроенного значения по умолчанию, на провод уходит именно оно. `rustls` — контрольная форма: она никого не изображает и оставляет заголовки и преамбулу HTTP/2 такими, какими их отправляли все прошлые измерения.
Проверка на `tls.peet.ws`: JA3/JA4, `User-Agent`, набор и порядок заголовков, `SETTINGS` и `WINDOW_UPDATE` совпадают с `curl_chrome107` в точности; `peetprint` (он сворачивает в один хэш ещё и порядок псевдозаголовков с приоритетом) — нет.
Проверка на `tls.peet.ws`: JA3 и его хэш, JA4/JA4_r, `peetprint` с хэшем, `User-Agent`, набор и порядок заголовков, `SETTINGS` (состав, значения и порядок), `WINDOW_UPDATE`, порядок псевдозаголовков, приоритет на `HEADERS` и akamai-отпечаток совпадают с бандлами в точности — у `curl_chrome107`, `curl_firefox133` и `curl_safari155` соответственно. Расхождения, которые остаются во всех трёх профилях, — ECH у Firefox и `accept-encoding: identity` (см. таблицу выше).
---

View file

@ -1,6 +1,7 @@
use std::io;
use super::alert;
use super::detail::{AlertKind, Detail};
use super::stack;
use super::types::{ConnectionStage, DpiStatus};
fn short_detail(msg: &str) -> String {
@ -16,6 +17,39 @@ fn is_tls_stage(stage: ConnectionStage) -> bool {
)
}
/// Messages about a protocol element our own TLS stack does not implement are
/// not evidence about the network. rustls words these with "certificate" (`got
/// CompressedCertificate when expecting Certificate`,
/// `UnknownCertificateExtension`), so without this they read as a certificate
/// interception — and, since rustls reports them the same way it reports a peer
/// that broke the protocol, they would also take a stack failure's name. Real
/// interference still lands in the alert, record and EOF branches.
fn is_unimplemented_element(msg_lower: &str) -> bool {
[
"unsupportedextension",
"unsupported extension",
"unknowncertificateextension",
"unknown certificate extension",
"compressedcertificate",
"compressed certificate",
]
.iter()
.any(|m| msg_lower.contains(m))
}
/// The detail for a message nothing else classified: a named TLS-stack failure
/// carries the rustls name it was classified by, anything else keeps its own
/// words.
fn unclassified_detail(msg_lower: &str, raw: &str) -> Detail {
if is_unimplemented_element(msg_lower) {
return Detail::Other(short_detail(raw));
}
match stack::from_message(msg_lower) {
Some(kind) => stack::detail_for(kind),
None => Detail::Other(short_detail(raw)),
}
}
/// The verdict for a message that reports a peer alert: the description it names
/// decides the detail.
///
@ -79,22 +113,10 @@ pub fn classify_ssl_error(
return (DpiStatus::TlsAlert, Detail::FakeTlsAlert);
}
// A message about a protocol element our own TLS stack does not implement is
// not evidence about the network. rustls words these with "certificate"
// (`got CompressedCertificate when expecting Certificate`,
// `UnknownCertificateExtension`), so without this they are reported as an
// interception. Real interference still lands in the branches above: a
// censor's spoofed ServerHello arrives as a version/record/alert error.
if [
"unsupportedextension",
"unsupported extension",
"unknowncertificateextension",
"unknown certificate extension",
"compressedcertificate",
"compressed certificate",
]
.iter()
.any(|m| msg.contains(m))
{
// not evidence about the network (see `is_unimplemented_element`). Real
// interference still lands in the branches above: a censor's spoofed
// ServerHello arrives as a version/record/alert error.
if is_unimplemented_element(&msg) {
return (DpiStatus::Unknown, Detail::Other(short_detail(err_msg)));
}
@ -141,7 +163,7 @@ pub fn classify_ssl_error(
return (DpiStatus::NoTls13, Detail::NoTls13);
}
(DpiStatus::Unknown, Detail::Other(short_detail(err_msg)))
(DpiStatus::Unknown, unclassified_detail(&msg, err_msg))
}
fn dns_failure_text(msg: &str) -> bool {
@ -271,7 +293,7 @@ pub fn classify_connect_error_full(
return (DpiStatus::OsErr, Detail::Other(format!("OS errno {}", code)));
}
(DpiStatus::Unknown, Detail::Other(short_detail(err_msg)))
(DpiStatus::Unknown, unclassified_detail(&full, err_msg))
}
/// Legacy io::Error-based entry point (stage unknown → tcp_connect).
@ -331,27 +353,31 @@ pub fn classify_tls_error(
// A reset/EOF after ClientHello with zero bytes back is a DPI SNI RST,
// even when the OS masks it as a generic error.
if status == DpiStatus::Unknown
&& (stage == ConnectionStage::TlsClientHelloSent || (bytes_sent > 0 && bytes_recv == 0))
{
if status == DpiStatus::Unknown {
let lower = err_msg.to_ascii_lowercase();
if lower.contains("reset")
|| lower.contains("eof")
|| lower.contains("closed")
|| lower.contains("abort")
|| lower.contains("broken pipe")
|| lower.contains("10054")
|| lower.contains(" 104")
if (stage == ConnectionStage::TlsClientHelloSent || (bytes_sent > 0 && bytes_recv == 0))
&& (lower.contains("reset")
|| lower.contains("eof")
|| lower.contains("closed")
|| lower.contains("abort")
|| lower.contains("broken pipe")
|| lower.contains("10054")
|| lower.contains(" 104"))
{
return (
DpiStatus::TlsRst,
Detail::TlsRstHello,
);
}
}
if status == DpiStatus::Unknown {
return (DpiStatus::Unknown, Detail::Other(format!("TLS error: {}", short_detail(err_msg))));
// The stack's own wording survives as a detail; anything else keeps its
// own words under the TLS label.
return match stack::from_message(&lower) {
Some(kind) if !is_unimplemented_element(&lower) => {
(DpiStatus::Unknown, stack::detail_for(kind))
}
_ => (DpiStatus::Unknown, Detail::Other(format!("TLS error: {}", short_detail(err_msg)))),
};
}
(status, detail)
}
@ -370,7 +396,7 @@ pub fn classify_read_error(
let (status, detail) =
classify_connect_error_full(err_msg, raw_os_error, kind, bytes_read, "reading_data");
if status == DpiStatus::Unknown {
return (DpiStatus::Unknown, Detail::Other(short_detail(err_msg)));
return (DpiStatus::Unknown, unclassified_detail(&err_msg.to_ascii_lowercase(), err_msg));
}
(status, detail)
}
@ -505,11 +531,48 @@ mod tests {
for msg in [
"received unexpected handshake message: got CompressedCertificate when expecting Certificate or CertificateRequest",
"received corrupt message of type UnknownCertificateExtension",
"peer misbehaved: UnsolicitedServerHelloExtension",
] {
let (s, d) = classify_ssl_error(msg, 0, ConnectionStage::TlsClientHelloSent);
assert_eq!(s, DpiStatus::Unknown, "{msg} -> {}", d.code());
// Our stack lacking a feature is not a peer failure: the message
// keeps its own words instead of taking a stack failure's name.
assert!(matches!(d, Detail::Other(_)), "{msg} -> {}", d.code());
}
// A peer that broke the protocol is a stack failure the reader can name,
// but still not evidence of interception: the status stays Unknown.
let (s, d) = classify_ssl_error(
"peer misbehaved: UnsolicitedServerHelloExtension",
0,
ConnectionStage::TlsClientHelloSent,
);
assert_eq!((s, d.code().as_ref()), (DpiStatus::Unknown, "tls_peer_misbehaved"));
}
/// A record that will not decrypt is the signature of an injected or
/// rewritten record, and it used to reach the table as the rustls sentence
/// in English. It now carries the stack failure's name; the status stays
/// Unknown because a buggy server produces the same message.
#[test]
fn test_a_stack_failure_is_named_not_quoted() {
let observed = "connection error | cannot decrypt peer's message";
let (s, d) = classify_read_error(observed, None, None, 0);
assert_eq!((s, d.code().as_ref()), (DpiStatus::Unknown, "tls_decrypt_error"));
// The same wording through the TLS path keeps the same detail.
let (s, d) = classify_tls_error(
ConnectionStage::TlsClientHelloSent,
100,
0,
observed,
false,
);
assert_eq!((s, d.code().as_ref()), (DpiStatus::Unknown, "tls_decrypt_error"));
// A message naming nothing known keeps its own words.
let (s, d) = classify_read_error("connection error | oops", None, None, 0);
assert_eq!(s, DpiStatus::Unknown);
assert_eq!(d.code().as_ref(), "connection error | oops");
}
#[test]

View file

@ -49,6 +49,10 @@ pub enum Detail {
/// A peer alert that named its description: the code is
/// `alert_<description>` (`alert_illegal_parameter`).
Alert(AlertKind),
/// A TLS-stack failure that is not an alert from the peer: our own TLS layer
/// refused the bytes it received — `cannot decrypt peer's message`, `peer
/// misbehaved: …`. The code is `tls_<kind>`.
StackFailure(StackKind),
// ─── Certificates ───
NoRootCa,
@ -107,6 +111,12 @@ pub enum Detail {
IspBlockpage { arrow: bool, ip: String },
/// The resolver answered with a loopback/private address (`-> <ip>`).
LocalIp { ip: String },
/// The peer redirected to `host`: on the same site family the verdict stays
/// OK, a foreign one is a red `REDIR`.
Redirect { host: String },
/// A hop to `https` on the same host: `-> https`, or `301 -> https` when the
/// status that carried it is known.
UpgradeHttps { status: Option<u16> },
/// An HTTP status line.
HttpStatus(u16),
/// How long a successful check took.
@ -211,6 +221,54 @@ impl AlertKind {
}
}
/// One failure of our own TLS stack over bytes the peer sent, named as rustls
/// names it.
///
/// These are the messages a middlebox leaves behind when it rewrites or injects
/// records: a record that does not decrypt, a message that arrived out of order,
/// a peer that broke the protocol. rustls words each one differently and the
/// difference is the diagnosis, so they are values, not free text — the free
/// text would be English prose inside a Russian table, and it changes with the
/// rustls version, which no test can pin.
///
/// The names are rustls's own [`Error`] variants, snake_case (see
/// `vendor/rustls/src/error.rs`). They are protocol tokens, so they stay Latin
/// in every language (Rule 4) and `--json` carries them inside the detail code
/// after `tls_`.
///
/// [`Error`]: https://docs.rs/rustls/latest/rustls/enum.Error.html
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StackKind {
/// `cannot decrypt peer's message`: a record we could not open.
DecryptError,
/// `received corrupt message of type …`.
InvalidMessage,
/// `received unexpected message: got … when expecting …`.
InappropriateMessage,
/// `received unexpected handshake message: got … when expecting …`.
InappropriateHandshakeMessage,
/// `peer misbehaved: …`.
PeerMisbehaved,
/// `peer doesn't support any known protocol`: no ALPN in common.
NoApplicationProtocol,
}
impl StackKind {
/// The rustls name, snake_case: the token a [`Detail::StackFailure`]'s code
/// carries after `tls_`.
pub fn code(self) -> &'static str {
use StackKind::*;
match self {
DecryptError => "decrypt_error",
InvalidMessage => "invalid_message",
InappropriateMessage => "inappropriate_message",
InappropriateHandshakeMessage => "inappropriate_handshake_message",
PeerMisbehaved => "peer_misbehaved",
NoApplicationProtocol => "no_application_protocol",
}
}
}
impl Detail {
/// The machine token. `--json` carries exactly this; it never changes with
/// `--lang`.
@ -231,6 +289,7 @@ impl Detail {
ProtocolVersionAlert => Cow::Borrowed("protocol_version_alert"),
FakeTlsAlert => Cow::Borrowed("fake_tls_alert"),
Alert(kind) => Cow::Owned(format!("alert_{}", kind.code())),
StackFailure(kind) => Cow::Owned(format!("tls_{}", kind.code())),
NoRootCa => Cow::Borrowed("no_root_certificates"),
CertExpired => Cow::Borrowed("cert_expired"),
SelfSigned => Cow::Borrowed("self_signed_cert"),
@ -266,6 +325,8 @@ impl Detail {
TimeoutStage { stage } => Cow::Owned(format!("timeout_{}", stage)),
IspBlockpage { .. } => Cow::Borrowed("isp_blockpage"),
LocalIp { .. } => Cow::Borrowed("local_ip"),
Redirect { .. } => Cow::Borrowed("redirect_to_host"),
UpgradeHttps { .. } => Cow::Borrowed("upgrade_https"),
HttpStatus(code) => Cow::Owned(format!("http_{}", code)),
Elapsed(secs) => Cow::Owned(format!("elapsed_{}ms", (secs * 1000.0).round() as u64)),
Other(text) => Cow::Owned(text.clone()),
@ -339,6 +400,11 @@ mod tests {
Detail::Elapsed(0.3),
Detail::Alert(AlertKind::IllegalParameter),
Detail::Alert(AlertKind::BadCertificateStatusResponse),
Detail::StackFailure(StackKind::DecryptError),
Detail::StackFailure(StackKind::InappropriateHandshakeMessage),
Detail::Redirect { host: "example.com".into() },
Detail::UpgradeHttps { status: Some(301) },
Detail::UpgradeHttps { status: None },
];
for d in &all {
let code = d.code();
@ -348,6 +414,13 @@ mod tests {
);
}
assert_eq!(Detail::HttpStatus(403).code(), "http_403");
assert_eq!(Detail::StackFailure(StackKind::DecryptError).code(), "tls_decrypt_error");
assert_eq!(
Detail::StackFailure(StackKind::InappropriateHandshakeMessage).code(),
"tls_inappropriate_handshake_message"
);
assert_eq!(Detail::Redirect { host: "example.com".into() }.code(), "redirect_to_host");
assert_eq!(Detail::UpgradeHttps { status: Some(301) }.code(), "upgrade_https");
assert_eq!(Detail::Elapsed(0.3).code(), "elapsed_300ms");
assert_eq!(Detail::None.code(), "");
}

View file

@ -1,10 +1,11 @@
pub mod alert;
pub mod classifier;
pub mod stack;
pub mod stream;
pub mod detail;
pub mod types;
pub use classifier::{classify_connect_error, classify_connect_error_full, classify_read_error, classify_ssl_error, classify_tls_error};
pub use stream::{DpiProbeStream, DpiProbeTracker};
pub use detail::{AlertKind, Detail};
pub use detail::{AlertKind, Detail, StackKind};
pub use types::{ConnectionStage, DpiStatus, ProbeMetrics};

View file

@ -0,0 +1,111 @@
//! The TLS-stack table: an error message to the rustls variant it names.
//!
//! Where `alert.rs` reads what the *peer* said, this reads what our own stack
//! said about the peer's bytes. The two never overlap: an alert arrives as
//! `received fatal alert: …`, a stack failure as rustls's own wording, and both
//! tables match on the same lowercased, separator-stripped message.
//!
//! Matching happens on the words with their separators removed, so
//! `peer misbehaved: …`, `PeerMisbehaved` and `peermisbehaved` reach the same
//! value. The needles are the `Display` texts of `rustls::Error` as the vendored
//! rustls spells them (`vendor/rustls/src/error.rs`); a rustls upgrade that
//! rewords one is a change to this table, and the tests below pin the wording so
//! that upgrade fails loudly instead of silently unclassifying the case.
//!
//! A message the table does not know is left to the caller, which keeps its own
//! words (`Detail::Other`) — the long tail of OS and library text stays as it
//! came.
//!
//! Ordering matters at the call sites: the classifier's "our stack does not
//! implement this" early-out runs first, so a server using certificate
//! compression is not read as a peer that misbehaved.
use super::detail::{Detail, StackKind};
use StackKind::*;
/// `(needle without separators, kind)`, matched in order against a lowercased,
/// separator-stripped message.
///
/// The longer wording comes first where one needle is a prefix of another
/// (`receivedunexpectedhandshakemessage` before `receivedunexpectedmessage`).
const TABLE: &[(&str, StackKind)] = &[
("receivedunexpectedhandshakemessage", InappropriateHandshakeMessage),
("receivedunexpectedmessage", InappropriateMessage),
("receivedcorruptmessageoftype", InvalidMessage),
("cannotdecryptpeer'smessage", DecryptError),
("peermisbehaved", PeerMisbehaved),
("doesn'tsupportanyknownprotocol", NoApplicationProtocol),
];
/// The rustls failure a message names, or `None` when it names none.
///
/// `msg_lower` is the message already lowercased by the caller — the classifier
/// keeps one lowercased copy of every error it classifies.
pub fn from_message(msg_lower: &str) -> Option<StackKind> {
let flat: String = msg_lower
.chars()
.filter(|c| !c.is_whitespace() && *c != '_' && *c != '-')
.collect();
TABLE.iter().find(|(needle, _)| flat.contains(needle)).map(|(_, kind)| *kind)
}
/// The detail a message reports as.
pub fn detail_for(kind: StackKind) -> Detail {
Detail::StackFailure(kind)
}
#[cfg(test)]
mod tests {
use super::*;
/// The wording rustls puts on the wire, as the probes see it: the stack's
/// own text, appended after hyper's `connection error` (see
/// `probe::http::hyper_err_info`).
#[test]
fn the_rustls_spelling_names_the_failure() {
for (msg, kind) in [
("connection error | cannot decrypt peer's message", DecryptError),
("cannot decrypt peer's message", DecryptError),
("received corrupt message of type ChangeCipherSpec", InvalidMessage),
(
"received unexpected message: got Alert when expecting Handshake",
InappropriateMessage,
),
(
"received unexpected handshake message: got ServerHello when expecting ClientHello",
InappropriateHandshakeMessage,
),
("peer misbehaved: UnsolicitedServerHelloExtension", PeerMisbehaved),
("peer doesn't support any known protocol", NoApplicationProtocol),
] {
let lower = msg.to_ascii_lowercase();
assert_eq!(from_message(&lower), Some(kind), "{msg}");
}
}
/// An alert is not a stack failure: its wording belongs to `alert.rs`, and
/// the two tables must not claim the same message.
#[test]
fn an_alert_wording_is_not_a_stack_failure() {
for msg in [
"connection error | received fatal alert: UnexpectedMessage",
"received fatal alert: IllegalParameter",
"peer sent no certificates",
"invalid peer certificate: Expired",
] {
assert_eq!(from_message(&msg.to_ascii_lowercase()), None, "{msg}");
}
}
/// A stack failure is still not interception: the status stays unclassified
/// while the detail says what rustls objected to.
#[test]
fn the_detail_is_the_kind() {
assert_eq!(detail_for(DecryptError).code(), "tls_decrypt_error");
assert_eq!(
detail_for(InappropriateHandshakeMessage).code(),
"tls_inappropriate_handshake_message"
);
}
}

View file

@ -327,6 +327,20 @@ pub trait BurstObserver {
/// One host finished its round, answered or not.
fn host_finished(&self) {}
/// One host's round landed, with every attempt it produced.
///
/// The live line counts hosts and ignores this; a trace prints the attempts
/// the moment they exist, which is what turns "SAFARI 0/4" into a status and
/// a millisecond count per attempt. A caller that wants neither implements
/// nothing.
fn host_probed(
&self,
_fingerprint: TlsFingerprint,
_domain: &str,
_report: &BurstProfileReport,
) {
}
}
impl BurstObserver for () {}
@ -381,6 +395,7 @@ pub async fn burst_targets(
while let Some(joined) = rounds.join_next().await {
observer.host_finished();
if let Ok((index, report)) = joined {
observer.host_probed(fingerprint, &targets[index].domain, &report);
reports[index].profiles.push(report);
}
}
@ -875,6 +890,7 @@ mod tests {
struct Rounds {
started: std::sync::Mutex<Vec<(TlsFingerprint, usize, usize, usize)>>,
finished: std::sync::atomic::AtomicUsize,
probed: std::sync::Mutex<Vec<String>>,
}
impl BurstObserver for Rounds {
fn round_started(&self, fp: TlsFingerprint, index: usize, total: usize, hosts: usize) {
@ -883,6 +899,12 @@ mod tests {
fn host_finished(&self) {
self.finished.fetch_add(1, Ordering::SeqCst);
}
fn host_probed(&self, fp: TlsFingerprint, domain: &str, report: &BurstProfileReport) {
self.probed
.lock()
.expect("lock")
.push(format!("{} {domain} x{}", fp.code(), report.attempts.len()));
}
}
let rounds = Rounds::default();
let reports = burst_targets(&AppConfig::default(), &targets, &plan, 4, &rounds).await;
@ -904,6 +926,19 @@ mod tests {
]
);
assert_eq!(rounds.finished.load(Ordering::SeqCst), 4, "one tick per host per round");
// The trace reads this: every host's round arrives once per shape, with
// the attempts it produced — silent hosts are not silently dropped.
let mut probed = rounds.probed.lock().expect("lock").clone();
probed.sort();
assert_eq!(
probed,
vec![
"chrome fast.example x2",
"chrome slow.example x2",
"rustls fast.example x2",
"rustls slow.example x2",
]
);
assert_eq!(reports.len(), 2);
for report in &reports {
let shapes: Vec<TlsFingerprint> = report.profiles.iter().map(|p| p.fingerprint).collect();

View file

@ -762,21 +762,50 @@ mod tests {
fn test_classify_redirect_same_https() {
let (s, d) = classify_redirect("example.com", "https://example.com", 301, "https://example.com/", false);
assert_eq!(s, DpiStatus::Ok);
assert_eq!(d, Detail::Other("→ https".to_string()));
assert_eq!(d, Detail::UpgradeHttps { status: None });
}
#[test]
fn test_classify_redirect_foreign() {
let (s, d) = classify_redirect("example.com", "https://example.com", 302, "https://evil.com/block", false);
assert_eq!(s, DpiStatus::RedirSuspect);
assert!(d.code().contains("evil.com"));
assert_eq!(d, Detail::Redirect { host: "evil.com".to_string() });
}
#[test]
fn test_classify_redirect_http_phase() {
let (s, d) = classify_redirect("example.com", "http://example.com", 301, "https://example.com/", true);
assert_eq!(s, DpiStatus::Ok);
assert_eq!(d, Detail::Other("301 → https".to_string()));
assert_eq!(d, Detail::UpgradeHttps { status: Some(301) });
}
/// The rule the table reads by: a redirect that stays inside one site family
/// is a normal `OK`, whatever the direction — a subdomain spelling out the
/// main domain (`www.holod.media` → `holod.media`) and the main domain
/// naming a subdomain (`holod.media` → `cdn.holod.media`) are both the site
/// itself. Only a name outside the family is a red `REDIR`.
#[test]
fn test_a_subdomain_redirect_inside_the_same_site_is_ok() {
let cases: &[(&str, &str, DpiStatus, &str)] = &[
// (requested domain, Location, expected status, expected redirect host when foreign)
("www.holod.media", "https://holod.media/x", DpiStatus::Ok, ""),
("holod.media", "https://www.holod.media/x", DpiStatus::Ok, ""),
("holod.media", "https://cdn.holod.media/x", DpiStatus::Ok, ""),
("m.holod.media", "https://holod.media/x", DpiStatus::Ok, ""),
("www.holod.media", "http://holod.media/x", DpiStatus::Ok, ""),
// A different registrable domain is not the same site, however it
// reads: this is the instagram → facebook case.
("www.instagram.com", "https://www.facebook.com/x", DpiStatus::RedirSuspect, "www.facebook.com"),
("holod.media", "https://not-holod.media/x", DpiStatus::RedirSuspect, "not-holod.media"),
("holod.media", "https://holod.media.evil.com/x", DpiStatus::RedirSuspect, "holod.media.evil.com"),
];
for (domain, location, want, foreign) in cases {
let (s, d) = classify_redirect(domain, &format!("https://{domain}"), 301, location, false);
assert_eq!(s, *want, "{domain} + {location} -> {}", d.code());
if *want == DpiStatus::RedirSuspect {
assert_eq!(d, Detail::Redirect { host: (*foreign).to_string() }, "{domain} + {location}");
}
}
}
/// Same-host-or-subdomain redirects read as a plain `OK`, a redirect to
@ -787,31 +816,30 @@ mod tests {
#[test]
fn test_classify_redirect_matrix() {
// (base, status, location, expected status, expected detail)
// (base, status, location, expected status, expected detail code)
let cases: &[(&str, u16, &str, DpiStatus, &str)] = &[
("https://holod.media", 301, "https://holod.media/", DpiStatus::Ok, "→ https"),
("https://holod.media", 301, "/en/", DpiStatus::Ok, "→ https"),
("https://holod.media", 301, "index.html", DpiStatus::Ok, "→ https"),
("https://holod.media", 301, "?q=1", DpiStatus::Ok, "→ https"),
("https://holod.media", 301, "https://sub.holod.media/x", DpiStatus::Ok, "→ https"),
("https://holod.media", 302, "http://holod.media/x", DpiStatus::Ok, "→ holod.media"),
("http://holod.media", 301, "https://holod.media/", DpiStatus::Ok, "301 → https"),
("http://holod.media", 301, "https://sub.holod.media/x", DpiStatus::Ok, "301 → https"),
("http://holod.media", 301, "http://holod.media/x", DpiStatus::Ok, "http_301"),
("http://holod.media", 301, "/en/", DpiStatus::Ok, "http_301"),
("http://holod.media", 301, "index.html", DpiStatus::Ok, "http_301"),
("http://holod.media", 301, "?q=1", DpiStatus::Ok, "http_301"),
("https://holod.media", 302, "//evil.com/block", DpiStatus::RedirSuspect, "→ evil.com"),
("http://holod.media", 302, "//evil.com/block", DpiStatus::RedirSuspect, "→ evil.com"),
("https://holod.media", 302, "https://evil.com/block", DpiStatus::RedirSuspect, "→ evil.com"),
("https://holod.media", 302, "https://not-holod.media/", DpiStatus::RedirSuspect, "→ not-holod.media"),
("https://holod.media", 302, "https://holod.media.evil.com/", DpiStatus::RedirSuspect, "→ holod.media.evil.com"),
let cases: &[(&str, u16, &str, DpiStatus, Detail)] = &[
("https://holod.media", 301, "https://holod.media/", DpiStatus::Ok, Detail::UpgradeHttps { status: None }),
("https://holod.media", 301, "/en/", DpiStatus::Ok, Detail::UpgradeHttps { status: None }),
("https://holod.media", 301, "index.html", DpiStatus::Ok, Detail::UpgradeHttps { status: None }),
("https://holod.media", 301, "?q=1", DpiStatus::Ok, Detail::UpgradeHttps { status: None }),
("https://holod.media", 301, "https://sub.holod.media/x", DpiStatus::Ok, Detail::UpgradeHttps { status: None }),
("https://holod.media", 302, "http://holod.media/x", DpiStatus::Ok, Detail::Redirect { host: "holod.media".into() }),
("http://holod.media", 301, "https://holod.media/", DpiStatus::Ok, Detail::UpgradeHttps { status: Some(301) }),
("http://holod.media", 301, "https://sub.holod.media/x", DpiStatus::Ok, Detail::UpgradeHttps { status: Some(301) }),
("http://holod.media", 301, "http://holod.media/x", DpiStatus::Ok, Detail::HttpStatus(301)),
("http://holod.media", 301, "/en/", DpiStatus::Ok, Detail::HttpStatus(301)),
("http://holod.media", 301, "index.html", DpiStatus::Ok, Detail::HttpStatus(301)),
("http://holod.media", 301, "?q=1", DpiStatus::Ok, Detail::HttpStatus(301)),
("https://holod.media", 302, "//evil.com/block", DpiStatus::RedirSuspect, Detail::Redirect { host: "evil.com".into() }),
("http://holod.media", 302, "//evil.com/block", DpiStatus::RedirSuspect, Detail::Redirect { host: "evil.com".into() }),
("https://holod.media", 302, "https://evil.com/block", DpiStatus::RedirSuspect, Detail::Redirect { host: "evil.com".into() }),
("https://holod.media", 302, "https://not-holod.media/", DpiStatus::RedirSuspect, Detail::Redirect { host: "not-holod.media".into() }),
("https://holod.media", 302, "https://holod.media.evil.com/", DpiStatus::RedirSuspect, Detail::Redirect { host: "holod.media.evil.com".into() }),
];
for (base, status, location, want, detail) in cases {
let http_phase = base.starts_with("http:");
let (s, d) = classify_redirect("holod.media", base, *status, location, http_phase);
assert_eq!(s, *want, "{base} + {location} -> {}", d.code());
assert_eq!(d.code(), *detail, "{base} + {location}");
assert_eq!(&d, detail, "{base} + {location}");
// A legitimate redirect counts as success, a foreign one does not.
assert_eq!(s.is_ok_status(), *want != DpiStatus::RedirSuspect, "{base} + {location}");
}

View file

@ -269,8 +269,18 @@ fn resolve_location(base: &str, location: &str) -> String {
}
}
/// Classifies a redirect by host: same host or subdomain → OK (in the HTTP phase a
/// same-host hop to https reads `301 → https`), a foreign host → REDIR with its short name.
/// Classifies a redirect by host: the same site family → OK (in the HTTP phase a
/// same-host hop to https reads `301 → https`, elsewhere `→ https`), a foreign
/// host → REDIR with the host it named.
///
/// "Same family" means the two hosts are the same name, or one is a subdomain of
/// the other at a label boundary, with a leading `www.` ignored on both sides.
/// The comparison is symmetric because both directions are how a site spells
/// itself: `holod.media` → `www.holod.media` and `www.holod.media` →
/// `holod.media`, and equally `m.holod.media` → `holod.media`. Everything else
/// counts as foreign, so a redirect from `www.instagram.com` to
/// `www.facebook.com` stays a red REDIR — and so does `a.example.com` →
/// `b.example.com`, which is two different sites under one parent.
pub(crate) fn classify_redirect(
domain: &str,
base_url: &str,
@ -285,28 +295,30 @@ pub(crate) fn classify_redirect(
let scheme_https = resolved.to_ascii_lowercase().starts_with("https");
let norm_loc = strip_www(&loc_host).to_string();
let norm_dom = strip_www(&domain.to_ascii_lowercase()).to_string();
let same_host = norm_loc == norm_dom || norm_loc.ends_with(&format!(".{}", norm_dom));
let same_host = norm_loc == norm_dom
|| norm_loc.ends_with(&format!(".{}", norm_dom))
|| norm_dom.ends_with(&format!(".{}", norm_loc));
let short_host: String = loc_host.chars().take(30).collect();
if http_phase {
if same_host && scheme_https {
return (DpiStatus::Ok, Detail::Other(format!("{} → https", status)));
return (DpiStatus::Ok, Detail::UpgradeHttps { status: Some(status) });
}
if same_host {
// Same domain (or a subdomain) is a normal redirect: OK, not a badge
// of its own. Only a foreign domain is flagged, as a red REDIR.
return (DpiStatus::Ok, Detail::HttpStatus(status));
}
return (DpiStatus::RedirSuspect, Detail::Other(format!("→ {}", short_host)));
return (DpiStatus::RedirSuspect, Detail::Redirect { host: short_host });
}
if same_host && scheme_https {
return (DpiStatus::Ok, Detail::Other("→ https".to_string()));
return (DpiStatus::Ok, Detail::UpgradeHttps { status: None });
}
if same_host {
return (DpiStatus::Ok, Detail::Other(format!("→ {}", short_host)));
return (DpiStatus::Ok, Detail::Redirect { host: short_host });
}
(DpiStatus::RedirSuspect, Detail::Other(format!("→ {}", short_host)))
(DpiStatus::RedirSuspect, Detail::Redirect { host: short_host })
}
pub(crate) fn inner_hyper(

View file

@ -32,6 +32,9 @@ pub struct CliArgs {
pub burst_tls: Option<String>,
/// Test 6: ALPN to offer, `h2` or `http/1.1`.
pub burst_alpn: Option<String>,
/// Test 6: per-attempt trace. `Some("")` is stderr, `Some(path)` that file,
/// `None` means no trace.
pub trace: Option<String>,
}
/// Builds the CLI definition with `msg`'s language: `about`, per-argument help
@ -179,6 +182,17 @@ pub fn command(msg: &Messages) -> Command {
.help_heading(msg.cli_options_heading)
.help(msg.cli_burst_alpn),
)
.arg(
// An optional value: no path means stderr, a path means that file.
// The empty string is what clap stores for "flag given, no value".
Arg::new("trace")
.long("trace")
.value_name("PATH")
.num_args(0..=1)
.default_missing_value("")
.help_heading(msg.cli_options_heading)
.help(msg.cli_trace),
)
.arg(
Arg::new("help")
.short('h')
@ -221,5 +235,6 @@ pub fn parse_cli(lang: Language) -> CliArgs {
burst_profiles: m.get_one::<String>("burst-profiles").cloned(),
burst_tls: m.get_one::<String>("burst-tls").cloned(),
burst_alpn: m.get_one::<String>("burst-alpn").cloned(),
trace: m.get_one::<String>("trace").cloned(),
}
}

View file

@ -65,6 +65,16 @@ pub fn detail_text(detail: &Detail, lang: Language) -> String {
&format!("Alert-e TLS ({name})"),
)
}
Detail::StackFailure(kind) => {
let name = kind.code();
t4(
lang,
&format!("TLS-стек ({name})"),
&format!("TLS stack ({name})"),
&format!("TLS 协议栈({name})"),
&format!("Khatay-e TLS ({name})"),
)
}
Detail::UnknownConnectionFailure => t4(lang, "Unknown connection failure", "Unknown connection failure", "未知连接错误", "Khata-ye nashenakhte dar ettesal"),
Detail::DomainNotFound => t4(lang, "Домен не найден", "Domain not found", "域名未找到", "Domain peyda nashod"),
Detail::DnsTimeoutUnavailable => t4(lang, "DNS таймаут/недоступен", "DNS timeout/unavailable", "DNS 超时/不可用", "DNS mohlat ya dar dastras nist"),
@ -91,6 +101,11 @@ pub fn detail_text(detail: &Detail, lang: Language) -> String {
Detail::IspBlockpage { arrow: false, ip } => format!("{} {}", msg.detail_isp_stub, ip),
Detail::LocalIp { ip } => format!("{} -> {}", msg.detail_local_ip, ip),
Detail::HttpStatus(code) => format!("HTTP {}", code),
// The target of a redirect is a host and a scheme, not prose: the arrow
// is the same in every language (and maps to `->` on an ASCII console).
Detail::Redirect { host } => format!("→ {host}"),
Detail::UpgradeHttps { status: Some(code) } => format!("{code} → https"),
Detail::UpgradeHttps { status: None } => "→ https".to_string(),
Detail::Elapsed(secs) => format!("{:.1}s", secs),
// Free text from the OS or the TLS stack: appended as it came.
Detail::Other(text) => text.clone(),
@ -101,7 +116,7 @@ pub fn detail_text(detail: &Detail, lang: Language) -> String {
mod tests {
use super::*;
use crate::i18n::Language::*;
use dpi_core::classify::detail::AlertKind;
use dpi_core::classify::detail::{AlertKind, StackKind};
#[test]
fn composed_details_keep_their_tokens() {
@ -119,6 +134,22 @@ mod tests {
assert_eq!(detail_text(&stub, En), "ISP blockpage -> 1.1.1.1");
assert_eq!(detail_text(&Detail::LocalIp { ip: "10.0.0.1".into() }, Zh), "本地 IP -> 10.0.0.1");
assert_eq!(detail_text(&Detail::HttpStatus(451), Zh), "HTTP 451");
assert_eq!(
detail_text(&Detail::StackFailure(StackKind::DecryptError), Ru),
"TLS-стек (decrypt_error)"
);
assert_eq!(
detail_text(&Detail::StackFailure(StackKind::PeerMisbehaved), En),
"TLS stack (peer_misbehaved)"
);
// A redirect reads as its target in every language, and an https hop
// names the status that carried it when one is known.
let foreign = Detail::Redirect { host: "www.facebook.com".into() };
for lang in [En, Ru, Zh, Fa] {
assert_eq!(detail_text(&foreign, lang), "→ www.facebook.com");
}
assert_eq!(detail_text(&Detail::UpgradeHttps { status: Some(301) }, Ru), "301 → https");
assert_eq!(detail_text(&Detail::UpgradeHttps { status: None }, Ru), "→ https");
assert_eq!(detail_text(&Detail::Elapsed(0.3), Ru), "0.3s");
assert_eq!(detail_text(&Detail::None, Ru), "");
assert_eq!(detail_text(&Detail::Other("→ https".into()), En), "→ https");

View file

@ -219,6 +219,8 @@ pub(crate) fn messages() -> Messages {
cli_burst_profiles: "Test 6 fingerprints: all|rustls,custom(firefox133),chrome(chrome107),safari(safari155) [default: all]",
cli_burst_tls: "Test 6 TLS version: 1.2|1.3 [default: 1.3]",
cli_burst_alpn: "Test 6 ALPN: h2 (offers h2 with the http/1.1 fallback)|http/1.1 (offers http/1.1 only) [default: h2]",
cli_trace: "Test 6 trace: one line per attempt (round, shape, host, status, detail, ms); without a path to stderr, with one to that file [default: off]",
trace_open_failed: "Could not open the trace file {}",
cli_domains: "Path to custom domain list file",
cli_tcp16: "Path to custom TCP16 target file",
cli_ascii: "ASCII-only output for legacy consoles (no Unicode glyphs or borders)",

View file

@ -212,6 +212,8 @@ pub(crate) fn messages() -> Messages {
cli_burst_profiles: "Fingerprint haye test 6: all|rustls,custom(firefox133),chrome(chrome107),safari(safari155) [pishfarz: all]",
cli_burst_tls: "Version-e TLS baraye test 6: 1.2|1.3 [pishfarz: 1.3]",
cli_burst_alpn: "ALPN baraye test 6: h2 (h2 ba bazgasht be http/1.1)|http/1.1 (faghat http/1.1) [pishfarz: h2]",
cli_trace: "Trace baraye test 6: yek khat baraye har talash (dore, fingerPrint, domain, vaziyat, joz'yat, ms); bedun-e masir be stderr, ba masir be an file [pishfarz: khamush]",
trace_open_failed: "File-e trace baz nashod {}",
cli_domains: "Masir-e file-e list-e domain ha",
cli_tcp16: "Masir-e file-e target haye TCP16",
cli_ascii: "Khorooj-e faghat ASCII baraye console haye ghadimi (bedun-e glyph ya border-e Unicode)",

View file

@ -232,6 +232,9 @@ pub struct Messages {
pub cli_burst_profiles: &'static str,
pub cli_burst_tls: &'static str,
pub cli_burst_alpn: &'static str,
pub cli_trace: &'static str,
/// Shown when the `--trace` path cannot be opened; `{}` is `<path>: <error>`.
pub trace_open_failed: &'static str,
pub cli_domains: &'static str,
pub cli_tcp16: &'static str,
pub cli_ascii: &'static str,

View file

@ -219,6 +219,8 @@ pub(crate) fn messages() -> Messages {
cli_burst_profiles: "Fingerprint для теста 6: all|rustls,custom(firefox133),chrome(chrome107),safari(safari155) [по умолчанию: all]",
cli_burst_tls: "Версия TLS для теста 6: 1.2|1.3 [по умолчанию: 1.3]",
cli_burst_alpn: "ALPN для теста 6: h2 (предлагает h2 с откатом на http/1.1)|http/1.1 (только http/1.1) [по умолчанию: h2]",
cli_trace: "Трассировка теста 6: строка на каждую попытку (раунд, отпечаток, домен, статус, деталь, мс); без пути — в stderr, с путём — в указанный файл [по умолчанию: выключено]",
trace_open_failed: "Не удалось открыть файл трассировки {}",
cli_domains: "Путь к файлу со списком доменов",
cli_tcp16: "Путь к файлу целей TCP16",
cli_ascii: "Только ASCII для старых консолей (без Unicode-глифов и рамок)",

View file

@ -219,6 +219,8 @@ pub(crate) fn messages() -> Messages {
cli_burst_profiles: "测试 6 的 Fingerprint: all|rustls,custom(firefox133),chrome(chrome107),safari(safari155) [默认: all]",
cli_burst_tls: "测试 6 的 TLS 版本: 1.2|1.3 [默认: 1.3]",
cli_burst_alpn: "测试 6 的 ALPN: h2(提供 h2 并回退 http/1.1)|http/1.1(仅 http/1.1)[默认: h2]",
cli_trace: "测试 6 追踪: 每次尝试一行(轮次、指纹、域名、状态、详情、毫秒);无路径写入 stderr,有路径写入该文件 [默认: 关闭]",
trace_open_failed: "无法打开追踪文件 {}",
cli_domains: "自定义域名列表文件路径",
cli_tcp16: "自定义 TCP16 目标文件路径",
cli_ascii: "面向旧终端的纯 ASCII 输出(无 Unicode 符号或边框)",

View file

@ -4,7 +4,7 @@
//! share one 1700-line file.
use std::collections::HashSet;
use std::io::IsTerminal;
use std::io::{IsTerminal, Write};
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant};
@ -21,8 +21,8 @@ use crate::i18n::{legend_text, Language, Messages};
use dpi_core::net::fingerprint::TlsFingerprint;
use dpi_core::net::netinfo::{detect_bypass_tools, fetch_public_ips, get_system_dns, is_tun_name};
use dpi_core::probe::burst::{
burst_targets, BurstAlpn, BurstObserver, BurstSettings, BurstTarget, BurstTlsVersion,
BURST_DEFAULT_ATTEMPTS, BURST_DEFAULT_TIMEOUT_SECS,
burst_targets, BurstAlpn, BurstObserver, BurstProfileReport, BurstSettings, BurstTarget,
BurstTlsVersion, BURST_DEFAULT_ATTEMPTS, BURST_DEFAULT_TIMEOUT_SECS,
};
use dpi_core::probe::cymru::{fetch_ip_cymru, IpCymruInfo};
use dpi_core::probe::domains::{
@ -266,6 +266,128 @@ impl BurstObserver for BurstLine {
}
}
/// `--trace`: one line per attempt of test 6, written the moment the attempt
/// exists.
///
/// The line is machine-readable on purpose — a UTC stamp, the round, the shape,
/// the host, the attempt, the status token, the detail code and the milliseconds
/// — so a round that fell over can be read back afterwards (`grep safari
/// trace.log`) or watched live in a second window (`Get-Content -Wait`) while
/// the screen redraws. It carries no translated text, so it reads the same in
/// every language, and it never touches stdout: the frame and the table stay
/// where they were.
struct TraceObserver {
out: std::sync::Mutex<Box<dyn Write + Send>>,
round: std::sync::Mutex<(usize, usize)>,
}
impl TraceObserver {
/// `None` when no trace was asked for, or when the file it named cannot be
/// opened — in which case the run continues untraced and says why.
fn new(target: Option<&str>, msg: &Messages) -> Option<Self> {
let target = target?;
let out: Box<dyn Write + Send> = if target.is_empty() {
Box::new(std::io::stderr())
} else {
match std::fs::File::create(target) {
Ok(file) => Box::new(file),
Err(err) => {
eprintln!(
"{}",
msg.trace_open_failed
.replacen("{}", &format!("{target}: {err}"), 1)
);
return None;
}
}
};
Some(Self { out: std::sync::Mutex::new(out), round: std::sync::Mutex::new((0, 0)) })
}
fn line(&self, text: &str) {
if let Ok(mut out) = self.out.lock() {
// A failed write is not worth stopping a diagnostic run for.
let _ = writeln!(out, "{text}");
let _ = out.flush();
}
}
}
impl BurstObserver for TraceObserver {
fn round_started(&self, fingerprint: TlsFingerprint, index: usize, total: usize, hosts: usize) {
if let Ok(mut round) = self.round.lock() {
*round = (index, total);
}
self.line(&format!(
"{} round {}/{} {} ({}) hosts={hosts}",
utc_clock(),
index + 1,
total,
fingerprint.display_label(),
fingerprint.code()
));
}
fn host_probed(&self, fingerprint: TlsFingerprint, domain: &str, report: &BurstProfileReport) {
let (index, total) = self.round.lock().map(|r| *r).unwrap_or((0, 0));
for (attempt, result) in report.attempts.iter().enumerate() {
self.line(&format!(
"{} r{}/{} {:<8} {:<28} #{:<2} {:<12} {:<40} {:>5} ms",
utc_clock(),
index + 1,
total,
fingerprint.code(),
domain,
attempt + 1,
result.status.as_str(),
result.detail.code(),
result.ms,
));
}
}
}
/// UTC, because a timezone database is not worth carrying for a trace: the lines
/// only have to line up with each other and with the run.
fn utc_clock() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{:02}:{:02}:{:02}Z", (secs / 3600) % 24, (secs / 60) % 60, secs % 60)
}
/// The observers one run may have: the live line when the terminal can take one,
/// and the trace when `--trace` asked for it. Either may be absent, so neither
/// mode has to know about the other.
struct Observers<'a> {
line: Option<&'a BurstLine>,
trace: Option<&'a TraceObserver>,
}
impl BurstObserver for Observers<'_> {
fn round_started(&self, fingerprint: TlsFingerprint, index: usize, total: usize, hosts: usize) {
if let Some(line) = self.line {
line.round_started(fingerprint, index, total, hosts);
}
if let Some(trace) = self.trace {
trace.round_started(fingerprint, index, total, hosts);
}
}
fn host_finished(&self) {
if let Some(line) = self.line {
line.host_finished();
}
}
fn host_probed(&self, fingerprint: TlsFingerprint, domain: &str, report: &BurstProfileReport) {
if let Some(trace) = self.trace {
trace.host_probed(fingerprint, domain, report);
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_test_suite(
tests_str: &str,
@ -734,12 +856,12 @@ pub(crate) async fn run_test_suite(
// live line that names the shape currently on the wire and how far the
// run has got, and nothing else is printed until the table.
let line = BurstLine { live: Arc::clone(&live), msg: *msg };
let observer: &dyn BurstObserver = if args.json || !std::io::stderr().is_terminal() {
&()
} else {
&line
let trace = TraceObserver::new(args.trace.as_deref(), msg);
let observers = Observers {
line: (!args.json && std::io::stderr().is_terminal()).then_some(&line),
trace: trace.as_ref(),
};
let reports = burst_targets(cfg, &targets, settings, concurrency, observer).await;
let reports = burst_targets(cfg, &targets, settings, concurrency, &observers).await;
if !args.json && std::io::stderr().is_terminal() {
live.finish();
}