Find a file
Kirill Minovsky 8a13578fda quic-stand: the host list grows to 69, and the address defect is recorded
`hosts.txt` gains 35 hosts — three Cloudflare names, five Google ones, Meta,
Microsoft, Apple, Amazon, Netflix, Twitch, Spotify, Reddit, DuckDuckGo, Stack
Overflow, BBC, CNN, Slack, Zoom, Dropbox, Shopify, the New York Times, four
Yandex/RU names and the three Telegram hosts — and the README carries what the
re-run measured: 60 of 69 agreeing with aioquic at the detector's own
addresses, and 27 of the original 34 where the earlier run had 13, so the
repeated flight is what moved it.

It also records the defect that is not in the hello. `www.whatsapp.com` drops
here for the detector, for aioquic and for Chrome's own first attempt, all at
`31.13.72.52` — the address the system resolver returns — while a browser
reaches `109.194.137.78`, `whatsapp-cdn-shv-01-arn2.fbcdn.net`, and completes
HTTP/3 there. The detector's own hello is answered at that edge in 0.01 s, so
the hello is not the difference and DoH is not the fix: `dns.google`'s answer,
`157.240.205.60`, is silent too. Two things `browser_check.py` needs before the
next run trusts it are written down next to the measurements.
2026-09-27 00:03:47 +03:00
.cargo chore(build): pin rustc 1.98.1, rust-lld, and the README's toolchain claim 2026-09-24 12:22:33 +03:00
.github ci: the vendored advisory step calls the script through bash 2026-09-25 17:43:55 +03:00
crates quic: test 2's QUIC column sends the RFC 9000 Initial itself 2026-09-27 00:03:47 +03:00
docs quic: test 2's QUIC column sends the RFC 9000 Initial itself 2026-09-27 00:03:47 +03:00
scripts quic-stand: the host list grows to 69, and the address defect is recorded 2026-09-27 00:03:47 +03:00
tools quic: test 2's QUIC column sends the RFC 9000 Initial itself 2026-09-27 00:03:47 +03:00
vendor quic: test 2's QUIC column sends the RFC 9000 Initial itself 2026-09-27 00:03:47 +03:00
.gitattributes chore(repo): pin LF in every checkout, renormalize the two CRLF sources 2026-09-20 00:15:02 +03:00
.gitignore refactor: probe/ holds only the six tests, and one file for the console 2026-09-24 18:01:57 +03:00
AGENTS.md quic: test 2's QUIC column sends the RFC 9000 Initial itself 2026-09-27 00:03:47 +03:00
burst-domains.txt chore(burst): keep three hosts in test 6's list 2026-09-18 22:51:25 +03:00
Cargo.lock quic: test 2's QUIC column sends the RFC 9000 Initial itself 2026-09-27 00:03:47 +03:00
Cargo.toml report: the clock is time, and the graph drops its C compiler 2026-09-25 04:56:49 +03:00
config.yml quic: test 2's QUIC column sends the RFC 9000 Initial itself 2026-09-27 00:03:47 +03:00
CONTRIBUTING.md docs: add AGENTS.md, CONTRIBUTING.md and docs/CI.md; English for the engineering docs 2026-09-24 12:22:46 +03:00
Cross.toml ci: bound every job, pin what floated, and smoke what was never started 2026-09-21 22:16:29 +03:00
deny.toml chore(lints): declare the posture in the manifests and gate what CI never checked 2026-09-24 12:23:33 +03:00
domains.txt feat(probe): declare expected redirects, drop a retired host, sort the profiles 2026-09-18 19:26:50 +03:00
install.ps1 chore: release v5.0.0-alpha.20 2026-09-24 23:37:34 +03:00
install.sh chore: release v5.0.0-alpha.20 2026-09-24 23:37:34 +03:00
README.md quic: test 2's QUIC column sends the RFC 9000 Initial itself 2026-09-27 00:03:47 +03:00
rust-toolchain.toml chore(build): pin rustc 1.98.1, rust-lld, and the README's toolchain claim 2026-09-24 12:22:33 +03:00
tcp16.json feat: Native Rust engine (v4.2.0) 2026-09-04 00:15:58 +03:00
whitelist_sni.txt feat: Native Rust engine (v4.2.0) 2026-09-04 00:15:58 +03:00


"Маяк у гаснущего горизонта свободного интернета"
Сквозь цифровые сумерки. Смотритель маяка, Runni

🦀 DPI Detector (Native Rust Engine)

Language: Rust License: MIT Release Telegram

