---
date: 2026-07-26
tags:
  - mtproto
  - mtproxy
  - faketls
  - clienthello
  - диагностика
  - tdesktop
aliases:
  - Инцидент MTProxy 26 июля 2026
  - Релей или клиент
  - Почему рабочий ключ не подключается
  - Диагностика FakeTLS без сборки клиента
---

# 🔬 Релей или клиент: диагностика MTProxy, когда рабочий ключ не подключается

![[faketls-relay-diagnosis-header.png]]

> [!info] О чём заметка
> Ключ `tg://proxy?...` работает в официальном Telegram, но не работает в другом клиенте (собственной сборке, форке, экспериментальном приложении). Заметка показывает, как за несколько минут и без пересборки клиента установить, кто именно виноват — релей или клиент, — и приводит измеренные требования, которые боевые FakeTLS-релеи предъявляют к первому пакету клиента. Про то, кто вообще формирует TLS-почерк и почему обход MTProxy делается на стороне клиента, — в [[mtproxy/ja4-sni-client-side|JA4/SNI задаёт клиент]].

> [!warning] Статус данных
> Все числа ниже — измерения 26 июля 2026 на боевых FakeTLS-релеях, поднятых через vpnbot: пять адресов и четыре разных порта (443, 442, 8445, 45633), с двух машин одновременно — одна на сети с активной фильтрацией, другая без неё. Каждый результат повторён минимум дважды со свежим ClientHello, а ключевые — раундами по четыре с чередованием проверяемых вариантов. Совпадение поведения разных релеев делает выводы правдоподобными для распространённых сборок, но это не спецификация: другая реализация может вести себя иначе, и проверять свой релей стоит своей пробой.

---

## Задача, ход и результат

**Что было.** Форк Telegram Desktop с переработанным контуром MTProxy. Ключи, которые в официальном приложении работают, в форке не подключались: «Подключение…» по кругу, в логе — рукопожатие ушло, ответа нет. Причём не на одном релее, а на всех сразу, и к вечеру становилось хуже. Со стороны это выглядело как «форк сломан».

**Чего хотелось.** Понять, где именно ломается, не гадая. Проблема в том, что клиент по своим логам этого узнать не может: FakeTLS устроен так, что отвергнутый клиент не получает отказа, а получает правдоподобный ответ камуфляжного сайта. Значит нужен взгляд снаружи — проба, которая воспроизводит путь клиента байт в байт, но запускается за минуты и без пересборки.

**Что сделали.** Написали такую пробу и прошли ею весь путь: TCP → FakeTLS → obfuscated2 → `resPQ`. Дальше — серия контролируемых опытов: одна переменная за раз, руки чередуются в раундах, всё запускается с обеих машин, чтобы сеть с фильтрацией сравнивалась с сетью без неё в те же минуты.

**К чему пришли.** Релеи были исправны почти всегда — ломалось четыре разных вещи, и путать их нельзя, потому что лечатся они по-разному:

| механизм | как выглядит | чем лечится |
|---|---|---|
| **TCP до адреса не проходит** | таймаут на всех открытых портах, десятки секунд | только другой адрес |
| **Имя в SNI не пропускают** | адрес отвечает (даже на мусор), но TLS с этим именем — тишина | ключ с другим маскируемым доменом |
| **Отпечаток ClientHello отвергают** | часть профилей проходит 12/12, другая 3/12, на том же релее в те же минуты | профиль, который не совпадает с эталоном браузера |
| **Релей теряет плечо до Telegram** | рукопожатие проходит, `resPQ` не приходит либо обратка замолкает | чинить сервер |

