telemt 3.4.x: чтение логов, TLS-фингерпринты и детект блокировок ТСПУ

О чём заметка

С версии 3.4.x telemt пишет в логи TLS-фингерпринт (JA4/JA3) каждого подключающегося клиента. Это даёт редкую возможность увидеть снаружи прокси, по какому именно признаку ТСПУ режет соединения. Здесь — как читать эти логи, что значит ошибка expected_64_got_0, и разбор реального наблюдения: DPI начал блокировать конкретную новую версию клиента Telegram по её JA4.

Статус данных

Ниже — разбор логов одного сервера (telemt 3.4.15) + сопоставление JA3 с публичными базами. Часть выводов (особенно «какой клиент это сломал») — гипотезы по наблюдениям, а не подтверждённая спецификация ТСПУ. Конкретные хеши и подсети — снимок на момент анализа; у вас будут другие. Читайте как метод, а не как готовые константы.


Что значит expected_64_got_0

Это ключевая ошибка в логах. Чтобы понять её, вспомним порядок FakeTLS-рукопожатия:

  1. Клиент → сервер: TLS ClientHello (здесь telemt и снимает фингерпринт).
  2. Сервер → клиент: ServerHello + ChangeCipherSpec + ApplicationData.
  3. Клиент → сервер: ApplicationData, внутри которого 64-байтный обфусцированный MTProto-хендшейк.

telemt ждёт на шаге 3 ровно 64 байта. Запись:

expected 64 got 0

означает: ClientHello пришёл (фингерпринт снят), но потом соединение закрылось, не прислав ни байта обфусцированного заголовка. Клиент так себя не ведёт — он всегда досылает 64 байта. А вот DPI ведёт себя именно так: видит ClientHello, опознаёт его как Telegram по фингерпринту и рвёт/душит соединение до того, как пойдут полезные данные.

Вывод

Массовый expected_64_got_0 с одной подсети/оператора = активная блокировка по TLS-фингерпринту, а не сетевой сбой. got 0 (а не got N) — признак именно обрыва после ClientHello.


Как читать JA4-фингерпринт в логах

telemt пишет JA4 вида t13d2014h2_<cipher_hash>_<extension_hash>. Расшифровка первой части (a-сегмент) — по той же схеме JA4 (разбор uTLS):

Полеt13d2014h2Значение
tTLS-over-TCP (q = QUIC)транспорт
13TLS 1.3версия (из supported_versions)
ddomain — SNI есть (i = по IP)наличие SNI
2020 cipher suitesчисло шифров
1414 extensionsчисло расширений
h2ALPN = h2 (HTTP/2)первый/последний символ первого ALPN

Дальше идут два хеша: _<cipher_hash>_<extension_hash> — усечённые SHA256 от отсортированных наборов шифров и расширений. Именно по ним отличают версии клиента при одинаковом a-сегменте.


