Find a file
Kirill Minovsky 97d0d0175f fix(release)!: build the MIPS targets with one expression, and cut alpha.18
The MIPS jobs have failed on every tag since the static-unwind change: their
`RUSTFLAGS` was assembled with a `${{ }}` nested inside another `${{ }}` —
`... -L /target/static-unwind-${{ matrix.target }}` inside the conditional that
picks the flags. GitHub renders a nested expression verbatim, so the value reached
the shell with the text of an unexpanded expression in it, the shell split it on
its spaces, and rustc refused to start (`multiple input filenames provided`, with
`${{`, `matrix.target` and `}}` as three arguments). No MIPS binary has been built
since.

What differs per target now lives in the matrix (`rustflags`), the two flags every
cross build wants stay in the step, and the step sets `RUSTFLAGS` from one
expression. The flags themselves are unchanged: `+crt-static` and
`link-self-contained=no` on the two MIPS targets, and `-L` pointing at the alias
step's directory as the cross container sees it.

Verified as far as a local run can: the YAML parses, no workflow has a nested
expression left, and the matrix resolves to the same flags for every cross target.
The proof that MIPS links is the release run this commit's tag triggers.

Cuts 5.0.0-alpha.18 — alpha.17's tag exists with a failed release behind it, so the
fix ships as the next version rather than moving a published tag. `Cargo.toml`,
`install.sh`, `install.ps1` and the README badge carry the new version.
2026-09-18 18:17:26 +03:00
.cargo build: vendor patched rustls, h2/compression features, release-local profile 2026-09-13 19:41:43 +03:00
.github/workflows fix(release)!: build the MIPS targets with one expression, and cut alpha.18 2026-09-18 18:17:26 +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 fix(release)!: build the MIPS targets with one expression, and cut alpha.18 2026-09-18 18:17:26 +03:00
Cargo.toml fix(release)!: build the MIPS targets with one expression, and cut alpha.18 2026-09-18 18:17:26 +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 fix(release)!: build the MIPS targets with one expression, and cut alpha.18 2026-09-18 18:17:26 +03:00
install.sh fix(release)!: build the MIPS targets with one expression, and cut alpha.18 2026-09-18 18:17:26 +03:00
README.md fix(release)!: build the MIPS targets with one expression, and cut alpha.18 2026-09-18 18:17:26 +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

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