Find a file
Kirill Minovsky f38a72f833 fix(fingerprint): size the padding slot against the finished hello
The two Chrome records whose hello can fall under the 512-byte floor — chrome123
and chrome131android, at 497 bytes with the shortest GREASE ECH body — sent an
unpadded hello where the reference client pads, because the padding slot measured
only what had been written before it. ECH is appended last (TLS 1.3 requires it)
and Chrome shuffles the extension order, so the slot counted neither the ECH body
nor anything else that followed it; a slot that tried to reach the floor overshot
by the whole body (803 bytes on the wire against the bundle's 517).

`ClientExtensions::encode` now encodes the extensions that follow the padding slot
into a scratch buffer, sizes the pad against the finished hello, and appends them —
the same thing BoringSSL does when it sizes its pad, and the reason its hellos land
on exactly 512. The per-extension encoding moved into one helper so both passes
write an extension the same way.

Both records get their padding back: `padding_to: Some(512)` and
`CHROME_PADDING_AND_ECH_EXT_ORDER`, Chrome 99–107's list with the padding slot and
the ECH the wrapper sends — padding in the order, ECH added outside it, as
BoringSSL's table has it. They pad when the body is short (one connection in four)
and stay above the floor otherwise, which is exactly what the client does; the
desktop chrome131 keeps no padding, whose hybrid-group hello is never near the
floor.

Verified: eight `dump` runs per record — chrome123 and chrome131android carry 512
bytes on the short-body connections with JA4 `t13d1517h2_8daaf6152771_b1ff8ab2d16f`
(the value the bundle's own padded capture measures) and 526/558/590 otherwise with
`t13d1516h2_8daaf6152771_02713d6af862`; chrome107 stays at 512
(`t13d1516h2_8daaf6152771_e5627efa2ab1`) as before. `tools/fingerprint all`:
`headers-diff` SAME everywhere, `hello-diff` SAME everywhere but the shufflers, and
on these two only the per-connection body draw differs. `cargo test --workspace`
(184 + 58) and `cargo clippy --workspace --all-targets` clean. `vendor/rustls/PATCH.diff`
regenerated (1357 lines) and proved with `patch -p1` against a pristine crates.io
copy.
2026-09-18 18:08:47 +03:00
.cargo build: vendor patched rustls, h2/compression features, release-local profile 2026-09-13 19:41:43 +03:00
.github/workflows chore(release): keep the MIPS unwinder alias per target 2026-09-17 12:20:15 +03:00
crates fix(fingerprint): size the padding slot against the finished hello 2026-09-18 18:08:47 +03:00
docs fix(fingerprint): size the padding slot against the finished hello 2026-09-18 18:08:47 +03:00
scripts feat(bench): let a run set the detector's check timeout 2026-09-17 09:37:52 +03:00
tools/fingerprint fix(fingerprint): size the padding slot against the finished hello 2026-09-18 18:08:47 +03:00
vendor fix(fingerprint): size the padding slot against the finished hello 2026-09-18 18:08:47 +03:00
.gitignore chore: keep run exports out of git 2026-09-13 22:32:39 +03:00
burst-domains.txt feat(burst)!: fire test 6's attempts one after another, on its own host list 2026-09-14 19:20:25 +03:00
Cargo.lock chore(release): v5.0.0-alpha.17 2026-09-18 15:10:07 +03:00
Cargo.toml chore(release): v5.0.0-alpha.17 2026-09-18 15:10:07 +03:00
config.yml docs(comments): describe behaviour instead of the retired prototype 2026-09-14 00:48:42 +03:00
Cross.toml fix(ci): use edge container for aarch64-linux-android and clean job names in release workflow 2026-09-05 01:38:47 +03:00
domains.txt chore: ignore the crash log, drop a dead domain from the list 2026-09-13 19:43:48 +03:00
install.ps1 chore(release): v5.0.0-alpha.17 2026-09-18 15:10:07 +03:00
install.sh chore(release): v5.0.0-alpha.17 2026-09-18 15:10:07 +03:00
README.md feat(fingerprint)!: name records after their newest version, and fill three shape gaps 2026-09-18 16:38:11 +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

🛠️ Установка на роутеры (Keenetic / OpenWrt / Entware)

При запуске на роутере скрипт install.sh:

  1. Автоматически находит директорию /opt/bin (Entware) и устанавливает бинарник туда (сохраняется после перезагрузки).
  2. Контролирует свободное место: если на накопителе осталось < 7 МБ, скрипт автоматически скачивает компактную версию со сжатием UPX (~1.4 МБ вместо ~4.0 МБ), защищая роутер от переполнения Flash или tmpfs.
  3. Заменяет файл атомарно (.tmp.$$ -> dpi-detector) с проверкой архитектуры через --version (при сбое UPX автоматически переключается на стандартный бинарник).
  4. Поддерживает принудительный выбор версии: DPI_UPX=1 (компактная) или DPI_UPX=0 (стандартная).
  5. Поддерживает запуск напрямую через SSH:
# Установка и запуск меню
curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install.sh | sh

# Повторный запуск после установки
/opt/bin/dpi-detector

⚙️ Параметры командной строки (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-глифов и рамок)
      --fingerprint <PROFILE>    Профиль отпечатка: ClientHello, User-Agent с заголовками и преамбула HTTP/2 одной закреплённой версии клиента; имена и воспроизводимые версии печатает --legend
      --burst <N>                Fingerprint/Сибирская блокировка (тест 6): соединений на домен, с задержкой 20 мс между стартами [по умолчанию: 5]
      --burst-timeout <SECONDS>  Тест 6: таймаут одного рукопожатия, секунды [по умолчанию: 8]
      --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]
      --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 от языка не зависит.

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

  • 0 — Информация о сети и системе (внешний IP, провайдер, AS, страна, тип NAT)
  • 1 — Доступность DNS-серверов (проверка UDP 53, DoH Cloudflare/Google/Quad9/Yandex/AdGuard)
  • 2 — Доступность сайтов (HTTP, TLS 1.2, TLS 1.3, классификация блокировок 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 — Легенда статусов (справка по вердиктам, следом — таблица профилей отпечатка с воспроизводимой версией и источником)

Статус host_unreach — это errno EHOSTUNREACH на connect: так ядро отдаёт любое ICMP-сообщение о недостижимости, и по одному errno не видно, был это отказ маршрутизации или фильтр провайдера. На Linux детектор читает из очереди ошибок сокета (IP_RECVERR) исходное ICMP-сообщение и, если это type 3 code 13 («administratively prohibited», именно так отвечает фильтр на пути), пишет в детали icmp_admin_prohibited — «ICMP: административно запрещено (фильтр провайдера)» вместо общего «нет маршрута до хоста». На Windows такой очереди нет, поэтому там деталь остаётся прежней.


Блок о перехвате: печатается перед тестами

Когда тесты запускаются на роутере 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, интерфейс или туннель, порт и, последним, фильтр стратегии. Единственный случай, когда список короче правды — нечитаемый конфиг пакета: тогда фактов нет вовсе, и блок говорит об этом прямо, а не молчит.

У фильтра по списку есть оговорка, и её стоит знать. Профиль со списком берёт только те хосты, которые в списке названы: хост вне списка уходит в следующий подходящий профиль (если имя хоста удовлетворяет листам, выбирается этот профиль. иначе идет переход к следующему — документация пакета). То есть какой профиль в итоге обработает нашу сессию, зависит от домена, а блок называет первый профиль, который мог бы её взять, и сам домен по спискам не сверяет — поэтому в пункте и сказано «могут не подхватываться», а не «не подхватываются». Рецепт при этом снимает фильтр целиком, так что вопрос «входит или нет» перестаёт иметь значение.

Рецепт печатается в двух видах, и это не косметика. Если переменная в конфиге записана одной строкой (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 мс, и видно, какие формы ответили, а какие нет.

Триггер, который тест воспроизводит, следит за частотой: несколько попыток соединения внутри окна в пару сотен миллисекунд. Отсюда и 20 мс между стартами. Строго по одной попытке этот порог на медленном канале не достигается вообще: интервал между стартами там равен длительности предыдущей попытки (у меня на восьми хостах это 90–400 мс, то есть «три старта подряд» занимают от 220 до 750 мс — половина уже вне окна 250 мс). Залпом всего сразу — противоположная ошибка: все N успевают установиться до того, как блок ляжет, весь раунд проходит целиком, и блок видно только в следующем прогоне. 20 мс — компромисс: пять попыток укладываются в 80 мс (внутри любого разумного окна), но блок успевает лечь между двумя стартами, поэтому в отчёте видно, на какой именно попытке он включился. Домены при этом по-прежнему идут параллельно (их ограничивает -c).

Домены у теста свои: burst-domains.txt рядом с бинарником, в сборке — realmeteo.ru, ezgame.su, scpslgame.ru, kyles.ru, amserg.com, smartape.ru, reg.ru, info.paymaster.ru. Это обычные сайты, которые на первом соединении отвечают нормально: заблокированный сайт провалит все попытки по причине, которую тест не измеряет. Список теста 2 (domains.txt) остаётся списком заблокированных сайтов и к тесту 6 отношения не имеет; -d и --domains перебивают оба.

При выборе теста 6 открывается свой экран настроек: число соединений на домен, таймаут одного рукопожатия в секундах, версия 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 (по умолчанию все). Поле домена подсвечивается: серый фон — строка выбрана, синий — в поле идёт ввод. Q/Esc отменяет тест.

Попытка не заканчивается рукопожатием: сразу после него тем же профилем (и тем же User-Agent/заголовками) уходит GET / по тому протоколу, который выбрал ALPN. Попытка считается удачной, только если вернулся ответ — обрыв на запросе, редирект на чужой хост, HTTP BLOCK или таймаут чтения видны как своя строка, а не как «ответили». Редирект внутри своего сайта вердикта не меняет: основной домен, его поддомены и два поддомена одного домена — одна и та же площадка, поэтому 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), так что двенадцать потерянных рукопожатий не схлопываются в одну «победившую» ошибку. Номер попытки в трассе — это номер старта, а не завершения: попытки идут внахлёст, поэтому в отчёте видно, на какой именно из них включился блок (2/5 — первые два соединения ответили, дальше перестало).

Раунд одной формы на одном домене — это N накладывающихся попыток, поэтому по времени он стоит примерно как одна: последний старт отстоит от первого на (N−1) × 20 мс, и раунд заканчивается, когда завершится самая долгая попытка. При --burst 5 и таймауте 8 это ~8.1 секунды на домен, а не 40, как было бы при строгой очереди. Домены идут параллельно, поэтому общее время прогона ≈ самое медленное домен-раунд, а не сумма.

Во время прогона печатается одна живая строка Тестируем: CHROME 107 3/7 12/35 · 00:04 — какая форма сейчас в эфире, который это раунд из скольких, сколько хостов раунда готово и сколько идёт время.

Из неинтерактивного запуска настройки задаются флагами --burst, --burst-timeout, --burst-profiles, --burst-tls, --burst-alpn; в --json результат лежит в ключе results.fingerprint_burst.

Трасса попыток (--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 в одном слое, выглядит так же и в остальных. Полную таблицу профилей — имя, воспроизводимую версию клиента и источник формы — печатает --legend; профиль описывается данными, так что новый добавляется записью, а не кодом.

  • rustls (по умолчанию) — контрольная форма: никого не изображает и версии в имени не несёт.
  • firefox133 — Firefox 133: 17 шифров, гибридная группа X25519MLKEM768 первой, без signed_certificate_timestamp.
  • firefox147 — Firefox 147: hello 133-й плюс signed_certificate_timestamp (18), уже с расширением ECH. Firefox 135, 144 и 147 шлют ровно этот hello, поэтому из трёх версий оставлена одна — самая новая.
  • chrome107 — Chrome 107: фиксированный порядок расширений, без ECH, без гибридной группы.
  • chrome116 — Chrome 116: тот же hello, но порядок расширений перемешивается каждое соединение — как в Chromium с 110-й версии. Это единственная форма в наборе, где перемешивание есть, а ECH нет: JA3 такого клиента не совпадает сам с собой, но и encrypted_client_hello он не шлёт.
  • chrome123 — Chrome 123: появился ECH, порядок расширений перемешивается каждое соединение, в accept-encoding добавился zstd. Chrome 119 и 120 шлют тот же hello, поэтому оставлена самая новая версия.
  • chrome131 — Chrome 131: добавлена гибридная группа, ALPS ещё на 17513.
  • chrome146 — Chrome 146: ALPS на 17613. Chrome 133, 136, 142 и 145 шлют тот же hello — те же JA4, набор расширений, преамбула h2 и имена заголовков, отличается только версия в User-Agent и sec-ch-ua, — поэтому из пяти версий оставлен один профиль, самый новый.
  • chrome99android и chrome131android — те же формы на Android 12 и Android 10 (у 131-й нет гибридной группы, о чём пишет и сама обёртка бандла).
  • safari155 — Safari 15.5: 20 шифров, без zstd, четыре заголовка в запросе.
  • safari170 — Safari 17.0: тот же hello, но преамбула с SETTINGS_ENABLE_PUSH впереди (2:0;4:4194304;3:100) и три Sec-Fetch-* в запросе — то, чем 17.0 отличается от 15.5, из-за чего он и не алиас.
  • safari172ios — Safari 17.2 на iPhone: форма 17.0 с окном потока 2 МиБ вместо 4.
  • safari153 — Safari 15.3: 26 шифров вместо 20, без compress_certificate.
  • safari180 — Safari 18.0: hello 15.5-й со схемами подписи 18.x и своей преамбулой h2 (8:1;9:1); safari184ios — 18.4 на iPhone, у неё 9:1 без 8:1.
  • safari260 и safari260ios — Safari 26.0 на macOS и iOS.
  • edge101 — Edge 101: Chromium-hello за идентичностью Edge.
  • tor145 — Tor Browser 14.5 (curl_tor145): форма Firefox 128 ESR, не гласящая GREASE.

Имена профилей — ровно те, что печатает --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, chrome146, firefox133, firefox147 и tor145 шлют encrypted_client_hello (65037) — ровно те семь профилей, чьи обёртки называют --ech true. Форма — 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 — ровно как у их прототипов из бандла.

Отличия от настоящих браузеров, о которых стоит знать при сравнении: тело 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. Настоящий 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 и chrome146 порядок расширений перемешивается каждое соединение — как у настоящего Chrome: BoringSSL гоняет по таблице расширений один проход Фишера–Йетса с зёрнами из CSPRNG (ssl_setup_extension_permutation), поэтому JA3 живого браузера от соединения к соединению меняется, а устойчив только JA4 (он хеширует отсортированный набор). Наши четыре профиля делают то же самое (TlsShape::permute_extensions, зёрна — 128 бит из SecureRandom), так что их 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, firefox133, chrome107, safari155, chrome146, safari180, edge101. Остальные тринадцать ходят только по явному запросу (--fingerprint или список в --burst-profiles), потому что тест 6 платит временем за каждый профиль; если запрошенных профилей больше, чем в наборе по умолчанию, CLI печатает предупреждение до запуска, а all остаётся явным способом попросить все двадцать.

Любую из этих записей можно перепроверить против оригинала: python tools/fingerprint/fingerprint.py all <профиль> прогоняет три сравнения — запись против собственного захвата форка, оба клиента против tls.peet.ws (хеши, Akamai-отпечаток h2, каждый кадр преамбулы и каждый заголовок запроса) и байты рукопожатия через локальный слушатель — и печатает расхождения. Намеренные отличия перечислены в tools/fingerprint/README.md; всё остальное, что он показывает, — ошибка в записи.


🏗️ Сборка из исходников

Для самостоятельной сборки потребуется установленный Rust (версии 1.80+):

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

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