Нативный инструмент для анализа цензуры и блокировок трафика на чистом Rust:

  • Никаких внешних зависимостей: без сторонних рантаймов, без C/C++ библиотек и системного OpenSSL — TLS на rustls с чисто-Rust крипто-провайдером rustls-rustcrypto (вендорен и пропатчен: vendor/rustls-rustcrypto/README-PATCH.md).
  • Легковесный: статический бинарник ≈4.0 МБ (4–6 МБ на роутерных сборках, UPX — ≈1.4 МБ), потребление оперативной памяти в работе — 3–6 МБ RAM.
  • Максимальная кросс-платформенность: нативная поддержка роутеров (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:

  1. Автоматически находит директорию /opt/bin (Entware) и устанавливает бинарник туда (сохраняется после перезагрузки).
  2. Контролирует свободное место: если на накопителе осталось < 200 МБ, скрипт автоматически ставит компактную версию со сжатием UPX (~1.4 МБ вместо ~4.0 МБ), защищая роутер от переполнения Flash или tmpfs.
  3. Заменяет файл атомарно (.tmp.$$ -> dpi-detector) и печатает фактически занятое место — по du уже установленного файла, а не обещанный размер из релиза. Если релиз публикует SHA256SUMS.txt, скачанное сверяется с ним до запуска, и манифест берётся только с самого релиза: копия манифеста на зеркале едет тем же каналом, что и бинарник этого зеркала, поэтому совпадёт с чем угодно, что зеркало решило отдать, и читать её значило бы заменить проверку её видимостью. Несовпадение — отказ от кандидата и переход к следующему зеркалу; когда GitHub недоступен или релиз манифеста не публикует, проверка пропускается с одним предупреждением, и остаётся проверка запуском и версией (см. п. 4): не стартовал — при сбое UPX-сборки установщик берёт стандартную, а провалившийся источник уступает следующему.
  4. Качает со всех зеркал одновременно: побеждает то, что загрузилось первым, остальные отменяются — вместе с их загрузками; скачанный файл всё равно проверяется запуском и сравнением версии, так что источник, отдавший другую сборку (например, закешировавший latest старый релиз), считается неудачным зеркалом и перебор идёт дальше. Гонка включается только тогда, когда в целевом каталоге хватает места под копию от каждого зеркала — число зеркал × 5 МБ, но не меньше 30 МБ; иначе зеркала идут по очереди, и установка просто медленнее на медленном зеркале. Прерванный по Ctrl-C запуск не оставляет за собой ни временного файла в целевом каталоге, ни каталога гонки.
  5. Поддерживает принудительный выбор версии: DPI_UPX=1 (компактная) или DPI_UPX=0 (стандартная).
  6. Поддерживает запуск напрямую через SSH:
# Установка и запуск меню
curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install.sh | sh

# Повторный запуск после установки
/opt/bin/dpi-detector
  1. Не скачивает то, что уже стоит: если в целевом каталоге лежит запускаемый 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 и без строки в меню не меняется ничего — сокеты открываются ровно так же, как до появления выбора.

Номера доступных тестов:

  • 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) (детект сброса соединений после 14–34 КБ данных)
  • 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.

Статус 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: колонка 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 со статусом :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 там, где пробник без повторов видел тишину. Отсюда и окно: QUIC_TIMEOUT в config.yml по умолчанию 8 с, потому что повтор уходит на ~0.7, 2 и 4.7 с, а ответ на него приходит ещё позже; четыре полёта за окно и один ответ — это то, что делает настоящий клиент. Число одновременных проб — общий MAX_CONCURRENT. В --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 с флагами своей обёртки (порядок перемешивается) доводит рукопожатие до конца, а uTLS HelloChrome_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 (uTLS HelloChrome_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 (uTLS HelloChrome_87): шифры и набор расширений те же, что у chrome107, но свой список схем подписи — JA4 отличается хешем расширений (…_de4a06bb82e3 против …_e5627efa2ab1), хотя набор тот же. Стоит и за Chrome 83: спеки этих версий совпадают, поэтому оставлена одна запись.
  • chrome72 — Chrome 72 (uTLS HelloChrome_72): набор расширений как у 87-й, но в списке шифров 16 позиций — есть TLS_RSA_WITH_3DES_EDE_CBC_SHA (10), которого 87-я уже не шлёт.
  • chrome70 — Chrome 70 (uTLS HelloChrome_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 (uTLS HelloFirefox_120): форма 105-й, но вместо padding'а (21) — encrypted_client_hello (65037); это единственная новая запись с ECH, и длина его GREASE-тела — единственное место, где запись отступает от спеки (см. «Отличия» ниже).
  • firefox105 — Firefox 105 (uTLS HelloFirefox_105): 17 шифров, две доли ключа (X25519 и P-256), record_size_limit (28) и delegated_credentials (34), без ECH.
  • firefox99 — Firefox 99 (uTLS HelloFirefox_99): набор расширений как у 105-й, но 18 шифров — в списке есть 3DES (10), которого 105-я уже не шлёт, поэтому JA4 отличается хешем шифров (…_e8a523a41297 против …_5b57614c22b0).
  • firefox65 — Firefox 65 (uTLS HelloFirefox_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

💬 Обратная связь