Отдельно выяснилось, что задержка, которую пользователи ощущали как «медленный прокси», сетью вообще не создавалась: на одном и том же релее сеть с фильтрацией и сеть без неё дали одинаковые цифры. Секунды добавлял сам клиент — этому посвящена [[#Задержка где она на самом деле|отдельная глава]].

**Почему это стоит читать целиком.** Половина текста — не находки, а отвергнутые версии: размер пакета, незафлашенная запись, пост-квантовый ключ, порядок расширений, JA4, накопительный порог. Каждая выглядела убедительно и каждая закрыта измерением. Ход рассуждений тут важнее выводов: те же грабли лежат в любом расследовании такого рода.

---

## Термины (чтобы заметка читалась без контекста)

- **MTProxy** — прокси-сервер, встроенный в клиенты Telegram; подключается по ссылке `tg://proxy?server=…&port=…&secret=…`. Обзор реализаций — в [[Zapret/mtproto/02-implementations|обзоре реализаций MTProto Proxy]].
- **FakeTLS (`ee`-секрет)** — режим маскировки, где первый пакет клиента выглядит как обычное TLS-рукопожатие к сайту. Секрет начинается с байта `0xEE`, за ним 16 байт ключа, а дальше — имя домена, которое клиент подставит в SNI.
- **ClientHello** — первый, ещё не зашифрованный пакет TLS: список шифров, расширения, SNI. В FakeTLS в его поле `random` (32 байта на позиции 11) кладётся HMAC-SHA256 от всего пакета с занулённым этим полем.
- **Камуфляж (доппельгангер)** — сайт, на который релей молча проксирует всё, что не опознал как своего клиента. Именно поэтому неудача выглядит не как отказ, а как «странный ответ».
- **obfuscated2** — внутренний слой Telegram поверх FakeTLS: 64 байта инициализации, из которых выводятся ключи AES-CTR, и тег протокола в байтах 56–59.
- **resPQ** — первый ответ самого Telegram (не релея) на запрос `req_pq_multi`. Дошёл resPQ — значит работает вся цепочка, а не только рукопожатие.

---

## TL;DR

- Релей отвечает валидным `ServerHello` **и** когда принял клиента, и когда отправил его на камуфляж — по префиксу `16 03 03` эти случаи неразличимы, отличать нужно по HMAC ответа.
- Контракт релея к ClientHello состоит из девяти требований: длина **≥ 517** и **≤ 4096** байт, SNI **строго из секрета**, `session_id` ровно **32 байта**, **первый же шифр после GREASE** из набора `1301`/`1302`/`1303`, совпадение **первых 28 байт** digest, метка времени **не в будущем** (запас ровно 3 секунды), метка времени **не слишком старая**, и **неповторяемость** client random.
- Порог 517 закреплён в коде: ветка FakeTLS входит только при `(packet_len >> 24) >= 2`, то есть при длине TLS-записи не меньше 512 байт.
- Сверху предел тоже есть, но далеко: 517, 600, 1200, 2000 и 3000 байт принимаются одинаково, 4096 — последняя принимаемая длина, 4097 уже уводится на камуфляж.
- Внутри FakeTLS релеи принимают **только padded intermediate** (`0xdddddddd`); теги `0xeeeeeeee` и `0xefefefef` обрываются без единого байта в ответ.
- Ограничение на параллельные подключения, которое часто закладывают в клиенты, у этих релеев не подтвердилось: 24 одновременных сессии дошли до resPQ за 210–520 мс суммарно.
- Номер дата-центра в obfuscated2 по resPQ не проверяется: этот запрос не привязан к дата-центру, поэтому ответ приходит и при `dc = 0`, `9`, `−1`, `−2`. Считать отсюда, что релей номер игнорирует, нельзя — а ошибка в нём всплывёт позже, на настоящей сессии.
- Практический вывод для клиента: часы, спешащие больше чем на 3 секунды, ClientHello короче 517 байт и повторная отправка того же самого рукопожатия ломают подключение к полностью исправному релею.
- Отдельно от контракта релея есть **фильтрация по отпечатку на стороне сети**: из шести профилей ClientHello клиента два отвергаются молча (3 попытки из 12), четыре проходят всегда — один релей, один секрет, одни минуты. Клиент, по умолчанию посылающий отвергаемый профиль, выглядит как «ни один прокси не работает», хотя релеи исправны.
- Блокировка отпечатка **включается со второго-третьего соединения** и дальше держится, поэтому «первые несколько соединений проходят» — это её форма, а не признак чего-то другого.
- Отличать отпечатки по JA4 нельзя: он сортирует расширения и выбрасывает GREASE, и у отвергаемого профиля JA4 совпадает с проходящим буква в букву.
- Задержка через прокси раскладывается на три слоя (до релея, ответ релея, релей → Telegram); измерено, что фильтрация к ней ничего не добавляет, а секунды создавал сам клиент — см. главу про задержку.
- Правильный способ найти такое — **перебрать все профили клиента с проблемной машины**, собирая байты из правил самого клиента, а не строить пробу «по мотивам».
- Различие двух шаблонов сузилось до **одного байта в теле GREASE-расширения**: отбираешь байт — отвергнутый шаблон проходит 6/6, добавляешь его проходящему — тот замолкает 1/6. Инверсия в обе стороны на одной сети в одни минуты.
- Селектор — **не одно поле, а точное совпадение с эталоном**: значение байта (`ff` вместо `00`), его позиция (первый GREASE вместо последнего) и длина ECH payload (143 вместо 144) — каждое по отдельности снимает блокировку при неизменном остальном. Сломай любой признак, и hello проходит.
- Одного хвоста недостаточно: `android_okhttp` заканчивается тем же байтом и проходит 12/12, потому что не несёт остального (ни ECH payload, ни ALPS, ни пост-квантового key_share). Отвергаются ровно те два профиля, у которых **и** хвост, **и** полный набор признаков Chromium.
- Правило покрывает **весь** набор ECH-длин, из которого клиент выбирает случайно (144, 176, 208, 240): все проверенные отвергаются одинаково, так что «повезёт с длиной» не работало никогда.
- Что это значит на практике: **копия должна оставаться неточной**. Любое отклонение — лишнее расширение в два байта, байт, срезанный с `enc` внутри ECH, длина ECH payload вне набора, переставленный порядок ALPN, переименованная группа key_share — снимает блокировку целиком. Проверено восемью правками по одному полю на трёх релеях.
- Правило **не привязано к адресу**: та же картина воспроизведена на другом IP и на нестандартном порту 45633. Отдельно от этого бывает блокировка самого адреса — там молчит всё, включая проходящий профиль и не-TLS мусор.
- Блокировка **непостоянна**: в одних прогонах эталонные hello замолкают со второго-третьего раза, в другом двадцать пять подряд прошли целиком. Значит режут выборочно и эпизодически; измерять состояние надо в момент, когда клиент уже видит тишину.
- Бывает и третий случай: **адрес заблокирован, но с белым списком имён**. Из четырнадцати маскируемых доменов к такому адресу прошли только поддомены `microsoft.com` — лечится ключом с таким доменом, адрес менять не нужно.
- Настоящий, побайтово браузерный ClientHello (дамп Яндекс.Браузера) эта сеть **тоже отвергает** — 1 из 6, — а наша упрощённая копия проходит 6 из 6. Значит фильтр не ищет «не-браузер», и цель «сделать отпечаток максимально похожим на настоящий браузер» неверна: похожесть тут вредит.
- **Привилегированное имя в SNI отключает фильтр отпечатка целиком.** Один адрес, одни минуты: отвергаемый отпечаток с именем из секрета — 0 из 4, тот же отпечаток с `update.microsoft.com` — 4 из 4. Поэтому для своего релея правильный маскируемый домен даёт больше, чем любая работа с отпечатком.

---

## Почему «прокси не работает» — это ещё не диагноз

Симптом, с которого начинается любое такое расследование, выглядит одинаково: ключ раздали пользователям, в официальном приложении он работает, а в другом клиенте — «Подключение…» по кругу. Дальше обычно начинается перебор гипотез: сервер блокируют, порт режут, секрет неправильный, DPI мешает. Перебор дорогой, потому что каждая проверка требует пересобрать клиент, а пересборка Telegram Desktop — это часы.

Проблема глубже, чем медленный цикл проверки. Клиент физически не может отличить «релей меня отверг» от «релей меня принял, но что-то сломалось дальше», потому что **отвергнутый клиент не получает отказа**. Релей, не опознавший своего, не разрывает соединение и не шлёт ошибку — он проксирует байты на камуфляжный сайт, а тот отвечает своим настоящим TLS-рукопожатием. Клиент видит корректный по форме `ServerHello`, не находит в нём ожидаемого HMAC и пишет в лог что-то вроде «плохой digest» — то есть жалуется на релей, до которого его пакет даже не дошёл в качестве клиентского.

Проще говоря: FakeTLS устроен так, что провал маскируется под успех. Поэтому первым делом нужно не читать код клиента, а выяснить снаружи, что именно релей считает приемлемым.

---

## Проба: четыре уровня, каждый отвечает на свой вопрос

Проверка снаружи занимает минуты, потому что весь путь до Telegram воспроизводится обычным скриптом без единой строчки клиентского кода. Уровни идут по нарастанию и каждый следующий имеет смысл только после того, как прошёл предыдущий.

**Уровень 1 — TCP.** Просто соединение с хостом и портом. Отсекает случаи «сервер лежит», «порт закрыт файрволом», «домен не резолвится».

**Уровень 2 — FakeTLS.** Собрать канонический 517-байтный ClientHello, отправить, дождаться `ServerHello` и **проверить его HMAC**. Проверка HMAC здесь не формальность, а единственное, что отличает релей от камуфляжа.

**Уровень 3 — весь путь до Telegram.** После рукопожатия отправить obfuscated2-инициализацию и запрос `req_pq_multi`, дождаться `resPQ`. Дошёл `resPQ` — релей не просто ответил, а реально пронёс запрос до дата-центра Telegram и вернул ответ.

**Уровень 4 — параллелизм.** Открыть N сессий одновременно и посмотреть, сколько из них доходят до `resPQ`. Это проверка предположений, которые часто зашивают в клиент («релей отвечает только на пару рукопожатий, остальное глотает») — предположений, которые почти никогда не измеряют. Важная деталь постановки: сессии нужно **оставлять открытыми и с трафиком**, потому что клиент их держит, а проба, закрывающая соединение сразу после ответа, проверяет совсем другой режим.

> [!important] Запускать пробу нужно с той машины, где симптом
> Проба, прошедшая с другого хоста, не говорит о сети пользователя ничего. В разобранном случае это стоило целого ложного вывода: с внешнего сервера релей отвечал на 24 параллельных сессии, из чего был сделан вывод «клиент чист, виновата сеть или лимит на IP пользователя» — а запуск той же пробы с проблемной машины дал 17 успешных рукопожатий подряд за 21–26 мс. Сеть оказалась ни при чём, и вывод пришлось отозвать.
>
> Правило простое: пока проба не запущена **оттуда**, где воспроизводится симптом, гипотеза «виновата сеть» не проверена, а лишь не опровергнута.

Четырёх уровней хватает, чтобы разделить «релей или клиент». Когда выяснилось, что сеть умеет отвергать выборочно, к ним добавились ещё два — и их порядок важен, потому что каждый отсекает предыдущие версии целиком:

**Уровень 0 — TCP до всех открытых портов.** Если рукопожатие не встаёт вовсе, содержимое пакета уже ни при чём, и дальше идти незачем. Отличать таймаут (фильтр глотает SYN) от мгновенного отказа (порт закрыт) обязательно: это разные диагнозы.

**Уровень 5 — перебор маскируемых имён.** Тот же hello к тому же адресу, меняется только имя в SNI. Критерий здесь особый — **пришёл ли хоть какой-то ответ**, а не сошёлся ли HMAC: релей сверяет имя со своей настройкой и отправляет чужое на камуфляж, поэтому по HMAC такие руки «неуспешны» всегда, и различие имён измерить было бы нельзя. Ответ камуфляжа означает ровно то, что нужно: пакет прошёл сеть.

### Минимальная проба второго уровня

Скрипт ниже самодостаточен: он строит канонический ClientHello (тот, что клиенты Telegram слали до перехода на постквантовый шаблон), отправляет его и проверяет цифровую подпись ответа. `RULES` — это тот же язык блоков, которым шаблон описан в исходниках клиента: `S` — литерал, `Z` — нули под digest, `G` — пара GREASE, `R` — случайные байты, `K` — 32-байтный key share, `D` — домен из секрета, `Open`/`Close` — двухбайтовый префикс длины.

```python
RULES = [
    ('S', '1603010200010001fc0303'), ('Z', 32), ('S', '20'), ('R', 32),
    ('S', '0020'), ('G', 0),
    ('S', '130113021303c02bc02fc02cc030cca9cca8c013c014009c009d002f003501000193'),
    ('G', 2), ('S', '00000000'),
    ('Open', 0), ('Open', 0), ('S', '00'), ('Open', 0), ('D', 0),
    ('Close', 0), ('Close', 0), ('Close', 0),
    ('S', '00170000ff01000100000a000a0008'), ('G', 4),
    ('S', '001d00170018000b00020100002300000010000e000c02683208687474702f312e31'
          '000500050100000000000d0012001004030804040105030805050108060601'
          '001200000033002b0029'),
    ('G', 4), ('S', '000100001d0020'), ('K', 0),
    ('S', '002d00020101002b000b0a'), ('G', 6),
    ('S', '0304030303020301001b0003020002'), ('G', 3), ('S', '0001000015'),
]
```

Сборка: пройти список, дописывая байты; на месте `Z` запомнить позицию digest; добить нулями до 517 байт через расширение padding (`00 15` + длина); посчитать `HMAC-SHA256(ключ, весь пакет)` и положить в digest; наконец подмешать метку времени — **XOR последних четырёх байт digest с текущим unixtime** (little-endian).

Проверка ответа: прочитать `16 03 03` + длина + тело, затем 11 байт `14 03 03 00 01 01 17 03 03` + длина и остаток. Склеить `клиентский digest + весь ответ`, занулить 32 байта на позиции 43 и сверить их с `HMAC-SHA256(ключ, склейка)`. Сошлось — ответил релей. Не сошлось (или вместо `14 03 03…` пришло что-то другое) — ответил камуфляж.

### Форматы, которые нужны, чтобы собрать пробу с нуля

**ClientHello.** Байты 0–2: `16 03 01`. Байты 3–4: длина TLS-записи (big-endian). Байт 5: `01` — тип handshake. Байты 6–8: длина handshake. Байты 9–10: `03 03`. **Байты 11–42: digest.** Байт 43: длина session id (`0x20`), следом 32 байта. Затем двухбайтовая длина списка шифров и сам список, методы сжатия (`01 00`), двухбайтовая длина расширений и расширения. Порядок сборки: собрать пакет с нулями на месте digest → добить до нужной длины расширением padding (`00 15` + длина + нули) → посчитать `HMAC-SHA256(ключ, весь пакет)` → положить результат в digest → подмешать время в байты 39–42 операцией XOR с unixtime (little-endian).

**ServerHello.** Ответ релея состоит из четырёх частей подряд: `16 03 03` + двухбайтовая длина + тело; `14 03 03 00 01 01`; `17 03 03` + двухбайтовая длина + тело. Подпись проверяется по склейке «клиентский digest ‖ весь ответ», в которой занулены 32 байта на позиции 43 (то есть байты 11–42 самого ServerHello). Тело последней части — случайные байты (`RAND_bytes`), и они входят в подпись наравне с остальным, поэтому проверять её можно только собрав все четыре части целиком: подпись считается по всему, что релей прислал, а не по одному ServerHello.

**obfuscated2.** 64 байта случайных данных со служебными ограничениями: первый байт не `0xef`; первые четыре не равны `HEAD`, `POST`, `GET `, `OPTI`, `0xdddddddd`, `0xeeeeeeee` и `16 03 01 02`; байты 4–7 не нулевые. Ключ шифрования — байты 8–39, IV — 40–55; для расшифровки берутся те же байты 8–55, развёрнутые задом наперёд. При работе через прокси оба ключа дополнительно прогоняются как `SHA-256(ключ ‖ 16-байтовый секрет из ee-секрета)`. Байты 56–59 — тег протокола, 60–61 — `dc_id`. В сеть уходят первые 56 байт открытым текстом и байты 56–63 в зашифрованном виде, после чего поток продолжается тем же шифром.

**Обёртка данных.** Перед первыми данными клиент шлёт `14 03 03 00 01 01`, дальше каждый кусок оборачивается в `17 03 03` + двухбайтовая длина. Telegram Desktop режет записи по 2878 байт.

**Первый запрос.** `req_pq_multi` — конструктор `0xbe7e8ef1` и 16 байт nonce, завёрнутые в незашифрованное MTProto-сообщение: восемь нулевых байт `auth_key_id`, восемь байт `message_id`, четыре байта длины. В padded intermediate сверху добавляется четырёхбайтовая длина и случайный хвост выравнивания. Ответ Telegram — конструктор `0x05162463` (resPQ) с тем же nonce.

### Четыре ловушки, на которых легко получить ложный вывод

**Ловушка первая: судить по префиксу.** `16 03 03` в начале ответа означает лишь «на том конце какой-то TLS-сервер». Камуфляжный nginx отвечает точно так же. Если считать такой ответ успехом, получится вывод вида «релей принимает ClientHello любой длины» — и он будет неверным: короткие пакеты принимал не релей, а сайт за ним. Единственный честный критерий — HMAC ответа.

**Ловушка вторая: считать digest до подмешивания времени.** Клиентский digest участвует дважды: он лежит в `random` отправленного ClientHello и он же — первые 32 байта склейки, по которой проверяется `ServerHello`. Между этими двумя применениями в него подмешивается время (XOR последних четырёх байт). Если проба возьмёт digest до подмешивания, а в пакет положит после, ClientHello релей примет, а проверка ответа не сойдётся — и получится ровно тот же ложный вывод «релей отвечает мусором». Порядок один: сначала HMAC, потом XOR со временем, и только потом брать копию для проверки ответа.

**Ловушка третья: писать в лог расхождение часов без точки отсчёта.** Клиент, решивший записывать, насколько его часы разошлись с истинным временем, считает разницу между системными часами и тем, что ушло в метку. Если поправки нет, в метку уходят те же системные часы, и разница выходит нулевой — при часах, спешащих на час. Ноль читается как «с часами всё хорошо», а означает «не с чем сравнить», то есть ровно тот случай, ради которого запись и заводилась. Число имеет смысл только рядом с признаком, была ли поправка вообще; и опасно не большое расхождение, а его отсутствие при отсутствующем отсчёте.

**Ловушка четвёртая: переносить шаблон клиента вручную.** Если проба должна повторить именно тот ClientHello, который шлёт конкретный клиент, шаблон удобно вытащить прямо из его исходников. Регулярное выражение, не учитывающее суффиксы строковых литералов конкретного языка (в C++ это, например, `"…"_q`), молча выбросит все литералы — проба соберёт мусор, релей его не признает, и родится ложный вывод «наш клиент шлёт неправильный ClientHello». Страховка простая: перед отправкой разобрать собранный пакет как TLS и сверить, что заявленная длина записи равна `len − 5`, длина handshake равна `len − 9`, а обход расширений заканчивается ровно на конце пакета.

---

## Что релеи требуют от ClientHello

Требования ниже воспроизвелись на обоих релеях одинаково, причём каждое проверялось при остальных заведомо выполненных. Порядок изложения совпадает с порядком проверок в коде официального MTProxy (`net/net-tcp-rpc-ext-server.c`), так что список можно читать и как контракт, и как маршрут отладки.

### Длина: не меньше 517 байт

Порог оказался ровным и жёстким. Пакеты 450, 512, 515 и 516 байт уводятся на камуфляж, а 517 байт и всё, что длиннее, принимаются релеем.

| Длина ClientHello | Релей А | Релей Б |
|---|---|---|
| 450 / 512 / 515 / 516 | камуфляж (alert или обрыв) | камуфляж |
| **517** | **релей** | **релей** |
| 518 / 600 / 1200 / 2000 / 3000 | релей | релей |

Число 517 не случайно: это размер канонического FakeTLS-рукопожатия, при котором в заголовке TLS-записи оказывается длина `0x0200`. Верхняя граница тоже есть, но далеко — 4096 байт, и постквантовые шаблоны современных клиентов в 1700–2100 байт до неё не достают.

> [!check] Порог виден прямо в исходниках официального MTProxy
> Вся ветка FakeTLS входит по одному условию:
>
> ```c
> } else if ((packet_len & 0xFFFFFF) == 0x010316 && (packet_len >> 24) >= 2
>            && ext_secret_cnt > 0 && allow_only_tls) {
> ```
>
> Первая половина — это префикс `16 03 01`, вторая — старший байт длины TLS-записи, и он обязан быть не меньше двух. Значит запись ≥ `0x0200` = 512 байт, а весь ClientHello ≥ 517. Ниже этого релей в разбор рукопожатия даже не заходит.
>
> Ещё два ограничения из той же ветки, о которых легко забыть:
>
> - `if (len > min_len)` — клиент не имеет права дослать ни байта поверх ClientHello, пока не пришёл `ServerHello`. Отправка данных одним залпом с рукопожатием уводит на камуфляж.
> - `int read_len = len <= 4096 ? len : 4096;` и следом `if (len != read_len)` — ClientHello длиннее 4096 байт тоже уходит на камуфляж. Верхняя граница всё-таки есть, просто далеко: постквантовые шаблоны в 1700–2100 байт до неё не достают.

Проще говоря: релею нужно, чтобы первый пакет был не короче эталонного и не длиннее четырёх килобайт; всё за этими границами он за своего клиента не считает.

> [!warning] Частая ошибка чтения этого кода
> Внутри ветки действительно нет никакой константы 517 — там только самосогласованность: `min_len = 5 + 256 * header[3] + header[4]`, ожидание остатка при нехватке байт и отказ при избытке. Отсюда рождается вывод «официальный MTProxy примет хелло любой длины, значит порог 517 — свойство чужой сборки». Вывод неверен: порог стоит **на входе в ветку**, а не внутри неё. `packet_len` — это первые четыре байта соединения, прочитанные как little-endian: для `16 03 01 XX` получается `0xXX010316`, поэтому `packet_len >> 24` и есть старший байт длины записи. Требование `>= 2` отсекает всё короче 517 байт ещё до того, как начнётся разбор.
>
> Проверяется это за минуту и снаружи: 516 байт уводятся на камуфляж, 517 принимаются, граница ровная и одинаковая на двух независимых релеях.
>
> Побочная деталь того же выражения: `packet_len` объявлен знаковым `int`, так что при старшем байте `0x80` и выше сдвиг даёт отрицательное число и условие не выполняется. Практического значения это не имеет — такие длины давно за лимитом 4096.
>
> Релей, принимающий короткое рукопожатие (наблюдался случай с 270 байтами), — это не опровержение, а признак **другой сборки**. У telemt порог задан константой `MIN_TLS_CLIENT_HELLO_SIZE = 100` с комментарием «структурный минимум для валидного TLS 1.3 ClientHello с SNI, намеренно консервативный», а собственный тест проекта закрепляет её в диапазоне между 64 и 512 — то есть 512 там сознательно исключено, а не совпало.

### Метка времени: окно от минус двух часов до «сейчас»

Последние четыре байта digest несут время клиента, подмешанное операцией XOR. Релей его извлекает и сверяет с собственными часами — окно оказалось узким и асимметричным.

| Сдвиг часов клиента | Результат на обоих релеях |
|---|---|
| +0 / +1 / +2 / +3 с | релей |
| +5 / +10 / +20 / +30 с, +1 мин, +1 ч, +1 сутки | камуфляж |
| −1 ч, −2 ч (в удачный момент — и до −2 ч 24 мин) | релей |
| −2 ч 30 мин и старше | камуфляж |

Верхняя граница попадает в код официального MTProxy до секунды: `if (timestamp > now + 3)` — три секунды принимаются, пять уже нет. Совпадение настолько точное, что служит и признаком сборки: релей, прощающий ровно +3 с, — это MTProxy или совместимая с ним реализация.

Нижняя граница ведёт себя иначе: она не фиксирована и **дрейфует**. Один и тот же возраст рукопожатия (2 ч 24 мин) в одном прогоне принимался, а через четверть часа — уже нет; в один и тот же момент один релей принимал возраст до 585 секунд, а второй — до 9042. Это не капризы сети, а прямое следствие второй ветки проверки:

```c
if (first_client_random != NULL && timestamp > first_client_random->time + 3) return 1;
const int MAX_ALLOWED_TIMESTAMP_ERROR = 10 * 60;
if (timestamp > now - MAX_ALLOWED_TIMESTAMP_ERROR) return 1;
```

Проще говоря: релей принимает старое рукопожатие, только если способен проверить его на повтор, то есть если оно новее самой старой записи в кеше client random. Кеш пуст или молод — работает запасное правило «не старше десяти минут». Кеш накопил записи за пару часов — окно расширяется до них. А когда у релея несколько воркеров (`-M`), кеш у каждого процесса свой, и результат зависит от того, кому досталось соединение. Сам MTProxy об этом предупреждает — но не в том файле, где живёт разбор рукопожатия, а в `mtproto/mtproto-proxy.c`, в описании опции: «spawn several slave workers; not recommended for TLS-transport mode for better replay protection».

Для клиента отсюда следовало бы, что отставание часов до десяти минут безопасно всегда. На официальном MTProxy это так, но как общее правило — неверно, и опровергается это не измерением, а исходниками других сборок: у mtg допуск по умолчанию `DefaultTolerateTimeSkewness = 3 * time.Second` и проверяется он симметрично, через `time.Since(createdAt).Abs()`. То есть на mtg часы, отставшие на четыре секунды, уже отбиваются — там, где MTProxy простил бы десять минут.

Пересечение трёх реализаций даёт единственное правило, верное везде: **метка должна отличаться от истинного времени не больше чем на три секунды в любую сторону**. Всё, что шире, работает у одних пользователей и не работает у других, и разница будет не в сети, а в том, какую сборку поставил владелец релея.

И обратное следствие, про которое легко забыть: **успешное рукопожатие само по себе является измерением**. Оно доказывает, что метка попала в окно, то есть часы клиента отстоят от часов релея не больше чем на десять минут и не спешат больше чем на три секунды. Одной такой записи в логе достаточно, чтобы снять с часов подозрение до конца сессии.

Отсюда практическое следствие, объясняющее часть жалоб «у меня прокси не работает, а у соседа работает»: **спешащие часы на устройстве ломают FakeTLS**, и внешне это выглядит как неисправный прокси.

> [!danger] На цензурируемой сети часы чинить нечем
> Telegram Desktop подставляет в метку не сырые системные часы, а поправленные дважды: сдвигом по времени серверов MTProto и сдвигом по заголовку `Date` из HTTP-ответа. Обе поправки на холодном старте равны нулю, и обе требуют выйти наружу **напрямую**: время MTProto берётся из установленной сессии, а HTTP-запрос за `Date` явным образом ставит `setProxy(QNetworkProxy::NoProxy)` и мимо прокси идёт всегда.
>
> Ровно на той сети, где прокси и нужен, оба канала могут быть закрыты. Тогда поправка не приезжает никогда, и в FakeTLS уходят локальные часы — навсегда, а не «до первой синхронизации». Сверять часы перед тем, как винить прокси, стоит именно поэтому: у клиента может не быть способа их починить.
>
> Две детали той же механики, которые видно только в исходнике `lib_base/base/unixtime.cpp`. Первая: поправка по серверам MTProto не применяется, если отличается от текущей меньше чем на `kIgnoreTimeDifference = 3` секунды — а три секунды это ровно то, что релей прощает в будущее. То есть штатно отработавшая синхронизация может оставить клиента на три секунды впереди, и этого уже достаточно. Вторая: применение поправки MTProto обнуляет поправку по `Date` и сбрасывает признак её достоверности, так что точка отсчёта не только может отсутствовать изначально — она может пропасть после успешной синхронизации.

### Повторы: тот же ClientHello второй раз не принимается

Отправка байт в байт того же самого пакета даёт релей на первой попытке и камуфляж на всех последующих. Это защита от повторов (replay protection), и в диагностике она важна практически: пробу нужно каждый раз генерировать заново. Скрипт, который собрал пакет один раз и шлёт его в цикле, покажет «первый раз работает, дальше сломалось» и уведёт расследование в сторону мнимого rate-limit.

Ключом кеша служат **первые 16 байт** digest, а живёт запись `MAX_CLIENT_RANDOM_CACHE_TIME = 2 * 86400`, то есть двое суток. Измерение это подтверждает с запасом: повтор через 5, 30 и 60 секунд отбивался одинаково, а свежий пакет на каждую попытку принимался всегда.

### Ещё четыре проверки, о которых легко забыть

**SNI обязан совпадать с доменом из секрета.** Релей достаёт имя из расширения SNI и ищет его среди разрешённых (у официального MTProxy они задаются ключом `-D`); не нашёл — камуфляж. Изменения **одного байта** в домене при полностью пересчитанной подписи достаточно, чтобы уйти на маскировочный сайт. Домен в секрете — часть контракта, а не подсказка: клиент, который подставляет собственный SNI ради обхода DPI, теряет доступ к релею.

**Первым шифром после GREASE обязан идти `13 01`, `13 02` или `13 03`.** Формулировка «в списке должен быть TLS 1.3» тут неверна и упускает главное — проверяется именно первая позиция:

```c
while (cipher_suites_length >= 2 && (client_hello[pos] & 0x0F) == 0x0A
       && (client_hello[pos + 1] & 0x0F) == 0x0A) {
  cipher_suites_length -= 2;
  pos += 2;
}
if (cipher_suites_length <= 1 || client_hello[pos] != 0x13
    || client_hello[pos + 1] < 0x01 || client_hello[pos + 1] > 0x03) { /* камуфляж */ }
```

Измерения на обоих релеях подтверждают каждую деталь этого фрагмента:

| Что подставлено в начало списка шифров | Результат |
|---|---|
| GREASE, затем `1301` (эталон) | релей |
| GREASE, затем `1302` или `1303` | релей |
| GREASE, затем `1300`, `1304` или `13ff` | камуфляж |
| GREASE, затем `c02b`, а `1301` дальше по списку | камуфляж |
| Две пары GREASE подряд, затем `1302` | релей |
| Без GREASE вовсе, сразу `1301` | релей |
| Вместо GREASE значение `1a1b` (младшие полубайты `A` и `B`) | камуфляж |

Отсюда два требования к клиенту, которые из общей формулировки не следуют. Первое: **GREASE обязан иметь форму `?A ?A`** — младший полубайт обоих байт равен `0xA`. Это проверка формы, а не таблица разрешённых значений: пара `0a 1a` из разных байтов пропускается штатно, а произвольная заглушка вроде `1a 1b` не пропускается и занимает место «первого настоящего шифра», после чего рукопожатие уходит на камуфляж. Второе: **важна именно первая позиция**, а не наличие TLS 1.3 где-то в списке. Профиль, у которого после GREASE идёт `c0 2b`, будет отвергнут, даже если `13 01` есть третьим или пятым.

Практический смысл второго требования выходит за рамки отладки: оно определяет, какие браузерные почерки вообще можно эмулировать в FakeTLS. Годятся только те, где TLS 1.3 стоит в списке первым — а это ровно то, как список строят современные Chrome, Firefox и производные от них.

**Сравниваются только первые 28 байт digest.** Порча одного бита внутри них — камуфляж; последние четыре байта в сравнении не участвуют, потому что несут время. Отсюда, кстати, и способ передать время, не ломая подпись.

**Верхний предел длины измеряется, а не выводится.** 3517 и 4096 байт принимаются, 4097 и 5000 — уже нет, ровно как предсказывает `read_len = len <= 4096 ? len : 4096` со следующим за ним `if (len != read_len)`.

Отдельно, не про клиент, но полезно при разборе чужой конфигурации: рукопожатие, пришедшее на **порт 80**, релей отправляет на камуфляж сразу, ничего не проверяя.

---

## Что установлено дальше по цепочке

**Внутренний протокол — только padded intermediate.** После того как FakeTLS установлен, клиент открывает obfuscated2 и объявляет режим тегом в **байтах 56–59 инициализации**. Оба релея принимают лишь `0xdddddddd` (padded intermediate); при `0xeeeeeeee` (intermediate) и `0xefefefef` (abridged) соединение закрывается без ответа.

> [!note] Одинаковые константы в двух разных местах
> Те же четыре значения — `0xef…`, `0xeeeeeeee`, `0xdddddddd` и HTTP-методы — встречаются в исходнике MTProxy ещё раз, в распознавании типа **внешнего** соединения (строки 1041–1069). Читая код, эти места очень легко перепутать: они выглядят одинаково и лежат рядом. Но внешний блок целиком обёрнут в `#if __ALLOW_UNOBFS__`, а в обычной сборке этот макрос не определён, так что все четыре ветки вырезаются препроцессором и снаружи остаются только FakeTLS и obfuscated2. Измеренное требование padded intermediate относится к внутреннему слою — к тем самым байтам 56–59 внутри уже установленного FakeTLS, а не к вырезанному распознаванию. Для `ee`-секрета клиенты Telegram и должны выбирать padded intermediate, так что это не сюрприз, но проверять стоит: ошибка в выборе тега даёт ровно ту же картину «рукопожатие прошло, дальше тишина».

**По resPQ нельзя проверить `dc_id`.** Байты 60–61 инициализации obfuscated2 несут номер дата-центра. Штатные значения 1–5, а вместе с ними 0, 9, −1 и −2 одинаково приводят к получению resPQ на обоих релеях — но вывод отсюда ровно один и он узкий: `req_pq_multi` не привязан к конкретному дата-центру, поэтому ответ на него приходит при любом номере. Из этого **не** следует, что релей номер игнорирует: он может приводить неизвестное значение к своему умолчанию. И дальше по цепочке номер критичен — ключ авторизации привязан к конкретному дата-центру, так что клиент с неверным `dc_id` получит resPQ и сломается на первой же настоящей сессии. Проверять корректность номера этим запросом просто нечем.

**Параллельные подключения — ограничения не обнаружено.** Восемь одновременных сессий дошли до `resPQ` за 91–182 мс каждая; двадцать четыре — все успешно, суммарно 210 мс на одном релее и 517 мс на другом. Это стоит подчеркнуть, потому что клиенты нередко строят внутренний ограничитель на предпосылке «публичный MTProxy отвечает на пару рукопожатий, остальное глотает». Предпосылка верна не для всех сборок, а цена ошибки высокая: ограничитель растягивает старт, а при накоплении мнимых промахов может отложить дозвон на десятки секунд.

**Камуфляж иногда виден невооружённым глазом.** Обычный HTTP-запрос на порт одного из релеев вернул ответ nginx `400 The plain HTTP request was sent to HTTPS port` — то есть за релеем стоит настоящий веб-сервер, и именно он отвечает всем неопознанным клиентам. Второй релей на такой запрос промолчал и закрыл соединение, так что признак работает не всегда. Про то, как такой фронт устраивают намеренно, — в [[Zapret/mtproto/07-nginx-haproxy|связке MTProxy с nginx и HAProxy]].

---

## Что этот метод вскрыл в клиенте

Расследование проводилось для стороннего форка Telegram Desktop с переработанным контуром MTProxy, и все четыре находки — клиентские, релеи были исправны с самого начала.

**Padding до канонической длины не выполнялся.** В генераторе ClientHello добивание пакета до 517 байт оказалось привязано к флагу, который включался только при синтетическом PSK-расширении, то есть практически никогда. Для шаблонов, которые и без padding длиннее 513 байт (Chrome, Firefox, Yandex — у всех крупный постквантовый key share), это ничего не меняло. Но шаблон, имитирующий Android-библиотеку OkHttp, давал 264 байта — и релей уводил такого клиента на камуфляж, а клиент рапортовал о плохом digest. После восстановления padding пакет стал ровно 517 байт и проходит до `resPQ` на обоих релеях.

**Ограничитель параллельных дозвонов исходил из непроверенной предпосылки.** В коде было зашито «релей отвечает на два рукопожатия одновременно», тогда как измерение даёт минимум 24. Такой ограничитель не ломает подключение сразу, но добавляет задержки на старте и штрафует релей за собственные же промахи клиента.

Цена ошибки здесь несимметрична, и это стоит разобрать, потому что ловушка типовая. Третий дозвон и все следующие ждали освобождения слота — до целого бюджета рукопожатия; пара промахов поднимала интервал шагами по 2 с до 30 с; очередь клампилась двумя минутами; состояние было общим на весь процесс и все аккаунты. Одного неудачного старта — сон, смена сети, переезд между дата-центрами — хватало, чтобы исправный релей попал за полминуты интервала, и клиент выглядел «не подключается» минутами. Лимит, взятый из чужого наблюдения, обошёлся дороже, чем отсутствие лимита.

Итог после разбора: лимит параллелизма убран целиком, остался интервал между дозвонами и штрафной интервал по фактическим промахам — начиная с четвёртого, шагами по 0,5 с, с потолком 4 с. Дальше собственный таймер переподключения сессии всё равно медленнее и берёт роль тормоза на себя, а рост сверх того только задерживает возвращение ожившего релея.

Интервал между дозвонами сначала поставили в 250 мс — из соображения «приходить как отдельные клиенты, а не залпом». Позже выяснилось, что и это предположение не выдерживает проверки, и его пришлось снизить до 50 мс; разбор — в главе про задержку.

**Фазовый расчёт таймаута оказался мёртвым.** Общий бюджет попытки для mtproxy складывается из фаз: гонка маршрутов (4000 + 300 × 2), одиночный маршрут (8000), ожидание ServerHello (5000), ещё один маршрут (8000) и запас 500 — итого 26 100 мс, которые затем клампятся потолком в 12 600 мс. Сумма никогда не проходит клампа, то есть весь расчёт всегда возвращает потолок. Вреда в наблюдаемых логах это не даёт (4 с на DNS плюс 8 с на маршрут укладываются в 12,6 с), но фазовая арифметика существует только на бумаге, и первое же увеличение любой фазы пройдёт незамеченным.

**Успешное соединение засчитывалось как промах.** Ветка, где клиент принимает подключившийся маршрут, не дождавшись более приоритетного, не помечала аренду слота как успешную — и ограничитель воспринимал исправный релей как проблемный.

Общее у всех четырёх: ни одна не следует из логов клиента напрямую, потому что в логах видно только «digest не сошёлся» или «нет ответа», а причина находится на стороне, о которой клиент ничего не знает.

### Улика, которая видна в логе

Оговорка «не диагностируется по логам» верна не до конца: одна улика в логе есть, и она про то, что клиент **отправил**, а не про то, что ему пришло.

**Длина ClientHello, зависящая от домена.** Канонический пакет всегда 517 байт, каким бы ни был SNI: padding домен и поглощает. Если в логе `длина − длина_SNI` даёт одну и ту же константу на разных прокси — padding не работает. В разобранном случае константа была `247` на пяти прокси подряд, и этого одного достаточно, чтобы найти баг, не подходя к серверу.

### Почему размер ответа ничего не решает

Соблазн отличить релей от камуфляжа по объёму ответа возникает у всех, кто видит рабочее соединение в двести байт рядом с неудачным в четыре тысячи. Поддаваться не стоит: измерения на двух релеях разваливают критерий сразу в обе стороны. Релей А на валидное рукопожатие отвечает 3381 байтом, а на короткое — 184; релей Б отвечает 196 байтами в обоих случаях, побайтово одинаково.

Причина в самом релее, и она видна в коде. Принимая клиента, он собирает ServerHello сам и заполняет первую запись данных случайными байтами, длину которых берёт под маскируемый домен — `RAND_bytes(response_buffer + pos, encrypted_size)`, где `encrypted_size` приходит из `get_domain_server_hello_encrypted_size(info)`, то есть из величины, измеренной у домена при старте. За тяжёлым `www.microsoft.com` ответ тяжёлый, за лёгким `*.sslip.io` — лёгкий. Отвергая клиента, релей проксирует соединение на настоящий адрес домена, и объём определяет уже тот сервер.

Есть и вторая причина, не зависящая от домена вовсе. Если зондирование домена при старте не удалось, размер берётся случайным из фиксированного диапазона: `info->server_hello_encrypted_size = 2500 + rand() % 1120`, то есть от 2500 до 3619 байт. Замеренные 3381 у релея А ложатся ровно в него. Иными словами, штатный ответ исправного релея бывает трёхкилобайтным просто потому, что маскируемый домен в тот момент не отозвался, — и размерный критерий хоронит не только измерение, но и константа в исходнике.

Вдобавок наблюдаемая величина — не «ответ» целиком, а первая запись application data: разбор фиксированного шаблона FakeTLS доходит ровно до её конца. Настоящий сервер TLS 1.3 кладёт туда EncryptedExtensions, а сертификат отправляет следующей записью, которую клиент к этому моменту ещё не читал. Отсюда и 46 байт там, где ожидался килобайт. Совпадение «двести против тысяч» в первых логах было свойством конкретных площадок, а не признаком.

> [!warning] 122 байта ServerHello не доказывают ничего
> Размер тела ServerHello, равный 122, встречается и у релея, и у настоящего сервера, поэтому по нему нельзя судить, кто ответил. Это естественная длина ответа TLS 1.3 с ключом x25519: 2 байта версии + 32 случайных + 33 на эхо session_id + 2 на шифр + 1 на сжатие + 2 на длину расширений + 6 на supported_versions + 40 на key_share = 118, плюс четыре байта заголовка handshake. MTProxy зашивает `\x16\x03\x03\x00\x7a\x02\x00\x00\x76\x03\x03` именно потому, что так выглядит любой современный сервер.

### Различать причины по ответу нельзя, по своему же пакету — можно

Раз объём ответа не голосует, а подпись при отказе не сходится в обоих случаях, единственный детерминированный признак лежит в том, что клиент отправил сам. Хелло, нарушивший контракт — короче 517 байт, длиннее 4096, с не-TLS 1.3 шифром первым после GREASE или с SNI, отличным от домена в секрете, — до разбора рукопожатия не доходит, и расхождение подписи в этом случае принадлежит клиенту, а не секрету. Хелло, контракту удовлетворяющий, оставляет ровно одну причину — секрет.

Отсюда практическое правило для авторов клиентов: **проверку контракта делать перед отправкой, а код ошибки выбирать по её вердикту**, а не по тому, что пришло в ответ. Заодно это единственный способ превратить бесполезное «bad Server Hello digest» в конкретную причину, названную в тот момент, когда она возникла.

### Контракт, который клиент может проверить сам

Всё, что релей требует от первого пакета, проверяется до отправки, и это дешевле любой внешней пробы.

Длина обязана лежать в границах от 517 до 4096 байт. Заявленные длины обязаны сходиться с фактической: длина TLS-записи равна размеру пакета минус пять, длина handshake — минус девять, а блок расширений обязан упираться ровно в конец пакета. Последнее стоит проверять именно обходом: парсер, который просто читает расширения подряд и выходит, когда осталось меньше четырёх байт, пропустит хвост в один-три байта внутри объявленного блока — то есть ровно ту ошибку в арифметике вложенных длин, ради которой проверка и нужна.

Первый набор шифров после отброшенных GREASE обязан быть одним из `1301`, `1302`, `1303`, причём GREASE отбрасывается по форме — младший полубайт `0x0A` в обоих байтах, — а не по таблице значений, так что заглушка другой формы займёт место первого настоящего набора и провалит проверку. SNI обязан байт в байт совпадать с доменом из секрета.

Проверку стоит делать предупреждающей, а не запрещающей: релей с другим набором требований всё равно заслуживает попытки, а ценность проверки в том, что причина называется в момент своего возникновения, а не через вечность в виде неподписанного ServerHello.

---

## Чек-лист требований к клиенту

- [ ] ClientHello не короче 517 байт — при необходимости добить расширением padding.
- [ ] ClientHello не длиннее 4096 байт.
- [ ] SNI берётся из секрета и не подменяется.
- [ ] `session_id` ровно 32 байта. Измерено: hello с 31 и с 30 байтами релей отправляет на камуфляж (ответ приходит, digest чужой) — он читает пакет по смещению, рассчитанному на 32 байта, как у любого настоящего браузера.
- [ ] Первым шифром после GREASE идёт `13 01`, `13 02` или `13 03` — не «где-то в списке», а именно первым.
- [ ] GREASE в списке шифров имеет форму `?A ?A`; произвольные заглушки релей за GREASE не считает.
- [ ] Digest считается по всему пакету с занулённым полем и кладётся на позицию 11.
- [ ] В последние четыре байта digest подмешано время; часы не должны спешить (запас всего 3 секунды) и желательно не отставать больше десяти минут.
- [ ] Соблазн вычесть из метки запас в полминуты «раз в прошлое можно на десять минут» — не следовать ему: асимметрия окна есть только у официального MTProxy, а у mtg допуск по умолчанию симметричный и равен трём секундам, так что такой сдвиг сломает mtg-релеи полностью.
- [ ] Каждое новое соединение — новое рукопожатие; повторная отправка того же пакета недопустима.
- [ ] Ничего не досылается поверх ClientHello, пока не пришёл ServerHello.
- [ ] Подпись ответа проверяется по всем четырём его частям сразу — хвост из случайных байт входит в неё, и его длина ничего не значит.
- [ ] Внутри — padded intermediate (`0xdddddddd`).
- [ ] Лимит параллельных дозвонов, если он вообще нужен, измеряется на своих релеях, а не берётся из чужих наблюдений.

---

## Чем отличаются другие реализации

Всё измеренное выше объясняется кодом официального MTProxy, но парк релеев неоднороден: vpnbot, например, умеет ставить три разные сборки. Различия, которые видно прямо в исходниках:

| Что | Официальный MTProxy | telemt | mtg |
|---|---|---|---|
| Минимальная длина ClientHello | 517 — через условие «старший байт длины записи ≥ 2» | `MIN_TLS_CLIENT_HELLO_SIZE = 100`, и собственный тест закрепляет диапазон `> 64` и `< 512` | явного порога в разборе нет: `client_side.go` читает структуру и проверяет её, а не длину |
| Будущее в метке времени | не дальше `now + 3` | `TIME_SKEW_MAX = +120 с` | `DefaultTolerateTimeSkewness = 3 с` |
| Прошлое в метке времени | до старейшей записи кеша, при пустом кеше — 10 минут | `TIME_SKEW_MIN = −120 с` | те же 3 с — проверка через `.Abs()`, окно симметрично |
| Защита от повторов | 16 байт client random, кеш 2 суток | первые 16 байт digest, окно настраивается | есть |
| Сравнение digest | первые 28 байт | первые 28 байт | первые 28 байт |

Отдельная деталь telemt, которой нет у остальных: метка, меньшая `BOOT_TIME_MAX_SECS`, трактуется как время с момента загрузки, а не как unix-время, и проверку перекоса тогда обходит — с потолком `BOOT_TIME_COMPAT_MAX_SECS = 120 с` и настроенного окна повторов. Это совместимость с клиентами, которые кладут в метку аптайм; полагаться на неё нельзя, но при разборе чужого поведения о ней стоит помнить.

Практический вывод: **писать клиент нужно по самому строгому контракту**. Рукопожатие в 517 байт с меткой, отличающейся от истинного времени не больше чем на три секунды, примут все три реализации; рукопожатие в 300 байт или с меткой минутной давности пройдёт только там, где проверки мягче, — и именно такой клиент будет «случайно» работать у одних пользователей и не работать у других.

---

## Симптом «первые соединения проходят, дальше тишина» и чем он оказался

Самый дорогой разбор за всю историю этой заметки: пять правдоподобных объяснений подряд, каждое отвергнуто измерением, и только шестое попадание. Записываю целиком — и находку, и все пять промахов, потому что промахи здесь полезнее находки.

Симптом в логе клиента: на каждом релее **первые три-четыре соединения проходят целиком** (`server_hello_ok`, ключ создан, первый mtproto-пакет получен), а все последующие получают ровно ноль байт в ответ и умирают по пятисекундному таймауту — `rx_after_ch=0`, `rx_class=zero`, `close_origin=local_timeout`. Локально пакет уходит полностью (`ch_accepted == ch_bytes`). Повторяется на трёх разных релеях подряд. Со временем становится хуже: к вечеру не проходит уже ни одно соединение.

### Причина: сеть отвергает конкретный отпечаток клиента

Клиент умеет шесть профилей ClientHello. Проба, которая строит **байты этих же шести профилей** из правил самого клиента и посылает их одному релею, дала на фильтрованной сети такое (двенадцать попыток на профиль, три прогона по четыре):

| профиль | фильтрованная сеть | чистая сеть |
|---|---|---|
| firefox | 12 / 12 | 6 / 6 |
| firefox_android | 12 / 12 | 6 / 6 |
| android_okhttp | 12 / 12 | 6 / 6 |
| yandex | 12 / 12 | 6 / 6 |
| **chrome_modern** | **3 / 12** | 6 / 6 |
| **android_chrome** | **3 / 12** | 6 / 6 |

Один релей, один секрет, одни минуты, одна машина. Клиент по умолчанию посылал `chrome_modern` — то есть ровно тот отпечаток, который эта сеть не пропускает. Отсюда и «у нас все прокси не работают»: на такой сети клиент не мог поднять ни одного соединения ни к одному релею, пока Python с той же машины в ту же минуту получал `ServerHello` за 8–10 мс.

Три успеха из двенадцати — не случайность, а форма отказа: **блокировка включается после первых одного-двух соединений с этим отпечатком и дальше держится**. В прогоне по раундам видно прямо: раунды 1–2 проходят, с третьего тишина до конца. Это же и объясняет исходное «первые три-четыре соединения работают, дальше ноль» — и объясняет, почему симптом усиливался к вечеру.

### Пять гипотез, отвергнутых измерением

Все пять выглядели убедительно, и каждая казалась «той самой». Порядок — как их проверяли:

1. **Размер пакета.** Проба шлёт 517 байт (один TCP-сегмент), клиент 1718–1823 (гарантированно два). Тридцать пар структурно идентичных hello, где растут все declared lengths и padding, так что меняется только длина: **60 из 60** получили `ServerHello` с верным digest, времена одинаковые.
2. **Незафлашенная запись.** `QTcpSocket::write()` только ставит байты в очередь, а клиент сразу запускает пятисекундный отсчёт; фрагментированный путь делал `flush()`, обычный — нет. Отвергнуто дважды: по 21 отказу интервал до таймаута равен 4.999–5.012 с, то есть event loop того же потока жив; и замолчали **уже установленные** соединения, где данные до этого шли в обе стороны. Незафлашенный hello ни того, ни другого объяснить не может.
3. **Пост-квантовый key_share (ML-KEM).** Отвергнуто составом: блок `M()` на 1216 байт несут три из четырёх проходящих профилей.
4. **Порядок расширений.** Chromium перемешивает расширения на каждом hello, и оба отвергнутых профиля — единственные, кто это делает. Контролируемый опыт: один профиль, две руки, различие только в порядке, длина внутри пары совпадает побайтово. Результат: `chrome_modern` молчит **и** упорядоченный (2 из 6 в первом прогоне, 1 из 6 во втором), `yandex` проходит **и** перемешанный (6 из 6 в обоих). Опыт повторён дважды с тем же выводом: порядок ни при чём.
5. **JA4.** Отвергнуто вычислением: у проходящего `yandex` и у отвергаемого `chrome_modern` JA4 **совпадает буква в букву** — `t13d1514h2_8daaf6152771_d8a2da3f94cd`, и он же у настоящего Яндекс.Браузера. JA4 сортирует расширения и выбрасывает GREASE, поэтому всё, чем эти два шаблона различаются, для него невидимо.

### Один байт переворачивает результат в обе стороны

Порядок расширений опровергнут тем же прогоном, который проверил следующее подозрение — единственное измеренное различие двух шаблонов: у `chrome_modern` одно GREASE-расширение несёт один нулевой байт, у `yandex` оба пустые. Две дополнительные руки к тому же опыту, всё остальное неизменно:

| рука | размер | фильтрованная сеть |
|---|---|---|
| `chrome_modern` как есть | 1718 | 1 / 6 |
| `chrome_modern`, тело GREASE опустошено | 1717 | **6 / 6** |
| `yandex` как есть | 1717 | 6 / 6 |
| `yandex`, в GREASE положен тот же байт | 1718 | **1 / 6** |

Инверсия работает в обе стороны: отбираешь байт — отвергнутый шаблон начинает проходить; добавляешь байт — проходящий начинает молчать. Одна переменная, четыре руки, раунды по кругу.

### Настоящий браузер эта сеть тоже отвергает

Дамп Яндекс.Браузера, снятый на той же машине, с единственными изменениями «SNI на домен релея» и «digest в поле random» — то есть все шифры, все расширения, все длины и все GREASE-значения браузерные:

| что послано | размер | фильтрованная сеть |
|---|---|---|
| настоящие байты Яндекс.Браузера | 1814 | **1 / 6** |
| наш шаблон `yandex` | 1717 | 6 / 6 |
| наш `chrome_modern` (контроль) | 1718 | 2 / 6 |

Это снимает предыдущее возражение, а не подтверждает его: в дампе GREASE-расширение `6a6a` несёт ровно один нулевой байт — то же, что у `chrome_modern`. То есть браузер отвергается **вместе со** своим байтом, а наша копия проходит **без** него, и обе картины согласуются.

Отсюда важный вывод, независимый от механизма: эта сеть **режет подлинный браузерный ClientHello и пропускает нашу подделку**. Значит фильтр не занят поиском «не-браузера», и вся рамка «сделать отпечаток похожим на настоящий браузер» — неверная. Похожесть тут не помогает, а мешает.

### Селектор — не одно поле, а точное совпадение

Тринадцать рук, размеры выровнены до байта там, где это нужно, чередование по раундам, один релей. На чистой сети — 13 из 13 в каждом раунде; на фильтрованной отвергнуты ровно три:

| рука | размер | ECH payload | последнее расширение | фильтр. сеть |
|---|---|---|---|---|
| `chrome_modern` как есть | 1718 | 144 | GREASE, тело `00` | **1 / 6** |
| `yandex` + тот же байт | 1718 | 144 | GREASE, тело `00` | **1 / 6** |
| настоящий Яндекс.Браузер | 1814 | 240 | GREASE, тело `00` | **1 / 6** |
| `chrome_modern`, тело опустошено, размер выровнен | 1718 | 145 | GREASE, пусто | 6 / 6 |
| `yandex` + байт, размер выровнен | 1717 | 143 | GREASE, тело `00` | 6 / 6 |
| `chrome_modern`, тело 8 байт | 1718 | 137 | GREASE, тело 8×`00` | 6 / 6 |
| `chrome_modern`, байт в **первом** GREASE | 1718 | 144 | GREASE, пусто | 6 / 6 |
| `chrome_modern`, байт `ff` вместо `00` | 1718 | 144 | GREASE, тело `ff` | 6 / 6 |
| `chrome_modern`, оба GREASE удалены | 1718 | 153 | не GREASE | 6 / 6 |
| дамп браузера с опустошённым GREASE | 1813 | 240 | GREASE, пусто | 6 / 6 |
| `yandex` как есть | 1717 | 144 | GREASE, пусто | 6 / 6 |

Три сравнения в этой таблице меняют **ровно одну** переменную:

- **значение байта.** `ff` вместо `00`, длина и ECH те же — блок исчезает.
- **позиция байта.** Тот же байт в первом GREASE-расширении, последнее пустое, длина и ECH те же — блок исчезает.
- **длина ECH payload.** Тело GREASE одинаковое, 144 против 143 — блок исчезает.

Значит правило — **конъюнкция**: hello отвергается, только когда все признаки одновременно в «браузерном» виде. Сломай любой — проходит. Это не эвристика «похоже на прокси», а сверка с эталоном.

Соответствие правилам клиента точное — и оно же показывает, что хвоста самого по себе недостаточно:

| профиль | ECH payload | ML-KEM | ALPS | хвост `G(3); S("00 01 00")` | фильтр. сеть |
|---|---|---|---|---|---|
| `firefox`, `firefox_android` | нет | да | нет | нет | 12 / 12 |
| `android_okhttp` | нет | нет | нет | **да** | 12 / 12 |
| `yandex` | да | да | да | нет (`S("00 00")`) | 12 / 12 |
| `chrome_modern`, `android_chrome` | да | да | да | **да** | **3 / 12** |

`android_okhttp` — контрпример, без которого правило звучало бы как «дело в хвосте»: тот же байт, и 12 из 12. Отвергаются ровно те два профиля, у которых хвост стоит **внутри** полного Chromium-набора. ECH payload клиент берёт из набора `{144, 176, 208, 240}` (`client_hello_builder.cpp`), и все проверенные длины этого набора отвергаются одинаково.

### Проверка конъюнкции: тринадцать рук, две сети

Отдельный прогон против той же цели, где все руки сохраняют отвергаемую форму хвоста и меняют по одной длине. На чистой сети — 13 из 13; на фильтрованной:

| рука | размер | чётность | ECH payload | фильтр. сеть |
|---|---|---|---|---|
| `chrome_modern` как есть | 1718 | чёт | 144 (в наборе) | **1 / 4** |
| ECH payload 176 | 1750 | чёт | 176 (в наборе) | **1 / 4** |
| ECH payload 240, как у браузера | 1814 | чёт | 240 (в наборе) | **1 / 4** |
| настоящие байты Яндекс.Браузера | 1814 | чёт | 240 | **0 / 4** |
| ECH payload 142 | 1716 | **чёт** | 142 (вне набора) | 4 / 4 |
| ECH payload 143 | 1717 | нечёт | 143 (вне набора) | 4 / 4 |
| + padding-расширение 1 байт перед последним | 1723 | нечёт | 144 | 4 / 4 |
| + padding-расширение 2 байта перед последним | 1724 | чёт | 144 | 4 / 4 |
| то же расширение в начале списка (1 и 2 байта) | 1723 / 1724 | нечёт / чёт | 144 | 4 / 4 |
| поле `enc` внутри ECH: 31 и 30 байт вместо 32 | 1717 / 1716 | нечёт / чёт | 144 | 4 / 4 |
| `yandex` как есть | 1717 | нечёт | 144 | 4 / 4 |

Что из этого следует:

- **Чётность общей длины опровергнута напрямую.** Рука с ECH 142 даёт 1716 байт — чётное, — и проходит, тогда как отвергаются 1718, 1750 и 1814, тоже чётные.
- **Правило покрывает весь набор ECH-длин клиента.** 144, 176 и 240 отвергаются одинаково, значит случайный выбор длины на каждое hello клиента не спасал никогда.
- **Любая правка выводит из-под подписи.** Лишнее расширение в два байта, где угодно в списке; байт, срезанный с `enc` внутри ECH; смена длины ECH payload — каждой хватает.
- **Точное совпадение отвергают жёстче.** Настоящий браузер получил 0 из 4 и был отвергнут уже в первом раунде, тогда как наши шаблоны первый раунд проходят и замолкают со второго.

### Правило не про адрес: четыре релея, один порядок

Тот же опыт — девять рук одинаковой длины, где меняется по одному полю, — прогнан против четырёх разных релеев с проблемной машины. Отвергаемая форма это `chrome_modern` как есть, контроль-позитив `yandex`, контроль на чужих байтах — дамп браузера.

| релей | порт | `chrome_modern` | восемь правок по одному полю | `yandex` | дамп браузера |
|---|---|---|---|---|---|
| 159.194.198.73 | 443 | **2 / 4** | 4 / 4 каждая | 4 / 4 | **1 / 4** |
| 155.212.137.130 | **45633** | **2 / 4** | 4 / 4 каждая | 4 / 4 | **1 / 4** |
| 82.26.171.54 | 442 | **3 / 4** | 4 / 4 каждая | 4 / 4 | **3 / 4** |
| 194.39.110.183 | 443 | 0 | 0 | **0** | 0 |

Что из этого следует:

- **Правило читает содержимое, а не адрес.** Одинаковая картина на двух разных IP, причём на втором — нестандартный порт 45633. Ни порт 443, ни конкретный адрес тут ни при чём.
- **Любое отклонение снимает блокировку.** Порядок ALPN; `h3` вместо `h2` в ALPS; переименованная группа пост-квантового key_share; переименованный x25519; порядок двух последних шифров; порядок в `supported_versions`; номер расширения `0017` → `0018`; номер `0023` → `0024`. Каждая рука той же длины, что контроль, и каждая проходит четыре раза из четырёх на всех релеях.
- **Блокировка накопительная, и порог разный.** На 159.194.198.73 сначала замолкает дамп браузера (со второго раунда), затем `chrome_modern` (с третьего). На 82.26.171.54 обе эталонные руки проходят три раунда и замолкают в четвёртом — **одновременно**. Синхронность указывает на общее состояние для эталонных hello к этому адресу, а не на отдельный счётчик у каждого отпечатка. Порог зависит от маршрута.
- **Порог не связан с повторением как таковым.** Правленые руки повторяются столько же раз, что и контроль, и не блокируются никогда. Значит считается не «часто встречающийся отпечаток», а именно совпадение с эталоном.
- **Четвёртый релей — другой случай.** Там молчит всё, включая `yandex` и не-TLS мусор, а с чистой сети этот же релей отвечает 11 из 11. Это блокировка адреса или маскируемого домена (`www.apple.com`), отдельный слой, к отпечатку отношения не имеющий.

> [!note] Как это формулировать точно
> «Правка поля X снимает блокировку» — измерено. «Фильтр читает поле X» — уже интерпретация: из данных следует лишь, что hello перестал совпадать с эталоном, а какие поля входят в сравнение и с каким весом, отсюда не выводится. На практике разницы нет — лечится любой правкой, — но в выводах это разные утверждения.

### Привилегированное имя в SNI отключает фильтр отпечатка

Самый практичный результат за всё расследование, и он получен одной переменной на одном адресе. На релее, где отпечаток режется прямо в эти минуты (проверено `test10` непосредственно перед: `chrome_modern` 2/4, дамп браузера 1/4), посланы четыре руки по кругу:

| рука | размер | фильтр. сеть |
|---|---|---|
| отвергаемый отпечаток + имя из секрета (`espn.com`) | 1718 | **0 / 4** |
| **тот же отпечаток** + `update.microsoft.com` | 1730 | **4 / 4** |
| проходящий отпечаток + `espn.com` (контроль) | 1717 | 4 / 4, HMAC сходится |
| проходящий отпечаток + `update.microsoft.com` | 1729 | 4 / 4 |

Один адрес, один порт, одни минуты, те же байты hello — меняется только имя, и вердикт переворачивается. Значит **фильтр отпечатка применяется избирательно, по имени в SNI**: привилегированное имя выводит hello из правила целиком.

Собирается связная трёхслойная картина, объясняющая все наблюдения:

1. **Блокировка по имени.** Некоторые имена к некоторым адресам не пропускаются вовсе — там молчит всё, включая проходящий профиль (случай `www.apple.com`).
2. **Белый список имён.** Поддомены `microsoft.com` пропускаются всегда, независимо от отпечатка. `login.microsoftonline.com` в список не входит — он ведётся по домену второго уровня.
3. **Фильтр отпечатка** — только для имён, не попавших ни в один список: точное совпадение с эталонными браузерными hello режется, неточная копия проходит.

> [!tip] Что это значит для настройки релея
> Маскируемый домен важнее любой работы с отпечатком. Ключ, выпущенный на привилегированное имя, проходит с **любым** отпечатком, включая побайтовый дамп настоящего браузера, который иначе режется. Правка в клиенте (`yandex` по умолчанию) остаётся нужной — клиент не выбирает домен, он приходит из секрета, — но для своих релеев дешевле поставить правильное имя, чем подбирать отпечаток.

Методологически здесь важен критерий: **«пришёл ли хоть какой-то ответ»**, а не «сошёлся ли HMAC». Релей проверяет SNI и отправляет чужое имя в камуфляж, поэтому по HMAC руки 2 и 4 «неуспешны» всегда — и эксперимент был бы невозможен. Ответ камуфляжа означает ровно то, что нужно измерить: пакет прошёл сеть.

### Заблокированный адрес с белым списком по имени

Четвёртый релей из таблицы выше молчал на всё — но не потому, что недоступен. Не-TLS мусор в 1700 байт получил в ответ `HTTP/1.1 …`, то есть адрес отвечает. Перебор четырнадцати маскируемых имён к тому же адресу и порту (hello один и тот же, меняется только имя в SNI; критерий — пришёл ли хоть какой-то ответ, потому что digest для чужого имени и не должен сойтись):

| имя в SNI | ответ |
|---|---|
| `update.microsoft.com` | **3 / 3** |
| `azure.microsoft.com` | **3 / 3** |
| `www.microsoft.com` | **2 / 3** |
| `login.microsoftonline.com` | 0 / 3 |
| `www.apple.com` (имя из секрета) | 0 / 3 |
| `www.icloud.com`, `ya.ru`, `yandex.ru`, `www.google.com`, `www.cloudflare.com`, `cdn.jsdelivr.net`, `www.gosuslugi.ru`, `vk.com`, `www.tinkoff.ru` | 0 / 3 |

Проходят ровно три имени, и все три — поддомены `microsoft.com`. При этом `login.microsoftonline.com` **не** проходит: список ведётся по домену второго уровня, а не по подстроке «microsoft».

Это не блокировка отпечатка и не блокировка адреса в чистом виде, а **заблокированный адрес с белым списком имён**: TLS к нему пропускают, только если SNI из привилегированного набора — очевидно, чтобы не ломать обновления Windows. Отсюда прямое решение: ключ с маскируемым домеником из `microsoft.com` на том же адресе, менять адрес не нужно.

Проверять релеем важно с обеих сторон: с чистой сети тот же релей ответил на **все четырнадцать** имён, включая `ya.ru` и `vk.com`. Значит различие создаёт сеть, а не релей — версия «релей не отвечает на чужой SNI» опровергнута измерением.

> [!warning] Подменить имя на стороне клиента нельзя
> Соблазнительная мысль: если сеть режет по имени, пусть клиент пошлёт другое имя с тем же ключом — и ключ менять не надо. Не работает. На живом релее с секретом `espn.com`: hello с его собственным именем верифицируется, а `update.microsoft.com`, `www.google.com` и даже трёхбайтовое `a.b` — с тем же ключом и корректно посчитанным digest — уходят в камуфляж. Релей сравнивает SNI с настроенным доменом отдельно от подписи.
>
> Отсюда же читается и предыдущий абзац: «пришёл TLS-ответ» в переборе имён означал ответ **камуфляжного сайта**, то есть «пакет дошёл», а не «релей принял». Поэтому имя, прошедшее перебор, годится только как кандидат: ключ с ним должен выпустить сервер, и проверять надо по HMAC.

> [!warning] Блокировка непостоянна во времени
> На релее, где `chrome_modern` устойчиво отвергался (2/4 и 1/6 в нескольких прогонах), отдельный прогон **двадцати пяти подряд идентичных** hello этого профиля прошёл целиком: 25 из 25 приняты. То есть отпечаток режут эпизодически, а не всегда.
>
> Что из этого следует для прежних выводов. Сравнения «одна переменная, руки чередуются в раунде» остаются в силе: обе руки в паре получали одинаковые условия, и разделение 1/6 против 6/6 не объясняется дрейфом. А вот версия про **накопительный порог** («блок включается после первого-второго эталонного hello») этим прогоном не подтверждается — 25 попыток подряд не включили ничего. Правдоподобнее выборочная проверка части соединений, состояние которой меняется само.
>
> Практический смысл `withheld` от этого не меняется: посылать отпечаток, который на части соединений режут, незачем. Но измерять состояние блокировки надо в тот момент, когда она активна — то есть когда клиент уже показывает тишину, а не в произвольное время.

### Ловушка при подборе ручек

**Менять длину `session_id` нельзя** — не из-за фильтра, а из-за самого релея: hello с 31 и 30 байтами уходит на камуфляж и возвращает чужой digest, потому что релей читает пакет по смещению, рассчитанному на 32 байта, как у любого настоящего браузера. Обнаружено прогоном на **чистой** сети, где такая рука обязана проходить: она единственная дала `digest MISMATCH`, пока остальные двенадцать верифицировались.

Отсюда правило для таких опытов: **сначала прогнать весь набор рук там, где блокировок нет**. Рука, которую не принимает релей, на фильтрованной сети выглядит точно как отвергнутый отпечаток, и вывод из неё будет ложным.

### Насколько это доказано

Разделять стоит три вещи, у них разная надёжность.

**Измерено и воспроизведено** (на это можно опираться):

- Какие отпечатки отвергаются, а какие проходят: шесть профилей × двенадцать попыток на двух сетях, плюс тринадцать рук × шесть и тринадцать × четыре в двух отдельных прогонах. Разделение всегда одно и то же.
- Что снимает блокировку при неизменном остальном: значение байта, его позиция, длина ECH payload, лишнее двухбайтовое расширение, укороченный `enc`. Каждое — отдельным однопеременным сравнением.
- Что не является причиной: размер пакета, незафлашенная запись, пост-квантовый key_share, порядок расширений, JA4, чётность длины. Каждое закрыто своим измерением, а не рассуждением.
- Что подделка проходит, а подлинный браузерный дамп нет.

**Модель, согласованная со всеми данными, но не наблюдавшаяся напрямую**: «фильтр сверяет hello с эталонными отпечатками браузеров и режет точные совпадения». Внутренностей фильтра никто не видел; любое описание, дающее те же предсказания, подходит не хуже. Это объяснение полезно как рабочая гипотеза и не должно подаваться как факт.

**Границы, за которые данные не выходят**:

- Один провайдер, одна точка доступа, один релей (два IP). Про другие сети это ничего не говорит.
- Одни сутки. Блокировка имеет состояние (включается со второго-третьего соединения), а значит и правила могут меняться.
- Проверены четыре поля из многих. Что ещё входит в совпадение — неизвестно; известно лишь, что каждого из проверенных достаточно, чтобы из него выйти.

Практический вывод от модели не зависит: клиент посылает шаблон, который на обеих сетях отвечали всегда, и это измерение, а не объяснение.
> Пока это не измерено, причина в заметке стоит как «отпечаток такой-то отвергается», а не как объяснение. Для дела это не критично: правка в клиенте стоит на измерении, а не на объяснении.

### Метод, который сработал

Все пять промахов родились из попытки объяснить механизм. Попадание пришло от перебора: **послать с проблемной машины байты каждого варианта, который умеет сам клиент, и посмотреть, какие проходят**. Три правила, которые из этого следуют:

- **Строить пробу из правил клиента, а не «похоже на клиент».** Проба, собранная по своему разумению, случайно оказалась старым 517-байтным шаблоном и потому проходила всюду — именно она и увела в сторону размера пакета. Правила надо разбирать из исходника клиента, тогда сравнение честное.
- **Перебор вариантов дешевле объяснения.** Шесть профилей × двенадцать попыток — двадцать минут. Пять гипотез о механизме — полдня.
- **Одна переменная за раз, руки чередовать.** Когда пришло время проверять механизм, сработала схема «один профиль, две руки, различие в одном поле, раунды по кругу»: сеть, которая режет пачками, не может подыграть одной руке. Ей же и опровергнут порядок расширений.

### Как снять эталонный дамп браузера

Проверять копию не с чем, пока нет оригинала. Достаточно двадцати строк: слушатель на порту, первый ClientHello целиком, hex в файл. Открывать надо `https://localhost:<порт>/` — **по имени, не по IP**, иначе браузер не пошлёт SNI и дамп будет непригоден. Браузер покажет ошибку, это и значит, что дамп снят.

Три ловушки, каждая из которых стоила отдельной отладки:

- **В SNI-расширении две длины в двух байтах друг от друга** — длина расширения и длина списка имён, они различаются на 2. Записать одно значение в оба — релей ответит TLS-алертом.
- **Поле random нужно занулить перед подсчётом HMAC.** В шаблоне оно нулевое по построению, в захвате там байты браузера, и digest не сойдётся.
- **Защита от повторов отбивает побайтово одинаковый hello.** Захват неизменен, поэтому вторая и третья попытки уходят в камуфляж — выглядит в точности как отвергнутый отпечаток. Настоящий браузер каждый раз ставит новый `session_id`; в пробе надо делать то же.

Что дал дамп: наш шаблон `yandex` оказался очень близкой копией. Совпадает набор шифров, совпадает набор расширений (шестнадцать плюс два GREASE по краям), совпадают три key_share (GREASE 1 байт, `0x11ec` 1216 байт, `0x001d` 32 байта), совпадает JA4. Расходятся две вещи: payload ECH (у браузера 240 байт, у нас случайный из 144/176/208/240) и тот самый GREASE-байт — у браузера он **есть**.

И главное, чего от дампа не ждали: он оказался не эталоном для подгонки, а независимой проверкой правила — см. [[#Настоящий браузер эта сеть тоже отвергает]]. Копия проходит, оригинал нет.

### Что из этого сделано в клиенте

Отпечаток:

- `Auto` больше не разворачивается в `chrome_modern`, дефолт — `yandex` (`b334f9c137`).
- Оба отвергаемых шаблона помечены `withheld` и отводятся к дефолту **в обоих местах**, где настройка превращается в hello, так что выбранный руками `chrome_modern` тоже не уйдёт в сеть. Сами шаблоны остались в таблице со своими метаданными о захвате — JA4-гард на них продолжает работать.
- Рядом с шаблоном `yandex` стоит предупреждение не «улучшать» его до точной копии браузера, и это закреплено тестом: измерено, что точная копия отвергается, а неточная проходит.

Задержка:

- Интервал между дозвонами к одному релею снижен с 250 до 50 мс. Основание — измерение: двадцать четыре дозвона вообще без интервала дошли до `resPQ` все двадцать четыре, вся пачка за 170 мс, тогда как через очередь те же двадцать четыре стоили около шести секунд. Рост интервала при промахах не тронут — именно он защищает релей, который действительно не тянет пачку.
- Выключен алгоритм Нейгла (`TCP_NODELAY`) на обоих транспортах. Тонкость: Qt применяет опции сокета через движок, который появляется только после установления соединения, поэтому ставить их в конструкторе бесполезно — нужно в обработчике подключения.

Диагностика:

- При «hello ушёл, ответа нет» в лог пишутся состояние сокета, число непрочитанных байт и число уведомлений о чтении (`fb0c74e0d4`) — это отличает «ответ не пришёл» от «ответ пришёл и не был прочитан».
- Хеш маскируемого домена в логе теперь солится случайным значением, взятым один раз за запуск. Причина прозаична: восемь байт SHA-256 от короткого доменного имени подбираются по словарю мгновенно — именно так `www.google.com` был восстановлен из настоящего лога за доли секунды. Внутри одного лога поле по-прежнему позволяет сопоставлять соединения, но имя из него больше не достать.
- `flush()` в обоих путях записи hello (`6a059504db`) оставлен, хотя причиной не был: полагаться на проход event loop там, где сразу за записью включается таймер, всё равно нельзя.

### Отвергнутая гипотеза: незафлашенная запись

Дальше в клиенте нашлась асимметрия, которая выглядела как объяснение: `QTcpSocket::write()` не отправляет данные, а ставит их в буфер, и уходят они, когда поток в следующий раз вернётся в event loop. Фрагментированный путь записи hello всегда делал `flush()`, а нефрагментированный — тот, которым идёт каждое соединение к mtproxy, — не делал ни разу. Клиент при этом сразу после записи запускает пятисекундный отсчёт ожидания ответа, и в логе честно писал `mtproxy client hello queued locally`. Форма отказа совпадала до буквы: `ch_accepted == ch_bytes`, `rx_after_ch=0`, `close_origin=local_timeout`.

Гипотеза **опровергнута тем же логом**, до того как собрался билд с исправлением. Два независимых замера:

1. **Таймаут срабатывает вовремя.** По 21 отказу интервал от `client_hello_sent` до `failed` равен 4.999–5.012 с. Таймер ожидания ServerHello живёт на том же потоке, что и сокет, значит его event loop исправно крутился и в конце пятисекундного окна. Чтобы незафлашенная запись пролежала все пять секунд, поток должен был не возвращаться в цикл ни разу — и тогда таймер не мог бы отработать с точностью до 12 мс, причём двадцать один раз подряд.
2. **Молчат и уже установленные соединения.** Четыре первых соединения к релею полностью прошли рукопожатие, два получили ключ, одно — первые данные Telegram. Затем создание ключа на третьем и четвёртом **не завершилось никогда**, а все четыре получили `mtp_receive_timeout` через 8–9 секунд. Незафлашенный hello физически не может остановить приём на соединении, которое уже обменивалось данными в обе стороны.

Правку (`6a059504db`, флаш в обоих путях) оставили: полагаться на проход event loop там, где сразу за записью включается таймер, всё равно неправильно. Но причиной наблюдаемого отказа она не была, и записывать её в «исправлено» нельзя.

### Что на самом деле показывает лог

Симптом сформулирован неверно с самого начала. Это не «новые соединения не получают ответа», а **вся обратка от релея замолкает разом**, примерно через 1.1 с после первого успешного соединения: и новые сокеты, и уже работавшие. При этом TCP-рукопожатие к тому же адресу продолжает проходить (`tcp_connected` спустя семь секунд после начала тишины). Повторяется на трёх релеях подряд с одинаковым профилем: 3–4 полностью успешных соединения, дальше ноль.

Отсюда следует главное: **отказ находится за пределами формата пакета и за пределами кода отправки**. Так выглядит либо обрыв у релея на его пути к Telegram, либо фильтрация обратного потока на маршруте после того, как поток классифицирован.

Первое подтверждение второй природы получено 26 июля в 12:30 UTC на обоих присланных релеях: FakeTLS-фронт по-прежнему отвечает `ServerHello` с верным digest за 15–46 мс, но сразу после 64-байтного obfuscated2-init релей закрывает соединение (FIN), не дожидаясь даже `req_pq`. То есть первый уровень жив, второго за ним в этот момент нет. Форма отличается от клиентской (там была тишина без FIN), поэтому это не то же самое событие — но это прямое доказательство, что второй уровень у этих релеев ломается независимо от клиента.

### Проверка сделана: виноват релей, а не клиент

26 июля проба, повторяющая схему дозвона клиента (соединение каждые 370 мс по восьми дата-центрам, затем `req_pq` каждые 500 мс на каждом), была запущена **с двух разных машин** — с сервера в другой стране и с той самой машины, где симптом. Результат идентичный на обеих:

- `ServerHello` с верным digest приходит за 20–28 мс на всех восьми соединениях;
- сразу после 64-байтного obfuscated2-init релей закрывает соединение (FIN), **не дожидаясь `req_pq`**;
- `resPQ` не приходит ни разу; одно соединение прожило 20 секунд и получило 38 запросов без единого ответа.

Первый уровень (FakeTLS-фронт) полностью исправен, второго за ним нет. Клиент в этой картине не участвует вообще — воспроизводится восьмьюдесятью строками Python. Совпадение результата с двух разных сетей исключает и маршрут пользователя.

### Как выглядит релей, который «падает», в логе клиента

Второй лог того же дня — другой прокси (`telegram.monkeyvillage.pro:443`, два адреса, **обычный obfuscated2 без FakeTLS**) и другая форма отказа. Сессии там работают: ключи создаются, файлы скачиваются. Но за 31 секунду — 73 события `error=remote_closed`, и распределены они не случайно:

- закрытия приходят **синхронными пачками**: 16 кластеров, крупнейший — **27 сокетов за 156 мс**, второй — 9 за 129 мс;
- все кластеры приходятся на **одну и ту же долю секунды** (.87–.93). То есть на релее раз в секунду срабатывает какая-то уборка, а не индивидуальные таймауты соединений;
- медианный срок жизни сокета после `connected` — 3 секунды.

Контрольный замер: тридцать **простаивающих** TCP-соединений к тому же адресу, удерживаемые 25 секунд, не закрылись ни одно. Значит это не лимит на число соединений и не таймаут простоя — уборка выкашивает именно те сокеты, по которым идёт трафик. Сходится с первым случаем: ломается плечо **релей → Telegram**, а не клиентская сторона.

Что смотреть на сервере в таком случае: свежесть `proxy-secret` и `proxy-multi.conf` (устаревшие — классическая причина «рукопожатие прошло, дальше ничего»), перезапуски и OOM воркеров прокси, переполнение таблицы `nf_conntrack`.

### Чем клиент делает хуже

Отказ серверный, но клиент подносит спичку. По тому же логу: **88 дозвонов за 31 секунду**, до **28 одновременных установленных соединений** к одному прокси, из них **14 сокетов — чистые потери**: клиент на каждое соединение гонку между двумя IP прокси и проигравшего закрывает. Плюс каждая медиа-полоса поднимает свою сессию — к одному DC 2 их в логе три (`160002`, `170002`, `180002`). Любой серверный лимит при таком профиле срабатывает в разы раньше, чем при одном сокете на сессию.

---

## Задержка: где она на самом деле

Жалоба «через прокси всё медленно и пинг большой» почти всегда приписывается сети. Померив, оказалось наоборот: сеть добавляет ноль, а секунды создаёт сам клиент. Разбирать надо по слоям, потому что каждый слой чинится по-своему.

### Три слоя, которые надо мерить отдельно

Одно соединение раскладывается на три измеримых отрезка:

- **tcp** — от SYN до SYN/ACK. Это путь до релея: география и оператор.
- **faketls** — от ClientHello до ServerHello. Релей отвечает сам, никуда не ходя.
- **respq** — от `req_pq_multi` до `resPQ`. Этот пакет идёт релей → Telegram → релей, поэтому **`respq` минус `tcp`** — примерно та дорога, которую релей добавляет позади себя.

Измерения с двух машин, одна на фильтрованной сети, одна на чистой:

| откуда | релей | tcp | faketls | respq | «за релеем» |
|---|---|---|---|---|---|
| фильтрованная | `194.39.110.183` | 70 | 71 | 94 | ~24 |
| фильтрованная | `159.194.198.73` | 17 | 9 | 55 | ~38 |
| чистая | `159.194.198.73` | 11 | 11 | 56 | ~45 |

Две вещи читаются сразу. Первая: на одном и том же релее фильтрованная и чистая сеть дают **одинаковые** цифры (17/55 против 11/56) — значит фильтрация не добавляет задержки, она либо пропускает, либо режет. Вторая: релеи различаются вдвое по суммарному RTT (55 против 94 мс), причём по разным причинам — первый дальше от Telegram, второй дальше от клиента. Выбирать релей по одному лишь пингу до него неправильно: `tcp` у `194.39.110.183` вчетверо хуже, зато позади него дорога короче.

Отдельно проверено, что установленное соединение живёт: пятьдесят запросов подряд за 245 секунд, все отвечены за ~90 мс без единого пропуска. Симптом «релей замолкает» на этом релее не воспроизводится — значит он относится к конкретным релеям, а не к схеме.

### Что создаёт задержку на самом деле

Раз сеть даёт 55–94 мс, а в клиенте ощущается заметно больше, разница делается внутри клиента. Нашлось два источника.

**Очередь дозвонов.** Клиент разносил каждую попытку к одному релею на четверть секунды. Холодный старт открывает десятки сокетов, поэтому последние ждали секундами: в логе видно прямо — `tcp_ms` доходит до 8666 мс там, где сетевой round trip 17 мс. Обоснование у этого разноса было такое: приходить как отдельные клиенты, а не одной пачкой, чтобы не выделяться. Две вещи это обоснование сняли — во-первых, стало известно, на что фильтр реально смотрит (форма hello и имя в SNI, не тайминг), во-вторых, измерена цена: **двадцать четыре совершенно непейсеных дозвона дошли до resPQ 24 из 24, вся пачка за 170 мс**. Те же двадцать четыре через очередь стоили около шести секунд.

**Nagle.** Отключение (`TCP_NODELAY`) не выставлялось нигде в дереве. Алгоритм придерживает короткую запись, пока не подтверждена предыдущая — а mtproto именно так и устроен: короткий запрос, потом ожидание. Тонкость реализации: Qt применяет опции сокета через движок, который появляется только после установления соединения, поэтому ставить их в конструкторе бесполезно, нужно в обработчике `connected`.

Оба исправлены (`7b3bd451f5`): разнос снижен до 50 мс, `LowDelayOption` выставляется на обоих транспортах после подключения. Эскалация разноса при неудачах не тронута — именно она защищает релей, который действительно глотает пачку.

> [!tip] Как выбирать релей по скорости
> Сравнивать надо не пинг до релея, а сумму: `tcp` плюс то, что позади. Релей с отличным пингом, но далёкий от дата-центра Telegram, окажется медленнее скромного, стоящего рядом с ним. Меряется одной командой на каждый релей, за минуту.

---

## Чего проба не показывает

- **Какая именно сборка стоит на площадке.** vpnbot умеет ставить mtg, официальный MTProxy и telemt ([[Zapret/mtproto/03-telemt|разбор telemt]], [[Zapret/mtproto/04-mtg|разбор mtg]]); версию у релеев напрямую не спрашивали. Косвенных признаков хватает: порог 517, потолок 4096, запас будущего ровно +3 секунды и дрейфующая нижняя граница — всё это поведение официального кода, и ни одна измеренная величина ему не противоречит. Но «ведёт себя как MTProxy» — не то же самое, что «это MTProxy».
- **Поведение под DPI.** Проба идёт из сети без активной фильтрации, поэтому она ничего не говорит про блокировки на маршруте — этим занимается [[Zapret/mtproto/05-censorship|разбор каскада детекции ТСПУ]].
- **Долгую работу соединения.** Проверяется путь до первого ответа Telegram; деградация на длинных сессиях, потеря соединений и скорость передачи остаются за рамками.
- **Мобильные сети.** Часть операторов ограничивает доступ к прокси по IP-диапазонам, и это видно только с самого устройства.

---

## Чек-лист диагностики

- [ ] Проверить TCP-доступность хоста, и не на одном порту, а на **всех открытых** — это отсекает сразу три остальных механизма. Если рукопожатие не встаёт, содержимое hello уже ни при чём. Различать исходы: таймаут в десятки секунд — фильтр глотает SYN; мгновенный RST — скорее закрытый порт. Измерено: адрес, доступный извне на 8445, 443 и 8443, с фильтрованной сети дал `False` на всех трёх с задержками 30–37 с, тогда как снаружи — 1–2 с. Такой адрес не лечится ни доменом, ни отпечатком, нужен другой.
- [ ] Отправить канонический 517-байтный ClientHello и **сверить HMAC ответа**, а не только префикс `16 03 03`.
- [ ] Каждую попытку генерировать заново — повтор идентичного пакета отбивается защитой от повторов.
- [ ] Сверить часы устройства: спешка даже на минуту уводит соединение на камуфляж, а на закрытой сети клиенту нечем их поправить.
- [ ] Если есть лог клиента — посмотреть, зависит ли длина ClientHello от длины SNI: одинаковая разница `длина − длина_SNI` на разных прокси означает, что padding не работает. Размер ответа при этом ни о чём не говорит — см. раздел про то, почему он не решает.
- [ ] Если рукопожатие проходит, довести пробу до `resPQ` — так проверяется тег протокола и весь путь до Telegram.
- [ ] Если и `resPQ` приходит, релей исправен: дальше искать в клиенте (длина и форма ClientHello, выбор тега протокола, внутренние ограничители дозвона).
- [ ] Прежде чем закладывать в клиент лимит параллельных подключений — измерить его на своих релеях, а не брать из чужих наблюдений.
- [ ] Пробу запускать **с той машины, где симптом**; результат с другого хоста ничего не говорит о сети пользователя.
- [ ] Сессии в пробе держать открытыми и с трафиком — клиент их держит, а проба, закрывающая соединение сразу, проверяет другой режим.
- [ ] Если проба проходит, а клиент нет — сравнить размеры первого пакета: 517 байт укладываются в один TCP-сегмент, 1700–1800 нет. На проверенных релеях разницы нет, но это единственное отличие, которое видно на проводе, поэтому проверять дёшево.
- [ ] Если и это совпало — искать не на проводе, а в клиенте: отправлена ли запись фактически (`write()` в Qt только ставит в очередь) и не занят ли поток, который должен прочитать ответ. Пробу для этого запускать **в момент зависания** клиента.
- [ ] Если клиент умеет несколько профилей ClientHello — **перебрать их все с проблемной машины**, собирая байты из правил самого клиента. Это дешевле любой гипотезы о механизме и находит «сеть режет наш отпечаток» за двадцать минут.
- [ ] Проверять отпечаток нельзя по JA4: он сортирует расширения и выбрасывает GREASE, поэтому два шаблона с одинаковым JA4 могут иметь разную судьбу на фильтрованной сети.
- [ ] Считать успехом только верный HMAC ответа. Первые попытки могут проходить и после включения блокировки — она включается со второго-третьего соединения.
- [ ] Не подгонять отпечаток «под настоящий браузер» без измерения: на фильтрованной сети побайтовый дамп браузера может отвергаться, а упрощённая копия проходить. Проверять надо тот отпечаток, который реально уйдёт в сеть, на той сети, где симптом.
- [ ] Когда сравниваете две руки, различающиеся одним полем, **следить за размером**: правка тела расширения меняет ещё и длину hello, и без компенсации нельзя сказать, что именно измерено. Компенсировать удобно внутри GREASE key_share — его длина произвольна.

---

## 📚 См. также

- [[mtproxy/ja4-sni-client-side|Кто может менять JA4 и SNI]] — почему форма ClientHello целиком на стороне клиента
- [[mtproxy/tdlib-obf-client-side-stealth|Клиентская маскировка в TDLib]] — что можно менять в клиенте, не ломая совместимость
- [[mtproxy/mtproto-zig|MTProxy и mtproto.zig]] — как устроен FakeTLS-релей изнутри
- [[Zapret/mtproto/02-implementations|Реализации MTProto Proxy]] — mtg, официальный MTProxy, telemt и их различия
- [[Zapret/mtproto/07-nginx-haproxy|MTProxy за nginx и HAProxy]] — как устроен камуфляжный фронт
- [[Zapret/mtproto/08-best-practices|Практики эксплуатации MTProxy]] — что делать после того, как релей признан исправным
- 🔗 [MTProxy: net/net-tcp-rpc-ext-server.c](https://github.com/telegramMessenger/MTProxy/blob/master/net/net-tcp-rpc-ext-server.c) — разбор ClientHello, `is_allowed_timestamp`, кеш client random и все константы из этой заметки
- 🔗 [telemt: src/protocol/tls.rs](https://github.com/telemt/telemt/blob/master/src/protocol/tls.rs) — те же проверки в реализации на Rust, с другими порогами
- 🔗 [mtg: mtglib/internal/tls/fake/client_side.go](https://github.com/9seconds/mtg/blob/master/mtglib/internal/tls/fake/client_side.go) — разбор рукопожатия в реализации на Go

---

> [!quote] 🤖 Эти статьи открыты — можно обучать на них ИИ
> При желании вы можете натренировать ИИ на наших статьях. Исходное форматирование и скачивание всего репозитория одним zip-архивом доступны в Forgejo: [исходник этой заметки](https://git.zapret.moe/zapretdiscordyoutube/todo/src/branch/main/mtproxy/faketls-relay-diagnosis.md) · [весь репозиторий](https://git.zapret.moe/zapretdiscordyoutube/todo/src/branch/main).
