`the_path_s_own_icmp_is_named_not_left_as_an_errno` bound `(s, d)` and used it only under `#[cfg(windows)]`, so the Linux `clippy -D warnings` run failed the test target with two unused variables — a lint the Windows runner cannot see, which is the reason `check.yml` carries both. The unix side gets its own assertion: `10052` is not `WSAENETRESET` there and no message was read, so the errno stands alone, which is exactly the platform difference the test pins. |
||
|---|---|---|
| .cargo | ||
| .github | ||
| crates | ||
| docs | ||
| scripts | ||
| tools | ||
| vendor | ||
| .gitattributes | ||
| .gitignore | ||
| AGENTS.md | ||
| burst-domains.txt | ||
| Cargo.lock | ||
| Cargo.toml | ||
| config.yml | ||
| CONTRIBUTING.md | ||
| Cross.toml | ||
| deny.toml | ||
| domains.txt | ||
| install.ps1 | ||
| install.sh | ||
| LICENSE | ||
| quic_unsupported.txt | ||
| README.md | ||
| rust-toolchain.toml | ||
| tcp16.json | ||
| whitelist_sni.txt | ||
"Маяк у гаснущего горизонта свободного интернета"
Сквозь цифровые сумерки. Смотритель маяка, Runni
🦀 DPI Detector (Native Rust Engine)
Нативный инструмент для анализа цензуры и блокировок трафика на Rust:
- Никаких внешних зависимостей: без сторонних рантаймов и системного OpenSSL — TLS на
rustls(вендорен и пропатчен:vendor/rustls/README-PATCH.md) с крипто-провайдеромring. - Легковесный: статический бинарник ≈7.0 МБ на настольных сборках, роутерные строки матрицы идут с
opt-level = "z"и жирным LTO и потому меньше (UPX — ≈1.4 МБ); рабочая память на роутере — единицы мегабайт. - Максимальная кросс-платформенность: нативная поддержка роутеров (MIPS, ARM), Windows (от Windows 7 до 11), Linux musl, macOS и Android (Termux).
- Интерактивный TUI + Batch: полноценное стрелочное меню в терминале и быстрый тихий запуск при передаче аргументов командной строки.
🚀 Быстрый запуск в 1 строку (Без установки)
🐧 Linux, macOS, роутеры Keenetic, OpenWrt & Entware
curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install.sh | sh
📱 Android (Termux)
curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install.sh | sh
Скрипт автоматически определит Termux, установит нативный Android-бинарник в $PREFIX/bin/dpi-detector и запустит его. Для повторного запуска достаточно ввести dpi-detector.
🪟 Windows (PowerShell)
irm https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install.ps1 | iex
📦 Готовые бинарные сборки
Все сборки полностью статичны и не требуют внешних библиотек:
| Платформа / Устройство | Архитектура | Бинарник в релизах |
|---|---|---|
| Windows 10, 11, Server | x86_64 | dpi-detector-windows-x86_64.exe |
| Windows 7, 8, Server 2008/2012 | x86_64 | dpi-detector-windows-7-x86_64.exe |
| Linux (PC, Серверы, VPS) | x86_64 musl | dpi-detector-linux-x86_64 |
| Роутеры ARM64 (Keenetic Hopper/Titan 2, RPi, OpenWrt) | aarch64 musl | dpi-detector-linux-arm64 |
| Роутеры ARMv7 (Keenetic Titan 1/Hero, Asus RT-AX58U) | armv7hf musl | dpi-detector-linux-armv7 (и -upx) |
| Роутеры MIPS LE (Keenetic Viva/Giga, MT7621, OpenWrt) | mipsel musl | dpi-detector-linux-mipsel (и -upx) |
| Роутеры MIPS BE (Atheros, Qualcomm, OpenWrt) | mips musl | dpi-detector-linux-mips (и -upx) |
| macOS Apple Silicon (M1, M2, M3, M4) | aarch64 | dpi-detector-macos-arm64 |
| macOS Intel | x86_64 | dpi-detector-macos-intel |
| Android ARM64 (Смартфоны, планшеты, Termux, ADB) | aarch64 bionic | dpi-detector-android-arm64 |
| Android ARM32 (Старые устройства, Android TV) | armv7 bionic | dpi-detector-android-armv7 |
Вариант -upx — та же сборка, сжатая UPX (≈1.4 МБ вместо ≈4.0 МБ). На роутерах со старым ядром он не запускается: распаковочный стаб UPX 5.x первым делом вызывает memfd_create, а этот вызов появился только в ядре 3.17, поэтому на 3.4 упакованный файл умирает с Trace/breakpoint trap ещё до того, как начнёт работать детектор (проверено на Xiaomi R3P, ядро 3.4.113: execve проходит, ни одного сетевого вызова, затем SIGTRAP от ядра; на Keenetic с ядром 4.9 тот же файл работает). Это не наш баг: апстрим закрыл такое как «kernel too old» и советует упаковывать UPX 4.2.4, стаб которого memfd_create не использует, — см. upx/upx#904 (mipsel, ядро 3.4) и upx/upx#902 (arm32, ядро 3.14). Релизный пайплайн поэтому закреплён на UPX 4.2.4 (.github/workflows/release.yml); в сборках alpha.18 и более ранних ассеты упакованы 5.2.0, так что на ядрах ниже 3.17 берите файл без -upx. install.sh выбирает вариант сам и запускает скачанное (--version), прежде чем поставить: если компактная сборка не стартует, он берёт стандартную. При скачивании вручную с ядром ниже 3.17 берите файл без -upx.
🛠️ Установка на роутеры (Keenetic / OpenWrt / Entware)
При запуске на роутере скрипт install.sh:
- Автоматически находит директорию
/opt/bin(Entware) и устанавливает бинарник туда (сохраняется после перезагрузки). - Контролирует свободное место: если на накопителе осталось
< 200 МБ, скрипт автоматически ставит компактную версию со сжатием UPX (~1.4 МБ вместо ~4.0 МБ), защищая роутер от переполнения Flash илиtmpfs. - Заменяет файл атомарно (
.tmp.$$->dpi-detector) и печатает фактически занятое место — поduуже установленного файла, а не обещанный размер из релиза. Если релиз публикуетSHA256SUMS.txt, скачанное сверяется с ним до запуска, и манифест берётся только с самого релиза: копия манифеста на зеркале едет тем же каналом, что и бинарник этого зеркала, поэтому совпадёт с чем угодно, что зеркало решило отдать, и читать её значило бы заменить проверку её видимостью. Несовпадение — отказ от кандидата и переход к следующему зеркалу; когда GitHub недоступен или релиз манифеста не публикует, проверка пропускается с одним предупреждением, и остаётся проверка запуском и версией (см. п. 4): не стартовал — при сбое UPX-сборки установщик берёт стандартную, а провалившийся источник уступает следующему. - Качает со всех зеркал одновременно: побеждает то, что загрузилось первым, остальные отменяются — вместе с их загрузками; скачанный файл всё равно проверяется запуском и сравнением версии, так что источник, отдавший другую сборку (например, закешировавший
latestстарый релиз), считается неудачным зеркалом и перебор идёт дальше. Гонка включается только тогда, когда в целевом каталоге хватает места под копию от каждого зеркала —число зеркал × 5 МБ, но не меньше 30 МБ; иначе зеркала идут по очереди, и установка просто медленнее на медленном зеркале. Прерванный поCtrl-Cзапуск не оставляет за собой ни временного файла в целевом каталоге, ни каталога гонки. - Поддерживает принудительный выбор версии:
DPI_UPX=1(компактная) илиDPI_UPX=0(стандартная). - Поддерживает запуск напрямую через SSH:
# Установка и запуск меню
curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install.sh | sh
# Повторный запуск после установки
/opt/bin/dpi-detector
- Не скачивает то, что уже стоит: если в целевом каталоге лежит запускаемый
dpi-detectorтой же версии, что несёт сам установщик, скрипт пишет об этом и сразу запускает его с переданными аргументами — повторныйcurl … | sh -s -- -t 2 -d rutracker.orgне тратит трафик и стартует тестер сразу. Файл, который не запускается (чужая архитектура, обрезанный) или не имеет бита выполнения, версией не считается, а версия, отличная от версии установщика, — обычная установка с явной строкой об этом. СDPI_VERSION=latestсравнение невозможно (тег движется), поэтому установка идёт как обычно.
⚙️ Параметры командной строки (CLI)
Использование: dpi-detector [ОПЦИИ]
Опции:
-t, --tests <TESTS> Строка выбора тестов (например, '012', '1', '2')
--json Вывод в машиночитаемом JSON
-v, --verbose Подробное / отладочное логирование
-l, --lang <LANG> Язык интерфейса (ru, en, zh, fa, auto). По умолчанию auto
--profile <PROFILE> Региональный профиль цензуры (ru, ir, cn, global) [по умолчанию: ru]
--legend Показать легенду статусов и таблицу профилей отпечатка и выйти
-p, --proxy <URL> URL SOCKS5-прокси (socks5://127.0.0.1:1080)
-c, --concurrency <N> Лимит параллельных запросов
-d, --domain <DOMAIN> Конкретные домены для проверки (флаг можно повторять: -d vk.com -d ya.ru)
-o, --output <PATH> Путь к файлу отчёта
--domains <PATH> Путь к файлу со списком доменов
--tcp16 <PATH> Путь к файлу целей TCP16
--ascii Только ASCII для старых консолей (без Unicode-глифов и рамок)
-i, --iface <NAME> Интерфейс, через который идут проверки: имя устройства или его адрес; по умолчанию выбирает таблица маршрутов (--iface wg0, --iface 172.16.0.2)
--fingerprint <PROFILE> Профиль отпечатка: ClientHello, User-Agent с заголовками и преамбула HTTP/2 одной закреплённой версии клиента; имена и воспроизводимые версии печатает --legend
--burst <N> Fingerprint/Сибирская блокировка (тест 6): соединений на домен, внахлёст [1–100, по умолчанию: 10]
--burst-timeout <SECONDS> Тест 6: таймаут одного рукопожатия, секунды [по умолчанию: 8]
--burst-gap <MS> Тест 6: задержка между стартами попыток, мс [0–1000, по умолчанию: 20]; 0 — весь раунд стартует в один момент
--burst-profiles <LIST> Fingerprint для теста 6: all или имена профилей через запятую; список печатает --legend [по умолчанию: набор по умолчанию]
--burst-tls <VERSION> TLS для теста 6: 1.3+1.2 (предложение браузера, ответ должен быть 1.3)|1.3 (только 1.3)|1.2 (только 1.2) [по умолчанию: 1.3+1.2]
--burst-alpn <PROTOCOL> ALPN для теста 6: h2 (предлагает h2 с откатом на http/1.1)|http/1.1 (только http/1.1) [по умолчанию: h2]
--burst-variant <DELTA> Тест 6, вариант: одна правка ClientHello каждого профиля, чтобы вердикт читался против формы, отличающейся одним полем — -ext:ID, ext-body:ID:HEX, +ext:ID, ext-body:ID:HEX, groups:A,B,…, key-shares:A,B,…, ext-order:A,B,… (0x0a0a — слот GREASE), padding:N, no-padding, sigalg-swap, +grease, +group:ID, alpn-reverse [по умолчанию: нет]
--trace [<PATH>] Тест 6: трасса по строке на попытку (раунд, отпечаток, домен, статус, деталь, мс); без пути — в stderr, с путём — в указанный файл [по умолчанию: выключено]
-h, --help Показать справку
-V, --version Показать версию
Справка и все сообщения локализованы: dpi-detector --lang en --help, --lang zh --help, --lang fa --help. Язык интерфейса задаётся --lang, таблицы, легенда и предупреждения следуют за ним; Farsi выводится латиницей (Finglish). Статусные бейджи и имена протоколов (OK, BLOCKED, TLS RST, SNI, ClientHello) остаются латиницей в любом языке. Машинный вывод --json от языка не зависит.
Потоки, на которых идёт прогон, выбираются сборкой, а не флагом. На роутерных сборках (за ними build.rs закрепляет признак dpi_router — musl на mips, arm и aarch64, то есть четыре роутерные сборки релиза: aarch64, armv7, mipsel и mips; признак ставится по целевому триплету, так что и собранный руками роутерный бинарник его получает) это два потока-исполнителя — измеренно именно два дают весь выигрыш по времени, а четыре не дают ничего сверх того и забирают половину ядер у самого роутера, который в это время раздаёт сеть; на машине с одним ядром остаётся один. На обычном ПК та же нагрузка сетевая (прогон упирается в сеть, а не в процессор), поэтому там берётся обычное для tokio число потоков — по одному на ядро. Переопределить можно переменной окружения: DPI_WORKERS=1 dpi-detector -t 2 — принудительно один поток, DPI_WORKERS=8 — восемь. Значение проверяется: 0 или мусор означает «как решено сборкой», а слишком большое число подрезается до четырёх на ядро — иначе процесс падал бы до того, как установлен обработчик паники, и на экране не осталось бы ничего своего. На роутере это единственный переключатель: переменную tokio TOKIO_WORKER_THREADS такая сборка не читает намеренно, по той же причине — её значение никто не проверяет. На ПК она работает как обычно.
В тесте 1 таблица и --json показывают одно и то же разными способами: строка эндпоинта несёт бейдж (NO CA BUNDLE, TLS DROP, SYN DROP), а results.dns_availability.failures — те же отказы как данные: provider, protocol (udp/doh_wire/dot), endpoint, счётчики ok/total и причина первого отказа (status — токен DpiStatus вроде no_ca_bundle, detail — код Detail вроде no_root_certificates; у отказа без классификации там исходное сообщение). Чистые эндпоинты в список не попадают, поэтому [] означает «ответили все». Два бейджа сами по себе не говорят, что случилось — UNKNOWN (классификатор не отнёс ошибку ни к одному вердикту, а в detail лежат слова транспорта) и NO CA BUNDLE (цепочка не дошла до корня из встроенного набора: локальное несовпадение доверия, а не доказательство блокировки), — поэтому их причина печатается блоком под таблицей, тем же текстом, что detail в --json; чистый прогон блока не получает. Рядом лежит fakeip_sub — доля подменённых UDP-ответов, пришедшая из диапазона fake-ip (198.18.0.0/15), то есть от прокси или VPN, а не от резолвера: в таблице это отдельная строка «FakeIP responses», и она же входит в subst_sub.
Список целей, заданный явно, тоже не бывает пустым молча. -d "" или файл --domains, в котором не нашлось ни одного пригодного домена (пустые строки и комментарии # не в счёт), — это ошибка запуска с кодом 2 и сообщением, а не прогон с нулём целей, который отчитался бы об успехе. Когда же список не задан, он берётся из встроенного набора или из файла рядом с бинарником, и пустой результат там по-прежнему означает запасной набор, а не отказ.
Проверки можно увести в выбранный интерфейс: строка Интерфейс в меню (по умолчанию «по умолчанию» — таблица маршрутов) и флаг -i/--iface <имя|адрес> для тихого запуска. Перед соединением сокет привязывается к адресу этого интерфейса — источник пакета и есть то, по чему ОС выбирает маршрут (таблица маршрутов на обычной машине, policy-правила на роутере, который делит трафик по источнику), — так прогон идёт через VPN-туннель (--iface wg0, --iface opkgtun10, --iface 172.16.0.2), а не через WAN провайдера; выбирать можно и по имени устройства, и по любому его адресу, TCP и UDP одинаково. Имя, которому ничего не соответствует, — это ошибка запуска, а не тихий прогон по маршруту по умолчанию: иначе «проверка через туннель» на деле проверяла бы провайдера. Без --iface и без строки в меню не меняется ничего — сокеты открываются ровно так же, как до появления выбора.
Что меню помнит между запусками
Таймаут задаётся строкой Таймаут в меню: шаг ровно одна секунда, от 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) тоже — он не должен решать, на чём откроется меню.
Каталог выбирается по платформе: берётся первый, который уже содержит файл, иначе первый, доступный на запись.
- Windows:
%APPDATA%\dpi-detector\state.json - macOS:
~/Library/Application Support/dpi-detector/state.json - Linux, BSD, Termux:
$XDG_CONFIG_HOME/dpi-detector/state.json, иначе~/.config/dpi-detector/state.json - OpenWrt, Entware: то же, иначе
$PREFIX/etc/dpi-detector/state.json(у EntwarePREFIX=/opt, то есть/opt/etc/dpi-detector/state.json), иначе рядом с бинарником
$HOME на роутере не гарантия: он может существовать и быть частью read-only прошивки, поэтому каталоги перебираются с проверкой записи, а не по одному имени. Последний в списке — каталог самого бинарника: оттуда config.rs уже читает поставляемые списки, и установщик выбирает только записываемый каталог.
Явный флаг сильнее сохранённого, а сохранённое — сильнее config.yml: --lang, --iface, -c/--concurrency, --fingerprint и -t/--tests перекрывают файл. Ошибка чтения не мешает запуску: битый или чужой JSON молча заменяется значениями по умолчанию. Вернуть исходные настройки — удалить файл.
Номера доступных тестов:
0— Информация о сети и системе (внешний IP, провайдер, AS, страна, тип NAT)1— Доступность DNS-серверов (проверка UDP 53, DoH Cloudflare/Google/Quad9/Yandex/AdGuard)2— Доступность сайтов (HTTP, TLS 1.2, TLS 1.3, QUIC, классификация блокировок DPI)3— Доступность CDN и хостингов (тест 16 KB) (детект сброса соединений после 12–36 КБ данных)4— Поиск белых SNI (перебор рабочих SNI для заблокированных AS: цель отбирается по 16 KB-обрыву, TLS RST и TLS DROP)5— Доступность Telegram (проверка доступности и скорости датацентров DC1–DC5)6— Fingerprint/Сибирская блокировка (N накладывающихся TLS-рукопожатий на один домен выбранными отпечатками, старты через настраиваемую задержку — по умолчанию 20 мс: сколько ответили и на какой попытке включился блок)7— Легенда статусов (справка по вердиктам, следом — таблица профилей отпечатка с воспроизводимой версией и источником)
Редирект на чужой домен тесты 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:. Когда прошли все протоколы строки, в деталях вместо одной строки стоят четыре времени подряд, по колонкам: 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 такой очереди нет, поэтому там деталь остаётся прежней.
Статус NO CA BUNDLE (no_ca_bundle, деталь no_root_certificates) означает ровно одно: цепочка сертификатов, которую увидел детектор, не сходится ни с одним корнем из вшитого в бинарь списка Mozilla (webpki-roots, только корни с правом серверной аутентификации; хранилище Windows не читается никогда — иначе ответ зависел бы от того, что установлено на конкретной машине). Проверяют сертификат только два места: DoH/DoT в тесте 1 и определение внешнего IP в тесте 0; остальные тесты сертификат не проверяют, потому что он там сам является сигналом DPI, — поэтому NO CA BUNDLE не может появиться в тесте 2.
Практически это почти всегда не цензура, а перехват TLS на самой машине: антивирусный web-shield или корпоративный прокси подставляет свой сертификат, подписанный его собственным корнем. Windows этот корень знает (браузеры открывают те же адреса), а детектор — нет, и каждая строка DoH/DoT читается как NO CA BUNDLE. Второй, более редкий случай — цепочка, ведущая к устаревшему корню, который Mozilla сняла с доверия для TLS (например DigiCert Global Root CA, GlobalSign Root CA, AAA Certificate Services): Windows такой корень всё ещё держит в своём хранилище.
Что с этим делать: посмотреть, какой издатель у листа и какой корень у цепочки. Готовый отчёт для Windows собирает tools/diag/dns-ca-report.ps1 — он печатает цепочку каждого DoH/DoT хоста, вердикт Windows, корни из «снятого с доверия» набора, корни-перехватчики в хранилище, резолв, hosts и NRPT, а также results.dns_availability.failures из --json. Если в цепочке виден корень антивируса, лечение одно: выключить проверку защищённых соединений (SSL/HTTPS-инспекцию) в его настройках или добавить dpi-detector в исключения — до тех пор любые замеры DoH/DoT на этой машине будут показывать чужой сертификат вместо сертификата сервера.
Тест 2: колонка HTTP
Первая колонка теста 2 отвечает на вопрос «что отдаёт этот хост по обычному HTTP»: на адрес из DNS-фазы, порт 80, уходит GET с тем же Host, что и у соседних колонок, и с той же идентичностью, что шлют TLS-пробы (accept-encoding: identity у базового rustls, полный браузерный набор у браузерных профилей), плюс Connection: close, чтобы ответ заканчивался EOF.
Запрос именно GET, а не HEAD, и это измеренная разница. Цензор, перехватывающий обычный HTTP, отвечает на HEAD синтетическим «перейди на https» на том же хосте — по правилам ниже это обычный редирект, то есть OK, — а на тот же GET отдаёт свою заглушку на чужом хосте. Замерено на живом lawfilter: HEAD → 301 → https://rutracker.org/, GET → 307 → http://lawfilter.ertelecom.ru/. Домен при этом заблокирован — TLS-колонки того же прогона дают TLS RST / tcp_rst_on_client_hello, — то есть на HEAD колонка показывала OK заблокированному домену.
Тело читается до общего лимита BODY_CAP (64 КБ — тот же, что у TLS-колонок, DoH и Telegram). Это не про содержимое: обрыв внутри окна TCP_BLOCK_MIN_KB…TCP_BLOCK_MAX_KB (по умолчанию 12–36) — тот же признак DPI, который тест 3 измеряет на своём потоке, — и колонка отдаёт его тем же бейджем.
| вердикт | что значит |
|---|---|
OK (http_200, http_3xx, …) |
ответ пришёл, обрыв не пойман; у редиректа в детали видно код и куда ведёт (→ хост, код → https) |
REDIR (redirect_to_host) |
редирект на чужой домен — так выглядит заглушка провайдера; редирект на тот же домен или поддомен — это OK |
16KB DROP (read_timeout_word_at_Nkb) |
передача оборвалась внутри окна: заголовки пришли, тело оборвалось на N КБ — тот же вердикт, что у теста 3 |
TIMEOUT, SEND TIMEOUT |
ответ не пришёл за TIMEOUT; в детали смещение в КБ |
SYN DROP, TCP RST, REFUSED, NET UNREACH |
соединение не состоялось — вердикты те же, что у соседних колонок |
Чего колонка не делает: не следует за редиректом (проверяется сам ответ, а не то, что за ним) и не сравнивает тело с образцом заглушки — блокпост виден по хосту, куда ведёт редирект, а не по HTML.
Тест 2: колонка QUIC
Четвёртая колонка теста 2 отвечает на вопрос «работает ли QUIC-путь до этого хоста»: на тот же адрес, что и соседние колонки, уходит Initial на UDP 443, внутри CRYPTO-фрейма — ClientHello в форме выбранного отпечатка: только TLS 1.3, h3 в ALPN, расширение quic_transport_parameters, которого требует любой эндпоинт, и пустое legacy_session_id (RFC 9001 §8.4: непустое поле сервер вправе считать нарушением протокола — Cloudflare на такое отвечает illegal_parameter вместо ServerHello). Ключи Initial выводятся из идентификатора соединения, который выбрала сама проба (RFC 9001 §5.2), поэтому ответ читается без всякого TLS-состояния:
| вердикт | что значит |
|---|---|
OK |
эндпоинт ответил на рукопожатие: ServerHello (или HelloRetryRequest), либо Retry с верным integrity-тегом. UDP-путь до 443 работает, ничего не отфильтровало Initial, HTTP/3-стек эндпоинта жив |
CLOSED |
ответил защищённым CONNECTION_CLOSE или stateless reset: путь работает, рукопожатие — нет. В детали код transport error десятичным числом (как и токен quic_close_<код> в --json); коды 256…511 — это CRYPTO_ERROR, и тогда TLS-алерт равен коду минус 256, то есть отказ на уровне рукопожатия, а не сети. Отдельные случаи в деталях: quic_unreadable_reply — ответ пришёл, но не открывается ни одним ключом соединения (так эдж отвечает на первый Initial: пакет выглядит как Initial, но его DCID пуст, хотя RFC 9000 §7.2 требует в первом ответе SCID клиента, и под ключами из нашего DCID (RFC 9001 §5.2) он не открывается ни в пробнике, ни в Wireshark; настоящий клиент такой пакет молча отбрасывает — RFC 9001 §5.4 — и пробник делает то же); quic_answered_without_handshake — ответ открылся, но данных рукопожатия в нём не было вовсе |
VN |
эндпоинт не поддерживает v1 и перечислил версии, которые умеет |
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 у этого хоста на другом адресе», а не блокировку.
Transport parameters у нашего hello — браузерные, а не минимальные, и это не косметика. RFC 9114 §6.2.1 требует от H3-клиента дать серверу возможность открыть свои служебные потоки (control и QPACK), а клиент, который не прислал параметров, оставляет все лимиты на нулевых значениях по умолчанию (RFC 9000 §18.2). Замерено на стоковом клиенте с обнулёнными лимитами: Cloudflare закрывает такое соединение с Error opening control stream (transport error 258, TLS-алерт 2), а Google не отвечает вовсе. Поэтому набор взят из захвата настоящего Chrome (скрипт scripts/quic/tp_dump.py): max_idle_timeout 30000, max_udp_payload_size 1472, initial_max_data 15728640, три stream-data по 6291456, initial_max_streams_bidi 100, initial_max_streams_uni 103, active_connection_id_limit 2 — плюс initial_source_connection_id, который обязан совпадать с SCID нашего же заголовка (RFC 9000 §7.3). До этой правки колонка читала CLOSED с кодом 10 на www.google.com и www.youtube.com, где браузер получает HTTP/3 200. Тем же сравнением с живым Chrome нашлась вторая неточность формы: расширение application settings (0x44cd) у нас несло h2 — верное для TCP, но в QUIC Chrome объявляет h3 (0003026833), и QUIC-hello теперь правит это тело, причём только у форм, которые это расширение вообще шлют. Третья: status_request (0x0005) и SCT (0x0012) Chrome шлёт в TCP-hello, но не в QUIC-hello — QUIC-вариант их теперь снимает (и снимает безусловно: status_request у rustls типизированное расширение, а не запись формы, поэтому «снимать только у форм, которые его шлют» оставляло его на проводе — это видно в захвате).
Четвёртая неточность, найденная тем же способом (захват каждого клиента + tp_dump.py --extensions): в QUIC-hello Chrome список signature_algorithms на два байта длиннее, чем в TCP — он добавляет rsa_pkcs1_sha1 (0201), бесполезный в TLS 1.3 и потому выброшенный из TCP-формы; у quinn, который эти эндпоинты тоже принимают, тело такое же двадцатибайтное. QUIC-hello теперь добавляет его. Оговорка честности: www.apkmirror.com отвечает нестабильно — шесть одиночных прогонов после правки дали один OK и пять CLOSED, — поэтому считать эту правку доказанным исправлением apkmirror нельзя. Пятая и последняя: пробник завершал обмен на первом же ответе. Захват показал, что Meta шлёт короткий (short-header) пакет до читаемого Initial со своим ServerHello, а Cloudflare отвечает на первый полёт одним подтверждением и отдаёт ServerHello только на повтор; пробник на коротком пакете бросал цикл, а на пустом Initial прекращал повторы. Теперь и то и другое запоминается, но не завершает обмен (Verdict::settled), и повтор идёт, пока не пришёл handshake, закрытие, Retry или version negotiation. Замерено: www.facebook.com, www.instagram.com, www.messenger.com и www.apkmirror.com дают OK — двенадцать прогонов из двенадцати, а полный свип по 34 хостам согласуется с обоими внешними тестерами: все строки, кроме OK, — это ровно те 14 хостов, которые тестеры называют «нет HTTP/3». Остаются строки, где закрытие (296/336) читает и пробник, и стоковый клиент, а проходит только форсированный браузер в обход обнаружения: aws.amazon.com, soundcloud.com, www.currenttime.tv, www.svoboda.org, www.coursera.org.
Заодно измерением снят ложный след: GREASE у нас был везде (шифр, два расширения, группа, версия), а у обоих работающих клиентов его нет вовсе — ни у Chrome в QUIC, ни у quinn в его минимальном 288-байтном hello. Снятие GREASE (HelloVariant::NoGrease, теперь применяется в QUIC-hello) не изменило ничего на apkmirror, facebook и instagram; вариант оставлен потому, что так шлёт браузер, а не потому, что он что-то починил.
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 там, где пробник без повторов видел тишину. Отсюда и окно: общий 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 отдают.
Блок о перехвате: печатается перед тестами
Когда тесты запускаются на роутере Keenetic/Netcraze с пакетом nfqws2-keenetic, отчёт может измерять блокировку через обход или мимо него — и от этого зависит, что вообще означают цифры. Перед тестами, сразу после шапки, детектор проверяет, видит ли пакет его собственный трафик, и печатает один заголовок и список: на первой строке пункта — что не так, под ней — что с этим делать. Проблемы собираются все сразу, а не первая попавшаяся, поэтому один прогон показывает всё, что мешает обходу, и чинить их не приходится по одной за прогон. Пункт про хостлисты заканчивается готовыми строками конфига — их можно скопировать как есть:
Обнаружен включённый nfqws2, но есть проблемы:
• Трафик детектора исключён политикой доступа «nfqws».
На время тестов поставьте POLICY_EXCLUDE=1 или укажите свой POLICY_NAME,
которого нет среди политик роутера
• Детектор запущен в режиме IPv6, но в конфиге стоит IPV6_ENABLED=0.
Поставьте IPV6_ENABLED=1 в /opt/etc/nfqws2/nfqws2.conf.
• Трафик идёт мимо nfqws2: порта 80 нет в TCP_PORTS.
• Каждый профиль, который берёт порты, тестируемые детектором, сужен списком hostlist/ipset,
поэтому тестируемые цели могут не подхватываться.
Для правильной проверки стратегий на время тестирования установите в конфиге:
NFQWS_EXTRA_ARGS="$MODE_ALL"
После применения изменений перезапустите nfqws2 и dpi-detector.
| когда печатается | что сообщает | что делать |
|---|---|---|
| трафик исключён политикой | политика доступа вернула нас выше очереди; так работает режим «обрабатывать только устройства политики», а роутер в неё не входит | на время тестов POLICY_EXCLUDE=1 или своё POLICY_NAME, которого нет среди политик |
| правила на другом интерфейсе | правила висят на ISP_INTERFACE, а наш трафик уходит другим путём (второй аплинк) — в тексте видны оба интерфейса |
добавить нужный интерфейс в ISP_INTERFACE |
| трафик уходит через туннель | наш трафик частично или полностью идёт через туннель (VPN), а правила висят на интерфейсе провайдера | оставить как есть или добавить интерфейс туннеля в ISP_INTERFACE |
| порт не обрабатывается | тесты говорят на 80 и 443, и про отсутствующий порт сказано, где именно его нет: в TCP_PORTS пакета, в --filter-tcp ни одного действующего профиля или в обоих местах |
добавить порт туда, где его нет |
| IPv6 не покрыт | наш трафик идёт по IPv6, а правила для IPv6 не ставятся: в конфиге IPV6_ENABLED=0 |
IPV6_ENABLED=1 в конфиге пакета |
| в стратегии есть хостлисты | все профили, которые могут взять нашу сессию, отфильтрованы по списку (--hostlist, --hostlist-auto, --hostlist-domains, --ipset): цель вне списка не попадает ни в один из них и идёт мимо обхода, цель внутри — через обход, и цифры получаются смешанными. Профиль со списком, за которым есть профиль без него, пункт не вызывает: хост вне списка доходит туда |
под пунктом печатается, что сделать с переменной, которая этот фильтр содержит: однострочную — целиком строкой ПЕРЕМЕННАЯ="значение без фильтра", а если переменная держит целую стратегию на несколько профилей, то вместо неё называется только та опция, которую надо убрать |
| очередь не привязана | пакет запущен, но очередь не привязана — обычно служба не успела подняться; печатается вместе с проблемами конфига, а не вместо них | перезапустить nfqws2 и dpi-detector |
| конфиг пакета не читается | разрешённый nfqws2.conf.run не читается, поэтому о правилах ничего не известно — пустой список здесь не значит «всё хорошо» |
проверить установку пакета |
| маршрут неизвестен | не удалось определить, каким интерфейсом уходит наш трафик, поэтому покрытие по интерфейсу не проверяется | ничего: менять в пакете нечего, и закрывающая строка не печатается |
Когда трафик перехватывается, и когда пакета на устройстве нет вообще — не печатается ничего: nfqws2 проверяется на роутере, а не на домашнем ПК, и на Windows/macOS блоков не бывает. Список прочих обходчиков (sing-box, zapret и т.д.) остался в тесте 0, в строке «Локальный обход DPI на устройстве».
Причины независимы, поэтому в блоке оказываются все сработавшие сразу, в порядке починки: политика (она решает выше очереди), затем IPv6, интерфейс или туннель, порт и, последним, фильтр стратегии. Единственный случай, когда список короче правды — нечитаемый конфиг пакета: тогда фактов нет вовсе, и блок говорит об этом прямо, а не молчит.
У фильтра по списку есть оговорка, и её стоит знать. Профиль со списком берёт только те хосты, которые в списке названы: хост вне списка уходит в следующий подходящий профиль (если имя хоста удовлетворяет листам, выбирается этот профиль. иначе идет переход к следующему — документация пакета). Поэтому пункт появляется только тогда, когда списком сужен каждый профиль, способный взять 80 или 443: если хоть один берёт порт без списка, любая цель доходит до него и десинк получают все. Кастомная стратегия для своих доменов пункт не вызывает — её список сужает только её саму (--filter-tcp=443 --filter-l7=tls --hostlist=…/google.list перед основной стратегией безвреден, а init-скрипт ставит NFQWS_ARGS_CUSTOM в начало). Когда профилей со списками несколько, в рецепте перечисляются все: сняв один, читатель получит следующий. Сам домен по спискам при этом не сверяется — поэтому в пункте и сказано «могут не подхватываться», а не «не подхватываются».
Рецепт печатается в двух видах, и это не косметика. Если переменная в конфиге записана одной строкой (NFQWS_EXTRA_ARGS="$MODE_ALL", NFQWS_ARGS_IPSET="--ipset=… --ipset-exclude=…"), блок показывает её целиком — скопировал и вставил. Если же переменная держит целую стратегию на несколько профилей (NFQWS_ARGS_CUSTOM в пресетах — там профили Discord, DNS, GameFilter и их десинки), показывать её значение бессмысленно: это страница текста, а не совет. Тогда блок называет переменную и ту опцию, которую из неё надо убрать, — а всё остальное содержимое остаётся нетронутым.
Режим проверяется отдельно и по своему значению: если в NFQWS_EXTRA_ARGS стоит не $MODE_ALL, эта строка попадает в рецепт независимо от того, видны ли списки режима в argv. Причина в поведении пакета: с MODE_AUTO соединение уходит в профиль с автолистом, как только становится известно имя хоста, — и стратегия на MODE_AUTO меряет не то же самое, что на MODE_ALL. Значение читается из конфига пакета, поэтому пункт появляется без перезапуска службы.
Тот же переход действует и для портов, и проверка это учитывает: порт считается покрытым, если его берёт какой-нибудь действующий профиль, а не обязательно тот, что берёт 443. Профиль, который берёт только 443, — это не «нет порта 80»: соединение на 80 просто уходит в следующий подходящий профиль. Порт попадает в пункт только тогда, когда его нет в TCP_PORTS или его не берёт ни один профиль, который что-то делает.
Факты берутся из состояния самого пакета: /opt/etc/nfqws2/nfqws2.conf.run (политика, интерфейсы, порты, IPV6_ENABLED), pid-файл, /proc/net/netfilter/nfnetlink_queue (очередь привязана), /proc/net/route и /proc/net/ipv6_route (куда уходит наш трафик) и /proc/net/nf_conntrack (метка собственного соединения). Счётчик пакетов очереди для вердикта не используется: на Keenetic его фоновый ход около 97 пакетов/с против наших единиц, и вердикт по нему прыгал между прогонами. Ничего не меняется, отправляются только SYN-пакеты одной-двух пробных попыток на порт 443 — по обеим семьям сразу, а в блок попадает та, которой пойдут тесты (ip_version).
🧬 Тест 6: Fingerprint / Сибирская блокировка
Тест отвечает на вопрос «блокируют ли именно мою форму TLS-рукопожатия»: на каждый домен отправляются N попыток выбранными отпечатками, старты разнесены на задержку (по умолчанию 20 мс, задаётся --burst-gap), и видно, какие формы ответили, а какие нет.
Триггер, который тест воспроизводит, следит за частотой: несколько попыток соединения внутри окна в пару сотен миллисекунд. Отсюда и задержка между стартами — по умолчанию 20 мс. Строго по одной попытке этот порог на медленном канале не достигается вообще: интервал между стартами там равен длительности предыдущей попытки (у меня на восьми хостах это 90–400 мс, то есть «три старта подряд» занимают от 220 до 750 мс — половина уже вне окна 250 мс). Залпом всего сразу — противоположная ошибка: все N успевают установиться до того, как блок ляжет, весь раунд проходит целиком, и блок видно только в следующем прогоне. 20 мс — компромисс: пять попыток укладываются в 80 мс (внутри любого разумного окна), но блок успевает лечь между двумя стартами, поэтому в отчёте видно, на какой именно попытке он включился. --burst-gap 0 — как раз залп, и стоит он того же, что описано выше: весь раунд проходит целиком, а включившийся блок видно только в следующем. Домены при этом по-прежнему идут параллельно (их ограничивает -c).
Домены у теста свои: burst-domains.txt рядом с бинарником, в сборке — ezgame.su, amserg.com, www.smartape.ru. Это обычные сайты, которые на первом соединении отвечают нормально: заблокированный сайт провалит все попытки по причине, которую тест не измеряет, а форма с www выбрана там, где без неё сайт отвечает редиректом на неё же — редирект тест тоже не измеряет. Оба случая расписаны в шапке файла: reg.ru выброшен за перехваченный TLS (UNKNOWN на всех формах), www.smartape.ru стоит вместо голого имени именно из-за его же 301. Список теста 2 (domains.txt) остаётся списком заблокированных сайтов и к тесту 6 отношения не имеет; -d и --domains перебивают оба.
При выборе теста 6 открывается свой экран настроек: число соединений на домен (от 1 до 100; переключатель заворачивается — ← на единице даёт сотню, → на сотне — единицу), таймаут одного рукопожатия в секундах, задержка перед стартами попыток в миллисекундах (шаг 10, от 0 до 1000; 0 — весь раунд стартует в один момент, и на границах значение просто упирается, а не заворачивается), версия TLS (1.3+1.2 — предложение браузера, ответ должен быть 1.3; 1.3 — только 1.3; 1.2 — только 1.2), протокол HTTP в ALPN (h2 — как у браузеров, с откатом на http/1.1, или http/1.1 — только он), домен (пустое поле — весь список теста 6; вставленный URL приводится к имени хоста) и Fingerprint (переключатель идёт по всем двадцати девяти формам, а первое его значение — «все», то есть все формы разом; набор из --burst-profiles, которому в переключателе нет места, экран заменяет этим первым значением, так что строка всегда называет то, что запустит Enter). Поле домена подсвечивается: серый фон — строка выбрана, синий — в поле идёт ввод. Q/Esc отменяет тест.
Попытка не заканчивается рукопожатием: сразу после него тем же профилем (и тем же User-Agent/заголовками) уходит GET / по тому протоколу, который выбрал ALPN. Попытка считается удачной, только если вернулся ответ — обрыв на запросе, редирект на чужой хост, BLOCKED или таймаут чтения видны как своя строка, а не как «ответили». Редирект внутри своего сайта вердикта не меняет: основной домен, его поддомены и два поддомена одного домена — одна и та же площадка, поэтому holod.media → www.holod.media, m.holod.media → holod.media и m.youtube.com → www.youtube.com читаются как OK, а на чужой домен (www.instagram.com → www.facebook.com) — как REDIR с именем хоста в детали.
В таблице строки — отпечатки, столбцы — сайты: так на экран помещаются все двадцать девять форм сразу. В ячейке M/N по каждому отпечатку на каждом сайте: зелёный — все попытки прошли целиком, жёлтый — часть, красный — никто. В колонке «Детали» — все вердикты этой формы по всем сайтам, сгруппированные по статусу, самые частые первыми (TLS ALERT ×8 · TLS RST ×4), так что двенадцать потерянных рукопожатий не схлопываются в одну «победившую» ошибку; сайт, который не разрешился, помечен прямо в заголовке своего столбца как DNS FAIL, иначе столбец был бы просто прочерками. Номер попытки в трассе — это номер старта, а не завершения: попытки идут внахлёст, поэтому в отчёте видно, на какой именно из них включился блок (2/5 — первые два соединения ответили, дальше перестало).
Раунд одной формы на одном домене — это N накладывающихся попыток, поэтому по времени он стоит примерно как одна: последний старт отстоит от первого на (N−1) × задержка мс (по умолчанию (N−1) × 20), и раунд заканчивается, когда завершится самая долгая попытка. При --burst 5 и таймауте 8 это ~8.1 секунды на домен, а не 40, как было бы при строгой очереди. Домены идут параллельно, поэтому общее время прогона ≈ самое медленное домен-раунд, а не сумма.
Во время прогона печатается одна живая строка Тестируем: CHROME 107 3/7 12/35 · 00:04 — какая форма сейчас в эфире, который это раунд из скольких, сколько хостов раунда готово и сколько идёт время.
Ключ, по которому форма блокируется, — упорядоченный список расширений (значения GREASE не в счёт): то поле, которое JA3 сохраняет, а JA4 сортирует и потому теряет. Измерено на домене теста 6: CHROME 107 падает 0–2/7, а он же — с тем же набором расширений, теми же телами, той же длиной — отвечает 7/7, когда ALPS переставлен за padding (--burst-variant ext-order:…); три пары baseline→вариант→baseline подряд, и обратный порядок расширений проходит так же (7/7). JA4 у этих двух форм совпадает (t13d1516h2_8daaf6152771_e5627efa2ab1), различается только JA3 — то есть решение принимается по тому, что JA4 отбрасывает. Снятие ALPS (-ext:17513) или padding (-ext:21) разблокирует по той же причине: список перестаёт совпадать с известным. По той же причине chrome87 с добавленным ALPS остаётся жив: правка дописывает расширение в конец, за padding, — это уже другой список. Порядок внутри supported_groups/key_share и длина тела ECH на вердикт не влияют. Наша CHROME 146 проходит потому, что перемешивает порядок расширений на каждом соединении, как настоящий Chrome 110+, — то есть никогда не повторяет ни один пришпиленный порядок, тогда как профили, транскрибированные с инструментов имперсонации, шлют один и тот же список каждый раз. Это видно и без профилей: если послать с одного сокета два захваченных ClientHello с одинаковым JA4 по очереди, перемешанный отвечает 6/6, а пришпиленный (uTLS HelloChrome_133) — 3/6, и в глухих раундах лежат все пришпиленные сразу, а он один нет. Поэтому оси теста — не только изоляция колонок: --burst-tls 1.3/1.2 и --burst-alpn http/1.1 меняют сам ключ, и «OK» на такой оси означает, что цензор пропустил изменённую форму, а не что сайт доступен исходной. Меняют они его предсказуемо: 1.3-only даёт t13d0312h2_…, 1.2-only — t12d1210h2_…, а http/1.1 двигает поле ALPN (t13d1516h2_… → t13d1516h1_…) при том же наборе расширений и шифров. Для диагностики это и полезно: оси показывают, читает цензор таблицу ключей или что-то более глубокое.
Помимо форменного блока полный прогон теста 6 закрывает домен целиком: любая форма отвечает 0/3, включая RUSTLS. Состояние пер-доменное — домен, по которому прогон не шёл, остаётся 3/3. Отпускает оно само, за единицы минут: 429–650 с по замерам на трёх доменах, причём край рваный — домен успевает ответить 2/3 и через две минуты снова уйти в 0/3, так что время меряется до первого полностью открытого замера. Объём прогона на длительность не влияет: дозы в 1, 2 и 4 раунда дали 642, 320 и 537 с, контрольная доза 1 час спустя — 437 с, то есть весь разброс тот же, что между доменами. Опрос блок не продлевает: в опыте с переменой ролей каждый домен побывал и опрашиваемым (раз в ~2 минуты), и нетронутым, и опрашиваемый открывался первым оба раза — amserg.com 429 с против ~573 с, ezgame.su 531 с против ~650 с. Почему утренний прогон всего набора держал домены закрытыми до ~1.8 ч (верхняя оценка по логам, а не измерение), при таком поведении теста 6 его объёмом не объясняется: либо дело в остальных тестах набора, либо цензор в то время был настроен иначе.
Из неинтерактивного запуска настройки задаются флагами --burst, --burst-timeout, --burst-gap, --burst-profiles, --burst-tls, --burst-alpn, --burst-variant; в --json результат лежит в ключе results.fingerprint_burst (там же настройки раунда: attempts, timeout_secs, gap_ms, tls, alpn, variant).
Трасса попыток (--trace)
--trace [<PATH>] пишет по строке на каждую попытку — время UTC, раунд, форма, домен, номер попытки, статус, деталь и миллисекунды:
13:19:30Z r1/1 chrome www.google.com #1 tls_alert alert_unexpected_message 719 ms
13:19:30Z r1/1 chrome standby-rezka.tv #1 tls_alert alert_illegal_parameter 1031 ms
Без пути трасса идёт в stderr, с путём — в указанный файл: --trace C:\temp\trace.log и Get-Content -Wait C:\temp\trace.log во втором окне дают разбор прямо во время работы интерактивного меню. Видно, какая именно из N попыток, с какой задержкой и по какой причине не прошла — тишина до нашего таймаута, сброс за десятки миллисекунд или алерт от сервера.
Отдельно стоит знать про разбор провалившейся колонки: если попытки умирают с задержкой ровно в таймаут (8000 мс при --burst-timeout 8) у всех форм сразу, а быстрые хосты в том же раунде успевают ответить, — это провал пути, а не блокировка отпечатка. В замерах через VPN-тоннель целые раунды из 35 доменов умирали так двадцать-сорок секунд и так же сами восстанавливались, причём жертвой оказывалась то одна форма, то другая: при последовательных раундах такое окно целиком попадает в одну колонку и выглядит как её блокировка.
Профили отпечатка (--fingerprint)
Профиль задаёт три слоя сразу — форму TLS-рукопожатия, User-Agent с набором и порядком заголовков и преамбулу HTTP/2. У записей из бандла curl-impersonate все три взяты из одного набора, поэтому клиент, выглядящий как Chrome 107 в одном слое, выглядит так же и в остальных. Девять записей — chrome87, chrome72, chrome70, chrome115pq, firefox120, firefox105, firefox99, firefox65 и go127 — транскрибированы из спек библиотеки uTLS (HelloChrome_87, HelloFirefox_120, HelloGolang, …) на закреплённой версии v1.8.2; HTTP-слоя у этой библиотеки нет вовсе, поэтому форма TLS у них снята со спеки буквально, а User-Agent с accept-encoding — документированный минимум, и преамбулы h2 они не шлют (об этом ниже). Полную таблицу профилей — имя, воспроизводимую версию клиента и источник формы — печатает --legend; профиль описывается данными, так что новый добавляется записью, а не кодом. Список ниже, как и --legend, идёт по семействам клиентов в алфавитном порядке (Chrome, Edge, Firefox, Go, Safari, Tor), а внутри семейства — от новой версии к старой, поэтому свежая запись встаёт на своё место по версии, а не в конец.
rustls(по умолчанию) — контрольная форма: никого не изображает и версии в имени не несёт.chrome146— Chrome 146: ALPS на 17613. Chrome 133, 136, 142 и 145 шлют тот же hello — те же JA4, набор расширений, преамбула h2 и имена заголовков, отличается только версия вUser-Agentиsec-ch-ua, — поэтому из пяти версий профиль один, самый новый.chrome133— Chrome 133: тот же hello под своей идентичностью. Единственная запись, повторяющая чужую форму намеренно:chrome146иchrome133различаются толькоsec-ch-uaиUser-Agent, которых в рукопожатии нет, поэтому прогон, где обе формы отвечают одинаково, говорит, что цензор читает hello, а не идентичность, а разошедшиеся вердикты сказали бы обратное. Измерено на трёх внутренних хостах теста6, по семь раундов с чередованием: обе формы ответили одинаково во всех раундах — идентичность не читается. Там же видно, что читается:curl_chrome133aс флагами своей обёртки (порядок перемешивается) доводит рукопожатие до конца, а uTLSHelloChrome_133(один и тот же список каждый раз) получает тишину после ClientHello — при одинаковом JA4 у обоих.chrome131— Chrome 131: добавлена гибридная группа, ALPS ещё на 17513.chrome131android— Chrome 131 на Android 10: та же форма, но без гибридной группы — об этом пишет и сама обёртка бандла.chrome123— Chrome 123: появился ECH, порядок расширений перемешивается каждое соединение, вaccept-encodingдобавилсяzstd. Chrome 119 и 120 шлют тот же hello, поэтому оставлена самая новая версия.chrome116— Chrome 116: тот же hello, но порядок расширений перемешивается каждое соединение — как в Chromium с 110-й версии. Это единственная запись из бандла, где перемешивание есть, а ECH нет: JA3 такого клиента не совпадает сам с собой, но иencrypted_client_helloон не шлёт (тот же случай из спек uTLS —chrome115pqниже).chrome115pq— Chrome 115 (uTLSHelloChrome_115_PQ): пост-квантовая гибридная группа Kyber-черновика (25497) идёт первой, ALPS ещё на 17513, порядок расширений перемешивается каждое соединение, а шар Kyber-768 весит больше килобайта — hello оригинала самый длинный в наборе (1524 байта). Группы 25497 у провайдера нет, поэтому запись объявляет её ради списка групп, а шар шлёт только по X25519: с ним уходит и тело гибридной доли, из-за чего наш hello короче исходных 1524 байт и попадает под 512-байтовый порог padding'а, под который настоящий Chrome 115 не попадает, — поэтому слота padding в записи тоже нет. JA3 и JA4 читают список групп и набор расширений, а не доли, и остаются ключами источника (t13d1515h2_8daaf6152771_f37e75b10bcc); отличается только тело доли ключа.chrome107— Chrome 107: фиксированный порядок расширений, без ECH, без гибридной группы.chrome99android— Chrome 99 на Android 12: формаchrome107за идентичностью телефона (Pixel 6 вUser-Agent,sec-ch-ua-mobile: ?1).chrome87— Chrome 87 (uTLSHelloChrome_87): шифры и набор расширений те же, что уchrome107, но свой список схем подписи — JA4 отличается хешем расширений (…_de4a06bb82e3против…_e5627efa2ab1), хотя набор тот же. Стоит и за Chrome 83: спеки этих версий совпадают, поэтому оставлена одна запись.chrome72— Chrome 72 (uTLSHelloChrome_72): набор расширений как у 87-й, но в списке шифров 16 позиций — естьTLS_RSA_WITH_3DES_EDE_CBC_SHA(10), которого 87-я уже не шлёт.chrome70— Chrome 70 (uTLSHelloChrome_70): порядок расширений начинается с65281, и в нём есть 30032 — старый channel ID с заготовкой тела. Стоит и за 360Browser 11.0: тела расширений и JA4 совпадают, отличается только порядок.edge101— Edge 101: Chromium-hello за идентичностью Edge.firefox147— Firefox 147: hello 133-й плюсsigned_certificate_timestamp(18), уже с расширением ECH. Firefox 135, 144 и 147 шлют ровно этот hello, поэтому из трёх версий оставлена одна — самая новая.firefox133— Firefox 133: 17 шифров, гибридная группаX25519MLKEM768первой, безsigned_certificate_timestamp.firefox120— Firefox 120 (uTLSHelloFirefox_120): форма 105-й, но вместо padding'а (21) —encrypted_client_hello(65037); это единственная новая запись с ECH, и длина его GREASE-тела — единственное место, где запись отступает от спеки (см. «Отличия» ниже).firefox105— Firefox 105 (uTLSHelloFirefox_105): 17 шифров, две доли ключа (X25519 и P-256),record_size_limit(28) иdelegated_credentials(34), без ECH.firefox99— Firefox 99 (uTLSHelloFirefox_99): набор расширений как у 105-й, но 18 шифров — в списке есть 3DES (10), которого 105-я уже не шлёт, поэтому JA4 отличается хешем шифров (…_e8a523a41297против…_5b57614c22b0).firefox65— Firefox 65 (uTLSHelloFirefox_65): 14 расширений вместо 15 — нетdelegated_credentials(34), зато в списке шифров есть две DHE-CBC-сюиты (51, 57) и 3DES.go127— Go 1.27 (HelloGolang): то, что шлёт стандартныйcrypto/tls— 13 шифров, шарX25519MLKEM768(4588) вместе с X25519 и без ALPN вовсе, поэтому JA4 такого клиента читаетсяt13d131000_…, где00— отсутствие ALPN.safari260иsafari260ios— Safari 26.0 на macOS и iOS.safari184ios— Safari 18.4 на iPhone: форма 18.0 с преамбулой9:1без8:1.safari180— Safari 18.0: hello 15.5-й со схемами подписи 18.x и своей преамбулой h2 (8:1;9:1).safari172ios— Safari 17.2 на iPhone: форма 17.0 с окном потока 2 МиБ вместо 4.safari170— Safari 17.0: тот же hello, но преамбула сSETTINGS_ENABLE_PUSHвпереди (2:0;4:4194304;3:100) и триSec-Fetch-*в запросе — то, чем 17.0 отличается от 15.5, из-за чего он и не алиас.safari155— Safari 15.5: 20 шифров, безzstd, четыре заголовка в запросе.safari153— Safari 15.3: 26 шифров вместо 20, безcompress_certificate.tor145— Tor Browser 14.5 (curl_tor145): форма Firefox 128 ESR, не гласящая GREASE. Это форма обёртки: в ней нетpsk_key_exchange_modes,session_ticketиcompress_certificate, которые шлёт любой живой Firefox (обе записи Firefox выше их шлют), поэтому блокировка этой строки — блокировка именно этой строки, а не доказательство, что Tor Browser отвечает ею же; захват живого клиента меряется режимомhello <capture.hex>.
У девяти записей из uTLS идентичность — это минимум, а не замер: в источнике HTTP-слоя нет вовсе, поэтому в ней ровно два заголовка — User-Agent своей версии и accept-encoding (gzip, deflate, br у браузеров, gzip у go127). Chromium'овы записи пишут имена в нижнем регистре, Firefox'овы и go127 — с большой буквы, как пишет сам клиент, приоритет на h1 они не объявляют, а преамбулы h2 у них нет: HTTP/2 идёт с гиперовскими настройками по умолчанию. Это осознанная граница записи, а не измеренная идентичность, — точная потребовала бы живого клиента и отдельного пина.
Имена профилей — ровно те, что печатает --legend: одно имя на запись, и в имени клиента всегда стоит его версия (chrome146, firefox147, tor145); версии нет только у rustls — это контрольная форма, а не клиент. Имена обёрток curl-impersonate (curl_chrome107, curl_safari180) и старые имена без версии (firefox, chrome, safari, tor, edge) не принимаются: имя, которое разрешается не в ту версию, чем называется, хуже имени, которое не разрешается вовсе, — первое молча измеряет другого клиента, второе печатает предупреждение и подсказывает, что есть. По той же причине нет и алиасов вида chrome99/safari170: обёртки с такими именами отличаются от наших записей не только User-Agent, но и преамбулой h2 (curl_safari170 шлёт SETTINGS 2:0;4:…;3:100, а curl_safari155 — 4:…;3:100), так что для них нужна отдельная запись, а не соседняя.
Проверить, какое имя во что разрешается, можно --fingerprint <имя> --json: в ключе tls_fingerprint будет код профиля, а неизвестное имя даёт предупреждение (имена и версии печатает --legend).
edge101, chrome99android и safari184ios — это те же рукопожатия, что у chrome107, chrome107 и safari180 соответственно: отличаются User-Agent, набор sec-ch-*, приоритет и преамбула h2, и именно это проверяет колонка теста 6. Chrome 146 — другая форма, чем 131-я: гибридная группа X25519MLKEM768 идёт первой (и шаром, и в списке), ALPS на кодпойнте 17613, padding не отправляется; у 131-й та же форма, но ALPS ещё на 17513, у 120-й гибридной группы нет вовсе, а у safari260 переставлены три TLS 1.3-шифра и добавлена гибридная группа.
chrome123, chrome131, chrome131android, chrome133, chrome146, firefox133, firefox147 и tor145 шлют encrypted_client_hello (65037) — ровно те семь профилей, чьи обёртки называют --ech true; восьмая форма с этим расширением — firefox120, где его несёт сама спека uTLS, а не обёртка. Форма — GREASE, как и у самих обёрток: curl берёт настоящий ECHConfigList только через DoH или --ecl:, а их не передаёт никто, поэтому на провод уходит заготовка по §6.2 черновика (тип outer, набор HKDF-SHA256+AES-128-GCM, случайный config_id, свежий enc на 32 байта и случайный payload одной из четырёх длин, которые берёт BoringSSL — 144, 176, 208 или 240 байт). Тело пересобирается каждое соединение, так что его байты не сравнимы ни у нас, ни у браузера; JA4 считает расширение, и счётчики теперь как у источника. Преамбула h2 у всех записей из бандла — преамбула оригинала, включая настройки 8:1 и 9:1 (SETTINGS_ENABLE_CONNECT_PROTOCOL и SETTINGS_NO_RFC7540_PRIORITIES), которые шлют Safari 18 и 26: их пишут патченные h2 и hyper, а safari260 ещё и не берёт PRIORITY в запросе — ровно как оригинал. У safari153 и tor145 нет compress_certificate — ровно как у их прототипов из бандла; у четырёх записей Firefox из uTLS и у go127 его тоже нет — так написаны их спеки.
Отличия от настоящих браузеров, о которых стоит знать при сравнении: тело GREASE-ECH собирается заново на каждом соединении (enc — свежий эфемерный ключ, payload случаен), поэтому байты не сравнимы ни с чем, но длину payload'а мы берём из тех же четырёх значений, что и BoringSSL у curl (144/176/208/240 байт — оценка внутреннего hello, округлённая до 32, плюс тег AEAD), так что объявленная длина — как у браузера; и accept-encoding у тестов 2–4 подменяется на identity — они считают байты до обрыва, и согласованное сжатие сделало бы эти числа зависящими от сжимаемости ответа. Тест 6 и всё, что сверяется с бандлом, шлёт accept-encoding профиля (gzip, deflate, br, у более новых версий — ещё и zstd) — ровно как curl-impersonate; у девяти записей из uTLS там тот же минимум — gzip, deflate, br, у go127 — gzip. У firefox120 длина GREASE-тела — та единственная, которую спека uTLS пиннует (CandidatePayloadLens = {223}, то есть 239 байт payload'а и 281-байтовое тело расширения на проводе), а мы, как и во всех остальных записях, тянем её из четырёх значений BoringSSL (тела 186/218/250/282), поэтому hello-diff печатает это расхождение на каждом броске; хеши от этого не двигаются. Девять записей из uTLS не шлют преамбулу h2 вовсе: HTTP/2 у них идёт с гиперовскими настройками по умолчанию, потому что своей преамбулы в спеке нет, а выдумывать её — уже не транскрипция. Настоящий ECH по конфигурации из DNS не отправляется: он был бы свойством хоста, а не клиента, и обёртка его не шлёт. Свой user_agent в config.yml перебивает профиль.
У профилей firefox133, firefox147 и tor145 список сжатия сертификата теперь как у бандла — zlib, brotli, zstd (06 0001 0002 0003), и zstd читает ruzstd с собственным потолком окна в 1 МиБ (у самой библиотеки он 100 МиБ, что для роутера неприемлемо). Доли ключа тоже как у бандла — три у Firefox и три у Tor, потому что патченный rustls умеет слать шар для каждой группы из списка (--tls-key-shares-limit 3); у Tor это на одну долю больше, чем у самого браузера в захвате форка, и это единственное место, где запись следует обёртке, а не браузеру.
У chrome123, chrome131, chrome131android, chrome133, chrome146 и chrome115pq порядок расширений перемешивается каждое соединение — как у настоящего Chrome: BoringSSL гоняет по таблице расширений один проход Фишера–Йетса с зёрнами из CSPRNG (ssl_setup_extension_permutation), поэтому JA3 живого браузера от соединения к соединению меняется, а устойчив только JA4 (он хеширует отсортированный набор). Наши пять профилей делают то же самое (TlsShape::permute_extensions, зёрна — 128 бит из SecureRandom) — у chrome115pq тасует не обёртка, а сама спека uTLS (ShuffleChromeTLSExtensions), — так что их JA3 тоже гуляет, а набор расширений и JA4 совпадают с источником. Хеш шифров 8daaf6152771 при этом совпадает с публикуемым для Chrome 133+, то есть список шифров у нас Chrome'овский.
Имена заголовков шлются так, как их пишет оригинал — и в HTTP/2 (где они в нижнем регистре по стандарту), и в HTTP/1.1: Sec-Fetch-Site, Accept-Encoding, TE у одних обёрток и всё в нижнем регистре у safari180 и более новых, а Host с большой буквы везде. Это делает карта регистра, которую h1-энкодер hyper читает из расширений запроса (tools/fingerprint, стадия headers).
Набор по умолчанию (тест 6 без --burst-profiles) — семь профилей, выбранных по тому, что каждый отвечает, а не по покрытию версий: rustls (контрольная форма), chrome146 (новейший Chrome), chrome123 и chrome116 (граница поколения — их различает одно расширение, encrypted_client_hello), firefox147 (новейший Firefox), safari260 (новейший Safari) и tor145 (закрытая форма не-Chromium-семейства, чтобы прогон видел таблицу цензора, если она есть). Дубли по ключу в набор не входят: edge101 и chrome99android отвечают ключом chrome107, safari170, safari172ios и safari184ios — ключами других записей Safari. Ни одна из девяти записей из uTLS в набор не входит: он остался тем же семипрофильным. Остальные двадцать два ходят только по явному запросу (--burst-profiles), потому что тест 6 платит временем за каждый профиль; если запрошенных профилей больше, чем в наборе по умолчанию, CLI печатает предупреждение до запуска. Циклический переключатель на экране настроек идёт по всем двадцати девяти формам, первым значением — «все»: прогон тогда стреляет формами подряд, и таблица получает по строке на каждую.
Отпечаток, по которому форма попадает под блокировку, — её JA4: --legend печатает ключ каждой записи (и предупреждает, что у формы, чей hello может провалиться под 512-байтовый порог padding'а, их два). Замеренная картина такая: цензор несёт таблицу ключей, и в ней лежат поколения, которых живой клиент больше не шлёт — Chrome до ECH (chrome107, chrome116, chrome99android и edge101 отвечают одним ключом t13d1516h2_8daaf6152771_e5627efa2ab1), Safari 15.3–18.4 и форма Tor-обёртки. Ни одна из форм новейшего поколения, firefox133/firefox147 и rustls в таблицу не попали. Порядок расширений от неё не спасает: ключ не зависит от порядка, поэтому перемешивающая chrome116 ловится так же, как закреплённая chrome107, — а выход из таблицы даёт только смена поколения.
Любую из этих записей можно перепроверить против оригинала: python tools/fingerprint/fingerprint.py all <профиль> прогоняет три сравнения — запись против собственного захвата форка, оба клиента против tls.peet.ws (хеши, Akamai-отпечаток h2, каждый кадр преамбулы и каждый заголовок запроса) и байты рукопожатия через локальный слушатель — и печатает расхождения. Для девяти записей из uTLS обёртки в бандле нет, поэтому «оригинал» для стенда — сама спека: её захват снимает дампер (go run . dump HelloChrome_87 -o capture.hex в tools/fingerprint/utls), а сравниваются с ним байты рукопожатия — тем же hello-diff и вручную, cargo run --release --example tls_fingerprint -- diff capture.hex our.hex против нашего dump-hex <код>; HTTP-слой у библиотеки отсутствует, так что сравнивать там нечего, и строки ours:<код> в паре utls-стенда — это и есть сверка спеки с записью. Намеренные отличия перечислены в tools/fingerprint/README.md; всё остальное, что он показывает, — ошибка в записи.
Как добавить новый профиль — откуда брать числа, что прибить в тестах и чем проверять: docs/ADDING_A_PROFILE.md.
🏗️ Сборка из исходников
Для самостоятельной сборки нужен Rust: версия закреплена в rust-toolchain.toml
(сейчас 1.98.1), и rustup поставит её сам при первой сборке — ничего выбирать не надо.
Если rustup не используется, компилятор должен быть не старее версии из этого файла.
git clone https://github.com/Runnin4ik/dpi-detector.git -b rust
cd dpi-detector
cargo build --release -p dpi-detector
Скомпилированный бинарник будет находиться в target/release/dpi-detector (dpi-detector.exe на Windows).
Для статической сборки под Windows без внешних зависимостей CRT:
RUSTFLAGS="-C target-feature=+crt-static" cargo build --release -p dpi-detector
💬 Обратная связь
- Чат: t.me/DPI_detector
- Баги и предложения: Issues
- Лицензия: MIT