feat(ui): test 6 is Fingerprint/Сибирская блокировка, with a live line and no summary

* Tests 6 and 7 swap places: the fingerprint burst is 6, the status legend 7.
* The settings screen says "Таймаут в секундах", calls the profile row
  "Fingerprint", and names profiles with the version they reproduce
  ("CHROME 107", not "CHROME") — the same labels the table headers and the CLI
  warnings now use. `token()` stays version-less because it is the round-trip
  name the parsers accept back; `display_label()` is the display form.
* The domain field paints its own state: a grey background while the row is
  only selected, blue while it is being typed into, brackets and pad included.
  The five colors are the 16-color set on purpose — the legacy console
  translator maps exactly those to SetConsoleTextAttribute.
* The burst no longer prints a header block of settings, and it no longer
  prints an Итог row: what it is doing is one live line
  (`Тестируем: CHROME 107 3/4  12/35 · 00:04`) fed by a `BurstObserver` the
  library calls once per profile round and once per finished host. An empty
  summary panel was already suppressed by `render_summary`.
* `standby-rezka.tv` (domains.txt:19, embedded in the binary) turned out to be
  the host that shows why the GREASE key share matters: see the vendor README.
This commit is contained in:
Kirill Minovsky 2026-09-13 21:48:34 +03:00
parent e9894df7ad
commit e6ae7db583
9 changed files with 317 additions and 173 deletions

View file

