The version moves in the seven places that carry it: the workspace manifest (which both crates inherit and the PE version resource reads through `CARGO_PKG_VERSION`), `Cargo.lock` for both members, the README badge, both installers' default `VERSION`, the fixture `check.yml` hands to the installer smoke test, and the two installer comments that illustrate the live variable. Everything else that names `alpha.19` keeps it on purpose — `release.yml`'s smoke notes, `build.rs`'s example of the PE transformation, `docs/CI.md`'s prerelease rule and the diag script's "appeared after" note all record what was true then, and an example that chases the current number ages worse than one that dates itself. What this release carries over alpha.19: the worker count is a build decision now (two on the router targets, one per core elsewhere, `DPI_WORKERS` overrides and is clamped), the router identity comes from `build.rs` reading the target triple, and §2.2, §2.5, §2.6 and §2.8 of `docs/OPTIMIZATIONS.md` were rewritten around measurements rather than arguments — including the resident-memory split, which turned out to be the code, nearly all of it. 311 tests pass, `clippy -D warnings` clean for the workspace and for `live-network`, `cargo doc -D warnings` clean. |
||
|---|---|---|
| .cargo | ||
| .github | ||
| crates | ||
| docs | ||
| scripts | ||
| tools | ||
| vendor | ||
| .gitattributes | ||
| .gitignore | ||
| AGENTS.md | ||
| burst-domains.txt | ||
| Cargo.lock | ||
| Cargo.toml | ||
| config.yml | ||
| CONTRIBUTING.md | ||
| Cross.toml | ||
| deny.toml | ||
| domains.txt | ||
| install.ps1 | ||
| install.sh | ||
| README.md | ||
| rust-toolchain.toml | ||
| tcp16.json | ||
| whitelist_sni.txt | ||
"Маяк у гаснущего горизонта свободного интернета"
Сквозь цифровые сумерки. Смотритель маяка, Runni
🦀 DPI Detector (Native Rust Engine)
Нативный инструмент для анализа цензуры и блокировок трафика на чистом 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:
- Автоматически находит директорию
/opt/bin(Entware) и устанавливает бинарник туда (сохраняется после перезагрузки). - Контролирует свободное место: если на накопителе осталось
< 200 МБ, скрипт автоматически ставит компактную версию со сжатием UPX (~1.4 МБ вместо ~4.0 МБ), защищая роутер от переполнения Flash илиtmpfs. - Заменяет файл атомарно (
.tmp.$$->dpi-detector) и печатает фактически занятое место — поduуже установленного файла, а не обещанный размер из релиза. Если релиз публикуетSHA256SUMS.txt, скачанное сверяется с ним до запуска, и манифест берётся только с самого релиза: копия манифеста на зеркале едет тем же каналом, что и бинарник этого зеркала, поэтому совпадёт с чем угодно, что зеркало решило отдать, и читать её значило бы заменить проверку её видимостью. Несовпадение — отказ от кандидата и переход к следующему зеркалу; когда GitHub недоступен или релиз манифеста не публикует, проверка пропускается с одним предупреждением, и остаётся проверка запуском и версией (см. п. 4): не стартовал — при сбое UPX-сборки установщик берёт стандартную, а провалившийся источник уступает следующему. - Качает со всех зеркал одновременно: побеждает то, что загрузилось первым, остальные отменяются — вместе с их загрузками; скачанный файл всё равно проверяется запуском и сравнением версии, так что источник, отдавший другую сборку (например, закешировавший
latestстарый релиз), считается неудачным зеркалом и перебор идёт дальше. Гонка включается только тогда, когда в целевом каталоге хватает места под копию от каждого зеркала —число зеркал × 5 МБ, но не меньше 30 МБ; иначе зеркала идут по очереди, и установка просто медленнее на медленном зеркале. Прерванный поCtrl-Cзапуск не оставляет за собой ни временного файла в целевом каталоге, ни каталога гонки. - Поддерживает принудительный выбор версии:
DPI_UPX=1(компактная) илиDPI_UPX=0(стандартная). - Поддерживает запуск напрямую через SSH:
# Установка и запуск меню
curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install.sh | sh
# Повторный запуск после установки
/opt/bin/dpi-detector
- Не скачивает то, что уже стоит: если в целевом каталоге лежит запускаемый
dpi-detectorтой же версии, что несёт сам установщик, скрипт пишет об этом и сразу запускает его с переданными аргументами — повторныйcurl … | sh -s -- -t 2 -d rutracker.orgне тратит трафик и стартует тестер сразу. Файл, который не запускается (чужая архитектура, обрезанный) или не имеет бита выполнения, версией не считается, а версия, отличная от версии установщика, — обычная установка с явной строкой об этом. СDPI_VERSION=latestсравнение невозможно (тег движется), поэтому установка идёт как обычно.
⚙️ Параметры командной строки (CLI)
Использование: dpi-detector [ОПЦИИ]
Опции:
-t, --tests <TESTS> Строка выбора тестов (например, '012', '1', '2')
--json Вывод в машиночитаемом JSON
-v, --verbose Подробное / отладочное логирование
-l, --lang <LANG> Язык интерфейса (ru, en, zh, fa, auto). По умолчанию auto
--profile <PROFILE> Региональный профиль цензуры (ru, ir, cn, global) [по умолчанию: ru]
--legend Показать легенду статусов и таблицу профилей отпечатка и выйти
-p, --proxy <URL> URL SOCKS5-прокси (socks5://127.0.0.1:1080)
-c, --concurrency <N> Лимит параллельных запросов
-d, --domain <DOMAIN> Конкретные домены для проверки (флаг можно повторять: -d vk.com -d ya.ru)
-o, --output <PATH> Путь к файлу отчёта
--domains <PATH> Путь к файлу со списком доменов
--tcp16 <PATH> Путь к файлу целей TCP16
--ascii Только ASCII для старых консолей (без Unicode-глифов и рамок)
-i, --iface <NAME> Интерфейс, через который идут проверки: имя устройства или его адрес; по умолчанию выбирает таблица маршрутов (--iface wg0, --iface 172.16.0.2)
--fingerprint <PROFILE> Профиль отпечатка: ClientHello, User-Agent с заголовками и преамбула HTTP/2 одной закреплённой версии клиента; имена и воспроизводимые версии печатает --legend
--burst <N> Fingerprint/Сибирская блокировка (тест 6): соединений на домен, внахлёст [1–100, по умолчанию: 10]
--burst-timeout <SECONDS> Тест 6: таймаут одного рукопожатия, секунды [по умолчанию: 8]
--burst-gap <MS> Тест 6: задержка между стартами попыток, мс [0–1000, по умолчанию: 20]; 0 — весь раунд стартует в один момент
--burst-profiles <LIST> Fingerprint для теста 6: all или имена профилей через запятую; список печатает --legend [по умолчанию: набор по умолчанию]
--burst-tls <VERSION> TLS для теста 6: 1.3+1.2 (предложение браузера, ответ должен быть 1.3)|1.3 (только 1.3)|1.2 (только 1.2) [по умолчанию: 1.3+1.2]
--burst-alpn <PROTOCOL> ALPN для теста 6: h2 (предлагает h2 с откатом на http/1.1)|http/1.1 (только http/1.1) [по умолчанию: h2]
--burst-variant <DELTA> Тест 6, вариант: одна правка ClientHello каждого профиля, чтобы вердикт читался против формы, отличающейся одним полем — -ext:ID, ext-body:ID:HEX, +ext:ID, ext-body:ID:HEX, groups:A,B,…, key-shares:A,B,…, ext-order:A,B,… (0x0a0a — слот GREASE), padding:N, no-padding, sigalg-swap, +grease, +group:ID, alpn-reverse [по умолчанию: нет]
--trace [<PATH>] Тест 6: трасса по строке на попытку (раунд, отпечаток, домен, статус, деталь, мс); без пути — в stderr, с путём — в указанный файл [по умолчанию: выключено]
-h, --help Показать справку
-V, --version Показать версию
Справка и все сообщения локализованы: dpi-detector --lang en --help, --lang zh --help, --lang fa --help. Язык интерфейса задаётся --lang, таблицы, легенда и предупреждения следуют за ним; Farsi выводится латиницей (Finglish). Статусные бейджи и имена протоколов (OK, BLOCKED, TLS RST, SNI, ClientHello) остаются латиницей в любом языке. Машинный вывод --json от языка не зависит.
Потоки, на которых идёт прогон, выбираются сборкой, а не флагом. На роутерных сборках (за ними build.rs закрепляет признак dpi_router — musl на mips, arm и aarch64, то есть четыре роутерные сборки релиза: aarch64, armv7, mipsel и mips; признак ставится по целевому триплету, так что и собранный руками роутерный бинарник его получает) это два потока-исполнителя — измеренно именно два дают весь выигрыш по времени, а четыре не дают ничего сверх того и забирают половину ядер у самого роутера, который в это время раздаёт сеть; на машине с одним ядром остаётся один. На обычном ПК та же нагрузка сетевая (прогон упирается в сеть, а не в процессор), поэтому там берётся обычное для tokio число потоков — по одному на ядро. Переопределить можно переменной окружения: DPI_WORKERS=1 dpi-detector -t 2 — принудительно один поток, DPI_WORKERS=8 — восемь. Значение проверяется: 0 или мусор означает «как решено сборкой», а слишком большое число подрезается до четырёх на ядро — иначе процесс падал бы до того, как установлен обработчик паники, и на экране не осталось бы ничего своего. На роутере это единственный переключатель: переменную tokio TOKIO_WORKER_THREADS такая сборка не читает намеренно, по той же причине — её значение никто не проверяет. На ПК она работает как обычно.
В тесте 1 таблица и --json показывают одно и то же разными способами: строка эндпоинта несёт бейдж (NO CA BUNDLE, TLS DROP, SYN DROP), а results.dns_availability.failures — те же отказы как данные: provider, protocol (udp/doh_wire/dot), endpoint, счётчики ok/total и причина первого отказа (status — токен DpiStatus вроде no_ca_bundle, detail — код Detail вроде no_root_certificates; у отказа без классификации там исходное сообщение). Чистые эндпоинты в список не попадают, поэтому [] означает «ответили все». Рядом лежит 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, классификация блокировок 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 на этой машине будут показывать чужой сертификат вместо сертификата сервера.
Блок о перехвате: печатается перед тестами
Когда тесты запускаются на роутере Keenetic/Netcraze с пакетом nfqws2-keenetic, отчёт может измерять блокировку через обход или мимо него — и от этого зависит, что вообще означают цифры. Перед тестами, сразу после шапки, детектор проверяет, видит ли пакет его собственный трафик, и печатает один заголовок и список: на первой строке пункта — что не так, под ней — что с этим делать. Проблемы собираются все сразу, а не первая попавшаяся, поэтому один прогон показывает всё, что мешает обходу, и чинить их не приходится по одной за прогон. Пункт про хостлисты заканчивается готовыми строками конфига — их можно скопировать как есть:
Обнаружен включённый nfqws2, но есть проблемы:
• Трафик детектора исключён политикой доступа «nfqws».
На время тестов поставьте POLICY_EXCLUDE=1 или укажите свой POLICY_NAME,
которого нет среди политик роутера
• Детектор запущен в режиме IPv6, но в конфиге стоит IPV6_ENABLED=0.
Поставьте IPV6_ENABLED=1 в /opt/etc/nfqws2/nfqws2.conf.
• Трафик идёт мимо nfqws2: порта 80 нет в TCP_PORTS.
• Каждый профиль, который берёт порты, тестируемые детектором, сужен списком hostlist/ipset,
поэтому тестируемые цели могут не подхватываться.
Для правильной проверки стратегий на время тестирования установите в конфиге:
NFQWS_EXTRA_ARGS="$MODE_ALL"
После применения изменений перезапустите nfqws2 и dpi-detector.
| когда печатается | что сообщает | что делать |
|---|---|---|
| трафик исключён политикой | политика доступа вернула нас выше очереди; так работает режим «обрабатывать только устройства политики», а роутер в неё не входит | на время тестов POLICY_EXCLUDE=1 или своё POLICY_NAME, которого нет среди политик |
| правила на другом интерфейсе | правила висят на ISP_INTERFACE, а наш трафик уходит другим путём (второй аплинк) — в тексте видны оба интерфейса |
добавить нужный интерфейс в ISP_INTERFACE |
| трафик уходит через туннель | наш трафик частично или полностью идёт через туннель (VPN), а правила висят на интерфейсе провайдера | оставить как есть или добавить интерфейс туннеля в ISP_INTERFACE |
| порт не обрабатывается | тесты говорят на 80 и 443, и про отсутствующий порт сказано, где именно его нет: в TCP_PORTS пакета, в --filter-tcp ни одного действующего профиля или в обоих местах |
добавить порт туда, где его нет |
| IPv6 не покрыт | наш трафик идёт по IPv6, а правила для IPv6 не ставятся: в конфиге IPV6_ENABLED=0 |
IPV6_ENABLED=1 в конфиге пакета |
| в стратегии есть хостлисты | все профили, которые могут взять нашу сессию, отфильтрованы по списку (--hostlist, --hostlist-auto, --hostlist-domains, --ipset): цель вне списка не попадает ни в один из них и идёт мимо обхода, цель внутри — через обход, и цифры получаются смешанными. Профиль со списком, за которым есть профиль без него, пункт не вызывает: хост вне списка доходит туда |
под пунктом печатается, что сделать с переменной, которая этот фильтр содержит: однострочную — целиком строкой ПЕРЕМЕННАЯ="значение без фильтра", а если переменная держит целую стратегию на несколько профилей, то вместо неё называется только та опция, которую надо убрать |
| очередь не привязана | пакет запущен, но очередь не привязана — обычно служба не успела подняться; печатается вместе с проблемами конфига, а не вместо них | перезапустить nfqws2 и dpi-detector |
| конфиг пакета не читается | разрешённый nfqws2.conf.run не читается, поэтому о правилах ничего не известно — пустой список здесь не значит «всё хорошо» |
проверить установку пакета |
| маршрут неизвестен | не удалось определить, каким интерфейсом уходит наш трафик, поэтому покрытие по интерфейсу не проверяется | ничего: менять в пакете нечего, и закрывающая строка не печатается |
Когда трафик перехватывается, и когда пакета на устройстве нет вообще — не печатается ничего: nfqws2 проверяется на роутере, а не на домашнем ПК, и на Windows/macOS блоков не бывает. Список прочих обходчиков (sing-box, zapret и т.д.) остался в тесте 0, в строке «Локальный обход DPI на устройстве».
Причины независимы, поэтому в блоке оказываются все сработавшие сразу, в порядке починки: политика (она решает выше очереди), затем IPv6, интерфейс или туннель, порт и, последним, фильтр стратегии. Единственный случай, когда список короче правды — нечитаемый конфиг пакета: тогда фактов нет вовсе, и блок говорит об этом прямо, а не молчит.
У фильтра по списку есть оговорка, и её стоит знать. Профиль со списком берёт только те хосты, которые в списке названы: хост вне списка уходит в следующий подходящий профиль (если имя хоста удовлетворяет листам, выбирается этот профиль. иначе идет переход к следующему — документация пакета). Поэтому пункт появляется только тогда, когда списком сужен каждый профиль, способный взять 80 или 443: если хоть один берёт порт без списка, любая цель доходит до него и десинк получают все. Кастомная стратегия для своих доменов пункт не вызывает — её список сужает только её саму (--filter-tcp=443 --filter-l7=tls --hostlist=…/google.list перед основной стратегией безвреден, а init-скрипт ставит NFQWS_ARGS_CUSTOM в начало). Когда профилей со списками несколько, в рецепте перечисляются все: сняв один, читатель получит следующий. Сам домен по спискам при этом не сверяется — поэтому в пункте и сказано «могут не подхватываться», а не «не подхватываются».
Рецепт печатается в двух видах, и это не косметика. Если переменная в конфиге записана одной строкой (NFQWS_EXTRA_ARGS="$MODE_ALL", NFQWS_ARGS_IPSET="--ipset=… --ipset-exclude=…"), блок показывает её целиком — скопировал и вставил. Если же переменная держит целую стратегию на несколько профилей (NFQWS_ARGS_CUSTOM в пресетах — там профили Discord, DNS, GameFilter и их десинки), показывать её значение бессмысленно: это страница текста, а не совет. Тогда блок называет переменную и ту опцию, которую из неё надо убрать, — а всё остальное содержимое остаётся нетронутым.
Режим проверяется отдельно и по своему значению: если в NFQWS_EXTRA_ARGS стоит не $MODE_ALL, эта строка попадает в рецепт независимо от того, видны ли списки режима в argv. Причина в поведении пакета: с MODE_AUTO соединение уходит в профиль с автолистом, как только становится известно имя хоста, — и стратегия на MODE_AUTO меряет не то же самое, что на MODE_ALL. Значение читается из конфига пакета, поэтому пункт появляется без перезапуска службы.
Тот же переход действует и для портов, и проверка это учитывает: порт считается покрытым, если его берёт какой-нибудь действующий профиль, а не обязательно тот, что берёт 443. Профиль, который берёт только 443, — это не «нет порта 80»: соединение на 80 просто уходит в следующий подходящий профиль. Порт попадает в пункт только тогда, когда его нет в TCP_PORTS или его не берёт ни один профиль, который что-то делает.
Факты берутся из состояния самого пакета: /opt/etc/nfqws2/nfqws2.conf.run (политика, интерфейсы, порты, IPV6_ENABLED), pid-файл, /proc/net/netfilter/nfnetlink_queue (очередь привязана), /proc/net/route и /proc/net/ipv6_route (куда уходит наш трафик) и /proc/net/nf_conntrack (метка собственного соединения). Счётчик пакетов очереди для вердикта не используется: на Keenetic его фоновый ход около 97 пакетов/с против наших единиц, и вердикт по нему прыгал между прогонами. Ничего не меняется, отправляются только SYN-пакеты одной-двух пробных попыток на порт 443 — по обеим семьям сразу, а в блок попадает та, которой пойдут тесты (ip_version).
🧬 Тест 6: Fingerprint / Сибирская блокировка
Тест отвечает на вопрос «блокируют ли именно мою форму TLS-рукопожатия»: на каждый домен отправляются N попыток выбранными отпечатками, старты разнесены на задержку (по умолчанию 20 мс, задаётся --burst-gap), и видно, какие формы ответили, а какие нет.
Триггер, который тест воспроизводит, следит за частотой: несколько попыток соединения внутри окна в пару сотен миллисекунд. Отсюда и задержка между стартами — по умолчанию 20 мс. Строго по одной попытке этот порог на медленном канале не достигается вообще: интервал между стартами там равен длительности предыдущей попытки (у меня на восьми хостах это 90–400 мс, то есть «три старта подряд» занимают от 220 до 750 мс — половина уже вне окна 250 мс). Залпом всего сразу — противоположная ошибка: все N успевают установиться до того, как блок ляжет, весь раунд проходит целиком, и блок видно только в следующем прогоне. 20 мс — компромисс: пять попыток укладываются в 80 мс (внутри любого разумного окна), но блок успевает лечь между двумя стартами, поэтому в отчёте видно, на какой именно попытке он включился. --burst-gap 0 — как раз залп, и стоит он того же, что описано выше: весь раунд проходит целиком, а включившийся блок видно только в следующем. Домены при этом по-прежнему идут параллельно (их ограничивает -c).
Домены у теста свои: burst-domains.txt рядом с бинарником, в сборке — ezgame.su, amserg.com, www.smartape.ru. Это обычные сайты, которые на первом соединении отвечают нормально: заблокированный сайт провалит все попытки по причине, которую тест не измеряет, а форма с www выбрана там, где без неё сайт отвечает редиректом на неё же — редирект тест тоже не измеряет. Оба случая расписаны в шапке файла: reg.ru выброшен за перехваченный TLS (UNKNOWN на всех формах), www.smartape.ru стоит вместо голого имени именно из-за его же 301. Список теста 2 (domains.txt) остаётся списком заблокированных сайтов и к тесту 6 отношения не имеет; -d и --domains перебивают оба.
При выборе теста 6 открывается свой экран настроек: число соединений на домен (от 1 до 100; переключатель заворачивается — ← на единице даёт сотню, → на сотне — единицу), таймаут одного рукопожатия в секундах, задержка перед стартами попыток в миллисекундах (шаг 10, от 0 до 1000; 0 — весь раунд стартует в один момент, и на границах значение просто упирается, а не заворачивается), версия TLS (1.3+1.2 — предложение браузера, ответ должен быть 1.3; 1.3 — только 1.3; 1.2 — только 1.2), протокол HTTP в ALPN (h2 — как у браузеров, с откатом на http/1.1, или http/1.1 — только он), домен (пустое поле — весь список теста 6; вставленный URL приводится к имени хоста) и Fingerprint (переключатель идёт по всем двадцати девяти формам, а первое его значение — «все», то есть все формы разом; набор из --burst-profiles, которому в переключателе нет места, экран заменяет этим первым значением, так что строка всегда называет то, что запустит Enter). Поле домена подсвечивается: серый фон — строка выбрана, синий — в поле идёт ввод. Q/Esc отменяет тест.
Попытка не заканчивается рукопожатием: сразу после него тем же профилем (и тем же User-Agent/заголовками) уходит GET / по тому протоколу, который выбрал ALPN. Попытка считается удачной, только если вернулся ответ — обрыв на запросе, редирект на чужой хост, BLOCKED или таймаут чтения видны как своя строка, а не как «ответили». Редирект внутри своего сайта вердикта не меняет: основной домен, его поддомены и два поддомена одного домена — одна и та же площадка, поэтому holod.media → www.holod.media, m.holod.media → holod.media и m.youtube.com → www.youtube.com читаются как OK, а на чужой домен (www.instagram.com → www.facebook.com) — как REDIR с именем хоста в детали.
В таблице строки — отпечатки, столбцы — сайты: так на экран помещаются все двадцать девять форм сразу. В ячейке M/N по каждому отпечатку на каждом сайте: зелёный — все попытки прошли целиком, жёлтый — часть, красный — никто. В колонке «Детали» — все вердикты этой формы по всем сайтам, сгруппированные по статусу, самые частые первыми (TLS ALERT ×8 · TLS RST ×4), так что двенадцать потерянных рукопожатий не схлопываются в одну «победившую» ошибку; сайт, который не разрешился, помечен прямо в заголовке своего столбца как DNS FAIL, иначе столбец был бы просто прочерками. Номер попытки в трассе — это номер старта, а не завершения: попытки идут внахлёст, поэтому в отчёте видно, на какой именно из них включился блок (2/5 — первые два соединения ответили, дальше перестало).
Раунд одной формы на одном домене — это N накладывающихся попыток, поэтому по времени он стоит примерно как одна: последний старт отстоит от первого на (N−1) × задержка мс (по умолчанию (N−1) × 20), и раунд заканчивается, когда завершится самая долгая попытка. При --burst 5 и таймауте 8 это ~8.1 секунды на домен, а не 40, как было бы при строгой очереди. Домены идут параллельно, поэтому общее время прогона ≈ самое медленное домен-раунд, а не сумма.
Во время прогона печатается одна живая строка Тестируем: CHROME 107 3/7 12/35 · 00:04 — какая форма сейчас в эфире, который это раунд из скольких, сколько хостов раунда готово и сколько идёт время.
Ключ, по которому форма блокируется, — упорядоченный список расширений (значения GREASE не в счёт): то поле, которое JA3 сохраняет, а JA4 сортирует и потому теряет. Измерено на домене теста 6: CHROME 107 падает 0–2/7, а он же — с тем же набором расширений, теми же телами, той же длиной — отвечает 7/7, когда ALPS переставлен за padding (--burst-variant ext-order:…); три пары baseline→вариант→baseline подряд, и обратный порядок расширений проходит так же (7/7). JA4 у этих двух форм совпадает (t13d1516h2_8daaf6152771_e5627efa2ab1), различается только JA3 — то есть решение принимается по тому, что JA4 отбрасывает. Снятие ALPS (-ext:17513) или padding (-ext:21) разблокирует по той же причине: список перестаёт совпадать с известным. По той же причине chrome87 с добавленным ALPS остаётся жив: правка дописывает расширение в конец, за padding, — это уже другой список. Порядок внутри supported_groups/key_share и длина тела ECH на вердикт не влияют. Наша CHROME 146 проходит потому, что перемешивает порядок расширений на каждом соединении, как настоящий Chrome 110+, — то есть никогда не повторяет ни один пришпиленный порядок, тогда как профили, транскрибированные с инструментов имперсонации, шлют один и тот же список каждый раз. Это видно и без профилей: если послать с одного сокета два захваченных ClientHello с одинаковым JA4 по очереди, перемешанный отвечает 6/6, а пришпиленный (uTLS HelloChrome_133) — 3/6, и в глухих раундах лежат все пришпиленные сразу, а он один нет. Поэтому оси теста — не только изоляция колонок: --burst-tls 1.3/1.2 и --burst-alpn http/1.1 меняют сам ключ, и «OK» на такой оси означает, что цензор пропустил изменённую форму, а не что сайт доступен исходной. Меняют они его предсказуемо: 1.3-only даёт t13d0312h2_…, 1.2-only — t12d1210h2_…, а http/1.1 двигает поле ALPN (t13d1516h2_… → t13d1516h1_…) при том же наборе расширений и шифров. Для диагностики это и полезно: оси показывают, читает цензор таблицу ключей или что-то более глубокое.
Помимо форменного блока полный прогон теста 6 закрывает домен целиком: любая форма отвечает 0/3, включая RUSTLS. Состояние пер-доменное — домен, по которому прогон не шёл, остаётся 3/3. Отпускает оно само, за единицы минут: 429–650 с по замерам на трёх доменах, причём край рваный — домен успевает ответить 2/3 и через две минуты снова уйти в 0/3, так что время меряется до первого полностью открытого замера. Объём прогона на длительность не влияет: дозы в 1, 2 и 4 раунда дали 642, 320 и 537 с, контрольная доза 1 час спустя — 437 с, то есть весь разброс тот же, что между доменами. Опрос блок не продлевает: в опыте с переменой ролей каждый домен побывал и опрашиваемым (раз в ~2 минуты), и нетронутым, и опрашиваемый открывался первым оба раза — amserg.com 429 с против ~573 с, ezgame.su 531 с против ~650 с. Почему утренний прогон всего набора держал домены закрытыми до ~1.8 ч (верхняя оценка по логам, а не измерение), при таком поведении теста 6 его объёмом не объясняется: либо дело в остальных тестах набора, либо цензор в то время был настроен иначе.
Из неинтерактивного запуска настройки задаются флагами --burst, --burst-timeout, --burst-gap, --burst-profiles, --burst-tls, --burst-alpn, --burst-variant; в --json результат лежит в ключе results.fingerprint_burst (там же настройки раунда: attempts, timeout_secs, gap_ms, tls, alpn, variant).
Трасса попыток (--trace)
--trace [<PATH>] пишет по строке на каждую попытку — время UTC, раунд, форма, домен, номер попытки, статус, деталь и миллисекунды:
13:19:30Z r1/1 chrome www.google.com #1 tls_alert alert_unexpected_message 719 ms
13:19:30Z r1/1 chrome standby-rezka.tv #1 tls_alert alert_illegal_parameter 1031 ms
Без пути трасса идёт в stderr, с путём — в указанный файл: --trace C:\temp\trace.log и Get-Content -Wait C:\temp\trace.log во втором окне дают разбор прямо во время работы интерактивного меню. Видно, какая именно из N попыток, с какой задержкой и по какой причине не прошла — тишина до нашего таймаута, сброс за десятки миллисекунд или алерт от сервера.
Отдельно стоит знать про разбор провалившейся колонки: если попытки умирают с задержкой ровно в таймаут (8000 мс при --burst-timeout 8) у всех форм сразу, а быстрые хосты в том же раунде успевают ответить, — это провал пути, а не блокировка отпечатка. В замерах через VPN-тоннель целые раунды из 35 доменов умирали так двадцать-сорок секунд и так же сами восстанавливались, причём жертвой оказывалась то одна форма, то другая: при последовательных раундах такое окно целиком попадает в одну колонку и выглядит как её блокировка.
Профили отпечатка (--fingerprint)
Профиль задаёт три слоя сразу — форму TLS-рукопожатия, User-Agent с набором и порядком заголовков и преамбулу HTTP/2. У записей из бандла curl-impersonate все три взяты из одного набора, поэтому клиент, выглядящий как Chrome 107 в одном слое, выглядит так же и в остальных. Девять записей — chrome87, chrome72, chrome70, chrome115pq, firefox120, firefox105, firefox99, firefox65 и go127 — транскрибированы из спек библиотеки uTLS (HelloChrome_87, HelloFirefox_120, HelloGolang, …) на закреплённой версии v1.8.2; HTTP-слоя у этой библиотеки нет вовсе, поэтому форма TLS у них снята со спеки буквально, а User-Agent с accept-encoding — документированный минимум, и преамбулы h2 они не шлют (об этом ниже). Полную таблицу профилей — имя, воспроизводимую версию клиента и источник формы — печатает --legend; профиль описывается данными, так что новый добавляется записью, а не кодом. Список ниже, как и --legend, идёт по семействам клиентов в алфавитном порядке (Chrome, Edge, Firefox, Go, Safari, Tor), а внутри семейства — от новой версии к старой, поэтому свежая запись встаёт на своё место по версии, а не в конец.
rustls(по умолчанию) — контрольная форма: никого не изображает и версии в имени не несёт.chrome146— Chrome 146: ALPS на 17613. Chrome 133, 136, 142 и 145 шлют тот же hello — те же JA4, набор расширений, преамбула h2 и имена заголовков, отличается только версия вUser-Agentиsec-ch-ua, — поэтому из пяти версий профиль один, самый новый.chrome133— Chrome 133: тот же hello под своей идентичностью. Единственная запись, повторяющая чужую форму намеренно:chrome146иchrome133различаются толькоsec-ch-uaиUser-Agent, которых в рукопожатии нет, поэтому прогон, где обе формы отвечают одинаково, говорит, что цензор читает hello, а не идентичность, а разошедшиеся вердикты сказали бы обратное. Измерено на трёх внутренних хостах теста6, по семь раундов с чередованием: обе формы ответили одинаково во всех раундах — идентичность не читается. Там же видно, что читается:curl_chrome133aс флагами своей обёртки (порядок перемешивается) доводит рукопожатие до конца, а uTLSHelloChrome_133(один и тот же список каждый раз) получает тишину после ClientHello — при одинаковом JA4 у обоих.chrome131— Chrome 131: добавлена гибридная группа, ALPS ещё на 17513.chrome131android— Chrome 131 на Android 10: та же форма, но без гибридной группы — об этом пишет и сама обёртка бандла.chrome123— Chrome 123: появился ECH, порядок расширений перемешивается каждое соединение, вaccept-encodingдобавилсяzstd. Chrome 119 и 120 шлют тот же hello, поэтому оставлена самая новая версия.chrome116— Chrome 116: тот же hello, но порядок расширений перемешивается каждое соединение — как в Chromium с 110-й версии. Это единственная запись из бандла, где перемешивание есть, а ECH нет: JA3 такого клиента не совпадает сам с собой, но иencrypted_client_helloон не шлёт (тот же случай из спек uTLS —chrome115pqниже).chrome115pq— Chrome 115 (uTLSHelloChrome_115_PQ): пост-квантовая гибридная группа Kyber-черновика (25497) идёт первой, ALPS ещё на 17513, порядок расширений перемешивается каждое соединение, а шар Kyber-768 весит больше килобайта — hello оригинала самый длинный в наборе (1524 байта). Группы 25497 у провайдера нет, поэтому запись объявляет её ради списка групп, а шар шлёт только по X25519: с ним уходит и тело гибридной доли, из-за чего наш hello короче исходных 1524 байт и попадает под 512-байтовый порог padding'а, под который настоящий Chrome 115 не попадает, — поэтому слота padding в записи тоже нет. JA3 и JA4 читают список групп и набор расширений, а не доли, и остаются ключами источника (t13d1515h2_8daaf6152771_f37e75b10bcc); отличается только тело доли ключа.chrome107— Chrome 107: фиксированный порядок расширений, без ECH, без гибридной группы.chrome99android— Chrome 99 на Android 12: формаchrome107за идентичностью телефона (Pixel 6 вUser-Agent,sec-ch-ua-mobile: ?1).chrome87— Chrome 87 (uTLSHelloChrome_87): шифры и набор расширений те же, что уchrome107, но свой список схем подписи — JA4 отличается хешем расширений (…_de4a06bb82e3против…_e5627efa2ab1), хотя набор тот же. Стоит и за Chrome 83: спеки этих версий совпадают, поэтому оставлена одна запись.chrome72— Chrome 72 (uTLSHelloChrome_72): набор расширений как у 87-й, но в списке шифров 16 позиций — естьTLS_RSA_WITH_3DES_EDE_CBC_SHA(10), которого 87-я уже не шлёт.chrome70— Chrome 70 (uTLSHelloChrome_70): порядок расширений начинается с65281, и в нём есть 30032 — старый channel ID с заготовкой тела. Стоит и за 360Browser 11.0: тела расширений и JA4 совпадают, отличается только порядок.edge101— Edge 101: Chromium-hello за идентичностью Edge.firefox147— Firefox 147: hello 133-й плюсsigned_certificate_timestamp(18), уже с расширением ECH. Firefox 135, 144 и 147 шлют ровно этот hello, поэтому из трёх версий оставлена одна — самая новая.firefox133— Firefox 133: 17 шифров, гибридная группаX25519MLKEM768первой, безsigned_certificate_timestamp.firefox120— Firefox 120 (uTLSHelloFirefox_120): форма 105-й, но вместо padding'а (21) —encrypted_client_hello(65037); это единственная новая запись с ECH, и длина его GREASE-тела — единственное место, где запись отступает от спеки (см. «Отличия» ниже).firefox105— Firefox 105 (uTLSHelloFirefox_105): 17 шифров, две доли ключа (X25519 и P-256),record_size_limit(28) иdelegated_credentials(34), без ECH.firefox99— Firefox 99 (uTLSHelloFirefox_99): набор расширений как у 105-й, но 18 шифров — в списке есть 3DES (10), которого 105-я уже не шлёт, поэтому JA4 отличается хешем шифров (…_e8a523a41297против…_5b57614c22b0).firefox65— Firefox 65 (uTLSHelloFirefox_65): 14 расширений вместо 15 — нетdelegated_credentials(34), зато в списке шифров есть две DHE-CBC-сюиты (51, 57) и 3DES.go127— Go 1.27 (HelloGolang): то, что шлёт стандартныйcrypto/tls— 13 шифров, шарX25519MLKEM768(4588) вместе с X25519 и без ALPN вовсе, поэтому JA4 такого клиента читаетсяt13d131000_…, где00— отсутствие ALPN.safari260иsafari260ios— Safari 26.0 на macOS и iOS.safari184ios— Safari 18.4 на iPhone: форма 18.0 с преамбулой9:1без8:1.safari180— Safari 18.0: hello 15.5-й со схемами подписи 18.x и своей преамбулой h2 (8:1;9:1).safari172ios— Safari 17.2 на iPhone: форма 17.0 с окном потока 2 МиБ вместо 4.safari170— Safari 17.0: тот же hello, но преамбула сSETTINGS_ENABLE_PUSHвпереди (2:0;4:4194304;3:100) и триSec-Fetch-*в запросе — то, чем 17.0 отличается от 15.5, из-за чего он и не алиас.safari155— Safari 15.5: 20 шифров, безzstd, четыре заголовка в запросе.safari153— Safari 15.3: 26 шифров вместо 20, безcompress_certificate.tor145— Tor Browser 14.5 (curl_tor145): форма Firefox 128 ESR, не гласящая GREASE. Это форма обёртки: в ней нетpsk_key_exchange_modes,session_ticketиcompress_certificate, которые шлёт любой живой Firefox (обе записи Firefox выше их шлют), поэтому блокировка этой строки — блокировка именно этой строки, а не доказательство, что Tor Browser отвечает ею же; захват живого клиента меряется режимомhello <capture.hex>.
У девяти записей из uTLS идентичность — это минимум, а не замер: в источнике HTTP-слоя нет вовсе, поэтому в ней ровно два заголовка — User-Agent своей версии и accept-encoding (gzip, deflate, br у браузеров, gzip у go127). Chromium'овы записи пишут имена в нижнем регистре, Firefox'овы и go127 — с большой буквы, как пишет сам клиент, приоритет на h1 они не объявляют, а преамбулы h2 у них нет: HTTP/2 идёт с гиперовскими настройками по умолчанию. Это осознанная граница записи, а не измеренная идентичность, — точная потребовала бы живого клиента и отдельного пина.
Имена профилей — ровно те, что печатает --legend: одно имя на запись, и в имени клиента всегда стоит его версия (chrome146, firefox147, tor145); версии нет только у rustls — это контрольная форма, а не клиент. Имена обёрток curl-impersonate (curl_chrome107, curl_safari180) и старые имена без версии (firefox, chrome, safari, tor, edge) не принимаются: имя, которое разрешается не в ту версию, чем называется, хуже имени, которое не разрешается вовсе, — первое молча измеряет другого клиента, второе печатает предупреждение и подсказывает, что есть. По той же причине нет и алиасов вида chrome99/safari170: обёртки с такими именами отличаются от наших записей не только User-Agent, но и преамбулой h2 (curl_safari170 шлёт SETTINGS 2:0;4:…;3:100, а curl_safari155 — 4:…;3:100), так что для них нужна отдельная запись, а не соседняя.
Проверить, какое имя во что разрешается, можно --fingerprint <имя> --json: в ключе tls_fingerprint будет код профиля, а неизвестное имя даёт предупреждение (имена и версии печатает --legend).
edge101, chrome99android и safari184ios — это те же рукопожатия, что у chrome107, chrome107 и safari180 соответственно: отличаются User-Agent, набор sec-ch-*, приоритет и преамбула h2, и именно это проверяет колонка теста 6. Chrome 146 — другая форма, чем 131-я: гибридная группа X25519MLKEM768 идёт первой (и шаром, и в списке), ALPS на кодпойнте 17613, padding не отправляется; у 131-й та же форма, но ALPS ещё на 17513, у 120-й гибридной группы нет вовсе, а у safari260 переставлены три TLS 1.3-шифра и добавлена гибридная группа.
chrome123, chrome131, chrome131android, chrome133, chrome146, firefox133, firefox147 и tor145 шлют encrypted_client_hello (65037) — ровно те семь профилей, чьи обёртки называют --ech true; восьмая форма с этим расширением — firefox120, где его несёт сама спека uTLS, а не обёртка. Форма — GREASE, как и у самих обёрток: curl берёт настоящий ECHConfigList только через DoH или --ecl:, а их не передаёт никто, поэтому на провод уходит заготовка по §6.2 черновика (тип outer, набор HKDF-SHA256+AES-128-GCM, случайный config_id, свежий enc на 32 байта и случайный payload одной из четырёх длин, которые берёт BoringSSL — 144, 176, 208 или 240 байт). Тело пересобирается каждое соединение, так что его байты не сравнимы ни у нас, ни у браузера; JA4 считает расширение, и счётчики теперь как у источника. Преамбула h2 у всех записей из бандла — преамбула оригинала, включая настройки 8:1 и 9:1 (SETTINGS_ENABLE_CONNECT_PROTOCOL и SETTINGS_NO_RFC7540_PRIORITIES), которые шлют Safari 18 и 26: их пишут патченные h2 и hyper, а safari260 ещё и не берёт PRIORITY в запросе — ровно как оригинал. У safari153 и tor145 нет compress_certificate — ровно как у их прототипов из бандла; у четырёх записей Firefox из uTLS и у go127 его тоже нет — так написаны их спеки.
Отличия от настоящих браузеров, о которых стоит знать при сравнении: тело GREASE-ECH собирается заново на каждом соединении (enc — свежий эфемерный ключ, payload случаен), поэтому байты не сравнимы ни с чем, но длину payload'а мы берём из тех же четырёх значений, что и BoringSSL у curl (144/176/208/240 байт — оценка внутреннего hello, округлённая до 32, плюс тег AEAD), так что объявленная длина — как у браузера; и accept-encoding у тестов 2–4 подменяется на identity — они считают байты до обрыва, и согласованное сжатие сделало бы эти числа зависящими от сжимаемости ответа. Тест 6 и всё, что сверяется с бандлом, шлёт accept-encoding профиля (gzip, deflate, br, у более новых версий — ещё и zstd) — ровно как curl-impersonate; у девяти записей из uTLS там тот же минимум — gzip, deflate, br, у go127 — gzip. У firefox120 длина GREASE-тела — та единственная, которую спека uTLS пиннует (CandidatePayloadLens = {223}, то есть 239 байт payload'а и 281-байтовое тело расширения на проводе), а мы, как и во всех остальных записях, тянем её из четырёх значений BoringSSL (тела 186/218/250/282), поэтому hello-diff печатает это расхождение на каждом броске; хеши от этого не двигаются. Девять записей из uTLS не шлют преамбулу h2 вовсе: HTTP/2 у них идёт с гиперовскими настройками по умолчанию, потому что своей преамбулы в спеке нет, а выдумывать её — уже не транскрипция. Настоящий ECH по конфигурации из DNS не отправляется: он был бы свойством хоста, а не клиента, и обёртка его не шлёт. Свой user_agent в config.yml перебивает профиль.
У профилей firefox133, firefox147 и tor145 список сжатия сертификата теперь как у бандла — zlib, brotli, zstd (06 0001 0002 0003), и zstd читает ruzstd с собственным потолком окна в 1 МиБ (у самой библиотеки он 100 МиБ, что для роутера неприемлемо). Доли ключа тоже как у бандла — три у Firefox и три у Tor, потому что патченный rustls умеет слать шар для каждой группы из списка (--tls-key-shares-limit 3); у Tor это на одну долю больше, чем у самого браузера в захвате форка, и это единственное место, где запись следует обёртке, а не браузеру.
У chrome123, chrome131, chrome131android, chrome133, chrome146 и chrome115pq порядок расширений перемешивается каждое соединение — как у настоящего Chrome: BoringSSL гоняет по таблице расширений один проход Фишера–Йетса с зёрнами из CSPRNG (ssl_setup_extension_permutation), поэтому JA3 живого браузера от соединения к соединению меняется, а устойчив только JA4 (он хеширует отсортированный набор). Наши пять профилей делают то же самое (TlsShape::permute_extensions, зёрна — 128 бит из SecureRandom) — у chrome115pq тасует не обёртка, а сама спека uTLS (ShuffleChromeTLSExtensions), — так что их JA3 тоже гуляет, а набор расширений и JA4 совпадают с источником. Хеш шифров 8daaf6152771 при этом совпадает с публикуемым для Chrome 133+, то есть список шифров у нас Chrome'овский.
Имена заголовков шлются так, как их пишет оригинал — и в HTTP/2 (где они в нижнем регистре по стандарту), и в HTTP/1.1: Sec-Fetch-Site, Accept-Encoding, TE у одних обёрток и всё в нижнем регистре у safari180 и более новых, а Host с большой буквы везде. Это делает карта регистра, которую h1-энкодер hyper читает из расширений запроса (tools/fingerprint, стадия headers).
Набор по умолчанию (тест 6 без --burst-profiles) — семь профилей, выбранных по тому, что каждый отвечает, а не по покрытию версий: rustls (контрольная форма), chrome146 (новейший Chrome), chrome123 и chrome116 (граница поколения — их различает одно расширение, encrypted_client_hello), firefox147 (новейший Firefox), safari260 (новейший Safari) и tor145 (закрытая форма не-Chromium-семейства, чтобы прогон видел таблицу цензора, если она есть). Дубли по ключу в набор не входят: edge101 и chrome99android отвечают ключом chrome107, safari170, safari172ios и safari184ios — ключами других записей Safari. Ни одна из девяти записей из uTLS в набор не входит: он остался тем же семипрофильным. Остальные двадцать два ходят только по явному запросу (--burst-profiles), потому что тест 6 платит временем за каждый профиль; если запрошенных профилей больше, чем в наборе по умолчанию, CLI печатает предупреждение до запуска. Циклический переключатель на экране настроек идёт по всем двадцати девяти формам, первым значением — «все»: прогон тогда стреляет формами подряд, и таблица получает по строке на каждую.
Отпечаток, по которому форма попадает под блокировку, — её JA4: --legend печатает ключ каждой записи (и предупреждает, что у формы, чей hello может провалиться под 512-байтовый порог padding'а, их два). Замеренная картина такая: цензор несёт таблицу ключей, и в ней лежат поколения, которых живой клиент больше не шлёт — Chrome до ECH (chrome107, chrome116, chrome99android и edge101 отвечают одним ключом t13d1516h2_8daaf6152771_e5627efa2ab1), Safari 15.3–18.4 и форма Tor-обёртки. Ни одна из форм новейшего поколения, firefox133/firefox147 и rustls в таблицу не попали. Порядок расширений от неё не спасает: ключ не зависит от порядка, поэтому перемешивающая chrome116 ловится так же, как закреплённая chrome107, — а выход из таблицы даёт только смена поколения.
Любую из этих записей можно перепроверить против оригинала: python tools/fingerprint/fingerprint.py all <профиль> прогоняет три сравнения — запись против собственного захвата форка, оба клиента против tls.peet.ws (хеши, Akamai-отпечаток h2, каждый кадр преамбулы и каждый заголовок запроса) и байты рукопожатия через локальный слушатель — и печатает расхождения. Для девяти записей из uTLS обёртки в бандле нет, поэтому «оригинал» для стенда — сама спека: её захват снимает дампер (go run . dump HelloChrome_87 -o capture.hex в tools/fingerprint/utls), а сравниваются с ним байты рукопожатия — тем же hello-diff и вручную, cargo run --release --example tls_fingerprint -- diff capture.hex our.hex против нашего dump-hex <код>; HTTP-слой у библиотеки отсутствует, так что сравнивать там нечего, и строки ours:<код> в паре utls-стенда — это и есть сверка спеки с записью. Намеренные отличия перечислены в tools/fingerprint/README.md; всё остальное, что он показывает, — ошибка в записи.
Как добавить новый профиль — откуда брать числа, что прибить в тестах и чем проверять: docs/ADDING_A_PROFILE.md.
🏗️ Сборка из исходников
Для самостоятельной сборки нужен Rust: версия закреплена в rust-toolchain.toml
(сейчас 1.98.1), и rustup поставит её сам при первой сборке — ничего выбирать не надо.
Если rustup не используется, компилятор должен быть не старее версии из этого файла.
git clone https://github.com/Runnin4ik/dpi-detector.git -b rust
cd dpi-detector
cargo build --release -p dpi-detector
Скомпилированный бинарник будет находиться в target/release/dpi-detector (dpi-detector.exe на Windows).
Для статической сборки под Windows без внешних зависимостей CRT:
RUSTFLAGS="-C target-feature=+crt-static" cargo build --release -p dpi-detector
💬 Обратная связь
- Чат: t.me/DPI_detector
- Баги и предложения: Issues
- Лицензия: MIT