diff --git a/README.md b/README.md index 6a4b076..1a2cb45 100644 --- a/README.md +++ b/README.md @@ -103,11 +103,11 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install --tcp16 Путь к файлу целей TCP16 --ascii Только ASCII для старых консолей (без Unicode-глифов и рамок) --fingerprint Профиль отпечатка 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 Тест 7: одновременных рукопожатий за раунд [по умолчанию: 4] - --burst-timeout Тест 7: таймаут одного рукопожатия, секунды [по умолчанию: 8] - --burst-profiles Отпечатки для теста 7: all|rustls,custom(firefox133),chrome(chrome107),safari(safari155) [по умолчанию: all] - --burst-tls Версия TLS для теста 7: 1.2|1.3 [по умолчанию: 1.3] - --burst-alpn ALPN для теста 7: h2 (предлагает h2 с откатом на http/1.1)|http/1.1 (только http/1.1) [по умолчанию: h2] + --burst Fingerprint/Сибирская блокировка (тест 6): одновременных запросов за раунд [по умолчанию: 4] + --burst-timeout Тест 6: таймаут одного рукопожатия, секунды [по умолчанию: 8] + --burst-profiles Fingerprint для теста 6: all|rustls,custom(firefox133),chrome(chrome107),safari(safari155) [по умолчанию: all] + --burst-tls Версия TLS для теста 6: 1.2|1.3 [по умолчанию: 1.3] + --burst-alpn 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` этого прогона). --- diff --git a/crates/dpi-core/src/i18n/mod.rs b/crates/dpi-core/src/i18n/mod.rs index ae2349f..0c9c727 100644 --- a/crates/dpi-core/src/i18n/mod.rs +++ b/crates/dpi-core/src/i18n/mod.rs @@ -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'), ""); } } diff --git a/crates/dpi-core/src/net/fingerprint.rs b/crates/dpi-core/src/net/fingerprint.rs index 0e20202..607da3b 100644 --- a/crates/dpi-core/src/net/fingerprint.rs +++ b/crates/dpi-core/src/net/fingerprint.rs @@ -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"); diff --git a/crates/dpi-core/src/probe/burst.rs b/crates/dpi-core/src/probe/burst.rs index c1e956f..5778865 100644 --- a/crates/dpi-core/src/probe/burst.rs +++ b/crates/dpi-core/src/probe/burst.rs @@ -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 { 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>, + 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 = report.profiles.iter().map(|p| p.fingerprint).collect(); diff --git a/crates/dpi-detector/src/args.rs b/crates/dpi-detector/src/args.rs index 500b30b..8414cd6 100644 --- a/crates/dpi-detector/src/args.rs +++ b/crates/dpi-detector/src/args.rs @@ -22,15 +22,15 @@ pub struct CliArgs { pub tcp16: Option, pub ascii: bool, pub fingerprint: Option, - /// Test 7: simultaneous handshakes per round. + /// Test 6: simultaneous handshakes per round. pub burst: Option, - /// Test 7: per-handshake timeout in seconds. + /// Test 6: per-handshake timeout in seconds. pub burst_timeout: Option, - /// 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, - /// Test 7: pinned TLS version, `1.2` or `1.3`. + /// Test 6: pinned TLS version, `1.2` or `1.3`. pub burst_tls: Option, - /// Test 7: ALPN to offer, `h2` or `http/1.1`. + /// Test 6: ALPN to offer, `h2` or `http/1.1`. pub burst_alpn: Option, } diff --git a/crates/dpi-detector/src/main.rs b/crates/dpi-detector/src/main.rs index 1e03115..3c0c3bb 100644 --- a/crates/dpi-detector/src/main.rs +++ b/crates/dpi-detector/src/main.rs @@ -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, } -/// 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::>().join(", "); + let fallback = + profiles.iter().map(|f| f.display_label()).collect::>().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, + 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::>().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 = 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); diff --git a/crates/dpi-detector/src/menu.rs b/crates/dpi-detector/src/menu.rs index e567e8a..f32757c 100644 --- a/crates/dpi-detector/src/menu.rs +++ b/crates/dpi-detector/src/menu.rs @@ -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::>().join(", "), + None => profiles.iter().map(|f| f.display_label()).collect::>().join(", "), }; lines.push(field(BURST_ROW_PROFILES, msg.burst_field_profiles, profile_value)); let lines: Vec = 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. diff --git a/crates/dpi-detector/src/render.rs b/crates/dpi-detector/src/render.rs index 573819b..ed6c5d1 100644 --- a/crates/dpi-detector/src/render.rs +++ b/crates/dpi-detector/src/render.rs @@ -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, }, diff --git a/vendor/rustls/README-PATCH.md b/vendor/rustls/README-PATCH.md index ba6b163..1e6fbbf 100644 --- a/vendor/rustls/README-PATCH.md +++ b/vendor/rustls/README-PATCH.md @@ -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`,