Корень проблемы: фингерпринт клиента «протух» (tdesktop #30733)

Самое важное — из официального issue telegramdesktop/tdesktop#30733 (тесты блокировки начались 22 мая в Сибири):

Telegram Desktop в FakeTLS мимикрирует под браузер, и сейчас шлёт фингерпринт t13d1516h2_8daaf6152771_d8a2da3f94cd — это Chrome 134 на macOS. Но реальный Chrome уже 148 (на Win — t13d1514h2_8daaf6152771_827b515c4f52). Пресет заморожен на старой версии, и эта несвежесть сама стала маркером.

Что тут видно при сравнении двух JA4:

Telegram Desktop (мимикрия)Реальный Chrome 148 (Win)
JA4t13d1516h2_8daaf6152771_d8a2da3f94cdt13d1514h2_8daaf6152771_827b515c4f52
Шифры (cipher hash)8daaf61527718daaf6152771совпадает
Расширений1614
Extension hashd8a2da3f94cd827b515c4f52

Cipher-хеш тот же (Telegram честно копирует набор шифров Chrome), но набор расширений разъехался — Telegram застрял на раскладке Chrome 134, а живой Chrome ушёл вперёд. DPI ловит именно это расхождение: «почерк Chrome, но такого Chrome уже нет в природе».

Ключевой вывод

Это ровно «несвежесть пресета» из сибирской заметки (там это про uTLS в REALITY, здесь — про встроенный FakeTLS-пресет Telegram). Лечится это только в самом клиенте Telegram (issue просит ротацию/обновление фингерпринтов). Оператор прокси фингерпринт не меняет — отсюда и весь набор костылей ниже (дробление, desync, SYN-ACK), которые прячут/ломают опознание ClientHello, а не правят его.


Разбор наблюдения (telemt 3.4.15, июнь 2026)

Точность хешей

Ниже — обработка логов одного сервера (частично нейросетью). Конкретные cipher/extension-хеши тут могут быть неточными — авторитетные значения берите из issue #30733 выше. Доверять стоит структуре наблюдения (два семейства, новый фингерпринт чаще падает), а не отдельным хешам.

Два семейства по cipher-хешу

СемействоCipher hashШифровКто это
Мобильные (Android/iOS)a09f3c6…20BoringSSL — стек мобильного Telegram
Desktopf57a46b…13Telegram Desktop

Extension-хеш 7f0f34a4126d совпадает у мобильных и десктопа → набор расширений у них одинаковый, различаются только шифры (мобильный BoringSSL тащит больше cipher suites).

Сопоставление JA3 с публичными базами

JA3 этих клиентов уже лежат в открытых базах (ja3er.com, sslbl.abuse.ch) — то есть они давно известны и ТСПУ их видел:

JA3Клиент
ecdf4f49dd59effc439639da29186671Telegram Android
8527da8b8a640065e72ec6b6f99764f3Telegram Desktop

Новый фингерпринт — и именно он ломается

t13d2014h2 с extension-хешем e42f34c56612 — мобильный клиент, у которого изменился набор расширений (отсюда другой ext-хеш и счётчик …14 расширений). Похоже на свежее обновление приложения, добавившее один extension.

И вот суть:

Именно t13d2014h2 (e42f34c56612) чаще всего падает в expected_64_got_0. На МТС подсеть 95.24.149.x почти целиком состоит из этого фингерпринта.

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

Осторожно с причинностью

Альтернативное объяснение: новый клиент мог сам поменять тайминг/поведение хендшейка, и got 0 — побочка, а не таргетированный блок по JA4. Но концентрация одного фингерпринта в одной подсети одного оператора склоняет к версии «таргетированный детект». Проверяется сравнением: режется ли тот же JA4 у других операторов и на других подсетях.


Лимит SYN-ACK — помогает или нет

Гуляет приём — дропать исходящие SYN-ACK сверх 1/сек правилом nft. Его часто называют то «вредным», то «волшебным» — на деле всё посередине: он иногда реально помогает (в т.ч. на telemt), но с понятной ценой. Разберём честно.

На пальцах: что такое SYN-ACK и почему его дроп сбивает DPI

Установка TCP-соединения — это как звонок:

  1. Клиент: «SYN» — «Алло, можем говорить?»
  2. Сервер: «SYN-ACK» — «Да, говори». ← вот его и дропаем
  3. Клиент: «ACK» — «Ок». И только теперь шлёт ClientHello — свою «визитку» (тот самый почерк, который ловит DPI).

Если сервер иногда роняет своё «Да, говори», происходит две полезные вещи:

  • DPI теряет нить. Цензор-«подслушка» строит запись о разговоре по этому рукопожатию. Когда «Да, говори» приходит с задержкой и повтором, запись у DPI получается кривая — и он не привязывает к ней визитку клиента, то есть не опознаёт её.
  • Нет «залпа». Пока «Да, говори» не дошло, клиент молчит и визитку не отправляет. Значит визитки выходят не пачкой, а по одной в секунду — и поведенческий триггер «много коннектов разом» не срабатывает.

Минус — звонок дольше устанавливается (надо переспросить через секунду). Для Telegram это разовая задержка на старте, потом соединение живёт долго — поэтому терпимо.

Что вообще делает правило

add table inet filter
add chain inet filter output { type filter hook output priority 0; }
add rule inet filter output tcp flags & (syn|ack) == syn|ack \
  tcp sport 45443 limit rate over 1/second burst 1 packets drop

45443 — порт, на котором слушает telemt. Правило дропает второй пакет TCP-рукопожатия (SYN-ACK, сервер→клиент), когда их больше 1/сек.

Почему это может ломать блокировку

Два механизма, оба правдоподобны:

  1. Десинхронизация stateful DPI. Дроп своего SYN-ACK заставляет ядро переслать его с задержкой. Рукопожатие завершается «криво» и позже — а stateful-трекер ТСПУ, который строит запись о потоке по хендшейку, на аномальном/переотправленном SYN-ACK может не привязать последующий ClientHello к потоку и не проинспектировать его. По духу это родственно nfqws-desync, только на уровне SYN-ACK.
  2. Слом «залпа соединений». Пока SYN-ACK не дошёл, клиент не шлёт ClientHello (TCP ещё не установлен). Значит частота ClientHello троттлится до 1/сек → рушится поведенческий Сигнал 3 (сибирская схема: «>3 коннектов с интервалом <100мс»).

Цена и оговорки

  • Медленная установка соединений. Каждый коннект сверх лимита ждёт ретрансмит SYN-ACK (~1с+). Для Telegram с долгоживущими соединениями это разовая задержка на старте — терпимо. Для high-churn — больно.
  • Глобальный вариант калечит всех юзеров разом — один бюджет 1/сек на весь порт. На многопользовательском прокси это плохо → нужен per-port вариант (ниже).
  • Не лечит корень (протухший фингерпринт из #30733) — это обходной костыль, может перестать работать при адаптации ТСПУ.
  • Сервер чуть аномален статистически, но «реально проходит» важнее «выглядит идеально».

Вердикт

Тестируйте на своём маршруте, и сразу per-port вариант. Это не замена дроблению/desync/mask, а дополнение к ним. Заработало — оставляйте, но мониторьте логи (expected_64_got_0), чтобы поймать момент, когда ТСПУ адаптируется и приём перестанет действовать.

Per-port вариант — правильный (бюджет 1/сек на каждый порт)

Проблему «один лимит на всех» решает раздача юзерам разных портов из диапазона + лимит, считаемый по порту:

table ip telemt_limit {
  set synack_ports {
    type inet_service
    size 65535
    flags dynamic,timeout
    timeout 5s
  }
  chain prerouting {
    type nat hook prerouting priority dstnat; policy accept;
    tcp dport 4000-5000 redirect to :443          # все порты 4000-5000 → telemt:443
  }
  chain postrouting {
    type filter hook postrouting priority srcnat + 1; policy accept;
    tcp flags & (syn|ack) == syn|ack tcp sport 4000-5000 \
      update @synack_ports { tcp sport limit rate over 1/second burst 1 packets } drop
  }
}

Командами:

nft add table ip telemt_limit
nft add set ip telemt_limit synack_ports { type inet_service \; flags dynamic, timeout \; timeout 5s \; }
nft add chain ip telemt_limit prerouting { type nat hook prerouting priority dstnat\; }
nft add rule ip telemt_limit prerouting tcp dport 4000-5000 redirect to :443
nft add chain ip telemt_limit postrouting { type filter hook postrouting priority srcnat + 1\; }
nft add rule ip telemt_limit postrouting tcp flags \& \(syn \| ack\) == syn \| ack tcp sport 4000-5000 \
  update @synack_ports { tcp sport limit rate over 1/second burst 1 packets } drop

Простыми словами: вместо одного порта 443 ты даёшь каждому пользователю свой порт из диапазона 4000-5000 (в ссылке). Все они внутри ведут на тот же telemt, но лимит «1 соединение в секунду» считается отдельно для каждого порта. Поэтому активность одного пользователя больше не мешает остальным — у каждого свой бюджет.

Как это работает технически:

  • prerouting (DNAT): клиент может коннектиться на любой порт 4000-5000, всё редиректится («заворачивается») на telemt:443. Раздаёшь каждому юзеру/ссылке свой порт из диапазона.
  • postrouting (priority srcnat+1): правило срабатывает после un-NAT, когда sport SYN-ACK уже переписан обратно в клиентский порт (4000-5000). update @synack_ports { tcp sport limit rate ... } заводит отдельный счётчик 1/сек на каждый порт (элементы set живут 5с).
  • Итог: каждый раздаваемый порт лимитируется независимо → один юзер не давит других. Это снимает главное возражение против глобального правила.

Нюансы per-port варианта

  • Только IPv4 (table ip). Для IPv6 нужен отдельный table ip6.
  • «Per-port = per-user» работает, только если реально раздавать каждому свой порт. Делят порт → делят бюджет.
  • Лимит считается по порту назначения, не по IP клиента — для приватного прокси это ок.

Что с этим делать

Диагностика — грепаем логи

# Сколько обрывов после ClientHello и с каких IP
docker compose logs telemt | grep "expected 64 got 0" | grep -oE '([0-9]+\.){3}[0-9]+' \
  | sort | uniq -c | sort -rn | head
 
# Привязка обрывов к фингерпринту (если JA4 в той же строке/рядом)
docker compose logs telemt | grep -E "expected 64 got 0|t13d" | tail -50

Если expected_64_got_0 концентрируется на одной подсети/операторе и одном JA4 — это подпись активного DPI, а не случайные сбои.

Лечение — то же, что и для любого FakeTLS

Фингерпринт генерирует приложение Telegram, а не сервер — на стороне telemt его не поменять (нет uTLS, как в REALITY). Единственное серверное лекарство — не дать DPI собрать и опознать ClientHello:

  • TCPMSS-дробление — анонсировать малый MSS, чтобы клиент порезал ClientHello на куски, и stateful DPI не пересобрал фингерпринт.
  • nfqws TCP desync (zapret) — fake-пакеты + TTL-limited split, чтобы сбить машину состояний DPI.

⚠️ В отличие от mtproto.zig (ставит это сам через mtbuddy install), telemt этим не занимается — обход на уровне ОС придётся накатывать руками поверх. Базовый рецепт TCPMSS+nfqws — в разборе mtproto.zig и в zapret.

  • Лимит SYN-ACK (1/сек) — десинхронизирует stateful DPI и троттлит «залп». Спорный, но рабочий приём — см. раздел выше; бери сразу per-port вариант.
  • Сменить узел/подсеть, если конкретный IP/диапазон попал под раздачу (Сигнал 1 сибирской схемы).
  • Не дёргаться — рефлекторная смена настроек под блоком сама по себе сигнал.

Главное

  1. Корень (issue #30733): FakeTLS-фингерпринт Telegram протух (мимикрия под Chrome 134, а живой — 148). Чинится только в самом клиенте Telegram — оператор фингерпринт не меняет.
  2. Детект: telemt 3.4.x логирует JA4 → expected_64_got_0 + один фингерпринт + одна подсеть = таргетированный блок по версии клиента. Это твой бесплатный детектор DPI.
  3. Обход на стороне сервера (фингерпринт не трогаем, прячем/ломаем опознание): дробление ClientHello (TCPMSS), nfqws-desync, лимит SYN-ACK (per-port), смена узла. Telemt сам это не ставит — накатывай поверх.

📚 См. также