mirror of
https://github.com/Runnin4ik/dpi-detector.git
synced 2026-10-19 09:23:17 +00:00
quic: test 2's column, one timeout, and the dash for hosts without HTTP/3
Test 2's fourth column, end to end. The probe repeats its Initial on the RFC 9002 §6.2 PTO ladder (0.67 s, doubling after), so an endpoint that answers only a repeat is not read as silence; a reply that arrives but opens under no key is `quic_closed` with `quic_unreadable_reply` rather than `DROP`; and the run's window was widened for the same measured reason (`MIN_WINDOW`: the ladder plus the PTO the answer to the last repeat needs). The run is pipelined per domain: one task walks a row from its own resolve to its last verdict, the QUIC Initial goes out beside the TCP chain, and every probe takes a permit from the same gate, so a step that hangs costs one permit instead of the rest of its column. The live line and `ProgressBlock` carry the columns that now advance together. One timeout replaces `connect_timeout`/`read_timeout`/`quic_timeout`: the menu's Timeout row, `config.yml` and `state.json` carry one value, and the QUIC window keeps its 6 s floor because the ladder above needs it. A host whose endpoint serves no HTTP/3 is not probed at all: the shipped `quic_unsupported.txt` (both third-party testers agreeing on "no", regenerated with `scripts/quic/online_h3.py`) makes its cell a dash — `quic_unsupported` in `--json` — and the summary counts the column over the rows that can answer it (`14/21 QUIC`, not `14/35`). The ICMP arm comes from the same work: a fake that borrows the client's 5-tuple brings its own ICMP back to the probe's socket, and Windows reports that as `WSAENETRESET` (10052) with no queued message to read, so the number is the whole signature there; `net::icmp_err` on Linux reads the three quoting types (destination unreachable, time exceeded, parameter problem) and names the verdict instead of leaving an errno.
This commit is contained in:
parent
c08ed62a58
commit
a4f37250e6
30 changed files with 1275 additions and 473 deletions
|
|
@ -132,7 +132,7 @@ registry/env keys, `Detail` codes, raw error text appended after a localized lab
|
|||
- `crates/dpi-detector/src/state.rs` — what the interactive menu remembers between runs: `state.json` in the per-OS config dir, then `$PREFIX/etc` on Entware, then beside the binary. Written where a run starts — never on a quit, never by a non-interactive run; CLI flags outrank it, `config.yml` is the fallback.
|
||||
- `crates/dpi-core/src/classify/classifier.rs` — error ⇒ verdict decisions.
|
||||
- `config.yml` (root, 430 lines) — shipped default runtime config, embedded via `include_str!`.
|
||||
- `domains.txt`, `burst-domains.txt`, `tcp16.json`, `whitelist_sni.txt` — per-test target lists, all embedded; test 6 keeps its own on purpose.
|
||||
- `domains.txt`, `burst-domains.txt`, `tcp16.json`, `whitelist_sni.txt`, `quic_unsupported.txt` — per-test target lists, all embedded; test 6 keeps its own on purpose, and `quic_unsupported.txt` names the hosts whose QUIC column test 2 does not probe (the cell prints `—` and the summary counts only the rest).
|
||||
- `README.md` — user manual (**Russian**), authoritative CLI reference. MUST update in the same commit as a CLI/`--json`/`Detail` change.
|
||||
- `Cargo.toml`, `rust-toolchain.toml`, `deny.toml`, `Cross.toml`, `.cargo/config.toml` — build, toolchain, supply-chain, cross-compile policy.
|
||||
- `.github/workflows/`, `.omp/rules/`, `.omp/agents/` — what CI runs (explained in `docs/CI.md`); repo-local agent rules and profiles.
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -131,7 +131,11 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
|
|||
|
||||
### Что меню помнит между запусками
|
||||
|
||||
Меню сохраняет выбор, с которым **начали тесты**, и в следующий раз открывается на нём: язык интерфейса, интерфейс для проверок, версию IP, число потоков, отпечаток и набор тестов. Запись идёт в момент старта, а не при выходе: выбор, подтверждённый Enter, — это выбор, и прогон, прерванный на середине (Ctrl-C, закрытый терминал, перезагрузка роутера), уже записан. Выход из меню по `Q`, `Esc` или Ctrl-C ничего не пишет, и тихий прогон (`--json` из скрипта или cron) тоже — он не должен решать, на чём откроется меню.
|
||||
Таймаут задаётся строкой **Таймаут** в меню: шаг ровно одна секунда, от `1` до `60` (стрелки `←`/`→`, на границах значение упирается, а не заворачивается). Это одно окно на все проверки, которые ходят к серверу, — HTTP, TLS 1.2, TLS 1.3 и QUIC: фаза зонда (подключение, ответ после него, окно на Initial) ждёт его целиком, поэтому худший случай одного зонда — два таких окна. Значение по умолчанию — 8 секунд, оно же стоит в `TIMEOUT` в `config.yml`; старые ключи `CONNECT_TIMEOUT`, `READ_TIMEOUT` и `QUIC_TIMEOUT` заменены им одним. В тихом запуске берётся сохранённое из меню значение, а если его не было — `TIMEOUT` из `config.yml`.
|
||||
|
||||
Одно исключение: **окно QUIC не бывает короче 6 секунд**, каким бы ни был таймаут. Пробник повторяет Initial по таймауту потери — 0.67, 1.33 и 2.67 с (RFC 9002 §6.2), и последний полёт уходит только на 4.66 с; окно, которое обрывает эту лестницу, описывает не путь, а само окно. Замерено с включённым локальным обходом (`nfqws2`, `--lua-desync=fake:blob=quic_google:repeats=3`): читаемое рукопожатие приходило только на третий повтор (~4.7 с), а при `Таймаут 3` колонка читала поддельные пакеты самого обхода как `CLOSED` с деталью `quic_unreadable_reply` — то есть «эндпоинт ответил, но рукопожатие не идёт», хотя эндпоинт отвечает нормально. Поэтому при `Таймаут 3` колонка QUIC всё равно ждёт 6 с (и в детали видно её собственное время), а `OK` остаётся `OK`; остальные колонки укорачиваются как раньше. Прогон с обходом вообще стоит читать вместе с блоком о перехвате, который печатается перед тестами: с выключенным обходом блокированные хосты честно дают `DROP`, а не «медленный OK». И отдельно: если у фейка обхода выставлен TTL (`ip_ttl`), фейк умирает по пути, и роутер, на котором он истёк, шлёт ICMP «time exceeded» — а поскольку фейк отправлен с нашим 5-tuple, этот ICMP приходит на наш UDP-сокет. Колонка читает его как `HOST UNREACH` с деталью `icmp_time_exceeded` (на Windows это `WSAENETRESET`, 10052) и **не обрывает попытку**: ICMP относится к фейку, а не к нашим датаграммам, поэтому пробник ждёт ответ эндпоинта до конца окна, а ICMP показывает только если ответа так и не было. Для замеров у фейка вместо TTL лучше `badsum` (пакет доходит, но стек сервера его молча выбрасывает — без ICMP и без состояния).
|
||||
|
||||
Меню сохраняет выбор, с которым **начали тесты**, и в следующий раз открывается на нём: язык интерфейса, интерфейс для проверок, версию IP, число потоков, таймаут, отпечаток и набор тестов. Запись идёт в момент старта, а не при выходе: выбор, подтверждённый Enter, — это выбор, и прогон, прерванный на середине (Ctrl-C, закрытый терминал, перезагрузка роутера), уже записан. Выход из меню по `Q`, `Esc` или Ctrl-C ничего не пишет, и тихий прогон (`--json` из скрипта или cron) тоже — он не должен решать, на чём откроется меню.
|
||||
|
||||
Каталог выбирается по платформе: берётся первый, который уже содержит файл, иначе первый, доступный на запись.
|
||||
|
||||
|
|
@ -156,7 +160,7 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
|
|||
|
||||
Редирект на чужой домен тесты `2` и `6` показывают как `REDIR`, но у пары хостов это их собственное поведение: `www.messenger.com` и `www.instagram.com` отвечают `301` на `https://www.facebook.com/` и больше никуда. Такие пары объявлены в `REDIRECT_EXCEPTIONS` (`crates/dpi-core/src/probe/http.rs`) — по строке на пару, — и такая строка читается `OK`, а в деталях остаётся хост, куда ушёл браузер. Список именно про пары, а не про семейства: обратное направление (`www.facebook.com` → `www.instagram.com`) и любой другой чужой редирект по-прежнему `REDIR`.
|
||||
|
||||
В столбце «Детали» каждая строка помечена протоколом, к которому относится (`HTTP:`, `TLS:`, `QUIC:`). Две TLS-версии — одна ступень, пока их вердикты совпадают: тогда обе колонки делят одну строку `TLS:` (а когда обе ответили — той же строкой идёт время). Как только вердикты разошлись (`TLS RST` рядом с `DROP`), общая строка скрыла бы, какая версия что дала, поэтому каждая получает свою — `TLS12:` и `TLS13:`.
|
||||
В столбце «Детали» каждая строка помечена протоколом, к которому относится (`HTTP:`, `TLS:`, `QUIC:`). Две TLS-версии — одна ступень, пока их вердикты совпадают: тогда обе колонки делят одну строку `TLS:` (а когда обе ответили — той же строкой идёт время). Как только вердикты разошлись (`TLS RST` рядом с `DROP`), общая строка скрыла бы, какая версия что дала, поэтому каждая получает свою — `TLS12:` и `TLS13:`. Когда прошли **все** протоколы строки, в деталях вместо одной строки стоят четыре времени подряд, по колонкам: `0.2s,0.2s,0.2s,0.2s` — HTTP, TLS 1.2, TLS 1.3, QUIC.
|
||||
|
||||
Статус `host_unreach` — это errno `EHOSTUNREACH` на connect: так ядро отдаёт **любое** ICMP-сообщение о недостижимости, и по одному errno не видно, был это отказ маршрутизации или фильтр провайдера. На Linux детектор читает из очереди ошибок сокета (`IP_RECVERR`) исходное ICMP-сообщение и, если это type 3 code 13 («administratively prohibited», именно так отвечает фильтр на пути), пишет в детали `icmp_admin_prohibited` — «ICMP: административно запрещено (фильтр провайдера)» вместо общего «нет маршрута до хоста». На Windows такой очереди нет, поэтому там деталь остаётся прежней.
|
||||
|
||||
|
|
@ -179,7 +183,7 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
|
|||
| `OK` (`http_200`, `http_3xx`, …) | ответ пришёл, обрыв не пойман; у редиректа в детали видно код и куда ведёт (`→ хост`, `код → https`) |
|
||||
| `REDIR` (`redirect_to_host`) | редирект на чужой домен — так выглядит заглушка провайдера; редирект на тот же домен или поддомен — это `OK` |
|
||||
| `16KB DROP` (`read_timeout_word_at_Nkb`) | передача оборвалась внутри окна: заголовки пришли, тело оборвалось на N КБ — тот же вердикт, что у теста 3 |
|
||||
| `TIMEOUT`, `SEND TIMEOUT` | ответ не пришёл за `READ_TIMEOUT`; в детали смещение в КБ |
|
||||
| `TIMEOUT`, `SEND TIMEOUT` | ответ не пришёл за `TIMEOUT`; в детали смещение в КБ |
|
||||
| `SYN DROP`, `TCP RST`, `REFUSED`, `NET UNREACH` | соединение не состоялось — вердикты те же, что у соседних колонок |
|
||||
|
||||
Чего колонка **не** делает: не следует за редиректом (проверяется сам ответ, а не то, что за ним) и не сравнивает тело с образцом заглушки — блокпост виден по хосту, куда ведёт редирект, а не по HTML.
|
||||
|
|
@ -196,6 +200,9 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
|
|||
| `SPOOF` | Retry с неверным integrity-тегом: посчитать его может только тот, кто видел наш Initial, поэтому пакет пришёл не от эндпоинта |
|
||||
| `DROP` | на Initial не ответили вовсе — та же тишина, что `SYN DROP` для TCP. Пробник повторяет полёт по таймауту потери, как это делает настоящий клиент (RFC 9002 §6.2: 0.67 с, затем вдвое), поэтому «тишина» здесь означает, что эндпоинт не ответил ни на один из четырёх полётов за окно |
|
||||
| `REFUSED` | ICMP «на UDP-порту никто не слушает»: путь работает, а QUIC-слушателя у эндпоинта нет вовсе — это не блокировка (блокировка выглядит как `DROP`) |
|
||||
| `—` | хост **не проверялся**: его эндпоинт не поддерживает HTTP/3 вовсе, и это известно до пробы — из встроенного списка `quic_unsupported.txt`. Initial не отправляется, в колонке прочерк, а в сводке колонка считается по остальным хостам (например `14/21 QUIC`, а не `14/35`) |
|
||||
|
||||
Список `quic_unsupported.txt` — данные, а не константа: одна строка на хост, `#` — комментарий, хост сравнивается целиком (без учёта регистра) с хостом домена, поэтому запись `example.com` накрывает и `example.com/какой-то/путь` из `domains.txt`. Содержимое взято у двух внешних тестеров HTTP/3 (`scripts/quic/online_h3.py` — intodns.ai и http3check.net, они пробуют с чужих сетей), правило отбора — **оба** сказали «нет»; строка, где они расходятся, остаётся проверяемой, потому что расхождение и есть вопрос этой колонки. Оговорка: пять хостов из списка (`aws.amazon.com`, `soundcloud.com`, `vk.ru`, `www.currenttime.tv`, `www.svoboda.org`) отвечают на рукопожатие, если его навязать (`scripts/quic/browser_check.py`), — они просто никогда не объявляют HTTP/3, а браузер, которому не сказали, им не пользуется; поэтому они в списке. Путь к файлу переопределяется ключом `quic_unsupported_file` в `config.yml` (как `whitelist_sni_file`), а `--json` отдаёт для таких строк `"quic": "quic_unsupported"`.
|
||||
|
||||
Чего колонка **не** доказывает: что сайт отвечает по HTTP/3 со статусом `:status 200`. Для этого нужен весь обмен рукопожатия и запрос, а у провайдера в дереве нет QUIC-поддержки (`quic: None` у всех суит — `docs/ADDING_A_PROFILE.md`, §6). Колонка говорит про путь: UDP 443 не фильтруется, эндпоинт жив и разобрал именно этот ClientHello. Адрес берётся тот же, что у соседних колонок, — A/AAAA хоста, а не адрес из `Alt-Svc`, которым пользуется браузер, поэтому `DROP` может означать «H3 у этого хоста на другом адресе», а не блокировку.
|
||||
|
||||
|
|
@ -207,7 +214,7 @@ Transport parameters у нашего hello — **браузерные, а не
|
|||
|
||||
Hello браузера не влезает в одну датаграмму: одна только постквантовая доля ключа — 1216 байт (`X25519MLKEM768`), поэтому первый полёт разбивается на столько Initial по 1200 байт, сколько нужно, а `CRYPTO`-фрейм в следующих пакетах продолжает поток по своему смещению (RFC 9000 §14.1 и §19.6). Урезать hello ради одного пакета значило бы отправлять не ту форму, которую отправляет клиент.
|
||||
|
||||
Полёт повторяется, если ответа нет: клиент обязан так делать при потере (RFC 9002 §6.2 — первый таймаут равен удвоенной начальной оценке RTT, 333 мс, дальше вдвое), и это не формальность. Замерено: эдж Cloudflare на **первый** Initial отвечает пакетом, который не открывается ни одним ключом соединения (см. `quic_unreadable_reply` выше), а читаемый ServerHello посылает только на повторный полёт — стоковый клиент получает `HTTP/3 200` там, где пробник без повторов видел тишину. Отсюда и окно: `QUIC_TIMEOUT` в `config.yml` по умолчанию 8 с, потому что повтор уходит на ~0.7, 2 и 4.7 с, а ответ на него приходит ещё позже; четыре полёта за окно и один ответ — это то, что делает настоящий клиент. Число одновременных проб — общий `MAX_CONCURRENT`. В `--json` колонка видна как `results.domain_inspection[].quic` (токен `DpiStatus`) и `quic_detail` (код `Detail`).
|
||||
Полёт повторяется, если ответа нет: клиент обязан так делать при потере (RFC 9002 §6.2 — первый таймаут равен удвоенной начальной оценке RTT, 333 мс, дальше вдвое), и это не формальность. Замерено: эдж Cloudflare на **первый** Initial отвечает пакетом, который не открывается ни одним ключом соединения (см. `quic_unreadable_reply` выше), а читаемый ServerHello посылает только на повторный полёт — стоковый клиент получает `HTTP/3 200` там, где пробник без повторов видел тишину. Отсюда и окно: общий `TIMEOUT` в `config.yml` по умолчанию 8 с (одно значение на HTTP, TLS и QUIC), потому что повтор уходит на ~0.7, 2 и 4.7 с, а ответ на него приходит ещё позже; четыре полёта за окно и один ответ — это то, что делает настоящий клиент. Число одновременных проб — общий `MAX_CONCURRENT`. Короче 6 с окно не бывает даже при меньшем таймауте: обрыв лестницы повторов описывал бы окно, а не эндпоинт (см. раздел про таймаут выше). В `--json` колонка видна как `results.domain_inspection[].quic` (токен `DpiStatus`) и `quic_detail` (код `Detail`).
|
||||
|
||||
Когда отпечаток прогона — базовый `rustls`, колонка берёт форму браузера: у базового hello нет профиля, к которому можно прицепить транспортные параметры, и без них любой эндпоинт ответил бы `TRANSPORT_PARAMETER_ERROR` — колонка читалась бы как `CLOSED` на всех хостах, которые H3 отдают.
|
||||
|
||||
|
|
|
|||
20
config.yml
20
config.yml
|
|
@ -51,10 +51,18 @@ TLS_FINGERPRINT: rustls
|
|||
PROXY_URL: null
|
||||
|
||||
# === Таймауты (в секундах) ===
|
||||
# Время ожидания установки TCP-соединения
|
||||
CONNECT_TIMEOUT: 8.0
|
||||
# Время ожидания ответа от сервера после установки соединения
|
||||
READ_TIMEOUT: 8.0
|
||||
# Единый таймаут для HTTP, TLS 1.2/1.3 и QUIC: одно значение вместо отдельных
|
||||
# CONNECT/READ/QUIC. Это окно одной фазы зонда — на установку соединения и на
|
||||
# ответ после него (худший случай зонда — два таких окна), и оно же окно ответа
|
||||
# на QUIC Initial. В меню строка Таймаут: шаг 1 с, диапазон 1–60.
|
||||
# Окно QUIC при этом не бывает короче 6 с: пробник повторяет Initial по
|
||||
# 0.67/1.33/2.67 с (RFC 9002 §6.2), и окно, обрывающее эту лестницу, описывало
|
||||
# бы себя, а не путь. Замерено с включённым локальным обходом (nfqws2,
|
||||
# --lua-desync=fake:blob=quic_google:repeats=3): читаемое рукопожатие приходило
|
||||
# только на третий повтор (~4.7 с), а окно 3 с читало поддельные пакеты самого
|
||||
# обхода как CLOSED (quic_unreadable_reply). Меньший таймаут остальные колонки
|
||||
# укорачивает, QUIC — нет.
|
||||
TIMEOUT: 8.0
|
||||
# Время ожидания свободного сокета в пуле соединений OS
|
||||
POOL_TIMEOUT: 2.0
|
||||
# Таймаут для первичного сбора IP-заглушек провайдера
|
||||
|
|
@ -350,8 +358,8 @@ TELEGRAM_DCS:
|
|||
# === Тест 2: колонка QUIC (HTTP/3) ===
|
||||
# Цели — тот же список, что у остальных колонок теста 2 (domains.txt или -d),
|
||||
# проверяются те же адреса, что у TCP-колонок, на UDP 443.
|
||||
QUIC_TIMEOUT: 8.0 # сек ожидания ответа на Initial: эдж отвечает
|
||||
# и на повторный полёт, а он уходит по PTO ~0.7/2/4.7 с
|
||||
# Окно ответа на Initial — общий TIMEOUT выше; повтор уходит по PTO ~0.7/2/4.7 с,
|
||||
# поэтому окно должно быть больше последнего повтора.
|
||||
|
||||
# === Кэш DNS-пининга ===
|
||||
PIN_CACHE_MAX: 256 # максимум записей
|
||||
|
|
|
|||
|
|
@ -235,15 +235,28 @@ cfg_select! {
|
|||
const HOST_UNREACH: i32 = 10065; // WSAEHOSTUNREACH
|
||||
const NET_DOWN: i32 = 10050; // WSAENETDOWN
|
||||
const HOST_DOWN: i32 = 10064; // WSAEHOSTDOWN
|
||||
const MSG_TOO_LONG: i32 = 10040; // WSAEMSGSIZE
|
||||
}
|
||||
_ => {
|
||||
const NET_UNREACH: i32 = libc::ENETUNREACH;
|
||||
const HOST_UNREACH: i32 = libc::EHOSTUNREACH;
|
||||
const NET_DOWN: i32 = libc::ENETDOWN;
|
||||
const HOST_DOWN: i32 = libc::EHOSTDOWN;
|
||||
const MSG_TOO_LONG: i32 = libc::EMSGSIZE;
|
||||
}
|
||||
}
|
||||
|
||||
/// `WSAENETRESET`: what a *connected* socket on Windows reports for an ICMP that
|
||||
/// is not a destination-unreachable verdict — a time exceeded among them. There
|
||||
/// is no queued message to read on that platform (the watcher in `net::icmp_err`
|
||||
/// is Linux-only), so this number is the whole signature there. Measured: a
|
||||
/// local `nfqws2` QUIC desync whose fake carried `ip_ttl` produced it, because
|
||||
/// the fake borrows the client's 5-tuple and the ICMP for it comes back to the
|
||||
/// probe's own socket; the column read `OS ERR / OS errno 10052` until this arm
|
||||
/// existed.
|
||||
#[cfg(windows)]
|
||||
const WSA_NET_RESET: i32 = 10052;
|
||||
|
||||
/// Classifies a TCP connection error: pool exhaustion, timeouts, DNS failures,
|
||||
/// TLS alerts surfacing inside connect errors, refusals, resets, aborts and
|
||||
/// unreachable hosts/routes.
|
||||
|
|
@ -372,6 +385,19 @@ pub fn classify_connect_error_full(
|
|||
if matches!(raw_os_error, Some(HOST_UNREACH) | Some(HOST_DOWN)) || full.contains("no route to host") || full.contains("host is down") {
|
||||
return (DpiStatus::HostUnreach, Detail::HostUnreach);
|
||||
}
|
||||
// The ICMP the socket was handed, when the platform says so by errno alone.
|
||||
// The same event reads `EHOSTUNREACH` on unix, where the watcher names it.
|
||||
#[cfg(windows)]
|
||||
if matches!(raw_os_error, Some(WSA_NET_RESET)) {
|
||||
return (DpiStatus::HostUnreach, Detail::IcmpTimeExceeded);
|
||||
}
|
||||
// A datagram the path will not carry: the kernel reports the ICMP
|
||||
// fragmentation-needed as this errno on both platforms, and the QUIC probe
|
||||
// sends 1200 bytes by construction, so this is the signature of a path
|
||||
// narrower than the protocol's own minimum.
|
||||
if matches!(raw_os_error, Some(MSG_TOO_LONG)) || full.contains("message too long") {
|
||||
return (DpiStatus::OsErr, Detail::IcmpFragNeeded);
|
||||
}
|
||||
|
||||
if let Some(code) = raw_os_error {
|
||||
return (DpiStatus::OsErr, Detail::Other(format!("OS errno {}", code)));
|
||||
|
|
@ -394,22 +420,36 @@ pub fn classify_connect_error_icmp(
|
|||
bytes_read: usize,
|
||||
stage: ProbeStage,
|
||||
) -> (DpiStatus, Detail) {
|
||||
let (status, detail) = classify_connect_error_full(
|
||||
let (mut status, mut detail) = classify_connect_error_full(
|
||||
&err.to_string(),
|
||||
err.raw_os_error(),
|
||||
Some(err.kind()),
|
||||
bytes_read,
|
||||
stage,
|
||||
);
|
||||
let refined = match (icmp, status) {
|
||||
(Some(code), DpiStatus::HostUnreach | DpiStatus::NetUnreach)
|
||||
if code == IcmpCode::ADMIN_PROHIBITED =>
|
||||
// The path named the reason itself, and the errno cannot: `EHOSTUNREACH`
|
||||
// covers the whole destination-unreachable family, `EMSGSIZE` a datagram the
|
||||
// path will not carry, `EPROTO` a header a router refused. The message is
|
||||
// what says which one it was.
|
||||
if let Some(code) = icmp {
|
||||
if code.icmp_type == IcmpCode::TIME_EXCEEDED.icmp_type {
|
||||
// The unreachable family is where a datagram the path could not
|
||||
// deliver belongs, and this event reaches the socket as
|
||||
// `EHOSTUNREACH` on unix but `WSAENETRESET` on Windows, so the pair
|
||||
// is set rather than only refined.
|
||||
status = DpiStatus::HostUnreach;
|
||||
detail = Detail::IcmpTimeExceeded;
|
||||
} else if code == IcmpCode::FRAG_NEEDED {
|
||||
detail = Detail::IcmpFragNeeded;
|
||||
} else if code.icmp_type == IcmpCode::PARAMETER_PROBLEM.icmp_type {
|
||||
detail = Detail::IcmpParameterProblem;
|
||||
} else if code.is_admin_prohibited()
|
||||
&& matches!(status, DpiStatus::HostUnreach | DpiStatus::NetUnreach)
|
||||
{
|
||||
Detail::IcmpAdminProhibited
|
||||
detail = Detail::IcmpAdminProhibited;
|
||||
}
|
||||
_ => detail,
|
||||
};
|
||||
(status, refined)
|
||||
}
|
||||
(status, detail)
|
||||
}
|
||||
|
||||
/// Legacy io::Error-based entry point (stage unknown → tcp_connect).
|
||||
|
|
@ -872,6 +912,68 @@ mod tests {
|
|||
assert_eq!((s, d), (DpiStatus::HostUnreach, Detail::HostUnreach));
|
||||
}
|
||||
|
||||
/// The ICMP a path answers with is named rather than left as an errno: a
|
||||
/// time-exceeded (a local desync's TTL-carrying fake puts one on the probe's
|
||||
/// own socket), a datagram the path will not carry, and a header a router
|
||||
/// refused. Measured for the first: with `ip_ttl` on the desync's fake the
|
||||
/// QUIC column read `OS ERR / OS errno 10052` until this arm existed.
|
||||
#[test]
|
||||
fn the_path_s_own_icmp_is_named_not_left_as_an_errno() {
|
||||
// Windows reports the time-exceeded class by errno alone (no watcher
|
||||
// there); unix reads the message, and both must land on one pair.
|
||||
let (s, d) = classify_connect_error_icmp(
|
||||
&io::Error::from_raw_os_error(10052),
|
||||
None,
|
||||
0,
|
||||
ProbeStage::TcpConnect,
|
||||
);
|
||||
#[cfg(windows)]
|
||||
assert_eq!((s, d), (DpiStatus::HostUnreach, Detail::IcmpTimeExceeded));
|
||||
let (s, d) = classify_connect_error_icmp(
|
||||
&io::Error::from_raw_os_error(HOST_UNREACH),
|
||||
Some(IcmpCode::TIME_EXCEEDED),
|
||||
0,
|
||||
ProbeStage::TcpConnect,
|
||||
);
|
||||
assert_eq!((s, d), (DpiStatus::HostUnreach, Detail::IcmpTimeExceeded));
|
||||
|
||||
// A datagram the path will not carry reaches the socket as
|
||||
// `EMSGSIZE`/`WSAEMSGSIZE` on both platforms, and as type 3 code 4 when
|
||||
// the message is read.
|
||||
let (s, d) = classify_connect_error_full("", Some(MSG_TOO_LONG), None, 0, ProbeStage::TcpConnect);
|
||||
assert_eq!((s, d), (DpiStatus::OsErr, Detail::IcmpFragNeeded));
|
||||
let (s, d) = classify_connect_error_icmp(
|
||||
&io::Error::from_raw_os_error(MSG_TOO_LONG),
|
||||
Some(IcmpCode::FRAG_NEEDED),
|
||||
0,
|
||||
ProbeStage::TcpConnect,
|
||||
);
|
||||
assert_eq!((s, d), (DpiStatus::OsErr, Detail::IcmpFragNeeded));
|
||||
|
||||
// A parameter problem is rare and is named when the watcher sees it.
|
||||
let (s, d) = classify_connect_error_icmp(
|
||||
&io::Error::from_raw_os_error(HOST_UNREACH),
|
||||
Some(IcmpCode::PARAMETER_PROBLEM),
|
||||
0,
|
||||
ProbeStage::TcpConnect,
|
||||
);
|
||||
assert_eq!((s, d), (DpiStatus::HostUnreach, Detail::IcmpParameterProblem));
|
||||
|
||||
// The filter's verdict is the whole administratively-prohibited family,
|
||||
// not just the code a provider was measured to send: RFC 1812 §4.3.3.1
|
||||
// groups 9, 10 and 13, and the three arrive as the same errno.
|
||||
for icmp_code in [9, 10, 13] {
|
||||
let code = IcmpCode { icmp_type: 3, icmp_code };
|
||||
let (s, d) = classify_connect_error_icmp(
|
||||
&io::Error::from_raw_os_error(HOST_UNREACH),
|
||||
Some(code),
|
||||
0,
|
||||
ProbeStage::TcpConnect,
|
||||
);
|
||||
assert_eq!((s, d), (DpiStatus::HostUnreach, Detail::IcmpAdminProhibited), "code {icmp_code}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The message refines an unreachable verdict and nothing else: a reset that
|
||||
/// happens to carry an ICMP message is still a reset.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -100,6 +100,27 @@ pub enum Detail {
|
|||
/// filter. Kept apart from [`Detail::HostUnreach`] because the two arrive as
|
||||
/// the same errno.
|
||||
IcmpAdminProhibited,
|
||||
/// ICMP time-exceeded: a router on the path expired the datagram's hop
|
||||
/// limit, and the kernel handed that ICMP to the sender's socket — so the
|
||||
/// probe reads a socket error where the wire was silent. Measured with a
|
||||
/// local `nfqws2` QUIC desync whose fake carried `ip_ttl`: the fake has to
|
||||
/// borrow the client's 5-tuple to fool the filter, so the ICMP for the fake
|
||||
/// lands on the probe's own socket. Windows reports that class as
|
||||
/// `WSAENETRESET` (10052) and carries no message; the unix half reads the
|
||||
/// ICMP itself (`net::icmp_err`), and both end at this detail.
|
||||
IcmpTimeExceeded,
|
||||
/// ICMP fragmentation needed (type 3 code 4, RFC 1191), or the local send
|
||||
/// that failed because of it: the datagram does not fit the path's MTU.
|
||||
/// Worth its own name because the QUIC probe *must* send a 1200-byte
|
||||
/// datagram (RFC 9000 §14.1), so on a path narrower than that — PPPoE, a
|
||||
/// tunnel — the honest verdict is "the path cannot carry this", not a bare
|
||||
/// `EMSGSIZE`. The errno is `EMSGSIZE` on unix and `WSAEMSGSIZE` (10040) on
|
||||
/// Windows; the ICMP is read on the wire where the platform allows it.
|
||||
IcmpFragNeeded,
|
||||
/// ICMP parameter problem (type 12): a header field was unacceptable to some
|
||||
/// router. Rare on a working path, and in the injection vocabulary beside a
|
||||
/// forged unreachable, so it is named rather than left as an errno.
|
||||
IcmpParameterProblem,
|
||||
UnknownConnectionFailure,
|
||||
Ipv6Unsupported,
|
||||
|
||||
|
|
@ -358,6 +379,9 @@ impl Detail {
|
|||
NetUnreach => Cow::Borrowed("net_unreachable"),
|
||||
HostUnreach => Cow::Borrowed("host_unreachable"),
|
||||
IcmpAdminProhibited => Cow::Borrowed("icmp_admin_prohibited"),
|
||||
IcmpTimeExceeded => Cow::Borrowed("icmp_time_exceeded"),
|
||||
IcmpFragNeeded => Cow::Borrowed("icmp_frag_needed"),
|
||||
IcmpParameterProblem => Cow::Borrowed("icmp_parameter_problem"),
|
||||
UnknownConnectionFailure => Cow::Borrowed("unknown_connection_failure"),
|
||||
Ipv6Unsupported => Cow::Borrowed("ipv6_unsupported"),
|
||||
QuicServerHello => Cow::Borrowed("quic_server_hello"),
|
||||
|
|
|
|||
|
|
@ -162,6 +162,26 @@ impl IcmpCode {
|
|||
/// Destination unreachable / administratively prohibited: something on the
|
||||
/// path refused the flow by policy — in practice a provider's filter.
|
||||
pub const ADMIN_PROHIBITED: Self = Self { icmp_type: 3, icmp_code: 13 };
|
||||
|
||||
/// Time exceeded: a router on the path expired the datagram's hop limit.
|
||||
/// The code is the RFC 792 one for "time to live exceeded in transit".
|
||||
pub const TIME_EXCEEDED: Self = Self { icmp_type: 11, icmp_code: 0 };
|
||||
|
||||
/// Fragmentation needed and the datagram asked not to be fragmented
|
||||
/// (RFC 1191): the path's MTU is smaller than the datagram.
|
||||
pub const FRAG_NEEDED: Self = Self { icmp_type: 3, icmp_code: 4 };
|
||||
|
||||
/// Parameter problem: a header field was unacceptable on the path.
|
||||
pub const PARAMETER_PROBLEM: Self = Self { icmp_type: 12, icmp_code: 0 };
|
||||
|
||||
/// Whether this is an "administratively prohibited" verdict — the family a
|
||||
/// provider's filter answers with. RFC 1812 §4.3.3.1 groups the
|
||||
/// net/host/communication codes (9, 10, 13) as one meaning, and the three
|
||||
/// arrive as the same errno, so the code is what separates them from the
|
||||
/// rest of the unreachable family.
|
||||
pub fn is_admin_prohibited(self) -> bool {
|
||||
self.icmp_type == 3 && matches!(self.icmp_code, 9 | 10 | 13)
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe statuses. `display_label()` is the Latin uppercase badge (Rule 4 — the
|
||||
|
|
@ -243,6 +263,14 @@ pub enum DpiStatus {
|
|||
QuicSpoof,
|
||||
/// Nothing came back within the window: the silence `SYN DROP` is for TCP.
|
||||
QuicDrop,
|
||||
/// The endpoint does not serve HTTP/3 at all, so no Initial was sent for
|
||||
/// this row: the QUIC cell prints a dash and the summary counts the row out
|
||||
/// of the column's own total instead of the row count. The list is the
|
||||
/// shipped `quic_unsupported.txt`, built from the two third-party testers
|
||||
/// (`scripts/quic/online_h3.py`); a host that serves HTTP/3 when asked stays
|
||||
/// probed even when it never advertises it, because that is the fact this
|
||||
/// column measures.
|
||||
QuicUnsupported,
|
||||
Err,
|
||||
Unknown,
|
||||
}
|
||||
|
|
@ -290,6 +318,7 @@ impl DpiStatus {
|
|||
DpiStatus::QuicVn,
|
||||
DpiStatus::QuicSpoof,
|
||||
DpiStatus::QuicDrop,
|
||||
DpiStatus::QuicUnsupported,
|
||||
DpiStatus::Err,
|
||||
DpiStatus::Unknown,
|
||||
];
|
||||
|
|
@ -332,6 +361,7 @@ impl DpiStatus {
|
|||
Self::QuicVn => "quic_vn",
|
||||
Self::QuicSpoof => "quic_spoof",
|
||||
Self::QuicDrop => "quic_drop",
|
||||
Self::QuicUnsupported => "quic_unsupported",
|
||||
Self::Err => "err",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
|
|
@ -375,6 +405,7 @@ impl DpiStatus {
|
|||
Self::QuicVn => "VN",
|
||||
Self::QuicSpoof => "SPOOF",
|
||||
Self::QuicDrop => "DROP",
|
||||
Self::QuicUnsupported => "—",
|
||||
Self::Err => "ERR",
|
||||
Self::Unknown => "UNKNOWN",
|
||||
}
|
||||
|
|
@ -492,7 +523,7 @@ mod tests {
|
|||
/// none, so the ones that drifted are the ones named here.
|
||||
///
|
||||
/// The QUIC column's members are pinned here for the same reason: row
|
||||
/// verdicts (five) and skipped-row statuses (three) all reach `--json`
|
||||
/// verdicts (five) and skipped-row statuses (four) all reach `--json`
|
||||
/// through this pair, and nothing else would fail if one of them were
|
||||
/// renamed.
|
||||
#[test]
|
||||
|
|
@ -510,6 +541,9 @@ mod tests {
|
|||
(DpiStatus::QuicVn, "quic_vn"),
|
||||
(DpiStatus::QuicSpoof, "quic_spoof"),
|
||||
(DpiStatus::QuicDrop, "quic_drop"),
|
||||
// The dash: no Initial was sent because the endpoint serves no
|
||||
// HTTP/3 (the shipped `quic_unsupported.txt`).
|
||||
(DpiStatus::QuicUnsupported, "quic_unsupported"),
|
||||
// What the cell carries on a row the DNS phase already decided
|
||||
// (`probe/domains.rs`): the same status the TLS and HTTP cells do.
|
||||
(DpiStatus::DnsFail, "dns_fail"),
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ use serde::{Deserialize, Serialize};
|
|||
fn d_max_concurrent() -> usize { 50 }
|
||||
fn d_ip_version() -> String { "ipv4".to_string() }
|
||||
fn d_tls_fingerprint() -> String { "rustls".to_string() }
|
||||
fn d_connect_timeout() -> f64 { 8.0 }
|
||||
fn d_read_timeout() -> f64 { 8.0 }
|
||||
fn d_timeout() -> f64 { 8.0 }
|
||||
fn d_pool_timeout() -> f64 { 2.0 }
|
||||
fn d_stub_ips_timeout() -> f64 { 5.0 }
|
||||
fn d_tcp_block_min_kb() -> u64 { 12 }
|
||||
|
|
@ -53,7 +52,7 @@ fn d_telegram_dc_port() -> u16 { 443 }
|
|||
fn d_domains_file() -> String { "domains.txt".to_string() }
|
||||
fn d_tcp16_file() -> String { "tcp16.json".to_string() }
|
||||
fn d_whitelist_sni_file() -> String { "whitelist_sni.txt".to_string() }
|
||||
fn d_quic_timeout() -> f64 { 8.0 }
|
||||
fn d_quic_unsupported_file() -> String { "quic_unsupported.txt".to_string() }
|
||||
|
||||
fn d_dns_check_domains() -> Vec<String> {
|
||||
vec![
|
||||
|
|
@ -343,10 +342,17 @@ pub struct AppConfig {
|
|||
/// Legacy alias: CLI --proxy writes here; holds the same value as proxy_url.
|
||||
#[serde(default)]
|
||||
pub proxy: Option<String>,
|
||||
#[serde(default = "d_connect_timeout")]
|
||||
pub connect_timeout: f64,
|
||||
#[serde(default = "d_read_timeout")]
|
||||
pub read_timeout: f64,
|
||||
/// Seconds one phase of a probe waits, for every category that speaks to a
|
||||
/// peer: the connection, the reply that follows it, and the QUIC Initial's
|
||||
/// window. One number for HTTP, TLS 1.2, TLS 1.3 and QUIC rather than a
|
||||
/// separate read/write pair, and the value the menu's Timeout row steps
|
||||
/// through, one second at a time from 1 to 60.
|
||||
///
|
||||
/// A probe's own worst case is twice this: the connection and the reply are
|
||||
/// two phases, each bounded by it. Short enough that a silent target does
|
||||
/// not hold the run, generous enough for a router link.
|
||||
#[serde(default = "d_timeout")]
|
||||
pub timeout: f64,
|
||||
#[serde(default = "d_pool_timeout")]
|
||||
pub pool_timeout: f64,
|
||||
#[serde(default = "d_stub_ips_timeout")]
|
||||
|
|
@ -448,11 +454,11 @@ pub struct AppConfig {
|
|||
pub tcp16_file: String,
|
||||
#[serde(default = "d_whitelist_sni_file")]
|
||||
pub whitelist_sni_file: String,
|
||||
/// Test 2's QUIC column: seconds one target's Initial waits for a reply. A
|
||||
/// ServerHello needs one round trip, so the default is generous for a router
|
||||
/// link and short enough that a silent target does not hold the run.
|
||||
#[serde(default = "d_quic_timeout")]
|
||||
pub quic_timeout: f64,
|
||||
/// Hosts whose endpoint serves no HTTP/3 (`quic_unsupported.txt`): test 2
|
||||
/// does not probe their QUIC column and the summary counts the column out of
|
||||
/// the rows that can answer it.
|
||||
#[serde(default = "d_quic_unsupported_file")]
|
||||
pub quic_unsupported_file: String,
|
||||
#[serde(default)]
|
||||
pub debug: bool,
|
||||
#[serde(skip)]
|
||||
|
|
@ -493,8 +499,7 @@ impl Default for AppConfig {
|
|||
tls_fingerprint: d_tls_fingerprint(),
|
||||
proxy_url: None,
|
||||
proxy: None,
|
||||
connect_timeout: d_connect_timeout(),
|
||||
read_timeout: d_read_timeout(),
|
||||
timeout: d_timeout(),
|
||||
pool_timeout: d_pool_timeout(),
|
||||
stub_ips_timeout: d_stub_ips_timeout(),
|
||||
tcp_block_min_kb: d_tcp_block_min_kb(),
|
||||
|
|
@ -539,7 +544,7 @@ impl Default for AppConfig {
|
|||
domains_file: d_domains_file(),
|
||||
tcp16_file: d_tcp16_file(),
|
||||
whitelist_sni_file: d_whitelist_sni_file(),
|
||||
quic_timeout: d_quic_timeout(),
|
||||
quic_unsupported_file: d_quic_unsupported_file(),
|
||||
debug: false,
|
||||
config_load_error: None,
|
||||
config_warnings: Vec::new(),
|
||||
|
|
@ -548,7 +553,7 @@ impl Default for AppConfig {
|
|||
}
|
||||
|
||||
const KNOWN_KEYS: &[&str] = &[
|
||||
"MAX_CONCURRENT", "IP_VERSION", "TLS_FINGERPRINT", "PROXY_URL", "CONNECT_TIMEOUT", "READ_TIMEOUT",
|
||||
"MAX_CONCURRENT", "IP_VERSION", "TLS_FINGERPRINT", "PROXY_URL", "TIMEOUT",
|
||||
"POOL_TIMEOUT", "STUB_IPS_TIMEOUT", "TCP_BLOCK_MIN_KB", "TCP_BLOCK_MAX_KB",
|
||||
"FAT_DEFAULT_SNI", "FAT_CONNECT_TIMEOUT", "FAT_READ_TIMEOUT", "USER_AGENT",
|
||||
"WSAECONNRESET", "WSAECONNREFUSED", "WSAETIMEDOUT", "WSAENETUNREACH",
|
||||
|
|
@ -564,7 +569,7 @@ const KNOWN_KEYS: &[&str] = &[
|
|||
"PIN_CACHE_TTL", "CONCURRENCY_PRESETS", "BYPASS_TOOLS", "DNS_KNOWN_RESOLVER_NAMES",
|
||||
"DNS_HIJACK_EXEMPT_RESOLVERS",
|
||||
"DNS_STUB_THRESHOLD", "DEBUG", "CYMRU_DOH_SERVERS", "IP4_LOOKUP_URLS",
|
||||
"IP6_LOOKUP_URLS", "IP_LOOKUP_URLS", "QUIC_TIMEOUT",
|
||||
"IP6_LOOKUP_URLS", "IP_LOOKUP_URLS",
|
||||
];
|
||||
|
||||
/// True when `value` can be an HTTP header value: `http` refuses every control
|
||||
|
|
@ -625,15 +630,15 @@ fn sanitize_mapping(mapping: &mut serde_yaml::Mapping, warnings: &mut Vec<Config
|
|||
v.as_u64().is_some_and(|p| (1..=65535).contains(&p))
|
||||
}
|
||||
"DNS_STUB_THRESHOLD" => v.as_u64().is_some_and(|t| (1..=50).contains(&t)),
|
||||
"CONNECT_TIMEOUT" | "READ_TIMEOUT" | "POOL_TIMEOUT" | "STUB_IPS_TIMEOUT"
|
||||
"TIMEOUT" | "POOL_TIMEOUT" | "STUB_IPS_TIMEOUT"
|
||||
| "DNS_CHECK_TIMEOUT" | "DNS_AVAILABILITY_TIMEOUT" | "FAT_CONNECT_TIMEOUT"
|
||||
| "FAT_READ_TIMEOUT" | "FAT_CHUNK_DELAY" | "TELEGRAM_MEDIA_SIZE_MB"
|
||||
| "TELEGRAM_UPLOAD_SIZE_MB" | "TELEGRAM_STALL_TIMEOUT" | "TELEGRAM_TOTAL_TIMEOUT"
|
||||
| "TELEGRAM_DC_PING_TIMEOUT" | "QUIC_TIMEOUT" => {
|
||||
| "TELEGRAM_DC_PING_TIMEOUT" => {
|
||||
if !is_num(v) || v.as_f64().is_none_or(|f| f <= 0.0) {
|
||||
false
|
||||
} else {
|
||||
// Normalize ints (e.g. `CONNECT_TIMEOUT: 15`) to f64 so the
|
||||
// Normalize ints (e.g. `TIMEOUT: 15`) to f64 so the
|
||||
// f64 struct field deserializes instead of failing the file.
|
||||
if v.as_u64().is_some() {
|
||||
if let Some(f) = v.as_f64() {
|
||||
|
|
@ -913,7 +918,8 @@ impl AppConfig {
|
|||
}
|
||||
|
||||
/// Shipped lists embedded into the binary (workspace-root config.yml,
|
||||
/// domains.txt, burst-domains.txt, tcp16.json, whitelist_sni.txt). External
|
||||
/// domains.txt, burst-domains.txt, tcp16.json, whitelist_sni.txt,
|
||||
/// quic_unsupported.txt). External
|
||||
/// files (cwd, then exe dir) win when present; the embedded copies keep the
|
||||
/// standalone binary fully working from any directory, including routers.
|
||||
pub const EMBEDDED_CONFIG_YML: &str = include_str!("../../../config.yml");
|
||||
|
|
@ -921,6 +927,7 @@ const EMBEDDED_DOMAINS_TXT: &str = include_str!("../../../domains.txt");
|
|||
const EMBEDDED_BURST_DOMAINS_TXT: &str = include_str!("../../../burst-domains.txt");
|
||||
const EMBEDDED_TCP16_JSON: &str = include_str!("../../../tcp16.json");
|
||||
const EMBEDDED_WHITELIST_SNI_TXT: &str = include_str!("../../../whitelist_sni.txt");
|
||||
const EMBEDDED_QUIC_UNSUPPORTED_TXT: &str = include_str!("../../../quic_unsupported.txt");
|
||||
|
||||
/// Locates config.yml: current dir first, then executable dir; `None` when
|
||||
/// neither has one.
|
||||
|
|
@ -1084,6 +1091,30 @@ pub fn embedded_whitelist_sni() -> Vec<(String, usize)> {
|
|||
parse_whitelist_sni(EMBEDDED_WHITELIST_SNI_TXT)
|
||||
}
|
||||
|
||||
/// Loads the hosts whose endpoint serves no HTTP/3. The read error is returned
|
||||
/// rather than flattened, for the reason `load_whitelist_sni` documents.
|
||||
pub fn load_quic_unsupported(path: impl AsRef<Path>) -> io::Result<Vec<String>> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
Ok(parse_quic_unsupported(&content))
|
||||
}
|
||||
|
||||
/// Line parsing shared by the file and the embedded list: blank lines and `#`
|
||||
/// comments dropped, the rest lowercased (hosts are matched whole, and DNS names
|
||||
/// are case-insensitive).
|
||||
fn parse_quic_unsupported(content: &str) -> Vec<String> {
|
||||
content
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.map(|l| l.to_ascii_lowercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The QUIC-unsupported list shipped inside the binary.
|
||||
pub fn embedded_quic_unsupported() -> Vec<String> {
|
||||
parse_quic_unsupported(EMBEDDED_QUIC_UNSUPPORTED_TXT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Tcp16Target {
|
||||
#[serde(default)]
|
||||
|
|
@ -1167,7 +1198,7 @@ mod tests {
|
|||
let cfg = AppConfig::default();
|
||||
assert_eq!(cfg.max_concurrent, 50);
|
||||
assert_eq!(cfg.ip_version, "ipv4");
|
||||
assert!(cfg.connect_timeout > 0.0);
|
||||
assert!(cfg.timeout > 0.0);
|
||||
assert!(cfg.dns_egress_concurrency > 0);
|
||||
assert_eq!(cfg.dns_stub_threshold, 2);
|
||||
assert_eq!(cfg.fat_chunks_count, 10);
|
||||
|
|
@ -1313,27 +1344,27 @@ mod tests {
|
|||
/// keeps its default and is named in the warnings.
|
||||
#[test]
|
||||
fn test_invalid_values_retain_default() {
|
||||
let yaml = "MAX_CONCURRENT: \"one hundred\"\nIP_VERSION: \"ipv5\"\nCONNECT_TIMEOUT: -5.0\nUNKNOWN_SECRET_KEY: 12345\n";
|
||||
let yaml = "MAX_CONCURRENT: \"one hundred\"\nIP_VERSION: \"ipv5\"\nTIMEOUT: -5.0\nUNKNOWN_SECRET_KEY: 12345\n";
|
||||
let cfg = AppConfig::from_yaml_str(yaml);
|
||||
assert_eq!(cfg.max_concurrent, 50);
|
||||
assert_eq!(cfg.ip_version, "ipv4");
|
||||
assert_eq!(cfg.connect_timeout, 8.0);
|
||||
assert_eq!(cfg.timeout, 8.0);
|
||||
// Assert the typed warning, not its `Debug` rendering: that derive is
|
||||
// not a stable format, and the variant plus its payload is the contract.
|
||||
assert!(cfg.config_warnings.contains(&ConfigWarning::InvalidValue { key: "MAX_CONCURRENT".to_string() }));
|
||||
assert!(cfg.config_warnings.contains(&ConfigWarning::InvalidValue { key: "IP_VERSION".to_string() }));
|
||||
assert!(cfg.config_warnings.contains(&ConfigWarning::InvalidValue { key: "CONNECT_TIMEOUT".to_string() }));
|
||||
assert!(cfg.config_warnings.contains(&ConfigWarning::InvalidValue { key: "TIMEOUT".to_string() }));
|
||||
assert!(cfg.config_warnings.contains(&ConfigWarning::UnknownKey { key: "UNKNOWN_SECRET_KEY".to_string() }));
|
||||
}
|
||||
|
||||
/// A config whose every key is valid loads with no warnings.
|
||||
#[test]
|
||||
fn test_valid_custom_config() {
|
||||
let yaml = "MAX_CONCURRENT: 25\nIP_VERSION: \"ipv6\"\nCONNECT_TIMEOUT: 15.0\nDNS_STUB_THRESHOLD: 3\n";
|
||||
let yaml = "MAX_CONCURRENT: 25\nIP_VERSION: \"ipv6\"\nTIMEOUT: 15.0\nDNS_STUB_THRESHOLD: 3\n";
|
||||
let cfg = AppConfig::from_yaml_str(yaml);
|
||||
assert_eq!(cfg.max_concurrent, 25);
|
||||
assert_eq!(cfg.ip_version, "ipv6");
|
||||
assert_eq!(cfg.connect_timeout, 15.0);
|
||||
assert_eq!(cfg.timeout, 15.0);
|
||||
assert_eq!(cfg.dns_stub_threshold, 3);
|
||||
assert!(cfg.config_warnings.is_empty(), "{:?}", cfg.config_warnings);
|
||||
}
|
||||
|
|
@ -1385,7 +1416,7 @@ mod tests {
|
|||
assert!(cfg.dns_known_resolver_names.contains(&"google".to_string()));
|
||||
assert!(cfg.dns_known_resolver_names.contains(&"yandex".to_string()));
|
||||
assert_eq!(cfg.dns_hijack_exempt_resolvers, vec!["MSK-IX", "НСДИ"]);
|
||||
assert_eq!(cfg.quic_timeout, 8.0);
|
||||
assert_eq!(cfg.timeout, 8.0);
|
||||
}
|
||||
|
||||
/// No-file fallbacks carry the same lists (embedded use without config.yml).
|
||||
|
|
@ -1404,7 +1435,7 @@ mod tests {
|
|||
assert_eq!(def.dns_known_resolver_names, from_yml.dns_known_resolver_names);
|
||||
assert_eq!(def.dns_hijack_exempt_resolvers, from_yml.dns_hijack_exempt_resolvers);
|
||||
assert_eq!(def.concurrency_presets, from_yml.concurrency_presets);
|
||||
assert_eq!(def.quic_timeout, from_yml.quic_timeout);
|
||||
assert_eq!(def.timeout, from_yml.timeout);
|
||||
}
|
||||
|
||||
/// Embedded fallbacks parse to the same lists as the shipped files.
|
||||
|
|
@ -1427,6 +1458,21 @@ mod tests {
|
|||
assert_eq!(embedded_tcp16_targets(), file_targets);
|
||||
assert!(!embedded_tcp16_targets().is_empty());
|
||||
assert!(!embedded_whitelist_sni().is_empty());
|
||||
// The QUIC-unsupported list: the same invariant (every shipped line
|
||||
// survives parsing) and the one property the column depends on — its
|
||||
// hosts are hosts test 2 actually probes, or the dash never appears.
|
||||
let unsupported = embedded_quic_unsupported();
|
||||
assert!(!unsupported.is_empty(), "the fallback ships a list");
|
||||
assert!(unsupported.contains(&"www.canva.com".to_string()));
|
||||
let shipped_unsupported = EMBEDDED_QUIC_UNSUPPORTED_TXT
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
|
||||
.count();
|
||||
assert_eq!(unsupported.len(), shipped_unsupported, "every shipped host survives parsing");
|
||||
assert!(
|
||||
unsupported.iter().any(|h| domains.contains(h)),
|
||||
"the list names hosts the shipped domains.txt probes"
|
||||
);
|
||||
// Test 6 ships its own short list of ordinary hosts: same invariant, and
|
||||
// it is not the censored-sites list test 2 probes.
|
||||
let burst = embedded_burst_domains();
|
||||
|
|
|
|||
|
|
@ -55,15 +55,15 @@ pub enum ProgressBlock {
|
|||
Dot,
|
||||
/// Egress fingerprint probes (`whoami.akamai.net`)
|
||||
Egress,
|
||||
/// Test 2 stage 0: DNS resolve of the target domains
|
||||
/// Test 2 column: DNS resolve of the target domains
|
||||
DomainDns,
|
||||
/// Test 2 stage 1: TLS 1.3 handshakes
|
||||
/// Test 2 column: TLS 1.3 handshakes
|
||||
DomainTls13,
|
||||
/// Test 2 stage 2: TLS 1.2 handshakes
|
||||
/// Test 2 column: TLS 1.2 handshakes
|
||||
DomainTls12,
|
||||
/// Test 2 stage 3: plain HTTP requests
|
||||
/// Test 2 column: plain HTTP requests
|
||||
DomainHttp,
|
||||
/// Test 2 stage 4: QUIC Initials on UDP 443
|
||||
/// Test 2 column: QUIC Initials on UDP 443
|
||||
DomainQuic,
|
||||
}
|
||||
|
||||
|
|
@ -84,10 +84,11 @@ impl ProgressBlock {
|
|||
}
|
||||
|
||||
impl PhaseId {
|
||||
/// The counter this phase owns on a line shared with other stages, for the
|
||||
/// phases that run one after another inside one test (test 2: DNS →
|
||||
/// TLS 1.3 → TLS 1.2 → HTTP → QUIC). Phases that own a whole line return
|
||||
/// `None`.
|
||||
/// The counter this phase owns on a line shared with other columns of the
|
||||
/// same test: test 2's five columns (DNS → TLS 1.3 → TLS 1.2 → HTTP → QUIC)
|
||||
/// share one line and are declared together before the run starts, because
|
||||
/// the columns advance at once rather than one after another. Phases that
|
||||
/// own a whole line return `None`.
|
||||
pub fn stage_block(self) -> Option<ProgressBlock> {
|
||||
match self {
|
||||
PhaseId::DomainDns => Some(ProgressBlock::DomainDns),
|
||||
|
|
|
|||
|
|
@ -919,7 +919,7 @@ pub(crate) async fn check_http(
|
|||
};
|
||||
|
||||
*stage.lock() = ProbeStage::SendingData;
|
||||
let resp = match timeout(Duration::from_secs_f64(cfg.read_timeout), sender.send(req)).await {
|
||||
let resp = match timeout(Duration::from_secs_f64(cfg.timeout), sender.send(req)).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
// The stage the request went out under, read out of the guard:
|
||||
|
|
@ -959,7 +959,7 @@ pub(crate) async fn check_http(
|
|||
let mut body = resp.into_body();
|
||||
let mut bytes_read: usize = 0;
|
||||
loop {
|
||||
match timeout(Duration::from_secs_f64(cfg.read_timeout), body.frame()).await {
|
||||
match timeout(Duration::from_secs_f64(cfg.timeout), body.frame()).await {
|
||||
Ok(Some(Ok(frame))) => {
|
||||
if let Some(data) = frame.data_ref() {
|
||||
bytes_read += data.len();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
//! unreachable" (type 3 code 1), "host administratively prohibited" (type 3
|
||||
//! code 13, what a provider's filter answers with) and the rest of the
|
||||
//! destination-unreachable codes all arrive as the same number, so the errno
|
||||
//! alone cannot say whether the route or a filter refused the flow.
|
||||
//! alone cannot say whether the route or a filter refused the flow. A time
|
||||
//! exceeded (type 11) is the same story from the other end: a router on the path
|
||||
//! expired the datagram, and the errno it produces differs by platform, so the
|
||||
//! message is what names it.
|
||||
//!
|
||||
//! The kernel does not hand that message over for a failed TCP connect: the
|
||||
//! socket's error queue stays empty even with `IP_RECVERR` set and the errno
|
||||
|
|
@ -43,8 +46,11 @@ use parking_lot::Mutex;
|
|||
|
||||
use crate::classify::IcmpCode;
|
||||
|
||||
/// ICMP type "destination unreachable" — the only type that quotes a packet.
|
||||
/// ICMP type "destination unreachable", "time exceeded" and "parameter problem"
|
||||
/// — the three types that quote the packet they are about.
|
||||
const DEST_UNREACH: u8 = 3;
|
||||
const TIME_EXCEEDED: u8 = 11;
|
||||
const PARAMETER_PROBLEM: u8 = 12;
|
||||
|
||||
/// Offset of the destination address inside a quoted IPv4 header, and the
|
||||
/// smallest header that can hold it.
|
||||
|
|
@ -157,9 +163,9 @@ cfg_select! {
|
|||
/// Reads the ICMP type, the code, and the destination of the packet it quotes.
|
||||
///
|
||||
/// A raw socket is handed the packet from its IPv4 header, so the ICMP message
|
||||
/// starts at the header's length, not at the first byte. Only a
|
||||
/// destination-unreachable quotes a packet, and only the quoted header's
|
||||
/// destination address is needed to name what the verdict is about.
|
||||
/// starts at the header's length, not at the first byte. A destination
|
||||
/// unreachable and a time exceeded both quote a packet, and only the quoted
|
||||
/// header's destination address is needed to name what the verdict is about.
|
||||
fn parse_icmp(packet: &[u8]) -> Option<(Ipv4Addr, IcmpCode)> {
|
||||
const ICMP_HEADER: usize = 8;
|
||||
|
||||
|
|
@ -168,7 +174,7 @@ fn parse_icmp(packet: &[u8]) -> Option<(Ipv4Addr, IcmpCode)> {
|
|||
return None;
|
||||
}
|
||||
let icmp = &packet[ip_len..];
|
||||
if icmp[0] != DEST_UNREACH {
|
||||
if icmp[0] != DEST_UNREACH && icmp[0] != TIME_EXCEEDED && icmp[0] != PARAMETER_PROBLEM {
|
||||
return None;
|
||||
}
|
||||
let quoted = &icmp[ICMP_HEADER..];
|
||||
|
|
@ -216,6 +222,19 @@ mod tests {
|
|||
assert_eq!(code, IcmpCode::ADMIN_PROHIBITED);
|
||||
}
|
||||
|
||||
/// The three types that quote a packet are read; anything else is not a
|
||||
/// verdict about a flow and must not be recorded as one.
|
||||
#[test]
|
||||
fn the_quoting_types_are_read_and_the_rest_are_not() {
|
||||
for (icmp_type, code) in [(DEST_UNREACH, 4), (TIME_EXCEEDED, 0), (PARAMETER_PROBLEM, 0)] {
|
||||
let parsed = parse_icmp(&unreachable(icmp_type, code, [203, 0, 113, 9]));
|
||||
assert!(parsed.is_some(), "type {icmp_type} quotes a packet");
|
||||
assert_eq!(parsed.unwrap().1, IcmpCode { icmp_type, icmp_code: code });
|
||||
}
|
||||
// An echo request (type 8) carries no quoted packet to name a flow by.
|
||||
assert!(parse_icmp(&unreachable(8, 0, [203, 0, 113, 9])).is_none());
|
||||
}
|
||||
|
||||
/// A real one, captured on the router: the provider's filter answering a
|
||||
/// connection to 194.41.112.143, with that SYN quoted inside. The leading
|
||||
/// fourteen bytes of the capture are the Ethernet header, which a raw socket
|
||||
|
|
|
|||
|
|
@ -353,6 +353,31 @@ fn seal_initial(
|
|||
scid: &[u8],
|
||||
packet_number: u64,
|
||||
frames: Vec<u8>,
|
||||
) -> Result<Vec<u8>, QuicError> {
|
||||
seal_with(&keys.client, dcid, scid, packet_number, frames)
|
||||
}
|
||||
|
||||
/// The same sealing on the server half of the keys. Only the tests need it: a
|
||||
/// probe that has to answer itself (a fake endpoint on the other end of the
|
||||
/// socket) is the only caller that seals what it does not read.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn seal_server_initial(
|
||||
keys: &InitialKeys,
|
||||
dcid: &[u8],
|
||||
scid: &[u8],
|
||||
packet_number: u64,
|
||||
frames: Vec<u8>,
|
||||
) -> Result<Vec<u8>, QuicError> {
|
||||
seal_with(&keys.server, dcid, scid, packet_number, frames)
|
||||
}
|
||||
|
||||
/// One protected Initial datagram in the direction of `secrets`.
|
||||
fn seal_with(
|
||||
secrets: &Secrets,
|
||||
dcid: &[u8],
|
||||
scid: &[u8],
|
||||
packet_number: u64,
|
||||
frames: Vec<u8>,
|
||||
) -> Result<Vec<u8>, QuicError> {
|
||||
let mut packet = Vec::with_capacity(INITIAL_DATAGRAM);
|
||||
packet.push(0xc0 | (PN_LEN as u8 - 1)); // long header, Initial, fixed bit
|
||||
|
|
@ -369,9 +394,9 @@ fn seal_initial(
|
|||
// The AEAD covers header + packet number as associated data (RFC 9001 §5.3).
|
||||
let aad = packet.clone();
|
||||
let mut payload = frames;
|
||||
seal(&keys.client, &aad, packet_number, &mut payload)?;
|
||||
seal(secrets, &aad, packet_number, &mut payload)?;
|
||||
packet.extend_from_slice(&payload);
|
||||
protect_header(&keys.client, &mut packet, pn_offset)?;
|
||||
protect_header(secrets, &mut packet, pn_offset)?;
|
||||
Ok(packet)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -667,7 +667,7 @@ fn answered(axis: BurstTlsVersion, negotiated: Option<ProtocolVersion>) -> (DpiS
|
|||
}
|
||||
|
||||
/// Runs the attempt: the handshake, and — when it completes — the request under
|
||||
/// `cfg.read_timeout` ([`check_http`]). The clock covers the whole attempt, dial
|
||||
/// `cfg.timeout` ([`check_http`]). The clock covers the whole attempt, dial
|
||||
/// excluded: what the reported duration compares across attempts is the answer
|
||||
/// the host gave, not how long the previous one took.
|
||||
async fn handshake_attempt(
|
||||
|
|
@ -700,7 +700,7 @@ async fn handshake_attempt(
|
|||
// answered and then cut, redirected or blocked the moment the
|
||||
// request goes out, and that is exactly what the test is for.
|
||||
let http = match timeout(
|
||||
Duration::from_secs_f64(cfg.read_timeout),
|
||||
Duration::from_secs_f64(cfg.timeout),
|
||||
check_http(stream, domain, cfg, fingerprint, &stage, false),
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -14,7 +14,7 @@ pub mod burst;
|
|||
pub mod quic;
|
||||
pub mod telegram;
|
||||
pub mod whitelist;
|
||||
pub use quic::{check_quic_all, QuicCheck};
|
||||
pub use quic::QuicCheck;
|
||||
pub use burst::{burst_targets, BurstAttempt, BurstProfileReport, BurstReport, BurstSettings, BurstTarget};
|
||||
pub use tcp16::check_tcp_16_20;
|
||||
pub use telegram::{probe_telegram_all_dcs, probe_telegram_dc, run_download, run_telegram_full, run_telegram_test, run_upload, TelegramDcResult, TelegramFullReport, TelegramReport, TransferStats, TransferStatus};
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@
|
|||
//! that did not come from the endpoint.
|
||||
//! * `DROP` — nothing came back, the silence `SYN DROP` is for TCP.
|
||||
//!
|
||||
//! A sixth state is not a verdict: the dash (`quic_unsupported`) is a row whose
|
||||
//! endpoint serves no HTTP/3 at all, known before the probe from the shipped
|
||||
//! `quic_unsupported.txt`, so no Initial is sent for it.
|
||||
//!
|
||||
//! What it does *not* say: that the site answers HTTP/3 with `:status 200`. That
|
||||
//! needs the whole handshake and a request, which nothing in this tree drives —
|
||||
//! the provider ships QUIC key material, but no TLS state is run over QUIC
|
||||
|
|
@ -31,11 +35,8 @@
|
|||
//! TLS columns beside it.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::classify::{classify_connect_error_icmp, Detail, DpiStatus, ProbeStage};
|
||||
use crate::config::AppConfig;
|
||||
use crate::net::bind::udp_socket;
|
||||
|
|
@ -45,9 +46,6 @@ use crate::net::fingerprint::{
|
|||
};
|
||||
use crate::net::quic::{self, CryptoStream, Frame, ServerReply, SERVER_HELLO};
|
||||
use crate::net::tls::{hello_record_for, TlsProfile, TlsVersion};
|
||||
use crate::probe::domains::DomainEntry;
|
||||
use crate::probe::permit;
|
||||
use crate::{PhaseId, PhaseProgress};
|
||||
|
||||
/// The QUIC transport-parameters extension (RFC 9000 §7.4.1 / RFC 9001 §8.2).
|
||||
/// rustls emits it only on a QUIC connection, so it is injected as a raw
|
||||
|
|
@ -115,6 +113,23 @@ const READ_BUF: usize = 2048;
|
|||
const PTO_FIRST: Duration = Duration::from_millis(666);
|
||||
const MAX_RETRANSMITS: u32 = 3;
|
||||
|
||||
/// The shortest window a QUIC probe accepts, whatever `TIMEOUT` says. The ladder
|
||||
/// above spends `1 + 2 + 4` PTOs before the last repeat goes out (4.66 s), and the
|
||||
/// reply to that repeat needs a PTO of its own. A shorter window stops the probe
|
||||
/// before its last repeat, and the verdict then describes the window instead of
|
||||
/// the path: measured with a local `nfqws2` QUIC desync in front of the machine
|
||||
/// (`fake:blob=quic_google:repeats=3`), the readable handshake arrived only after
|
||||
/// the third repeat, and a 3 s window read the desync's own fake packets as
|
||||
/// `CLOSED`/`quic_unreadable_reply`. Nine PTOs = the ladder's seven plus two for
|
||||
/// the reply.
|
||||
const MIN_WINDOW: Duration = PTO_FIRST.saturating_mul(9);
|
||||
|
||||
/// The window one QUIC probe gets: the configured timeout, never shorter than the
|
||||
/// ladder the probe itself runs ([`MIN_WINDOW`]).
|
||||
fn reply_window(timeout: f64) -> Duration {
|
||||
Duration::from_secs_f64(timeout.max(0.1)).max(MIN_WINDOW)
|
||||
}
|
||||
|
||||
/// The QUIC column of one domain row.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuicCheck {
|
||||
|
|
@ -129,6 +144,14 @@ impl QuicCheck {
|
|||
pub fn pending() -> Self {
|
||||
Self { status: DpiStatus::Unknown, detail: Detail::None, elapsed: 0.0 }
|
||||
}
|
||||
|
||||
/// The cell of a row whose endpoint serves no HTTP/3 at all (the shipped
|
||||
/// `quic_unsupported.txt`): no Initial was sent, so there is nothing to time
|
||||
/// and nothing to explain — the dash is the whole verdict, and the summary
|
||||
/// counts the row out of this column's total.
|
||||
pub fn unsupported() -> Self {
|
||||
Self { status: DpiStatus::QuicUnsupported, detail: Detail::None, elapsed: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// The serialized transport parameters of RFC 9000 §18.2: the browser set above,
|
||||
|
|
@ -386,7 +409,7 @@ pub async fn check_domain_quic(
|
|||
cfg: &AppConfig,
|
||||
) -> QuicCheck {
|
||||
let started = Instant::now();
|
||||
let timeout_dur = Duration::from_secs_f64(cfg.quic_timeout.max(0.1));
|
||||
let timeout_dur = reply_window(cfg.timeout);
|
||||
let addr = SocketAddr::new(target, QUIC_PORT);
|
||||
let fingerprint = TlsFingerprint::from_config(cfg);
|
||||
match probe_addr(addr, domain, fingerprint, timeout_dur).await {
|
||||
|
|
@ -418,6 +441,26 @@ fn quic_socket_verdict(
|
|||
}
|
||||
}
|
||||
|
||||
/// What a socket error does to a QUIC attempt.
|
||||
///
|
||||
/// Returns the verdict that ends the attempt, or `None` when the error is waited
|
||||
/// out — with the verdict left in `pending` to be reported if nothing better
|
||||
/// arrives. One class is waited out: the ICMP a *local* desync puts on this
|
||||
/// socket. Its fake has to borrow the flow's 5-tuple to fool a filter, so the
|
||||
/// ICMP for a TTL-carrying fake lands on the probe's own socket — and it says
|
||||
/// nothing about the probe's datagrams, which carry a normal hop limit. Ending
|
||||
/// the attempt on it read as `OS ERR` where the endpoint was about to answer.
|
||||
fn on_socket_error(
|
||||
classified: (DpiStatus, Detail),
|
||||
pending: &mut Option<(DpiStatus, Detail)>,
|
||||
) -> Option<(DpiStatus, Detail)> {
|
||||
if classified.1 == Detail::IcmpTimeExceeded {
|
||||
*pending = Some(classified);
|
||||
return None;
|
||||
}
|
||||
Some(classified)
|
||||
}
|
||||
|
||||
/// One address: the datagram, the window, and the verdict.
|
||||
///
|
||||
/// `Err` is a transport failure — the socket's own error — and `Ok` a verdict
|
||||
|
|
@ -474,6 +517,7 @@ async fn probe_addr(
|
|||
let mut next_retransmit = Instant::now() + PTO_FIRST;
|
||||
let mut retransmits: u32 = 0;
|
||||
let mut unreadable: usize = 0;
|
||||
let mut path_error: Option<(DpiStatus, Detail)> = None;
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
let left = deadline.saturating_duration_since(now);
|
||||
|
|
@ -523,7 +567,11 @@ async fn probe_addr(
|
|||
}
|
||||
Ok(Err(error)) => {
|
||||
let icmp = crate::net::icmp_err::verdict_wait(addr.ip()).await;
|
||||
return Err(quic_socket_verdict(&error, icmp));
|
||||
let classified = quic_socket_verdict(&error, icmp);
|
||||
if let Some(verdict) = on_socket_error(classified, &mut path_error) {
|
||||
return Err(verdict);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -540,50 +588,6 @@ async fn probe_addr(
|
|||
Ok((status, detail))
|
||||
}
|
||||
|
||||
/// Test 2's QUIC phase: one Initial per entry that resolved to a clean address,
|
||||
/// `concurrency` probes at a time, results written back into the rows.
|
||||
///
|
||||
/// The same filter the TLS phases use — a fake-IP or stub row has no real peer
|
||||
/// to ask — and the same resolved address, so the column describes the peer the
|
||||
/// TLS columns describe.
|
||||
pub async fn check_quic_all(
|
||||
entries: &mut [DomainEntry],
|
||||
cfg: &AppConfig,
|
||||
sem: &Arc<Semaphore>,
|
||||
phases: Option<PhaseProgress>,
|
||||
) {
|
||||
let total = entries.iter().filter(|e| e.dns_fake == Some(false)).count();
|
||||
let tick = phases.as_ref().map(|p| (p.on_phase)(PhaseId::DomainQuic, total));
|
||||
// One config for every task instead of one per domain: each deep clone
|
||||
// carries the whole DNS server list, and the clones pile up while the tasks
|
||||
// wait on the gate (`probe/whitelist.rs` shares it the same way).
|
||||
let cfg = Arc::new(cfg.clone());
|
||||
let mut handles = Vec::new();
|
||||
for (idx, e) in entries.iter().enumerate() {
|
||||
if e.dns_fake != Some(false) {
|
||||
continue;
|
||||
}
|
||||
let Some(target) = e.resolved else { continue };
|
||||
let domain = e.domain.clone();
|
||||
let cfg = Arc::clone(&cfg);
|
||||
let sem = Arc::clone(sem);
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = permit(&sem).await;
|
||||
let check = check_domain_quic(&domain, target, &cfg).await;
|
||||
(idx, check)
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
let done = h.await;
|
||||
if let Some(t) = tick.as_ref() {
|
||||
t();
|
||||
}
|
||||
if let Ok((idx, check)) = done {
|
||||
entries[idx].quic = check;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -930,47 +934,104 @@ mod tests {
|
|||
assert_eq!(dropped, Err((DpiStatus::QuicDrop, Detail::QuicTimeout)));
|
||||
}
|
||||
|
||||
/// The whole phase, on rows the caller already resolved: the report writes
|
||||
/// into the entries and skips the ones without a clean address.
|
||||
/// The window the probe takes must outlast the probe's own ladder: seven
|
||||
/// PTOs go before the last repeat is even sent, and the reply to it needs a
|
||||
/// PTO more. A `TIMEOUT` of 3 s used to end the probe at the second repeat.
|
||||
#[test]
|
||||
fn the_quic_window_outlasts_the_retransmit_ladder() {
|
||||
let ladder_end = PTO_FIRST * 7; // the 1 + 2 + 4 PTOs before the third repeat
|
||||
for asked in [1.0, 3.0, 8.0] {
|
||||
let window = reply_window(asked);
|
||||
assert!(
|
||||
window >= ladder_end + PTO_FIRST,
|
||||
"{asked}s asked, {window:?} granted, the ladder ends at {ladder_end:?}"
|
||||
);
|
||||
}
|
||||
// The floor raises a short timeout and never shortens a long one.
|
||||
assert_eq!(reply_window(60.0), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
/// An endpoint that answers the first flight with a packet no key opens and
|
||||
/// sends its ServerHello only later — the shape measured against Cloudflare's
|
||||
/// edge. Under a 3 s `TIMEOUT` the probe used to stop at the second repeat
|
||||
/// and report `CLOSED`/`quic_unreadable_reply`, which describes the window
|
||||
/// and not the endpoint: the ServerHello it never waited for is what a stock
|
||||
/// client gets. The floored window outlasts the ladder, so the reply lands.
|
||||
#[tokio::test]
|
||||
async fn the_phase_fills_clean_rows_and_skips_the_rest() {
|
||||
use crate::probe::domains::{DomainEntry, HttpCheck, TlsCheck};
|
||||
async fn a_late_server_hello_still_reads_ok_under_a_small_timeout() {
|
||||
let server = tokio::net::UdpSocket::bind("127.0.0.1:0").await.expect("a local socket");
|
||||
let addr = server.local_addr().expect("an address");
|
||||
let started = Instant::now();
|
||||
let answering = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 1500];
|
||||
let mut state: Option<(crate::net::quic::InitialKeys, Vec<u8>, Vec<u8>)> = None;
|
||||
while let Ok((len, from)) = server.recv_from(&mut buf).await {
|
||||
if len < 8 {
|
||||
continue;
|
||||
}
|
||||
if state.is_none() {
|
||||
// The client's destination connection ID is what both
|
||||
// directions of the Initial keys come from (RFC 9001 §5.2),
|
||||
// and its source ID is who the reply is addressed to.
|
||||
let dcid_len = usize::from(buf[5]);
|
||||
let dcid = buf[6..6 + dcid_len].to_vec();
|
||||
let at = 6 + dcid_len;
|
||||
let scid_len = usize::from(buf[at]);
|
||||
let scid = buf[at + 1..at + 1 + scid_len].to_vec();
|
||||
let keys = crate::net::quic::InitialKeys::derive(&dcid).expect("initial keys");
|
||||
state = Some((keys, dcid, scid));
|
||||
}
|
||||
let (keys, dcid, scid) = state.as_ref().expect("just set");
|
||||
if started.elapsed() < Duration::from_secs(3) {
|
||||
// A packet no key opens, counted as unreadable rather than
|
||||
// as silence.
|
||||
let mut junk = vec![0xc0u8; 1200];
|
||||
junk[1..5].copy_from_slice(&crate::net::quic::VERSION_1.to_be_bytes());
|
||||
let _ = server.send_to(&junk, from).await;
|
||||
continue;
|
||||
}
|
||||
let hello = server_hello([0x11; 32]);
|
||||
let mut frames = vec![0x06]; // CRYPTO
|
||||
crate::net::quic::write_varint(&mut frames, 0);
|
||||
crate::net::quic::write_varint(&mut frames, hello.len() as u64);
|
||||
frames.extend_from_slice(&hello);
|
||||
let packet = crate::net::quic::seal_server_initial(keys, scid, dcid, 0, frames)
|
||||
.expect("a sealed reply");
|
||||
let _ = server.send_to(&packet, from).await;
|
||||
}
|
||||
});
|
||||
|
||||
let dash = TlsCheck { status: DpiStatus::Unknown, detail: Detail::None, elapsed: 0.0 };
|
||||
let mut entries = vec![
|
||||
DomainEntry {
|
||||
domain: "silent.example".to_string(),
|
||||
resolved: Some("127.0.0.1".parse().expect("a documentation address")),
|
||||
dns_fake: Some(false),
|
||||
t13: dash.clone(),
|
||||
t12: dash.clone(),
|
||||
http: HttpCheck { status: DpiStatus::Unknown, detail: Detail::None },
|
||||
quic: QuicCheck::pending(),
|
||||
},
|
||||
DomainEntry {
|
||||
domain: "stub.example".to_string(),
|
||||
resolved: Some("198.18.5.4".parse().expect("a fake-IP address")),
|
||||
dns_fake: Some(true),
|
||||
t13: dash.clone(),
|
||||
t12: dash.clone(),
|
||||
http: HttpCheck { status: DpiStatus::Unknown, detail: Detail::None },
|
||||
quic: QuicCheck::pending(),
|
||||
},
|
||||
];
|
||||
let cfg = AppConfig { quic_timeout: 0.4, ..AppConfig::default() };
|
||||
let sem = Arc::new(Semaphore::new(2));
|
||||
check_quic_all(&mut entries, &cfg, &sem, None).await;
|
||||
|
||||
// 127.0.0.1:443 has no QUIC endpoint in a test run: either nothing
|
||||
// answers (a drop) or the port is closed (a refusal). Both are verdicts;
|
||||
// what matters is that the row was probed at all.
|
||||
assert!(
|
||||
matches!(entries[0].quic.status, DpiStatus::QuicDrop | DpiStatus::Refused),
|
||||
"got {:?} / {:?}",
|
||||
entries[0].quic.status,
|
||||
entries[0].quic.detail.code()
|
||||
let verdict = probe_addr(addr, "example.com", TlsFingerprint::Chrome107, reply_window(3.0)).await;
|
||||
answering.abort();
|
||||
assert_eq!(
|
||||
verdict,
|
||||
Ok((DpiStatus::QuicOk, Detail::QuicServerHello)),
|
||||
"the late ServerHello is what the window has to wait for"
|
||||
);
|
||||
assert_eq!(entries[1].quic.status, DpiStatus::Unknown, "a fake-IP row is not probed");
|
||||
}
|
||||
|
||||
/// The ICMP a local desync's fake puts on this socket must not end the
|
||||
/// attempt: it is about the fake, not about the probe's datagrams, and
|
||||
/// ending on it read as `OS ERR` where the endpoint was about to answer.
|
||||
#[test]
|
||||
fn a_stray_icmp_does_not_end_the_quic_attempt() {
|
||||
let mut pending = None;
|
||||
assert!(on_socket_error(
|
||||
(DpiStatus::HostUnreach, Detail::IcmpTimeExceeded),
|
||||
&mut pending
|
||||
)
|
||||
.is_none());
|
||||
assert_eq!(pending, Some((DpiStatus::HostUnreach, Detail::IcmpTimeExceeded)));
|
||||
|
||||
// A verdict the probe recognizes still ends it, and leaves nothing to
|
||||
// report in place of it.
|
||||
let mut pending = None;
|
||||
assert!(on_socket_error(
|
||||
(DpiStatus::Refused, Detail::QuicPortUnreachable),
|
||||
&mut pending
|
||||
)
|
||||
.is_some());
|
||||
assert_eq!(pending, None);
|
||||
}
|
||||
|
||||
/// A closed port must read `REFUSED` whichever path noticed it.
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ pub async fn run_whitelist_sni(
|
|||
let item = (*item).clone();
|
||||
let cfg = Arc::clone(&cfg_arc);
|
||||
let sem = Arc::clone(sem);
|
||||
let tick = tick_base.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let default_sni = if cfg.fat_default_sni.is_empty() {
|
||||
"example.com".to_string()
|
||||
|
|
@ -122,16 +123,17 @@ pub async fn run_whitelist_sni(
|
|||
let sni = item.sni.clone().unwrap_or(default_sni);
|
||||
let (status, detail, rtt) =
|
||||
check_tcp_16_20(&item.ip, 443, &sni, &cfg, &sem, None).await;
|
||||
// Ticked by the probe itself, never by the collector loop: the
|
||||
// counter is a live reading and a slow IP must not hold it back.
|
||||
if let Some(t) = tick.as_ref() {
|
||||
t();
|
||||
}
|
||||
(item, status, detail, rtt)
|
||||
}));
|
||||
}
|
||||
let mut base_rows = Vec::new();
|
||||
for h in handles {
|
||||
let done = h.await;
|
||||
if let Some(t) = tick_base.as_ref() {
|
||||
t();
|
||||
}
|
||||
if let Ok(r) = done {
|
||||
if let Ok(r) = h.await {
|
||||
base_rows.push(r);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,9 @@ pub(crate) fn detail_text(detail: &Detail, lang: Language) -> String {
|
|||
Detail::NetUnreach => t4(lang, "Нет маршрута (ICMP unreach)", "Net unreachable", "网络不可达", "Shabake dar dastras nist"),
|
||||
Detail::HostUnreach => t4(lang, "Нет маршрута до хоста", "Host unreachable", "主机不可达", "Mizban dar dastras nist"),
|
||||
Detail::IcmpAdminProhibited => t4(lang, "ICMP: административно запрещено (фильтр провайдера)", "ICMP administratively prohibited (provider filter)", "ICMP 管理禁止(运营商过滤)", "ICMP admin prohibited (filter-e provider)"),
|
||||
Detail::IcmpTimeExceeded => t4(lang, "ICMP: TTL истёк", "ICMP: TTL exceeded", "ICMP:TTL 超时", "ICMP: TTL tamam shod"),
|
||||
Detail::IcmpFragNeeded => t4(lang, "ICMP: нужна фрагментация", "ICMP: fragmentation needed", "ICMP:需要分片", "ICMP: fragment niaz ast"),
|
||||
Detail::IcmpParameterProblem => t4(lang, "ICMP: ошибка в заголовке (parameter problem)", "ICMP: parameter problem", "ICMP:参数问题", "ICMP: moshkel dar header (parameter problem)"),
|
||||
Detail::Ipv6Unsupported => t4(lang, "IPv6 не поддерживается/отключён", "IPv6 not supported/disabled", "IPv6 不受支持或已禁用", "IPv6 poshtibani nemishavad ya ghayr-e fa'al ast"),
|
||||
// Composed: the head is itself a detail, the offset keeps its unit.
|
||||
Detail::AtKb { head, kb } => {
|
||||
|
|
@ -108,20 +111,20 @@ pub(crate) fn detail_text(detail: &Detail, lang: Language) -> String {
|
|||
Detail::UpgradeHttps { status: Some(code) } => format!("{code} → https"),
|
||||
Detail::UpgradeHttps { status: None } => "→ https".to_string(),
|
||||
Detail::Elapsed(secs) => format!("{:.1}s", secs),
|
||||
Detail::QuicServerHello => t4(lang, "Сервер ответил на QUIC-рукопожатие (ServerHello)", "The server answered the QUIC handshake (ServerHello)", "服务器回应了 QUIC 握手(ServerHello)", "Server be handshake-e QUIC pasokh dad (ServerHello)"),
|
||||
Detail::QuicRetry => t4(lang, "Сервер запросил Retry-токен (QUIC-путь работает)", "The server asked for a Retry token (the QUIC path works)", "服务器要求 Retry 令牌(QUIC 路径可用)", "Server token-e Retry khast (masir-e QUIC kar mikonad)"),
|
||||
Detail::QuicForgedRetry => t4(lang, "Retry с неверным integrity-тегом — пакет не от сервера", "Retry with a bad integrity tag — the packet is not the server's", "Retry 的完整性标签错误 — 报文不是服务器发出的", "Retry ba integrity tag-e ghalat — packet az server nist"),
|
||||
Detail::QuicServerHello => t4(lang, "ServerHello получен", "ServerHello received", "已收到 ServerHello", "ServerHello daryaft shod"),
|
||||
Detail::QuicRetry => t4(lang, "Retry-токен", "Retry token", "Retry 令牌", "Token-e Retry"),
|
||||
Detail::QuicForgedRetry => t4(lang, "Поддельный Retry", "Fake Retry", "伪造的 Retry", "Retry-e ja'li"),
|
||||
Detail::QuicClose { error_code } => format!(
|
||||
"{} ({})",
|
||||
t4(lang, "Сервер закрыл QUIC-соединение", "The server closed the QUIC connection", "服务器关闭了 QUIC 连接", "Server ettesal-e QUIC ra bast"),
|
||||
t4(lang, "Закрыто сервером", "Closed by server", "服务器已关闭", "Baste shod az server"),
|
||||
error_code
|
||||
),
|
||||
Detail::QuicReset => t4(lang, "Stateless reset: ответил тот, у кого нет состояния этого соединения", "Stateless reset: something answered that has no state for this connection", "无状态重置:回应方没有此连接的状态", "Stateless reset: kasani pasokh dad ke hich state-i baraye in ettesal nadarad"),
|
||||
Detail::QuicVersionNegotiation => t4(lang, "Сервер не поддерживает QUIC v1 (version negotiation)", "The server does not speak QUIC v1 (version negotiation)", "服务器不支持 QUIC v1(version negotiation)", "Server version-e QUIC v1 ra nadarad (version negotiation)"),
|
||||
Detail::QuicTimeout => t4(lang, "Ответа на Initial нет (UDP 443 молчал всё окно)", "No reply to the Initial (UDP 443 stayed silent for the whole window)", "Initial 没有回应(UDP 443 在整个窗口内没有响应)", "Be Initial pasokhi nayamad (UDP 443 dar tamam-e window sokut kard)"),
|
||||
Detail::QuicUnreadableReply => t4(lang, "Ответ пришёл, но не открывается ни одним ключом соединения (так отвечает эдж на первый Initial)", "Something answered, and no key of this connection opens it (what an edge sends to a first Initial)", "有回应,但本连接的任何密钥都无法解开(边缘对首个 Initial 的回应方式)", "Pasokh amad, vali ba hich kelid-e in ettesal baz nemishavad (pasokh-e edge be avvalin Initial)"),
|
||||
Detail::QuicAnsweredWithoutHandshake => t4(lang, "Эндпоинт ответил, но данных рукопожатия не прислал (только подтверждение)", "The endpoint answered, and sent no handshake data at all (an acknowledgement, nothing else)", "端点有回应,但完全没有握手数据(只有确认)", "Endpoint pasokh dad, vali hich dade-ye handshake nafrestad (faghat acknowledgement)"),
|
||||
Detail::QuicPortUnreachable => t4(lang, "ICMP: на UDP-порту никто не слушает", "ICMP says nothing listens on the UDP port", "ICMP 表示该 UDP 端口无人监听", "ICMP migooyad hich kas ru-ye in port-e UDP gush nemidahad"),
|
||||
Detail::QuicReset => t4(lang, "Сброс без состояния", "Stateless reset", "无状态重置", "Reset-e bedun-e state"),
|
||||
Detail::QuicVersionNegotiation => t4(lang, "Нет QUIC v1", "No QUIC v1", "无 QUIC v1", "QUIC v1 nadarad"),
|
||||
Detail::QuicTimeout => t4(lang, "Таймаут Initial", "Initial timeout", "Initial 超时", "Mohlat-e Initial"),
|
||||
Detail::QuicUnreadableReply => t4(lang, "Нечитаемый ответ", "Unreadable reply", "无法解析的回应", "Pasokh baz nemishavad"),
|
||||
Detail::QuicAnsweredWithoutHandshake => t4(lang, "Ответ без handshake", "Reply without handshake", "回应无握手", "Pasokh bedun-e handshake"),
|
||||
Detail::QuicPortUnreachable => t4(lang, "ICMP: порт закрыт", "ICMP: port closed", "ICMP:端口已关闭", "ICMP: port baste"),
|
||||
// Free text from the OS or the TLS stack: appended as it came.
|
||||
Detail::Other(text) => text.clone(),
|
||||
}
|
||||
|
|
@ -172,11 +175,11 @@ mod tests {
|
|||
// once ended in "(transport error" and the number hung outside it).
|
||||
assert_eq!(
|
||||
detail_text(&Detail::QuicClose { error_code: 10 }, En),
|
||||
"The server closed the QUIC connection (10)"
|
||||
"Closed by server (10)"
|
||||
);
|
||||
assert_eq!(
|
||||
detail_text(&Detail::QuicClose { error_code: 0x12f }, Zh),
|
||||
"服务器关闭了 QUIC 连接 (303)"
|
||||
"服务器已关闭 (303)"
|
||||
);
|
||||
assert_eq!(detail_text(&Detail::None, Ru), "");
|
||||
assert_eq!(detail_text(&Detail::Other("→ https".into()), En), "→ https");
|
||||
|
|
@ -267,6 +270,9 @@ mod tests {
|
|||
Detail::NetUnreach,
|
||||
Detail::HostUnreach,
|
||||
Detail::IcmpAdminProhibited,
|
||||
Detail::IcmpTimeExceeded,
|
||||
Detail::IcmpFragNeeded,
|
||||
Detail::IcmpParameterProblem,
|
||||
Detail::UnknownConnectionFailure,
|
||||
Detail::Ipv6Unsupported,
|
||||
Detail::QuicServerHello,
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ pub(crate) fn messages() -> Messages {
|
|||
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 6): connections per host, overlapping [1-100, default: 10]",
|
||||
cli_burst_timeout: "Test 6: handshake timeout, seconds [default: 8]; the request that follows waits read_timeout",
|
||||
cli_burst_timeout: "Test 6: handshake timeout, seconds [default: 8]; the request that follows waits the shared TIMEOUT",
|
||||
cli_burst_gap: "Test 6: delay between attempt starts, ms [0-1000, default: 20]; 0 fires the whole round at one instant",
|
||||
cli_burst_profiles: "Test 6 fingerprints: all, or profile names separated by commas; --legend lists them [default: the default set]",
|
||||
cli_burst_tls: "Test 6 TLS: 1.3+1.2 (the browser's own offer, must answer 1.3)|1.3 (offers 1.3 only)|1.2 (offers 1.2 only) [default: 1.3+1.2]",
|
||||
|
|
@ -270,6 +270,7 @@ pub(crate) fn messages() -> Messages {
|
|||
domains_load_failed: "Could not build the target list {} — a run with no targets measures nothing, so it stops",
|
||||
domains_list_empty: "{} (no usable domain in it)",
|
||||
whitelist_load_failed: "Could not read the whitelist SNI file {}: using the embedded list instead",
|
||||
quic_unsupported_load_failed: "Could not read the QUIC-unsupported list {}: using the embedded list instead",
|
||||
tcp16_load_failed: "Could not read the --tcp16 list {} — the shipped targets would measure hosts that were not asked for, so it stops",
|
||||
cli_domains: "Path to custom domain list file",
|
||||
cli_tcp16: "Path to custom TCP16 target file",
|
||||
|
|
@ -332,6 +333,7 @@ pub(crate) fn legend_sections_en() -> Vec<(&'static str, Vec<(&'static str, &'st
|
|||
("SPOOF", "A Retry whose integrity tag does not match: the packet did not come from the endpoint, since only a party that saw the Initial can compute that tag"),
|
||||
("DROP", "No reply to the Initial: UDP 443 is filtered or the endpoint is down — the silence SYN DROP is for TCP"),
|
||||
("REFUSED", "ICMP says nothing listens on UDP 443: the path works and the endpoint runs no QUIC at all — not a block (a block is DROP)"),
|
||||
("—", "The endpoint serves no HTTP/3 at all, so no Initial was sent: the host is in the shipped quic_unsupported.txt (the two online testers say so), and the summary counts this column without it"),
|
||||
]),
|
||||
("— Other —", vec![
|
||||
("OK", "Site reachable (200–4xx with no block signs)"),
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ pub(crate) fn messages() -> Messages {
|
|||
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: "Fingerprint/Sibir blocking (test 6): darkhast be har host, hampooshani [1-100, pishfarz: 10]",
|
||||
cli_burst_timeout: "Test 6: timeout-e mosafehe, sanie [pishfarz: 8]; darkhast-e baad az aan montazer-e read_timeout mimanad",
|
||||
cli_burst_timeout: "Test 6: timeout-e mosafehe, sanie [pishfarz: 8]; darkhast-e baad az aan montazer-e TIMEOUT-e moshtarak mimanad",
|
||||
cli_burst_gap: "Test 6: ta'khir beyn-e shoroo-e talash ha, ms [0-1000, pishfarz: 20]; 0 yani hame-ye dore dar yek lahze shoroo mishavad",
|
||||
cli_burst_profiles: "Fingerprint haye test 6: all ya nam-e profile ha ba kama; list ra --legend chap mikonad [pishfarz: set-e pishfarz]",
|
||||
cli_burst_tls: "TLS baraye test 6: 1.3+1.2 (pishnahad-e browser, pasokh bayad 1.3 bashad)|1.3 (faghat 1.3)|1.2 (faghat 1.2) [pishfarz: 1.3+1.2]",
|
||||
|
|
@ -264,6 +264,7 @@ pub(crate) fn messages() -> Messages {
|
|||
domains_load_failed: "List-e target {} sakhte nashod - run-e bedun-e target chizi ra andaze-giri nemikonad, pas motevaqef mishavad",
|
||||
domains_list_empty: "{} (hich domain-e ghabele estefade dar an nist)",
|
||||
whitelist_load_failed: "File-e whitelist SNI {} khande nashod: list-e dakheli estefade mishavad",
|
||||
quic_unsupported_load_failed: "List-e domain-haye bedun-e QUIC {} khande nashod: list-e dakheli estefade mishavad",
|
||||
tcp16_load_failed: "List-e --tcp16 {} khande nashod - target haye dakheli host haye na-khaste ra andaze-giri mikonand, pas motevaqef mishavad",
|
||||
cli_domains: "Masir-e file-e list-e domain ha",
|
||||
cli_tcp16: "Masir-e file-e target haye TCP16",
|
||||
|
|
@ -328,6 +329,7 @@ pub(crate) fn legend_sections_fa() -> Vec<(&'static str, Vec<(&'static str, &'st
|
|||
("SPOOF", "Retry ba integrity tag-e ghalat: packet az endpoint nayamade, chon tag ra faghat kasani ke Initial ra dideand mitavanand hesab konand"),
|
||||
("DROP", "Be Initial pasokhi nayamad: UDP 443 filter shode ya endpoint kharab ast (haman sokuti ke SYN DROP baraye TCP ast)"),
|
||||
("REFUSED", "ICMP: ru-ye UDP-port hich kas goosh nemidahad - masir kar mikonad va endpoint aslan QUIC nadarad; in block nist (block = DROP)"),
|
||||
("—", "Endpoint aslan HTTP/3 nadarad, pas Initial ferestade nashod: host dar quic_unsupported.txt-e dakheli ast (har do tester-e khareji hamin ra migooyand) va summary in sotun ra bedun-e an mishomarad"),
|
||||
]),
|
||||
("- Sayer -", vec![
|
||||
("OK", "Site dar dastras ast (kode 200-4xx bedun-e alayem-e filtering)"),
|
||||
|
|
@ -349,7 +351,10 @@ mod tests {
|
|||
/// language uses in the same places.
|
||||
#[test]
|
||||
fn test_finglish_is_latin_only() {
|
||||
const MARKS: [char; 4] = ['✓', '×', '⚠', '↑'];
|
||||
// The badge glyphs are data, not prose: the legend's term column carries
|
||||
// `DpiStatus::display_label()` verbatim (rule 4), and the dash the QUIC
|
||||
// column prints for a host that serves no HTTP/3 is one of them.
|
||||
const MARKS: [char; 5] = ['✓', '×', '⚠', '↑', '—'];
|
||||
let mut texts = vec![format!("{:?}", messages())];
|
||||
for (section, entries) in legend_sections_fa() {
|
||||
texts.push(section.to_string());
|
||||
|
|
|
|||
|
|
@ -334,6 +334,11 @@ pub(crate) struct Messages {
|
|||
/// `{}` is `<path>: <error>`; the embedded list is used instead, and this is
|
||||
/// what tells an unreadable file from an absent one.
|
||||
pub whitelist_load_failed: &'static str,
|
||||
/// Non-fatal: the QUIC-unsupported list the config named cannot be read.
|
||||
/// `{}` is `<path>: <error>`; the embedded list stands in. The same notice
|
||||
/// the whitelist file gets, for the same reason — an unreadable file must not
|
||||
/// look like an absent one.
|
||||
pub quic_unsupported_load_failed: &'static str,
|
||||
/// Fatal: the list `--tcp16` named cannot be read. `{}` is
|
||||
/// `<path>: <error>`; the run stops, because the shipped targets would
|
||||
/// measure hosts the operator never asked for.
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ pub(crate) fn messages() -> Messages {
|
|||
cli_domain: "Конкретные домены для проверки (флаг можно повторять: -d vk.com -d ya.ru)",
|
||||
cli_output: "Путь к файлу отчёта",
|
||||
cli_burst: "Fingerprint/Сибирская блокировка (тест 6): соединений на домен, внахлёст [1–100, по умолчанию: 10]",
|
||||
cli_burst_timeout: "Тест 6: таймаут рукопожатия, секунды [по умолчанию: 8]; запрос после него ждёт read_timeout",
|
||||
cli_burst_timeout: "Тест 6: таймаут рукопожатия, секунды [по умолчанию: 8]; запрос после него ждёт общий TIMEOUT",
|
||||
cli_burst_gap: "Тест 6: задержка между стартами попыток, мс [0–1000, по умолчанию: 20]; 0 — весь раунд стартует в один момент",
|
||||
cli_burst_profiles: "Fingerprint для теста 6: all или имена профилей через запятую; список печатает --legend [по умолчанию: набор по умолчанию]",
|
||||
cli_burst_tls: "TLS для теста 6: 1.3+1.2 (предложение браузера, ответ должен быть 1.3)|1.3 (только 1.3)|1.2 (только 1.2) [по умолчанию: 1.3+1.2]",
|
||||
|
|
@ -270,6 +270,7 @@ pub(crate) fn messages() -> Messages {
|
|||
domains_load_failed: "Не удалось собрать список целей {} — прогон без целей ничего не измеряет, поэтому он останавливается",
|
||||
domains_list_empty: "{} (нет ни одного пригодного домена)",
|
||||
whitelist_load_failed: "Не удалось прочитать файл белого списка SNI {}: используется встроенный список",
|
||||
quic_unsupported_load_failed: "Не удалось прочитать список доменов без QUIC {}: используется встроенный список",
|
||||
tcp16_load_failed: "Не удалось прочитать список --tcp16 {} — встроенные цели измерили бы не те хосты, поэтому прогон останавливается",
|
||||
cli_domains: "Путь к файлу со списком доменов",
|
||||
cli_tcp16: "Путь к файлу целей TCP16",
|
||||
|
|
@ -331,6 +332,7 @@ pub(crate) fn legend_sections() -> Vec<(&'static str, Vec<(&'static str, &'stati
|
|||
("SPOOF", "Retry с неверным integrity-тегом: пакет пришёл не от эндпоинта — тег может посчитать только тот, кто видел Initial"),
|
||||
("DROP", "Ответа на Initial нет: UDP 443 фильтруется или эндпоинт недоступен (тишина, как SYN DROP для TCP)"),
|
||||
("REFUSED", "ICMP: на UDP-порту никто не слушает — путь работает, QUIC-слушателя у эндпоинта нет вовсе; это не блокировка (блокировка — DROP)"),
|
||||
("—", "Эндпоинт не поддерживает HTTP/3 вовсе, поэтому Initial не отправлялся: хост есть во встроенном quic_unsupported.txt (так говорят оба внешних тестера), и summary считает эту колонку без него"),
|
||||
]),
|
||||
("— Прочее —", vec![
|
||||
("OK", "Сайт доступен (200–4xx без признаков блокировки)"),
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ pub(crate) fn messages() -> Messages {
|
|||
cli_domain: "要测试的指定域名(可重复指定: -d vk.com -d ya.ru)",
|
||||
cli_output: "保存报告的输出文件路径",
|
||||
cli_burst: "Fingerprint/西伯利亚封锁 (测试 6): 每主机连接数, 重叠发起 [1-100, 默认: 10]",
|
||||
cli_burst_timeout: "测试 6: 握手超时, 秒 [默认: 8]; 之后的请求等待 read_timeout",
|
||||
cli_burst_timeout: "测试 6: 握手超时, 秒 [默认: 8]; 之后的请求等待共用的 TIMEOUT",
|
||||
cli_burst_gap: "测试 6: 尝试启动之间的延迟, 毫秒 [0-1000, 默认: 20]; 0 表示整个轮次在同一瞬间发起",
|
||||
cli_burst_profiles: "测试 6 的 Fingerprint: all,或以逗号分隔的配置名;列表见 --legend [默认: 默认集合]",
|
||||
cli_burst_tls: "测试 6 的 TLS: 1.3+1.2(浏览器提议,应答必须为 1.3)|1.3(仅提供 1.3)|1.2(仅提供 1.2)[默认: 1.3+1.2]",
|
||||
|
|
@ -266,6 +266,7 @@ pub(crate) fn messages() -> Messages {
|
|||
domains_load_failed: "无法构建目标列表 {} — 没有目标的测试不会测得任何结果,因此中止",
|
||||
domains_list_empty: "{}(其中没有可用的域名)",
|
||||
whitelist_load_failed: "无法读取白名单 SNI 文件 {}: 改用内置列表",
|
||||
quic_unsupported_load_failed: "无法读取不支持 QUIC 的域名列表 {}: 改用内置列表",
|
||||
tcp16_load_failed: "无法读取 --tcp16 列表 {} — 改用内置目标会测到并非指定的主机,因此中止",
|
||||
cli_domains: "自定义域名列表文件路径",
|
||||
cli_tcp16: "自定义 TCP16 目标文件路径",
|
||||
|
|
@ -328,6 +329,7 @@ pub(crate) fn legend_sections_zh() -> Vec<(&'static str, Vec<(&'static str, &'st
|
|||
("SPOOF", "Retry 的完整性标签不匹配:该报文并非来自端点(只有看到 Initial 的一方能计算该标签)"),
|
||||
("DROP", "Initial 没有回应:UDP 443 被过滤或端点不可用(TCP 中的对应物是 SYN DROP)"),
|
||||
("REFUSED", "ICMP:UDP 端口无人监听——路径可用,端点根本没有 QUIC 监听器;这不是封锁(封锁是 DROP)"),
|
||||
("—", "端点完全不支持 HTTP/3,因此未发送 Initial:该主机在内置的 quic_unsupported.txt 中(两个外部测试器均如此判定),汇总统计该列时不计入它"),
|
||||
]),
|
||||
("— 其他 —", vec![
|
||||
("OK", "站点可正常访问 (状态码 200–4xx 无阻断特征)"),
|
||||
|
|
|
|||
|
|
@ -547,6 +547,11 @@ async fn run() {
|
|||
// this run's word, the file is the last thing the user chose, and the config
|
||||
// is what the operator wrote down.
|
||||
let mut concurrency = saved.concurrency.unwrap_or(cfg.max_concurrent);
|
||||
// The menu's last timeout sits between the flags (there is none for it) and
|
||||
// `config.yml`: what the user last left the row on is this run's word.
|
||||
if let Some(secs) = saved.timeout() {
|
||||
cfg.timeout = secs;
|
||||
}
|
||||
let mut ip_version = saved.ip_version().unwrap_or_else(|| cfg.ip_version.clone());
|
||||
let mut tls_fingerprint = saved
|
||||
.fingerprint
|
||||
|
|
@ -593,6 +598,7 @@ async fn run() {
|
|||
// target the flag had set. Before the fields move out of `sel`.
|
||||
apply_interface(&sel);
|
||||
state::save(&state::SavedState::remember(&sel));
|
||||
cfg.timeout = sel.timeout;
|
||||
tests_str = sel.selected_tests;
|
||||
concurrency = sel.concurrency;
|
||||
ip_version = sel.ip_version;
|
||||
|
|
@ -646,6 +652,7 @@ async fn run() {
|
|||
MenuResult::Run(chosen) => {
|
||||
apply_interface(&chosen);
|
||||
state::save(&state::SavedState::remember(&chosen));
|
||||
cfg.timeout = chosen.timeout;
|
||||
tests_str = chosen.selected_tests;
|
||||
concurrency = chosen.concurrency;
|
||||
ip_version = chosen.ip_version;
|
||||
|
|
@ -832,6 +839,7 @@ async fn run() {
|
|||
ip_version = chosen.ip_version.clone();
|
||||
cfg.ip_version = chosen.ip_version.clone();
|
||||
cfg.tls_fingerprint = chosen.tls_fingerprint.code().to_string();
|
||||
cfg.timeout = chosen.timeout;
|
||||
lang = chosen.language;
|
||||
msg = get_messages(lang);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ use std::time::{Duration, Instant};
|
|||
use dpi_core::classify::Detail;
|
||||
use dpi_core::config::{
|
||||
clean_domain, embedded_burst_domains, embedded_domains,
|
||||
embedded_tcp16_targets, embedded_whitelist_sni, load_domains_from_file,
|
||||
load_tcp16_targets_from_file, load_whitelist_sni, resource_path, AppConfig, Tcp16Target,
|
||||
embedded_quic_unsupported, embedded_tcp16_targets, embedded_whitelist_sni,
|
||||
load_domains_from_file, load_quic_unsupported, load_tcp16_targets_from_file,
|
||||
load_whitelist_sni, resource_path, AppConfig, Tcp16Target,
|
||||
};
|
||||
use dpi_core::probe::tcp16::Tcp16Row;
|
||||
use dpi_core::probe::dns_avail::check_dns_availability;
|
||||
|
|
@ -27,11 +28,11 @@ use dpi_core::probe::burst::{
|
|||
};
|
||||
use dpi_core::dns::cymru::{fetch_ip_cymru, IpCymruInfo};
|
||||
use dpi_core::probe::domains::{
|
||||
check_http_all, check_tls_all, collect_stub_ips, domain_stats, resolve_all, IpFamily,
|
||||
check_domains, collect_stub_ips, domain_stats, IpFamily,
|
||||
};
|
||||
use dpi_core::probe::telegram::run_telegram_full;
|
||||
use dpi_core::probe::whitelist::run_whitelist_sni;
|
||||
use dpi_core::probe::{check_quic_all, check_tcp_16_20, domains};
|
||||
use dpi_core::probe::{check_tcp_16_20, domains};
|
||||
use dpi_core::profile::RegionProfile;
|
||||
use dpi_core::{PhaseProgress, ProgressTick};
|
||||
use tokio::sync::Semaphore;
|
||||
|
|
@ -179,6 +180,28 @@ pub(crate) fn load_whitelist_sni_list(cfg: &AppConfig, msg: &Messages) -> Vec<(S
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_quic_unsupported_list(cfg: &AppConfig, msg: &Messages) -> Vec<String> {
|
||||
let path = resource_path(&cfg.quic_unsupported_file);
|
||||
let from_file = match load_quic_unsupported(&path) {
|
||||
Ok(list) => list,
|
||||
Err(err) => {
|
||||
if err.kind() != io::ErrorKind::NotFound {
|
||||
eprintln!(
|
||||
"{}",
|
||||
msg.quic_unsupported_load_failed
|
||||
.replacen("{}", &format!("{}: {err}", path.display()), 1)
|
||||
);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if from_file.is_empty() {
|
||||
embedded_quic_unsupported()
|
||||
} else {
|
||||
from_file
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mask_proxy(url: &str) -> String {
|
||||
// socks5://user:pass@host → socks5://user:***@host
|
||||
match url.find('@') {
|
||||
|
|
@ -848,7 +871,7 @@ pub(crate) async fn run_test_suite(
|
|||
msg.ip_col,
|
||||
if cfg.ip_version == "ipv6" { "IPv6" } else { "IPv4" },
|
||||
msg.timeout_label,
|
||||
cfg.connect_timeout
|
||||
cfg.timeout
|
||||
));
|
||||
}
|
||||
// Silent stub collection with its own timeout.
|
||||
|
|
@ -859,9 +882,18 @@ pub(crate) async fn run_test_suite(
|
|||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// All four stages are on the road from the first second (test 1
|
||||
// style), so the run reads as one progressing line rather than four
|
||||
// headers that scroll away.
|
||||
// The hosts whose endpoint serves no HTTP/3: no Initial goes out for
|
||||
// them, their cell is a dash, and the QUIC column — the live line and the
|
||||
// summary alike — counts only the rows that can answer it.
|
||||
let quic_unsupported = load_quic_unsupported_list(cfg, msg);
|
||||
let quic_total = domains
|
||||
.iter()
|
||||
.filter(|d| domains::quic_applicable(d, &quic_unsupported))
|
||||
.count();
|
||||
|
||||
// Every column is on the road from the first second: the five stages are
|
||||
// pipelined per domain, so there is no stage start to announce — the line
|
||||
// reads as one progressing run, not as five that scroll away.
|
||||
if !args.json {
|
||||
live.begin_stages(
|
||||
msg.stages_label.to_string(),
|
||||
|
|
@ -870,15 +902,12 @@ pub(crate) async fn run_test_suite(
|
|||
(dpi_core::ProgressBlock::DomainTls13, domains.len()),
|
||||
(dpi_core::ProgressBlock::DomainTls12, domains.len()),
|
||||
(dpi_core::ProgressBlock::DomainHttp, domains.len()),
|
||||
(dpi_core::ProgressBlock::DomainQuic, domains.len()),
|
||||
(dpi_core::ProgressBlock::DomainQuic, quic_total),
|
||||
],
|
||||
);
|
||||
}
|
||||
let mut entries = resolve_all(domains, family, &stub_ips, &sem, phases.clone()).await;
|
||||
check_tls_all(&mut entries, false, cfg, &sem, phases.clone()).await;
|
||||
check_tls_all(&mut entries, true, cfg, &sem, phases.clone()).await;
|
||||
check_http_all(&mut entries, cfg, &stub_ips, &sem, phases.clone()).await;
|
||||
check_quic_all(&mut entries, cfg, &sem, phases.clone()).await;
|
||||
let entries =
|
||||
check_domains(domains, family, cfg, &stub_ips, &quic_unsupported, &sem, phases.clone()).await;
|
||||
live.finish();
|
||||
|
||||
let stats = domain_stats(&entries);
|
||||
|
|
@ -933,6 +962,7 @@ pub(crate) async fn run_test_suite(
|
|||
let item = item.clone();
|
||||
let cfg_c = Arc::clone(&cfg_arc);
|
||||
let sem_c = Arc::clone(&sem);
|
||||
let tick = tcp_tick.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let port = item.port;
|
||||
let sni = if port == 80 {
|
||||
|
|
@ -946,15 +976,16 @@ pub(crate) async fn run_test_suite(
|
|||
let elapsed = t0.elapsed().as_secs_f64();
|
||||
let detail = tcp16_detail(detail, elapsed);
|
||||
let asn_str = item.display_asn();
|
||||
// Ticked by the probe itself, never by the collector loop: the
|
||||
// counter is a live reading and a slow target must not hold it.
|
||||
if let Some(t) = tick.as_ref() {
|
||||
t();
|
||||
}
|
||||
(item.id, asn_str, item.provider, status, detail)
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
let done = h.await;
|
||||
if let Some(t) = tcp_tick.as_ref() {
|
||||
t();
|
||||
}
|
||||
if let Ok((id, asn, provider, status, detail)) = done {
|
||||
if let Ok((id, asn, provider, status, detail)) = h.await {
|
||||
rows.push(Tcp16Row { id, asn, provider, status, detail });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ pub(crate) struct MenuSelection {
|
|||
pub selected_tests: String,
|
||||
pub ip_version: String, // "ipv4" or "ipv6"
|
||||
pub concurrency: usize,
|
||||
/// Seconds one phase of a probe waits, HTTP/TLS/QUIC alike: the value the
|
||||
/// menu's Timeout row was left on (`AppConfig::timeout`).
|
||||
pub timeout: f64,
|
||||
pub language: Language,
|
||||
pub tls_fingerprint: TlsFingerprint,
|
||||
/// The interface the probes leave through, when the user picked one: `None`
|
||||
|
|
@ -36,6 +39,18 @@ pub(crate) struct MenuSelection {
|
|||
pub interface: Option<String>,
|
||||
}
|
||||
|
||||
/// The Timeout row steps one second at a time inside this range. Both ends are
|
||||
/// the practical ones: a second is the shortest window a router link answers in,
|
||||
/// a minute the longest a silent target is worth waiting out.
|
||||
pub(crate) const TIMEOUT_MIN_SECS: f64 = 1.0;
|
||||
pub(crate) const TIMEOUT_MAX_SECS: f64 = 60.0;
|
||||
|
||||
/// The timeout as the menu prints it: whole seconds without a trailing `.0`, in
|
||||
/// the spelling the rest of the tool uses for seconds (`0.2s`).
|
||||
fn timeout_value(timeout: f64) -> String {
|
||||
format!("{}s", timeout)
|
||||
}
|
||||
|
||||
/// Applies the interface a menu selection carries to the process-wide bind.
|
||||
///
|
||||
/// The bind is a global rather than a field of the run, so *every* place that
|
||||
|
|
@ -165,6 +180,10 @@ async fn run_menu_loop(
|
|||
})
|
||||
.unwrap_or(0);
|
||||
let v6_supported = ipv6_supported();
|
||||
// The menu's last timeout, else the configured one, kept inside the row's
|
||||
// range: a config or a hand-edited state file must not put the row outside
|
||||
// what the arrows can step through.
|
||||
let mut timeout = saved.timeout().unwrap_or(cfg.timeout).clamp(TIMEOUT_MIN_SECS, TIMEOUT_MAX_SECS);
|
||||
|
||||
// What the run was asked for: the flag or the menu's last selection, already
|
||||
// resolved by `main.rs` — a `--domain` run must not be re-ticked from the
|
||||
|
|
@ -187,7 +206,7 @@ async fn run_menu_loop(
|
|||
let mut notice: Option<String> = None;
|
||||
let mut reader = EventStream::new();
|
||||
loop {
|
||||
let offset = 5;
|
||||
let offset = 6;
|
||||
let test_options = get_test_options(&msg);
|
||||
let total_rows = offset + test_options.len();
|
||||
|
||||
|
|
@ -199,6 +218,7 @@ async fn run_menu_loop(
|
|||
current_lang,
|
||||
&ip_version,
|
||||
presets[conc_idx],
|
||||
timeout,
|
||||
&presets,
|
||||
TlsFingerprint::ALL[fp_idx],
|
||||
v6_supported,
|
||||
|
|
@ -293,6 +313,8 @@ async fn run_menu_loop(
|
|||
conc_idx = (conc_idx + presets.len() - 1) % presets.len();
|
||||
} else if cursor == 4 {
|
||||
fp_idx = (fp_idx + TlsFingerprint::ALL.len() - 1) % TlsFingerprint::ALL.len();
|
||||
} else if cursor == 5 {
|
||||
timeout = (timeout - 1.0).max(TIMEOUT_MIN_SECS);
|
||||
} else if cursor >= offset {
|
||||
let t_idx = cursor - offset;
|
||||
if t_idx < test_options.len() {
|
||||
|
|
@ -325,6 +347,8 @@ async fn run_menu_loop(
|
|||
conc_idx = (conc_idx + 1) % presets.len();
|
||||
} else if cursor == 4 {
|
||||
fp_idx = (fp_idx + 1) % TlsFingerprint::ALL.len();
|
||||
} else if cursor == 5 {
|
||||
timeout = (timeout + 1.0).min(TIMEOUT_MAX_SECS);
|
||||
} else if cursor >= offset {
|
||||
let t_idx = cursor - offset;
|
||||
if t_idx < test_options.len() {
|
||||
|
|
@ -353,6 +377,8 @@ async fn run_menu_loop(
|
|||
conc_idx = (conc_idx + 1) % presets.len();
|
||||
} else if cursor == 4 {
|
||||
fp_idx = (fp_idx + 1) % TlsFingerprint::ALL.len();
|
||||
} else if cursor == 5 {
|
||||
timeout = (timeout + 1.0).min(TIMEOUT_MAX_SECS);
|
||||
} else if cursor >= offset {
|
||||
let t_idx = cursor - offset;
|
||||
if t_idx < test_options.len() {
|
||||
|
|
@ -381,6 +407,7 @@ async fn run_menu_loop(
|
|||
selected_tests: sorted_selection(&selected_tests),
|
||||
ip_version: ip_version.clone(),
|
||||
concurrency: presets[conc_idx],
|
||||
timeout,
|
||||
language: current_lang,
|
||||
tls_fingerprint: TlsFingerprint::ALL[fp_idx],
|
||||
interface: iface_idx
|
||||
|
|
@ -428,6 +455,7 @@ fn menu_rows(
|
|||
current_lang: Language,
|
||||
ip_version: &str,
|
||||
concurrency: usize,
|
||||
timeout: f64,
|
||||
presets: &[usize],
|
||||
fingerprint: TlsFingerprint,
|
||||
v6_supported: bool,
|
||||
|
|
@ -437,7 +465,7 @@ fn menu_rows(
|
|||
iface_value: &str,
|
||||
) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let offset = 5;
|
||||
let offset = 6;
|
||||
|
||||
// Language row
|
||||
let lang_opts = Language::ALL
|
||||
|
|
@ -506,6 +534,17 @@ fn menu_rows(
|
|||
let fp_cursor = if cursor == 4 { "►" } else { " " };
|
||||
let fp_lbl = format!("{}:", msg.fingerprint_label.trim_end_matches(':'));
|
||||
lines.push(format!(" {} {} {}", fp_cursor, pad_width(&fp_lbl, 16), fp_opt));
|
||||
// Timeout row: a stepper, not a cycler — every second from 1 to 60 is a value
|
||||
// a user may want, so the arrows move by one instead of jumping between
|
||||
// presets. The value is one window for HTTP, TLS and QUIC alike.
|
||||
let timeout_cursor = if cursor == 5 { "►" } else { " " };
|
||||
let timeout_lbl = format!("{}:", msg.timeout_label.trim_end_matches(':'));
|
||||
lines.push(format!(
|
||||
" {} {} < {} >",
|
||||
timeout_cursor,
|
||||
pad_width(&timeout_lbl, 16),
|
||||
timeout_value(timeout)
|
||||
));
|
||||
lines.push(format!(" {}", "─".repeat(BOX_WIDTH - 8)));
|
||||
for (i, (digit, label)) in test_options.iter().enumerate() {
|
||||
let is_selected = selected_tests.contains(digit);
|
||||
|
|
@ -534,6 +573,7 @@ fn draw_menu(
|
|||
current_lang: Language,
|
||||
ip_version: &str,
|
||||
concurrency: usize,
|
||||
timeout: f64,
|
||||
presets: &[usize],
|
||||
fingerprint: TlsFingerprint,
|
||||
v6_supported: bool,
|
||||
|
|
@ -556,6 +596,7 @@ fn draw_menu(
|
|||
current_lang,
|
||||
ip_version,
|
||||
concurrency,
|
||||
timeout,
|
||||
presets,
|
||||
fingerprint,
|
||||
v6_supported,
|
||||
|
|
@ -736,6 +777,7 @@ mod tests {
|
|||
selected_tests: "123".to_string(),
|
||||
ip_version: "ipv4".to_string(),
|
||||
concurrency: 50,
|
||||
timeout: 8.0,
|
||||
language: Language::Ru,
|
||||
tls_fingerprint: TlsFingerprint::Rustls,
|
||||
interface: None,
|
||||
|
|
@ -756,6 +798,7 @@ mod tests {
|
|||
selected_tests: "1".to_string(),
|
||||
ip_version: "ipv4".to_string(),
|
||||
concurrency: 50,
|
||||
timeout: 8.0,
|
||||
language: Language::Ru,
|
||||
tls_fingerprint: TlsFingerprint::Rustls,
|
||||
interface: None,
|
||||
|
|
@ -782,6 +825,36 @@ mod tests {
|
|||
assert!(bind::target().is_none(), "a name that resolves to nothing binds nothing");
|
||||
}
|
||||
|
||||
/// The Timeout row: the seconds the run will use, with the cursor marker on
|
||||
/// it only while the cursor is there.
|
||||
#[test]
|
||||
fn test_the_timeout_row_shows_the_seconds_the_run_will_use() {
|
||||
let msg = get_messages(Language::En);
|
||||
let test_options = get_test_options(&msg);
|
||||
let selected: HashSet<char> = ['2'].into_iter().collect();
|
||||
let row = |cursor: usize| {
|
||||
menu_rows(
|
||||
cursor,
|
||||
Language::En,
|
||||
"ipv4",
|
||||
50,
|
||||
15.0,
|
||||
&[1, 5, 20, 50, 100],
|
||||
TlsFingerprint::Rustls,
|
||||
true,
|
||||
&test_options,
|
||||
&selected,
|
||||
&msg,
|
||||
"auto",
|
||||
)
|
||||
.into_iter()
|
||||
.find(|line| line.contains("15s"))
|
||||
.expect("the timeout row is drawn")
|
||||
};
|
||||
assert!(row(5).contains('►'), "the cursor row is marked");
|
||||
assert!(!row(0).contains('►'), "a row the cursor is away from is not marked");
|
||||
}
|
||||
|
||||
/// The menu is a fixed-width box and every row in it is exactly that wide. The
|
||||
/// panel helper pads a line to the right edge, so a row *wider* than the box
|
||||
/// keeps its length and the border loses its shape instead of wrapping.
|
||||
|
|
@ -818,6 +891,7 @@ mod tests {
|
|||
lang,
|
||||
ip_version,
|
||||
50,
|
||||
8.0,
|
||||
&presets,
|
||||
TlsFingerprint::Chrome146,
|
||||
v6_supported,
|
||||
|
|
@ -846,6 +920,7 @@ mod tests {
|
|||
lang,
|
||||
"ipv4",
|
||||
100,
|
||||
60.0,
|
||||
&presets,
|
||||
fingerprint,
|
||||
true,
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ pub(crate) struct SavedState {
|
|||
/// The menu's selection string, e.g. `023` — the same shape `--tests` takes.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tests: Option<String>,
|
||||
/// Seconds one phase of a probe waits — the menu's Timeout row. One value for
|
||||
/// HTTP, TLS and QUIC, so there is one field and not three.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<f64>,
|
||||
/// `None` is the routing table; `Some` is a named interface.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub interface: Option<String>,
|
||||
|
|
@ -85,6 +89,7 @@ impl SavedState {
|
|||
concurrency: Some(selection.concurrency),
|
||||
fingerprint: Some(selection.tls_fingerprint.code().to_string()),
|
||||
tests: Some(selection.selected_tests.clone()),
|
||||
timeout: Some(selection.timeout),
|
||||
interface: selection.interface.clone(),
|
||||
}
|
||||
}
|
||||
|
|
@ -99,6 +104,14 @@ impl SavedState {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The timeout to start from, ignoring a value the menu could not have
|
||||
/// produced (1–60 seconds): the same rule as `ip_version`, and the window a
|
||||
/// probe waits is not a place for a hand-edited zero or a negative.
|
||||
pub(crate) fn timeout(&self) -> Option<f64> {
|
||||
self.timeout
|
||||
.filter(|secs| (crate::screens::main::TIMEOUT_MIN_SECS..=crate::screens::main::TIMEOUT_MAX_SECS).contains(secs))
|
||||
}
|
||||
}
|
||||
|
||||
/// An environment variable that is set to something, or `None`.
|
||||
|
|
@ -262,6 +275,7 @@ mod tests {
|
|||
concurrency: Some(20),
|
||||
fingerprint: Some("chrome146".to_string()),
|
||||
tests: Some("023".to_string()),
|
||||
timeout: Some(15.0),
|
||||
interface: Some("eth0".to_string()),
|
||||
};
|
||||
let text = serde_json::to_string(&state).expect("serializes");
|
||||
|
|
@ -289,11 +303,24 @@ mod tests {
|
|||
let state = SavedState { language: Some(Language::En), ..SavedState::default() };
|
||||
let text = serde_json::to_string(&state).expect("serializes");
|
||||
assert!(text.contains("\"language\":\"en\""), "{text}");
|
||||
for absent in ["ip_version", "concurrency", "fingerprint", "tests", "interface"] {
|
||||
for absent in ["ip_version", "concurrency", "fingerprint", "tests", "interface", "timeout"] {
|
||||
assert!(!text.contains(absent), "{absent} in {text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The timeout is checked the way the IP version is: the value a hand-edited
|
||||
/// file could carry (zero, negative, an hour) is not one the menu can leave
|
||||
/// behind, and it never reaches a probe.
|
||||
#[test]
|
||||
fn test_a_timeout_outside_the_row_range_is_ignored() {
|
||||
let inside = SavedState { timeout: Some(15.0), ..SavedState::default() };
|
||||
assert_eq!(inside.timeout(), Some(15.0));
|
||||
for outside in [0.0, -5.0, 0.5, 61.0, 3600.0] {
|
||||
let odd = SavedState { timeout: Some(outside), ..SavedState::default() };
|
||||
assert_eq!(odd.timeout(), None, "{outside}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_run_start_records_the_tokens_the_file_carries() {
|
||||
// What pressing Enter writes: the tokens a user could type back, not a
|
||||
|
|
@ -304,6 +331,7 @@ mod tests {
|
|||
selected_tests: "023".to_string(),
|
||||
ip_version: "ipv6".to_string(),
|
||||
concurrency: 20,
|
||||
timeout: 12.0,
|
||||
language: Language::Zh,
|
||||
tls_fingerprint: TlsFingerprint::ALL[1],
|
||||
interface: None,
|
||||
|
|
@ -312,6 +340,7 @@ mod tests {
|
|||
assert_eq!(state.language, Some(Language::Zh));
|
||||
assert_eq!(state.ip_version.as_deref(), Some("ipv6"));
|
||||
assert_eq!(state.concurrency, Some(20));
|
||||
assert_eq!(state.timeout, Some(12.0));
|
||||
assert_eq!(state.fingerprint.as_deref(), Some(TlsFingerprint::ALL[1].code()));
|
||||
assert_eq!(state.tests.as_deref(), Some("023"));
|
||||
assert_eq!(state.interface, None, "the routing table is an absent key");
|
||||
|
|
|
|||
|
|
@ -161,6 +161,9 @@ pub(crate) fn status_color(s: DpiStatus) -> Color {
|
|||
DpiStatus::QuicOk => Color::Green,
|
||||
DpiStatus::QuicClosed | DpiStatus::QuicVn => Color::Yellow,
|
||||
DpiStatus::QuicSpoof | DpiStatus::QuicDrop => Color::Red,
|
||||
// The dash: the endpoint serves no HTTP/3, so there is nothing to read in
|
||||
// this cell — grey, like the other "no verdict here", never red.
|
||||
DpiStatus::QuicUnsupported => Color::DarkGrey,
|
||||
// A refusal is the peer *answering*: ECONNREFUSED on TCP is the
|
||||
// endpoint's own RST, and on the QUIC column it is the kernel handing up
|
||||
// an ICMP "nothing listens on that port". Both say the path works and the
|
||||
|
|
|
|||
|
|
@ -23,14 +23,14 @@ pub(crate) fn render_domain_table(entries: &[DomainEntry], msg: &Messages) -> St
|
|||
]);
|
||||
|
||||
for e in entries {
|
||||
let (http_s, t12_s, t13_s, quic_s, raw_details) = dpi_core::probe::domains::build_domain_row(e);
|
||||
let (http, tls12, tls13, quic, raw_details) = dpi_core::probe::domains::build_domain_row(e);
|
||||
let details = row_details(&raw_details, msg.lang);
|
||||
table.add_row(vec![
|
||||
Cell::new(cell_color(&e.domain, Color::Cyan)),
|
||||
Cell::new(cell_color(http_s.display_label(), status_color(http_s))),
|
||||
Cell::new(cell_color(t12_s.display_label(), status_color(t12_s))),
|
||||
Cell::new(cell_color(t13_s.display_label(), status_color(t13_s))),
|
||||
Cell::new(cell_color(quic_s.display_label(), status_color(quic_s))),
|
||||
Cell::new(cell_color(http.display_label(), status_color(http))),
|
||||
Cell::new(cell_color(tls12.display_label(), status_color(tls12))),
|
||||
Cell::new(cell_color(tls13.display_label(), status_color(tls13))),
|
||||
Cell::new(cell_color(quic.display_label(), status_color(quic))),
|
||||
Cell::new(details),
|
||||
]);
|
||||
}
|
||||
|
|
@ -44,12 +44,19 @@ pub(crate) fn render_domain_table(entries: &[DomainEntry], msg: &Messages) -> St
|
|||
/// Detail cell of the domain table: one `<proto>:<detail>` line per failing
|
||||
/// protocol, or a single line when the failure is shared. Protocol tags are
|
||||
/// canonical Latin tokens (Rule 4) and stay untranslated.
|
||||
///
|
||||
/// A row where every protocol passed carries the four stage timings instead —
|
||||
/// `0.2s,0.2s,0.2s,0.2s`, one per column in the table's order (HTTP, TLS 1.2,
|
||||
/// TLS 1.3, QUIC).
|
||||
fn row_details(lines: &[DetailLine], lang: Language) -> String {
|
||||
lines
|
||||
.iter()
|
||||
.map(|(tag, detail)| match tag {
|
||||
Some(tag) => format!("{}:{}", tag, detail_text(detail, lang)),
|
||||
None => detail_text(detail, lang),
|
||||
.map(|line| match line {
|
||||
DetailLine::Tagged(Some(tag), detail) => format!("{}:{}", tag, detail_text(detail, lang)),
|
||||
DetailLine::Tagged(None, detail) => detail_text(detail, lang),
|
||||
DetailLine::Timings([http, tls12, tls13, quic]) => {
|
||||
format!("{:.1}s,{:.1}s,{:.1}s,{:.1}s", http, tls12, tls13, quic)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
|
|
|
|||
|
|
@ -78,13 +78,22 @@ pub(crate) fn render_summary(data: &SummaryData, msg: &Messages) -> String {
|
|||
}
|
||||
|
||||
if let Some(d) = data.domains {
|
||||
let stat = |label: &str, ok: usize| {
|
||||
let c = frac_sgr(ok, d.total);
|
||||
format!("\x1b[{}m{}/{} {}\x1b[0m", c, ok, d.total, label)
|
||||
let stat = |label: &str, ok: usize, total: usize| {
|
||||
let c = frac_sgr(ok, total);
|
||||
format!("\x1b[{}m{}/{} {}\x1b[0m", c, ok, total, label)
|
||||
};
|
||||
items.push((
|
||||
msg.summary_domains.to_string(),
|
||||
format!("{} {} {}", stat("HTTP", d.http_ok), stat("TLS1.2", d.t12_ok), stat("TLS1.3", d.t13_ok)),
|
||||
format!(
|
||||
"{} {} {} {}",
|
||||
stat("HTTP", d.http_ok, d.total),
|
||||
stat("TLS1.2", d.t12_ok, d.total),
|
||||
stat("TLS1.3", d.t13_ok, d.total),
|
||||
// The QUIC column carries its own denominator: a host whose
|
||||
// endpoint serves no HTTP/3 is a dash in the table, not a zero in
|
||||
// this ratio.
|
||||
stat("QUIC", d.quic_ok, d.quic_total),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
33
quic_unsupported.txt
Normal file
33
quic_unsupported.txt
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Hosts whose endpoint serves no HTTP/3 at all, so test 2 does not probe their
|
||||
# QUIC column: the cell prints a dash, the row keeps its TCP verdicts, and the
|
||||
# summary counts the QUIC column out of the hosts that can answer it instead of
|
||||
# the whole list.
|
||||
#
|
||||
# The rule is the two third-party testers agreeing on "no HTTP/3"
|
||||
# (`scripts/quic/online_h3.py` — intodns.ai and http3check.net, probing from
|
||||
# networks that are not ours; run 2026-10-02). A row where they disagree stays
|
||||
# probed: the disagreement is exactly the question this column asks.
|
||||
#
|
||||
# Caveat, recorded in scripts/quic/README.md: five of these hosts
|
||||
# (aws.amazon.com, soundcloud.com, vk.ru, www.currenttime.tv, www.svoboda.org)
|
||||
# do answer a handshake when one is forced on them — they just never advertise
|
||||
# HTTP/3, and a browser that is never told does not use it. They stay listed.
|
||||
#
|
||||
# Regenerate the data with: python scripts/quic/online_h3.py
|
||||
#
|
||||
# One host per line, `#` starts a comment, the host is matched whole
|
||||
# (case-insensitive) against the domain's host part.
|
||||
aws.amazon.com
|
||||
browserleaks.com
|
||||
protonvpn.com
|
||||
shikimori.io
|
||||
soundcloud.com
|
||||
vk.ru
|
||||
www.canva.com
|
||||
www.cdn77.com
|
||||
www.coursera.org
|
||||
www.currenttime.tv
|
||||
www.linuxserver.io
|
||||
www.svoboda.org
|
||||
www.themoscowtimes.com
|
||||
www.torproject.org
|
||||
Loading…
Add table
Add a link
Reference in a new issue