@ -103,11 +103,11 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
--tcp16 <PATH> Путь к файлу целей TCP16
--ascii Только ASCII для старых консолей (без Unicode-глифов и рамок)
--fingerprint <PROFILE> Профиль отпечатка TLS ClientHello (rustls|custom|chrome|safari). custom — форма Firefox 133, chrome — Chrome 107 / Edge 99-101, safari — Safari 15.5-18.4 из curl-impersonate; все предлагают h2
--burst <N> Тест 7: одновременных рукопожатий за раунд [по умолчанию: 4]
--burst-timeout <SECONDS> Тест 7: таймаут одного рукопожатия, секунды [по умолчанию: 8]
--burst-profiles <LIST> Отпечатки для теста 7: all|rustls,custom(firefox133),chrome(chrome107),safari(safari155) [по умолчанию: all]
--burst-tls <VERSION> Версия TLS для теста 7: 1.2|1.3 [по умолчанию: 1.3]
--burst-alpn <PROTOCOL> ALPN для теста 7: h2 (предлагает h2 с откатом на http/1.1)|http/1.1 (только http/1.1) [по умолчанию: h2]
--burst <N> Fingerprint/Сибирская блокировка (тест 6): одновременных запросов за раунд [по умолчанию: 4]
--burst-timeout <SECONDS> Тест 6: таймаут одного рукопожатия, секунды [по умолчанию: 8]
--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]
-h, --help Показать справку
-V, --version Показать версию
```
@ -128,14 +128,17 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
* `3` — **Доступность CDN и хостингов (тест 16 KB)** (детект сброса соединений после 14–34 КБ данных)
* `4` — **Поиск белых SNI** (перебор рабочих SNI для заблокированных AS: цель отбирается по 16 KB-обрыву, TLS RST и TLS DROP)
* `5` — **Доступность Telegram** (проверка доступности и скорости датацентров DC1–DC5)
* `7` — **Стресс отпечатка (burst)** (N одновременных TLS-рукопожатий на один домен выбранными отпечатками: сколько ответили и что именно перестало отвечать)
* `6` — **Fingerprint/Сибирская блокировка** (N одновременных TLS-рукопожатий на один домен выбранными отпечатками: сколько ответили и что именно перестало отвечать)
* `7` — **Легенда статусов** (справка по вердиктам)
### Тест 7: стресс отпечатка
### Тест 6: Fingerprint/Сибирская блокировка
При выборе теста `7` открывается свой экран настроек: число одновременных рукопожатий, таймаут одного рукопожатия, **версия TLS** (`1.2`/`1.3`), **протокол HTTP в ALPN** (`h2` — как у браузеров, с откатом на `http/1.1`, или `http/1.1` — только он), домен (пусто — весь список, как в тесте `2`) и отпечатки (по умолчанию все). `Q`/`Esc` отменяет тест.
При выборе теста `6` открывается свой экран настроек: число одновременных рукопожатий, таймаут одного рукопожатия **в секундах**, **версия TLS** (`1.2`/`1.3`), **протокол HTTP в ALPN** (`h2` — как у браузеров, с откатом на `http/1.1`, или `http/1.1` — только он), домен (пусто — весь список, как в тесте `2`) и **Fingerprint** (по умолчанию все). Поле домена подсвечивается: серый фон — строка выбрана, синий — в поле идёт ввод. `Q`/`Esc` отменяет тест.
На каждый домен и отпечаток выполняется N одновременных рукопожатий выбранной версии TLS — отдельные TCP-соединения, свежие сессии без resumption. Две оси дают 4 комбинации, и они измеряют разные вещи: `1.3` против `1.2` — реакцию на версию протокола, `h2` против `http/1.1` — реакцию на ALPN (некоторые триггеры срабатывают именно на h2-хелло; JA4 при этом меняет поле ALPN, `h2` → `h1`). В таблице `M/N` по каждому отпечатку (зелёный — ответили все, жёлтый — часть, красный — никто), в колонке «Детали» — самый частый вердикт отказа (`TLS RST`, `TLS DROP`, `SYN DROP`, …). Тест идёт последним: серия рукопожатий может «сжечь» цель на время, и измерения после неё недействительны.
Во время прогона печатается одна живая строка `Тестируем: CHROME 107 3/4 12/35 · 00:04` — какая форма сейчас в эфире, который это раунд из скольких, сколько хостов раунда готово и сколько идёт время. Отдельной шапки с настройками и блок «Итог» тест не выводит: отпечаток с версией и так виден в колонках таблицы, а пустой итог не рисуется.
Из неинтерактивного запуска настройки задаются флагами `--burst`, `--burst-timeout`, `--burst-profiles`, `--burst-tls`, `--burst-alpn`; в `--json` результат лежит в аддитивном ключе `results.fingerprint_burst` (вместе с `tls` и `alpn` этого прогона).
---

View file

@ -166,10 +166,9 @@ pub struct Messages {
pub burst_field_http: &'static str,
pub burst_field_profiles: &'static str,
pub burst_profiles_all: &'static str,
pub burst_title: &'static str,
pub burst_attempts_label: &'static str,
pub burst_summary_label: &'static str,
pub burst_summary_value: &'static str,
/// Word in front of the profile the burst is firing right now
/// (`Тестируем: CHROME 107 2/4`).
pub burst_testing: &'static str,
pub fingerprint_label: &'static str,
pub fingerprint_note: &'static str,
pub lang: Language,
@ -398,7 +397,8 @@ impl Messages {
'3' => self.menu_test_tcp,
'4' => self.menu_test_sni,
'5' => self.menu_test_telegram,
'6' => self.menu_test_legend,
'6' => self.menu_test_burst,
'7' => self.menu_test_legend,
_ => "",
}
}
@ -443,21 +443,18 @@ pub fn get_messages(lang: Language) -> Messages {
menu_test_sni: "Whitelist SNI discovery",
menu_test_telegram: "Telegram availability",
menu_test_legend: "Status legend (help)",
menu_test_burst: "Fingerprint stress (burst)",
burst_settings_title: "Test 7 settings",
menu_test_burst: "Fingerprint/Siberian blocking",
burst_settings_title: "Test 6 settings",
burst_field_attempts: "Simultaneous requests",
burst_field_timeout: "Timeout, s",
burst_field_timeout: "Timeout in seconds",
burst_field_domain: "Domain to test",
burst_domain_placeholder: "Press → to start typing",
burst_domain_default_hint: "By default — all domains",
burst_field_tls: "TLS version",
burst_field_http: "HTTP protocol",
burst_field_profiles: "Fingerprints",
burst_field_profiles: "Fingerprint",
burst_profiles_all: "all",
burst_title: "Simultaneous handshakes (fingerprint stress)",
burst_attempts_label: "Requests at once",
burst_summary_label: "Stress",
burst_summary_value: "{} of {} handshakes answered · hosts with losses: {}",
burst_testing: "Testing",
fingerprint_label: "Fingerprint",
fingerprint_note: "Each profile reproduces one pinned curl-impersonate shape: FIREFOX = firefox133, CHROME = chrome107 (also edge 99-101), SAFARI = safari155. All offer h2 and http/1.1 as browsers do. None is byte-for-byte a real browser — deeper fingerprinting (HTTP/2 settings, record timing) can still distinguish them.",
lang: Language::En,
@ -633,11 +630,11 @@ pub fn get_messages(lang: Language) -> Messages {
cli_concurrency: "Concurrency limit for parallel requests",
cli_domain: "Specific domain(s) to test (repeat the flag: -d vk.com -d ya.ru)",
cli_output: "Output file path to save report",
cli_burst: "Fingerprint stress (test 7): simultaneous requests per round [default: 4]",
cli_burst_timeout: "Test 7: timeout of one handshake, seconds [default: 8]",
cli_burst_profiles: "Test 7 fingerprints: all|rustls,custom(firefox133),chrome(chrome107),safari(safari155) [default: all]",
cli_burst_tls: "Test 7 TLS version: 1.2|1.3 [default: 1.3]",
cli_burst_alpn: "Test 7 ALPN: h2 (offers h2 with the http/1.1 fallback)|http/1.1 (offers http/1.1 only) [default: h2]",
cli_burst: "Fingerprint stress (test 6): simultaneous requests per round [default: 4]",
cli_burst_timeout: "Test 6: timeout of one handshake, seconds [default: 8]",
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_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)",
@ -680,22 +677,19 @@ pub fn get_messages(lang: Language) -> Messages {
menu_test_sni: "Поиск белых SNI",
menu_test_telegram: "Доступность Telegram",
menu_test_legend: "Легенда статусов (справка)",
menu_test_burst: "Стресс отпечатка (burst)",
burst_settings_title: "Настройки теста 7",
menu_test_burst: "Fingerprint/Сибирская блокировка",
burst_settings_title: "Настройки теста 6",
burst_field_attempts: "Одновременных запросов",
burst_field_timeout: "Таймаут, с",
burst_field_timeout: "Таймаут в секундах",
burst_field_domain: "Домен для тестирования",
burst_domain_placeholder: "Переключитесь для ввода",
burst_domain_default_hint: "По умолчанию — все домены",
burst_field_tls: "Версия TLS",
burst_field_http: "Протокол HTTP",
burst_field_profiles: "Отпечатки",
burst_field_profiles: "Fingerprint",
burst_profiles_all: "все",
burst_title: "Одновременные рукопожатия (стресс отпечатка)",
burst_attempts_label: "Запросов сразу",
burst_summary_label: "Стресс",
burst_summary_value: "{} из {} рукопожатий ответили · доменов с потерями: {}",
fingerprint_label: "Отпечаток TLS",
burst_testing: "Тестируем",
fingerprint_label: "Fingerprint",
fingerprint_note: "Каждый профиль воспроизводит одну закреплённую форму curl-impersonate: FIREFOX = firefox133, CHROME = chrome107 (и edge 99-101), SAFARI = safari155. Все предлагают h2 и http/1.1, как браузеры. Ни один не является побайтовой копией настоящего браузера — более глубокий фингерпринтинг (настройки HTTP/2, тайминги записей) всё ещё может их отличить.",
lang: Language::Ru,
replies_label: "ответов",
@ -870,11 +864,11 @@ pub fn get_messages(lang: Language) -> Messages {
cli_concurrency: "Лимит параллельных запросов",
cli_domain: "Конкретные домены для проверки (флаг можно повторять: -d vk.com -d ya.ru)",
cli_output: "Путь к файлу отчёта",
cli_burst: "Стресс отпечатка (тест 7): одновременных запросов за раунд [по умолчанию: 4]",
cli_burst_timeout: "Тест 7: таймаут одного рукопожатия, секунды [по умолчанию: 8]",
cli_burst_profiles: "Отпечатки для теста 7: all|rustls,custom(firefox133),chrome(chrome107),safari(safari155) [по умолчанию: all]",
cli_burst_tls: "Версия TLS для теста 7: 1.2|1.3 [по умолчанию: 1.3]",
cli_burst_alpn: "ALPN для теста 7: h2 (предлагает h2 с откатом на http/1.1)|http/1.1 (только http/1.1) [по умолчанию: h2]",
cli_burst: "Fingerprint/Сибирская блокировка (тест 6): одновременных запросов за раунд [по умолчанию: 4]",
cli_burst_timeout: "Тест 6: таймаут одного рукопожатия, секунды [по умолчанию: 8]",
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_domains: "Путь к файлу со списком доменов",
cli_tcp16: "Путь к файлу целей TCP16",
cli_ascii: "Только ASCII для старых консолей (без Unicode-глифов и рамок)",
@ -917,22 +911,19 @@ pub fn get_messages(lang: Language) -> Messages {
menu_test_sni: "发现白名单 SNI",
menu_test_telegram: "Telegram 可用性",
menu_test_legend: "状态图例 (帮助)",
menu_test_burst: "指纹压力测试 (burst)",
burst_settings_title: "测试 7 设置",
menu_test_burst: "Fingerprint/西伯利亚封锁",
burst_settings_title: "测试 6 设置",
burst_field_attempts: "同时请求数",
burst_field_timeout: "超时, 秒",
burst_field_timeout: "超时秒数",
burst_field_domain: "要测试的域名",
burst_domain_placeholder: "按 → 开始输入",
burst_domain_default_hint: "默认 — 全部域名",
burst_field_tls: "TLS 版本",
burst_field_http: "HTTP 协议",
burst_field_profiles: "指纹",
burst_field_profiles: "Fingerprint",
burst_profiles_all: "全部",
burst_title: "同时握手 (指纹压力)",
burst_attempts_label: "并发请求",
burst_summary_label: "压力",
burst_summary_value: "{} / {} 次握手得到响应 · 有丢包的域名: {}",
fingerprint_label: "TLS 指纹",
burst_testing: "正在测试",
fingerprint_label: "Fingerprint",
fingerprint_note: "每个配置复现一个固定的 curl-impersonate 形态:FIREFOX = firefox133,CHROME = chrome107(以及 edge 99-101),SAFARI = safari155。三者都像浏览器一样提供 h2 与 http/1.1。它们均非真实浏览器的逐字节复制,更深层的指纹识别(HTTP/2 设置、记录时序)仍可能将它们区分开。",
lang: Language::Zh,
replies_label: "响应",
@ -1107,11 +1098,11 @@ pub fn get_messages(lang: Language) -> Messages {
cli_concurrency: "并行请求的并发上限",
cli_domain: "要测试的指定域名(可重复指定: -d vk.com -d ya.ru)",
cli_output: "保存报告的输出文件路径",
cli_burst: "指纹压力测试 (测试 7): 每轮同时请求数 [默认: 4]",
cli_burst_timeout: "测试 7: 单次握手超时, 秒 [默认: 8]",
cli_burst_profiles: "测试 7 的指纹: all|rustls,custom(firefox133),chrome(chrome107),safari(safari155) [默认: all]",
cli_burst_tls: "测试 7 的 TLS 版本: 1.2|1.3 [默认: 1.3]",
cli_burst_alpn: "测试 7 的 ALPN: h2(提供 h2 并回退 http/1.1)|http/1.1(仅 http/1.1)[默认: h2]",
cli_burst: "Fingerprint/西伯利亚封锁 (测试 6): 每轮同时请求数 [默认: 4]",
cli_burst_timeout: "测试 6: 单次握手超时, 秒 [默认: 8]",
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_domains: "自定义域名列表文件路径",
cli_tcp16: "自定义 TCP16 目标文件路径",
cli_ascii: "面向旧终端的纯 ASCII 输出(无 Unicode 符号或边框)",
@ -1154,21 +1145,18 @@ pub fn get_messages(lang: Language) -> Messages {
menu_test_sni: "Jost o juye SNI haye whitelist",
menu_test_telegram: "Dastrasi be Telegram",
menu_test_legend: "Rahnemaye barname",
menu_test_burst: "Stress-e fingerprint (burst)",
burst_settings_title: "Tanzimat-e test 7",
menu_test_burst: "Fingerprint/Sibir blocking",
burst_settings_title: "Tanzimat-e test 6",
burst_field_attempts: "Darkhast haye hamzaman",
burst_field_timeout: "Timeout, sanie",
burst_field_timeout: "Timeout be sanie",
burst_field_domain: "Domain baraye test",
burst_domain_placeholder: "Baraye neveshtan -> bezanid",
burst_domain_default_hint: "Pishfarz - hame-ye domain ha",
burst_field_tls: "Version-e TLS",
burst_field_http: "Protocol-e HTTP",
burst_field_profiles: "Fingerprint ha",
burst_field_profiles: "Fingerprint",
burst_profiles_all: "hame",
burst_title: "Mosafehe haye hamzaman (stress-e fingerprint)",
burst_attempts_label: "Darkhast dar yek bar",
burst_summary_label: "Stress",
burst_summary_value: "{} az {} mosafehe javab dad | domain ba ziyan: {}",
burst_testing: "Dar hal-e test",
fingerprint_label: "Fingerprint",
fingerprint_note: "Har profile yek shape-e pin-shode-ye curl-impersonate ra bazsazi mikonad: FIREFOX = firefox133, CHROME = chrome107 (va edge 99-101), SAFARI = safari155. Hame h2 va http/1.1 ra mesl-e browser ha pishnahad mikonand. Hich yek copy-e byte-be-byte-e yek browser-e vaghe-i nist - fingerprinting-e amigh-tar (HTTP/2 settings, record timing) hanuz mitavanad anha ra tafzil konad.",
lang: Language::Fa,
@ -1337,11 +1325,11 @@ pub fn get_messages(lang: Language) -> Messages {
cli_concurrency: "Hadde aksar-e darkhast haye hamzaman",
cli_domain: "Domain haye khass baraye barresi (mitavanid tekrar konid: -d vk.com -d ya.ru)",
cli_output: "Masir-e file baraye zakhire-ye report",
cli_burst: "Stress-e fingerprint (test 7): darkhast haye hamzaman dar har round [pishfarz: 4]",
cli_burst_timeout: "Test 7: timeout-e yek mosafehe, sanie [pishfarz: 8]",
cli_burst_profiles: "Fingerprint haye test 7: all|rustls,custom(firefox133),chrome(chrome107),safari(safari155) [pishfarz: all]",
cli_burst_tls: "Version-e TLS baraye test 7: 1.2|1.3 [pishfarz: 1.3]",
cli_burst_alpn: "ALPN baraye test 7: h2 (h2 ba bazgasht be http/1.1)|http/1.1 (faghat http/1.1) [pishfarz: h2]",
cli_burst: "Fingerprint/Sibir blocking (test 6): darkhast haye hamzaman dar har round [pishfarz: 4]",
cli_burst_timeout: "Test 6: timeout-e yek mosafehe, sanie [pishfarz: 8]",
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_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)",
@ -1708,12 +1696,13 @@ mod tests {
('3', msg.menu_test_tcp),
('4', msg.menu_test_sni),
('5', msg.menu_test_telegram),
('6', msg.menu_test_legend),
('6', msg.menu_test_burst),
('7', msg.menu_test_legend),
] {
assert!(!field.is_empty());
assert_eq!(msg.menu_test_label(d), field);
}
assert_eq!(msg.menu_test_label('7'), "");
assert_eq!(msg.menu_test_label('8'), "");
}
}

View file

@ -68,6 +68,11 @@ pub enum TlsFingerprint {
struct Spec {
id: TlsFingerprint,
token: &'static str,
/// Token plus the version the shape reproduces, for the places that have
/// room for it (burst table headers, the settings screen, the live line).
/// The bare `token` stays version-less because it is also the round-trip
/// name the parsers accept back.
label: &'static str,
code: &'static str,
}
@ -75,21 +80,25 @@ const SPECS: [Spec; 4] = [
Spec {
id: TlsFingerprint::Rustls,
token: "RUSTLS",
label: "RUSTLS",
code: "rustls",
},
Spec {
id: TlsFingerprint::Custom,
token: "FIREFOX",
label: "FIREFOX 133",
code: "custom",
},
Spec {
id: TlsFingerprint::Chrome,
token: "CHROME",
label: "CHROME 107",
code: "chrome",
},
Spec {
id: TlsFingerprint::Safari,
token: "SAFARI",
label: "SAFARI 155",
code: "safari",
},
];
@ -107,6 +116,13 @@ impl TlsFingerprint {
self.spec().token
}
/// Token plus the version it reproduces ("CHROME 107"). Latin like `token`
/// and never translated (rule 4): a table header or a progress line that
/// says only "CHROME" hides which shape was actually sent.
pub fn display_label(self) -> &'static str {
self.spec().label
}
/// Stable value for machine JSON and the config file.
pub fn code(self) -> &'static str {
self.spec().code
@ -601,6 +617,21 @@ mod tests {
const SAFARI_155_JA4: &str = "t13d2014h2_a09f3c656075_14788d8d241b";
const FIREFOX_133_JA4_LESS_ECH: &str = "t13d1715h2_5b57614c22b0_8fb63dbc839a";
/// The version-bearing label is display only: it must not collide with a
/// parser name, and every profile whose shape is a pinned curl version has
/// to say which one.
#[test]
fn display_labels_name_the_pinned_version() {
assert_eq!(TlsFingerprint::Custom.display_label(), "FIREFOX 133");
assert_eq!(TlsFingerprint::Chrome.display_label(), "CHROME 107");
assert_eq!(TlsFingerprint::Safari.display_label(), "SAFARI 155");
assert_eq!(TlsFingerprint::Rustls.display_label(), "RUSTLS");
for fp in TlsFingerprint::ALL {
assert!(fp.display_label().starts_with(fp.token()), "{fp:?}");
assert!(fp.display_label().is_ascii(), "{fp:?}");
}
}
#[test]
fn fingerprint_tokens_are_stable() {
assert_eq!(TlsFingerprint::Rustls.token(), "RUSTLS");

View file

@ -297,6 +297,26 @@ impl BurstReport {
}
}
/// Follows a burst round by round. The runner draws its live line from this;
/// the no-op implementation below lets tests and library callers ignore it.
pub trait BurstObserver {
/// A profile round starts: `index` (0-based) of `total` shapes is about to
/// be fired at `hosts` resolved targets.
fn round_started(
&self,
_fingerprint: TlsFingerprint,
_index: usize,
_total: usize,
_hosts: usize,
) {
}
/// One host finished its round, answered or not.
fn host_finished(&self) {}
}
impl BurstObserver for () {}
/// Fires every profile at every target, one profile at a time.
///
/// The outer loop is the profile: all targets are probed with the first shape,
@ -310,6 +330,7 @@ pub async fn burst_targets(
targets: &[BurstTarget],
settings: &BurstSettings,
concurrency: usize,
observer: &dyn BurstObserver,
) -> Vec<BurstReport> {
let gate = Arc::new(Semaphore::new(concurrency.max(1)));
let addresses = resolve_targets(cfg, targets, &gate).await;
@ -323,7 +344,11 @@ pub async fn burst_targets(
})
.collect();
for &fingerprint in &settings.profiles {
// Unresolved hosts are not probed, so they must not count towards the
// round's total either.
let hosts = addresses.iter().filter(|address| address.is_some()).count();
for (index, &fingerprint) in settings.profiles.iter().enumerate() {
observer.round_started(fingerprint, index, settings.profiles.len(), hosts);
let mut rounds = JoinSet::new();
for (index, target) in targets.iter().enumerate() {
let Some(address) = addresses[index] else {
@ -339,6 +364,7 @@ pub async fn burst_targets(
});
}
while let Some(joined) = rounds.join_next().await {
observer.host_finished();
if let Ok((index, report)) = joined {
reports[index].profiles.push(report);
}
@ -734,7 +760,24 @@ mod tests {
BurstTarget { domain: "fast.example".to_string(), address: Some(fast_addr) },
];
let plan = settings(2, 4000, vec![TlsFingerprint::Rustls, TlsFingerprint::Chrome]);
let reports = burst_targets(&AppConfig::default(), &targets, &plan, 4).await;
// The live line the runner draws comes from the observer: a round must
// be announced once, in the order the profiles are fired, or the line
// would name a shape that is not on the wire.
#[derive(Default)]
struct Rounds {
started: std::sync::Mutex<Vec<(TlsFingerprint, usize, usize, usize)>>,
finished: std::sync::atomic::AtomicUsize,
}
impl BurstObserver for Rounds {
fn round_started(&self, fp: TlsFingerprint, index: usize, total: usize, hosts: usize) {
self.started.lock().expect("lock").push((fp, index, total, hosts));
}
fn host_finished(&self) {
self.finished.fetch_add(1, Ordering::SeqCst);
}
}
let rounds = Rounds::default();
let reports = burst_targets(&AppConfig::default(), &targets, &plan, 4, &rounds).await;
slow.await.expect("slow stand");
fast.await.expect("fast stand");
@ -745,6 +788,14 @@ mod tests {
witness.unexpected.lock()
);
assert!(!witness.overlapped.load(Ordering::SeqCst), "two shapes were in flight at the same time");
assert_eq!(
*rounds.started.lock().expect("lock"),
vec![
(TlsFingerprint::Rustls, 0, 2, 2),
(TlsFingerprint::Chrome, 1, 2, 2),
]
);
assert_eq!(rounds.finished.load(Ordering::SeqCst), 4, "one tick per host per round");
assert_eq!(reports.len(), 2);
for report in &reports {
let shapes: Vec<TlsFingerprint> = report.profiles.iter().map(|p| p.fingerprint).collect();

View file

@ -22,15 +22,15 @@ pub struct CliArgs {
pub tcp16: Option<String>,
pub ascii: bool,
pub fingerprint: Option<String>,
/// Test 7: simultaneous handshakes per round.
/// Test 6: simultaneous handshakes per round.
pub burst: Option<usize>,
/// Test 7: per-handshake timeout in seconds.
/// Test 6: per-handshake timeout in seconds.
pub burst_timeout: Option<u64>,
/// Test 7: `all` or a comma list of profile codes/names.
/// Test 6: `all` or a comma list of profile codes/names.
pub burst_profiles: Option<String>,
/// Test 7: pinned TLS version, `1.2` or `1.3`.
/// Test 6: pinned TLS version, `1.2` or `1.3`.
pub burst_tls: Option<String>,
/// Test 7: ALPN to offer, `h2` or `http/1.1`.
/// Test 6: ALPN to offer, `h2` or `http/1.1`.
pub burst_alpn: Option<String>,
}

View file

@ -25,7 +25,8 @@ use dpi_core::probe::domains::{
};
use dpi_core::probe::telegram::run_telegram_full;
use dpi_core::probe::burst::{
burst_targets, BurstAlpn, BurstSettings, BurstTarget, BurstTlsVersion, BURST_DEFAULT_ATTEMPTS,
burst_targets, BurstAlpn, BurstObserver, BurstSettings, BurstTarget, BurstTlsVersion,
BURST_DEFAULT_ATTEMPTS,
BURST_DEFAULT_TIMEOUT_SECS,
};
use dpi_core::probe::whitelist::run_whitelist_sni;
@ -49,8 +50,8 @@ use render::{
SummaryData, TcpRow,
};
/// Splits a test selection string into per-test flags (mirrors `_selection_flags`).
/// Tests: 0 netinfo, 1 DNS, 2 domains, 3 TCP, 4 white-SNI, 5 Telegram, 6 legend,
/// 7 fingerprint stress (burst).
/// Tests: 0 netinfo, 1 DNS, 2 domains, 3 TCP, 4 white-SNI, 5 Telegram,
/// 6 fingerprint/burst, 7 legend.
fn selection_flags(selection: &str) -> (bool, bool, bool, bool, bool, bool, bool, bool, bool) {
let has = |c: char| selection.contains(c);
let net = has('0');
@ -59,8 +60,8 @@ fn selection_flags(selection: &str) -> (bool, bool, bool, bool, bool, bool, bool
let tcp = has('3');
let sni = has('4');
let tg = has('5');
let burst = has('7');
let legend = has('6');
let burst = has('6');
let legend = has('7');
let only_legend = legend && !(net || dns || dom || tcp || sni || tg || burst);
(net, dns, dom, tcp, sni, tg, burst, legend, only_legend)
}
@ -650,14 +651,14 @@ async fn main() {
let mut selection = tests_str.clone();
loop {
// Test 7 is destructive for its targets, so it asks for its own settings
// Test 6 is destructive for its targets, so it asks for its own settings
// screen before it runs instead of starting with guesses: cancelling the
// screen drops test 7 from the selection and runs the rest. The screen
// belongs to the test itself, so an explicit `-t 7` gets it too — but
// screen drops test 6 from the selection and runs the rest. The screen
// belongs to the test itself, so an explicit `-t 6` gets it too — but
// only on a terminal at both ends: with stdin or stdout redirected there
// is nobody to answer the screen, and the CLI flags stand in for it.
let settings_screen = !args.json
&& selection.contains('7')
&& selection.contains('6')
&& std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
&& tui_available();
@ -674,9 +675,9 @@ async fn main() {
}
}
None => {
selection = selection.replace('7', "");
selection = selection.replace('6', "");
if selection.is_empty() {
// Test 7 was the only selection and its settings were
// Test 6 was the only selection and its settings were
// cancelled: leave instead of printing an empty report.
println_out("");
return;
@ -843,13 +844,13 @@ fn timeout_family() -> NetFamilyInfo {
}
}
/// Everything test 7 needs: the burst shape and the hosts to fire it at.
/// Everything test 6 needs: the burst shape and the hosts to fire it at.
struct BurstPlan {
settings: BurstSettings,
targets: Vec<String>,
}
/// Reports an unknown test 7 axis value (TLS version, ALPN) and keeps the
/// Reports an unknown test 6 axis value (TLS version, ALPN) and keeps the
/// default, the way an unknown profile name is reported rather than swapped
/// silently.
fn warn_unknown_axis(msg: &Messages, args: &CliArgs, flag: &str, value: &str, fallback: &str) {
@ -865,7 +866,7 @@ fn warn_unknown_axis(msg: &Messages, args: &CliArgs, flag: &str, value: &str, fa
);
}
/// Builds test 7's plan from the CLI. An unknown profile name is reported (the
/// Builds test 6's plan from the CLI. An unknown profile name is reported (the
/// test then runs the profiles it did understand) rather than silently swapped
/// for a different set.
fn burst_plan_from_cli(args: &CliArgs, domains: &[String], msg: &Messages) -> BurstPlan {
@ -875,7 +876,8 @@ fn burst_plan_from_cli(args: &CliArgs, domains: &[String], msg: &Messages) -> Bu
};
if !args.json {
for token in unknown {
let fallback = profiles.iter().map(|f| f.token()).collect::<Vec<_>>().join(", ");
let fallback =
profiles.iter().map(|f| f.display_label()).collect::<Vec<_>>().join(", ");
eprintln!(
"{}",
msg.warn_unknown_fingerprint.replacen("{}", &token, 1).replacen("{}", &fallback, 1)
@ -913,6 +915,34 @@ fn burst_plan_from_cli(args: &CliArgs, domains: &[String], msg: &Messages) -> Bu
BurstPlan { settings, targets }
}
/// Draws the burst's live line: the shape on the wire right now, which round
/// that is of how many, how many hosts of the round are done, and the clock.
struct BurstLine {
live: Arc<LiveProgress>,
msg: Messages,
}
impl BurstObserver for BurstLine {
fn round_started(&self, fingerprint: TlsFingerprint, index: usize, total: usize, hosts: usize) {
// `LiveProgress` resets its clock on every `set`, so the timer reads as
// the round's elapsed time rather than the whole run's.
self.live.set(
format!(
"{}: {} {}/{}",
self.msg.burst_testing,
fingerprint.display_label(),
index + 1,
total
),
hosts,
);
}
fn host_finished(&self) {
self.live.tick();
}
}
#[allow(clippy::too_many_arguments)]
async fn run_test_suite(
tests_str: &str,
@ -990,7 +1020,6 @@ async fn run_test_suite(
let mut dom_stats = None;
let mut tcp_summary = None;
let mut tg_full = None;
let mut burst_summary = None;
// ── Test 0: network & system ──
if run_net {
@ -1373,49 +1402,29 @@ async fn run_test_suite(
tg_full = Some(rep);
}
// ── Test 7: fingerprint stress (simultaneous handshakes) ──
// ── Test 6: fingerprint / Siberian blocking (simultaneous handshakes) ──
if run_burst {
let settings = &burst.settings;
if !args.json {
let profile_label = if settings.profiles.len() == TlsFingerprint::ALL.len() {
msg.burst_profiles_all.to_string()
} else {
settings.profiles.iter().map(|f| f.token()).collect::<Vec<_>>().join(", ")
};
emitter.emit(&format!(
"\n{} {}: {} | {}: {} | {}: {}s | {}: {} | {}: {} | {}: {}\n\n",
msg.burst_title,
msg.targets_label,
burst.targets.len(),
msg.burst_attempts_label,
settings.attempts,
msg.timeout_label,
settings.timeout.as_secs(),
msg.burst_field_tls.trim_end_matches(':'),
settings.tls.token(),
msg.burst_field_http.trim_end_matches(':'),
settings.alpn.token(),
msg.burst_field_profiles.trim_end_matches(':'),
profile_label,
));
}
let spinner = (!args.json).then(|| Spinner::start(msg.burst_title));
// Profile-major: `burst_targets` probes every target with the first
// shape to the end before the next shape starts, so a block one shape
// triggers can never be read as the other's result. The simultaneity the
// test measures is *within* a host (its N handshakes all leave together);
// hosts inside one shape overlap like any other phase of the suite.
let targets: Vec<BurstTarget> = burst.targets.iter().map(BurstTarget::new).collect();
let reports = burst_targets(cfg, &targets, settings, concurrency).await;
if let Some(spinner) = spinner {
spinner.finish();
// No banner and no header block: the test says what it is doing on one
// 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 reports = burst_targets(cfg, &targets, settings, concurrency, observer).await;
if !args.json && std::io::stderr().is_terminal() {
live.finish();
}
let answered: usize = reports.iter().map(|r| r.answered()).sum();
let total: usize = reports.iter().map(|r| r.total()).sum();
let lossy = reports.iter().filter(|r| r.has_losses()).count();
burst_summary = Some((answered, total, lossy));
if !args.json {
emitter.emit(&render_burst_table(&reports, settings, msg));
} else {
@ -1459,7 +1468,7 @@ async fn run_test_suite(
}
}
// ── Test 6: legend ──
// ── Test 7: legend ──
if run_legend && !args.json {
print_out(&legend_text(lang, msg));
}
@ -1472,7 +1481,6 @@ async fn run_test_suite(
dns: dns_stats.as_ref(),
domains: dom_stats.as_ref(),
tcp: tcp_summary,
burst: burst_summary,
run_telegram: run_tg,
telegram: tg_full.as_ref(),
},
@ -1543,11 +1551,17 @@ mod tests {
assert!(run_leg);
assert!(!only_leg);
let (_, _, _, _, _, _, _, run_leg, only_leg) = selection_flags("6");
// The legend is test 7 now, and the fingerprint burst test 6.
let (_, _, _, _, _, _, run_burst, run_leg, only_leg) = selection_flags("6");
assert!(run_burst);
assert!(!run_leg);
assert!(!only_leg);
let (_, _, _, _, _, _, _, run_leg, only_leg) = selection_flags("7");
assert!(run_leg);
assert!(only_leg);
let (run_net, _, _, _, _, _, _, run_leg, only_leg) = selection_flags("06");
let (run_net, _, _, _, _, _, _, run_leg, only_leg) = selection_flags("07");
assert!(run_net);
assert!(run_leg);
assert!(!only_leg);

View file

@ -599,8 +599,8 @@ fn get_test_options(msg: &Messages) -> [(char, &'static str); 8] {
('3', msg.menu_test_tcp),
('4', msg.menu_test_sni),
('5', msg.menu_test_telegram),
('6', msg.menu_test_legend),
('7', msg.menu_test_burst),
('6', msg.menu_test_burst),
('7', msg.menu_test_legend),
]
}
@ -624,6 +624,18 @@ const BURST_ROW_PROFILES: usize = 5;
const BURST_LABEL_WIDTH: usize = 24;
/// Inner cells of the domain input box (`[ ` … ` ]` is drawn around them).
const BURST_INPUT_WIDTH: usize = 34;
/// Background of the domain input box while its row is selected but not being
/// typed into, and while it is: grey reads as "a field you can open", the blue
/// as "the keyboard goes here". The text colors keep the two states apart on a
/// light console theme too. All five are the 16-colour set on purpose: the
/// legacy console translator maps exactly those to `SetConsoleTextAttribute`,
/// while a 256-colour code would leave a Windows 7/8 console with no background
/// at all.
const IDLE_BG: &str = "\x1b[100m";
const IDLE_FG: &str = "\x1b[37m";
const TYPED_FG: &str = "\x1b[97m";
const EDITING_BG: &str = "\x1b[44m";
const EDITING_FG: &str = "\x1b[97m";
/// The last `width` characters of the text: the caret sits at the end, so a long
/// host keeps its end in view rather than its beginning.
@ -636,12 +648,12 @@ fn tail_of(text: &str, width: usize) -> String {
}
}
/// Test 7's own screen: how many handshakes at once, how long each may take,
/// Test 6's own screen: how many handshakes at once, how long each may take,
/// which host, and which ClientHello profiles.
///
/// Returns `None` when the user cancels (Q/Esc/Ctrl-C): the caller then skips
/// test 7 instead of running it with guesses. The screen is only shown for an
/// interactive run; a piped `-t 7` takes the values from the CLI.
/// test 6 instead of running it with guesses. The screen is only shown for an
/// interactive run; a piped `-t 6` takes the values from the CLI.
pub async fn burst_settings_menu(
lang: Language,
initial: &BurstSettings,
@ -852,27 +864,29 @@ fn burst_settings_rows(
lines.push(field(BURST_ROW_TLS, msg.burst_field_tls, steer(BURST_ROW_TLS, tls.token().to_string())));
lines.push(field(BURST_ROW_HTTP, msg.burst_field_http, steer(BURST_ROW_HTTP, alpn.token().to_string())));
// Domain: a grey input box. Right makes it active (caret, letters become
// text), Left returns it to grey; what an empty field means is spelled out
// on the line below it.
// Domain: an input box whose own background carries the state — dark grey
// while the row is only selected (letters are still hotkeys), blue while it
// is being typed into (letters become text). The brackets and the pad are
// painted with the same background, so the box reads as one field instead of
// a color behind some text.
let domain_value = {
let placeholder = label(msg.burst_domain_placeholder);
let (inner, visible) = if editing {
let (background, foreground, inner, visible) = if editing {
let shown = tail_of(text, BURST_INPUT_WIDTH - 1);
let visible = shown.chars().count() + 1;
(format!("\x1b[1;36m{}\x1b[0m\x1b[1;33m▏\x1b[0m", shown), visible)
(EDITING_BG, EDITING_FG, format!("{}\x1b[1;33m▏", shown), visible)
} else if text.is_empty() {
let placeholder = label(msg.burst_domain_placeholder);
let visible = placeholder.chars().count();
(format!("\x1b[2m{}\x1b[0m", placeholder), visible)
(IDLE_BG, IDLE_FG, placeholder, visible)
} else {
let shown = tail_of(text, BURST_INPUT_WIDTH);
let visible = shown.chars().count();
(format!("\x1b[2m{}\x1b[0m", shown), visible)
(IDLE_BG, TYPED_FG, shown, visible)
};
let brackets = if editing { "\x1b[1;33m" } else { "\x1b[2m" };
format!(
"{}[ \x1b[0m{}\x1b[2m{} ]\x1b[0m",
brackets,
"{}{}[ {}{} ]\x1b[0m",
background,
foreground,
inner,
" ".repeat(BURST_INPUT_WIDTH.saturating_sub(visible))
)
@ -894,13 +908,14 @@ fn burst_settings_rows(
let name = if index == 0 {
label(msg.burst_profiles_all)
} else {
// Canonical tokens, like the table headers: the cycler names the
// profile the run will actually use (rule 4, never translated).
TlsFingerprint::ALL[index - 1].token().to_string()
// Latin with the pinned version, like the table headers: the
// cycler names the exact shape the run will use (rule 4, never
// translated).
TlsFingerprint::ALL[index - 1].display_label().to_string()
};
format!("{} \x1b[2m[{}/{}]\x1b[0m", name, index + 1, PROFILE_CHOICES)
}
None => profiles.iter().map(|f| f.token()).collect::<Vec<_>>().join(", "),
None => profiles.iter().map(|f| f.display_label()).collect::<Vec<_>>().join(", "),
};
lines.push(field(BURST_ROW_PROFILES, msg.burst_field_profiles, profile_value));
let lines: Vec<String> = lines.into_iter().map(|l| asc(&l)).collect();
@ -1036,12 +1051,55 @@ mod tests {
assert!(!joined.contains("Переключитесь для ввода"), "{joined}");
// A single-profile selection is what the cycler shows after a press: the
// position counts the `all` entry, so CHROME is the fourth of five.
// position counts the `all` entry, so CHROME is the fourth of five — and
// it is named with the version it reproduces, not with the bare family.
let chrome = [TlsFingerprint::Chrome];
let single = burst_settings_rows(
&msg, Language::Ru, 3, 4, 8, BurstTlsVersion::Tls12, BurstAlpn::Http2, "", false, &chrome, profile_index_of(&chrome), 35,
);
assert!(strip_ansi(&single.join("\n")).contains("CHROME [4/5]"));
assert!(strip_ansi(&single.join("\n")).contains("CHROME 107 [4/5]"));
}
/// The domain box announces its state with its own background — grey while
/// the row is only selected, blue while it is being typed into — and the
/// brackets and pad carry it too, so the box reads as one field. A pair of
/// screenshots is not something a test can hold, so the escapes are.
#[test]
fn burst_domain_box_paints_its_state() {
let msg = get_messages(Language::Ru);
let all = vec![TlsFingerprint::Chrome];
let box_row = |editing: bool, text: &str| {
burst_settings_rows(
&msg,
Language::Ru,
BURST_ROW_DOMAIN,
4,
8,
BurstTlsVersion::Tls13,
BurstAlpn::Http2,
text,
editing,
&all,
Some(0),
35,
)
.into_iter()
.find(|row| row.contains("[ ") && row.contains(" ]"))
.expect("domain input box")
};
let idle = box_row(false, "");
assert!(idle.contains(IDLE_BG) && idle.contains(IDLE_FG), "{idle:?}");
assert!(!idle.contains(EDITING_BG), "a selected row is not an open field: {idle:?}");
let typed = box_row(false, "www.google.com");
assert!(typed.contains(IDLE_BG) && typed.contains(TYPED_FG), "{typed:?}");
assert!(typed.contains("www.google.com"), "{typed:?}");
let editing = box_row(true, "www.google.com");
assert!(editing.contains(EDITING_BG) && editing.contains(EDITING_FG), "{editing:?}");
assert!(!editing.contains(IDLE_BG), "an open field must not look selected: {editing:?}");
assert!(editing.contains('▏'), "the caret marks the active field: {editing:?}");
}
/// The keycaps of both screens must not exceed an 80-column console.

View file

@ -1767,16 +1767,19 @@ pub fn render_dns_availability(report: &DnsAvailReport, cfg: &AppConfig, msg: &M
// ─── Test 2: domains ──────────────────────────────────────────────────────────
/// Test 7's table: one row per host, one column per profile, the cell being how
/// many of the simultaneous handshakes came back. The header carries the
/// canonical profile tokens (rule 4: never translated), and the detail column
/// names the failure that happened most often across the profile columns.
/// Test 6's table: one row per host, one column per profile, the cell being how
/// many of the simultaneous handshakes came back. The header names each profile
/// with the pinned version it reproduces (rule 4: Latin, never translated), and
/// the detail column names the failure that happened most often across the
/// profile columns.
pub fn render_burst_table(reports: &[BurstReport], settings: &BurstSettings, msg: &Messages) -> String {
let profiles: &[TlsFingerprint] = &settings.profiles;
let mut table = Table::new();
let mut header = vec![Cell::new(format_bidi(msg.domain, msg.lang))];
for fingerprint in profiles {
header.push(Cell::new(fingerprint.token()));
// The version, not just the family: two runs of "CHROME" can differ in
// the shape they sent, and the header is where that is read off.
header.push(Cell::new(fingerprint.display_label()));
}
header.push(Cell::new(format_bidi(msg.detail, msg.lang)));
table
@ -2196,8 +2199,6 @@ pub struct SummaryData<'a> {
pub dns: Option<&'a dpi_core::dns::availability::DnsAvailStats>,
pub domains: Option<&'a DomainStats>,
pub tcp: Option<(usize, usize, usize, usize)>, // ok, blocked, mixed, total
/// Test 7: handshakes answered, handshakes fired, hosts that lost at least one.
pub burst: Option<(usize, usize, usize)>,
pub run_telegram: bool,
pub telegram: Option<&'a TelegramFullReport>,
}
@ -2265,19 +2266,6 @@ pub fn render_summary(data: &SummaryData, msg: &Messages) -> String {
));
}
// Test 7's totals ride in the same panel: a burst that lost handshakes is a
// different finding from a host that is simply unreachable, and the two are
// read side by side.
if let Some((answered, total, lossy)) = data.burst {
let color = frac_color(answered, total);
let value = msg
.burst_summary_value
.replacen("{}", &answered.to_string(), 1)
.replacen("{}", &total.to_string(), 1)
.replacen("{}", &lossy.to_string(), 1);
items.push((msg.burst_summary_label.to_string(), format!("[{}]{}[/]", color, value)));
}
if let Some((ok, blocked, mixed, total)) = data.tcp {
let pct = ok.checked_mul(100).and_then(|v| v.checked_div(total)).unwrap_or(0);
let mut value = format!("[green]√ {}/{} OK[/]", ok, total);
@ -2838,7 +2826,6 @@ mod tests {
dns: None,
domains: None,
tcp: Some((104, 0, 0, 110)),
burst: None,
run_telegram: false,
telegram: None,
},
@ -2904,7 +2891,6 @@ mod tests {
dns: Some(&stats),
domains: None,
tcp: None,
burst: None,
run_telegram: false,
telegram: None,
},

View file

@ -140,6 +140,18 @@ failing handshake or a real mismatched fingerprint, not a theoretical concern:
and registers a verbatim body. Listing GREASE only in `raw_extensions` — as the
first version did — silently drops it, because the encoder iterates the order.
* **The GREASE key share is load-bearing on a censored link — but it is not the
whole shape.** A greasing profile also offers a key share for its GREASE group
(one dummy byte, Chrome's placement at the head of the list), which is what
`curl_chrome107`/`curl_safari155` send. Measured against `standby-rezka.tv`
over TLS 1.3: with the share our chrome/safari hellos get a fatal alert every
time (12/12 across three interleaved rounds), without it they complete, while
the bundle's own `curl_chrome107` — which also carries a GREASE share —
completes. So the peer reads more than the share: the hello we still do not
reproduce exactly is `supported_versions` (`[0x0304]` here against
`[GREASE, 0x0304, 0x0303]` in the bundle), and until that matches, a
chrome/safari block on such a host is *our* hello being refused, not evidence
that the pinned browser shape is.
* **`sent_extensions` must be the set that reaches the wire.** rustls builds
`ClientHelloDetails::sent_extensions` from its typed fields, but a profile can
add extensions rustls has no typed field for (`ec_point_formats`,