Files
tglock/docs/ARCHITECTURE_V2.md
T
Никита Sonic 8ce9355243 fix: медиа и «прокси не настроен»: пакет на кадр, настоящие фронты, kwsN-1 только для медиа; beta.19 (#75)
* fix(tunnel): медиа и «прокси не настроен»: один пакет на кадр, настоящие фронты, kwsN-1 только для медиа

Три причины, каждая проверена на живом Telegram запросом req_DH_params:
сервер сверяет номер DC внутри него с собой и отвечает -444 на чужой.

- WebSocket-фронт разбирает один пакет на входящий кадр, остаток ждёт
  следующего. Клиент пишет запросы пачками, TGLock слал их одним кадром,
  и всё, что шло вторым, застревало: текст проходил, файлы вставали.
  Теперь поток режется на пакеты, как в Flowseal/tg-ws-proxy. Пачка через
  beta.18 висит на всех DC, через эту сборку принимается на всех.
- Кластер kwsN-1 отвергает обычное соединение с -444, а Telegram Desktop
  в ответ выключает прокси как настроенный неверно. При сбое kwsN основное
  соединение откатывалось именно туда. Теперь kwsN-1 только для медиа.
- Адреса из списка DC не говорят на TLS ни из какой сети (проверено с чистой
  сети), поэтому первый маршрут каждого DC не работал никогда. Закреплены
  настоящие фронты, и DC1, DC3, DC5 стали доступны по WebSocket, чего не было.
  У CDN203 своего фронта нет: остались TCP, Worker и CF-прокси.

Обрыв туннеля, через который уже шли данные, больше не ставит маршрут на
паузу. Таймаут соединения 8 с, как в tg-ws-proxy. probe_proxy.mjs получил
--identity и --batched: они и нашли эти баги.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: журнал ошибок в диагностике, --log-file для роутеров; 2.0.0-beta.19

«Скопировать диагностику» отдавала только счётчики, и по ней нельзя было
понять, какой маршрут не ответил. Теперь в отчёте есть строки сбоев туннелей,
а домены Worker и CF-прокси в них заменены на <домен>.

--log-file и --log-max-mb (log_file, log_max_mb в настройках), как в
tg-ws-proxy: журнал идёт в файл с ротацией, а не в системный журнал роутера.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(routes): запасной WebSocket на порту 80 родных адресов, включая CDN203

Родные адреса Telegram принимают WebSocket без TLS на порту 80. Измерено
req_DH_params: DC определяется адресом, имя хоста не важно, а 91.105.192.100
отдаёт ключи CDN. Для CDN203, откуда часто идёт видео, это первый прямой
WebSocket: на 443 фронта у него нет, и там, где провайдер закрыл только 443,
до него раньше было не достать без Worker.

Защита та же, что у родного TCP: внутри тот же обфусцированный MTProto. В Host
идёт адрес, имени Telegram в открытом виде нет. Маршрут разрешён только до
закреплённых адресов и пробуется последним.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): паузы маршрутов по результатам ревью, пределы и журнал

- Обрыв не наказывает маршрут, только если туннель работал и получал ответы
  15 с. Одного ответа мало: маршрут, который ответил раз и умер, оставался
  предпочтительным навсегда.
- Клиент спросил, не дождался ответа за 5 с и ушёл: маршрут считается
  неработающим. Иначе CDN, принявший TCP и молчащий, держал первое место, и
  очередь до порта 80 не доходила.
- Предел пакета 2 МиБ вместо 16: чужое устройство в LAN-режиме не заставит
  роутер копить десятки мегабайт на соединение.
- Журнал: разовая ошибка записи его больше не выключает, неудачная ротация
  не теряет бэкап и держит предел, права 0600 и на уже существующем файле.
- В отчёт диагностики не попадает Worker, заданный адресом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 07:32:03 +00:00

26 KiB
Raw Permalink Blame History

TGLock 2.0 architecture

Сборка: ядро, GUI и headless

Крейт собирается в библиотеку и два бинаря:

  • src/lib.rs — ядро: mtproto, transport, proxy, config. Не зависит ни от Tauri, ни от оконной системы;
  • src/main.rs — GUI, доступен только при включённой фиче gui (required-features);
  • src/bin/cli.rs — headless tglock-cli.

Фича gui включена по умолчанию и подтягивает tauri, tauri-build и open. При --no-default-features ни Tauri, ни системный WebView, ни фронтенд в сборку не попадают, и build.rs не вызывает tauri_build. Так TGLock собирается и работает там, где WebView недоступен в принципе: сервер без графического окружения, контейнер, машина без монитора. Для случаев, где WebView есть, но нет 3D-ускорения, GUI дополнительно просит программный рендер — см. Current limitations.

Local protocols

Один TCP-порт автоматически принимает два типа клиентов:

  • MTProto proxy с постоянным 16-байтовым secret — основной режим;
  • SOCKS5 без авторизации — режим совместимости.

MTProto init проверяется по secret и transport tag. Из него извлекаются DC, признак media-соединения и тип транспорта. Для upstream создаётся новый стандартный obfuscated2 init, а последующий поток пере-шифровывается между локальным secret и Telegram.

Различение протоколов

Определять протокол по первому байту нельзя. SOCKS5-приветствие начинается с 0x05, но MTProto init — это 64 случайных байта, и is_reserved_init исключает только 0xef, 0xee, 0xdd, HTTP-глаголы и заголовок TLS-записи. Значение 0x05 попадается примерно в одном init из 256, и такое соединение уходило в SOCKS5-ветку и умирало — снаружи это выглядит как «Telegram отправляет сообщения через раз».

Поэтому неоднозначный первый байт разрешается так: не потребляя данные, ожидается полный 64-байтовый init и делается попытка разобрать его под текущим secret. Успешный разбор означает MTProto. Настоящий SOCKS5-клиент присылает короткое приветствие и блокируется на ответе, поэтому 64 байта у него не появятся и по истечении короткого таймаута он корректно уходит в SOCKS5-ветку.

Политика прямого релея

Не-Telegram адреса релеятся напрямую, без шифрования, и пользы для обхода в этом нет. Поэтому такой релей разрешён только когда прокси слушает loopback, где до него дотягиваются лишь локальные процессы. На 0.0.0.0 и любом другом сетевом адресе не-Telegram запросы отклоняются кодом SOCKS5 0x02, иначе LAN-режим превращал бы машину в открытый прокси. Правило выражено в типе config::ListenConfig, а не в условиях по месту вызова; переопределяется только явным --allow-direct в CLI.

Что считается адресом Telegram

Список сетей — опубликованный самим Telegram (https://core.telegram.org/resources/cidr.txt), он лежит в telegram_net и проверяется по маске префикса. До 2.0.0-beta.9 сравнивались два первых октета, то есть «телеграмом» считались целиком 149.154.0.0/16, 91.108.0.0/16, 91.105.0.0/16 и 185.76.0.0/16, а IPv6 не распознавался вовсе. Ошибка была в обе стороны:

  • чужие адреса внутри этих /16 уходили в MTProto-туннель и умирали там;
  • настоящие адреса дата-центров по IPv6 отклонялись как посторонние.

Второе и давало «на компьютере работает, с телефона нет» (#42): на loopback неопознанный адрес всё равно релеился напрямую, поэтому там дефект не проявлялся, а на сетевом слушателе тот же адрес получал отказ.

Назначение делится на три вида:

Вид Что это Что делаем
Дата-центр IP из опубликованных сетей, v4 или v6 заворачиваем в WebSocket
Веб Telegram имя из telegram.org, t.me, telegram.me, telesco.pe, cdn-telegram.org пропускаем как есть — это обычный HTTPS, а не MTProto
Всё остальное — напрямую на loopback, отказ на сетевом адресе

Имена сопоставляются по границе метки, поэтому telegram.org.example.com — посторонний домен. В ограниченном режиме имя разрешается заранее, и адреса внутри локальной сети (127.0.0.0/8, 10/8, 172.16/12, 192.168/16, 100.64/10, fc00::/7, fe80::/10) отбрасываются: назначение выбирает чужое устройство, и DNS-ответ не должен превращать TGLock в дверь во внутреннюю сеть этой машины.

Отказ перестаёт быть молчаливым

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

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

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

Теперь такие клиенты считает unknown_clients, и журнал называет адрес и причину. Их две:

  • MTProto-init не разбирается под текущим секретом. Почти всегда это ссылка tg://proxy от прошлого запуска: секрет — её половина, и клиент со сохранённой старой ссылкой попадает ровно сюда. Со стороны Telegram это и есть «прокси настроен неверно и будет отключён» (#37).
  • SOCKS5-приветствие не разбирается. Сюда же попадает MTProto-соединение, ушедшее в SOCKS5-ветку по неоднозначному первому байту, если полный init не успел прийти за PROTOCOL_PROBE_TIMEOUT.

Счётчик ws_failures от них отличается тем, что растёт после успешного рукопожатия с клиентом: там договорились с клиентом, но не смогли с Telegram.

Почему не поднялся туннель

ws_failures говорит, что каскад маршрутов упал целиком, и молчит о причине. Текст с перечислением попыток собирался в TransportEngine::connect и там же пропадал: наверх уходил Err, который выбрасывался в serve. При туннелей 0 и растущих сбоях отличить «провайдер режет закреплённые адреса» от «воркер отвечает отказом» было нечем — ровно та стена, в которую упёрся репортёр #50.

Теперь причина попадает в журнал одной строкой на каждый набор отказов:

Не поднялся туннель до DC2: 149.154.167.51 — не отвечает (таймаут TCP);
kws2.web.telegram.org — таймаут TLS/WebSocket; my.workers.dev — рукопожатие
WebSocket: HTTP error: 403 Forbidden

Дедупликация журнала делает эту строку разовой: маршруты у DC стабильны, и повтор той же комбинации отказов не пишется.

Домены Cloudflare Worker отчитываются так же. Строка, не похожая на имя хоста, раньше отбрасывалась молча — https://name.workers.dev/ со схемой или слэшем не проходит valid_domain, маршрут не появлялся, и «воркер настроен» ничем не отличалось от «воркера нет». Теперь отвергнутая строка называется вместе с причиной, а принятая подтверждается: Cloudflare Worker в списке маршрутов: name.workers.dev.

Туннель: два независимых направления

Каждое клиентское соединение получает свой WebSocket-туннель, и внутри него данные идут в обе стороны сразу. До 2.0.0-beta.12 оба направления обслуживал один select! с пометкой biased, и это давало два дефекта, снаружи выглядевших одинаково: «Подключено», а ничего не идёт.

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

Второй дефект — одна задача на оба направления. tcp_w.write_all ждёт, пока клиент разберёт присланное, и всё это время не опрашивается чтение от клиента. Телефон по Wi-Fi разбирает поток медленнее, чем Telegram Desktop на той же машине через loopback, — отсюда асимметрия «на компьютере работает, на телефоне нет» из #42.

Для MTProto это фатально: клиент обязан слать подтверждения, а за каждым следующим куском файла — свой upload.getFile. Первый запрос уходит, дальше идёт поток вниз, и следующие запросы наверх не попадают. Загрузка встаёт при живом туннеле, нулевых сбоях и нулевых отклонениях — ровно картина из #32.

Теперь это две независимые половины: ws.split() плюс CryptoContext::split(), потому что шифры направлений независимы — два потока AES-CTR со своими ключами. Ping приходит в читающую половину, а отвечает на него пишущая, через канал на четыре слота: владелец отправляющей половины должен оставаться ровно один.

Оба дефекта закрыты тестами, которые падают на beta.11. Первый: за пять секунд непрерывной загрузки наверх не уходит ни одного байта. Второй: клиент, не успевающий читать, замораживает собственную отправку.

Учёт состояния

Stats::ws считает установленные туннели: счётчик поднимается после успешного transport.connect, а не перед попыткой. Иначе интерфейс рапортовал бы «Telegram на связи», пока рукопожатие ещё перебирает маршруты по несколько секунд каждый. Состояния «порт открыт», «идёт перебор маршрутов» и «туннель установлен» различимы и в GUI, и в выводе CLI.

Дата-центр и маршрут пишутся одним значением, в момент, когда туннель поднялся. Пока это были два независимых поля, номер писало соединение при разборе init, а маршрут — другое соединение после рукопожатия, и при десятках одновременных соединений в строку статуса попадала пара из разных из них. Читалась она как «до этого DC шли этим маршрутом», хотя означала другое: в диагностике #42 встречались строки DC5 · Запасной Telegram IP, а у DC5 закреплённый адрес всего один и запасного у него не бывает вовсе.

Секрет прокси — половина ссылки tg://proxy. Для сервиса его нужно закрепить файлом (--secret-file): под DynamicUser и ProtectHome домашней папки нет, путь по умолчанию не определяется, и секрет генерировался бы заново при каждом старте, отключая всех уже настроенных клиентов.

Transport cascade

Для каждого DC и отдельно для media-соединений строится список маршрутов:

  1. сохранённый успешный маршрут;
  2. основной WebSocket-фронт DC (web_fronts) с настоящим именем хоста в TLS SNI и WebSocket Host;
  3. явно настроенные пользователем CF-прокси и Cloudflare Worker;
  4. запасные фронты DC;
  5. системный DNS;
  6. WebSocket без TLS на порту 80 адреса DC и родной MTProto TCP, только если всё остальное не ответило.

Адреса фронтов и родные адреса DC разные. 149.154.167.51, .91, 149.154.175.50, .100, 149.154.171.5 и 91.105.192.100 говорят только на MTProto и на TLS не отвечают, в том числе из сети без блокировок. WebSocket принимают 149.154.167.220 и .99 (DC2, DC4), 149.154.174.100 и .200, 149.154.175.209 (DC1, DC3), 149.154.170.100, .200 и .96 (DC5).

Фронт выбирает дата-центр по имени хоста, а не по заголовку клиента, поэтому имя и адрес нельзя менять местами. Какой DC стоит за маршрутом, проверяет probe_proxy.mjs --identity: сервер сверяет номер DC внутри req_DH_params с собой и отвечает -444, если номер чужой. resPQ этого не показывает, на него отвечает любой сервер при любом номере в заголовке.

Кластер kwsN-1 только для медиа: обычное соединение он отвергает с -444, а Telegram Desktop на этот код выключает прокси как настроенный неверно. Основной кластер kwsN принимает оба вида соединений и остаётся запасным для медиа.

Фронт разбирает один транспортный пакет на входящий кадр, остаток ждёт следующего кадра. Клиенты пишут запросы пачками, поэтому поток режется на пакеты (PacketSplitter), и каждый уходит отдельным кадром. Для Worker это не нужно: он пишет кадры в обычный TCP.

На порту 80 родные адреса принимают WebSocket без TLS. Там DC определяет адрес, а не имя хоста, поэтому в Host идёт сам адрес и имя Telegram в открытом виде не появляется. Защита та же, что у родного TCP: внутри тот же обфусцированный MTProto, а сервер подтверждает себя в самом протоколе. Маршрут разрешён только до закреплённых адресов.

Поддерживаются DC1–5 и CDN DC203. У DC203 нет фронта на 443, а kws2 на любом фронте это обычный DC2, поэтому его маршруты: MTProto TCP к закреплённому 91.105.192.100:443, Worker, CF-прокси и порт 80 того же адреса.

После ошибки маршрут получает exponential cooldown от 30 секунд до 30 минут. Успешный маршрут становится первым для следующего соединения того же DC и типа трафика. Попытки запускаются с интервалом 250 мс, не больше трёх одновременно на соединение; остальные отменяются после первого успеха. Если все маршруты на паузе, возвращается причина и время до повторной попытки. Новое подключение клиента не обходит cooldown. Маршрут тоже теряет предпочтение и получает cooldown, если Telegram закрыл соединение с кодом ошибки, туннель оборвался, не проработав 15 секунд, или клиент спросил, не дождался ответа за 5 секунд и ушёл сам. Обрыв туннеля, который работал дольше и получал ответы, маршрут не наказывает: это обычное дело, а пауза оставила бы весь DC без связи.

TLS policy

Проверка сертификатов и hostname никогда не отключается. При подключении к заданному Telegram IP TCP destination отделён от URI host: TLS продолжает проверять сертификат настоящего kws*.web.telegram.org. Используется rustls с провайдером ring и встроенными WebPKI roots; системный OpenSSL для ядра и CLI не нужен. Собственные корневые сертификаты ОС автоматически не подхватываются. Тесты проверяют доверенный сертификат, SNI, неверное имя хоста и недоверенного издателя.

Это относится к TLS-маршрутам. Прямой CDN TCP — отдельный транспорт MTProto, а не TLS с отключённой проверкой. Он доступен только для точного назначения DC203. Для произвольных адресов такой обход политики не добавляется.

Cloudflare Worker принимается только как пользовательская настройка. TGLock не загружает и не скрывает публичные списки чужих доменов.

Cloudflare Worker contract

Worker должен принимать WebSocket на:

/apiws?dst=<telegram-ip>&dc=<dc-id>

и проксировать бинарные frames в TCP <telegram-ip>:443. Ответ 101 отправляется после открытия upstream TCP; таймаут/отказ — HTTP 504/502. Список назначений фиксирован, текстовые сообщения отклоняются. Очередь входящих записей ограничена 1 МиБ и 256 сообщениями. Авторизация токеном требует собственного клиента: штатный TGLock токен Worker пока не передаёт.

Запись в сокет Telegram обязана быть последовательной: следующий чанк уходит после того, как записан предыдущий, и только когда писатель к этому готов (writer.ready). В worker/tglock-worker.js этого не было — write() вызывался поверх незавершённого, без backpressure. Пока в клиенте голодала отправка, настоящего потока вверх через воркер не возникало и это не проявлялось; после того как голодание починили, поток появился. Кто разворачивал воркер раньше — обновите скрипт.

Current limitations

  • SNI camouflage не включена: небезопасное отключение hostname verification из референсной реализации не переносится.
  • Голосовые звонки по UDP не поддерживаются.
  • GUI зависит от WebView, а тот — от 3D-ускорения. Начиная с 2.0.0-beta.2 приложение перед стартом Tauri само просит программный рендер: на Windows через WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS, на Linux через WEBKIT_DISABLE_COMPOSITING_MODE и WEBKIT_DISABLE_DMABUF_RENDERER. Уже заданные оператором значения не перезаписываются, а TGLOCK_FORCE_GPU=1 возвращает аппаратное ускорение. Для машин без монитора остаётся tglock-cli, которому WebView не нужен вовсе (issues #10, #17).
  • Windows-инсталлятор не подписан, macOS-сборка не нотарифицирована, и подписывать их не планируется: сертификат — ежегодный платёж, а проект бесплатный. Часть антивирусов будет реагировать на неподписанный установщик, который открывает локальный сокет и прописывается прокси-сервером — это тот же профиль, по которому ищут прокси-трояны. Вместо доверия предлагаются проверяемые пути: sha256 каждого артефакта публикуется GitHub на странице релиза, сборка идёт в GitHub Actions из публичного коммита с открытым логом, CLI собирается одной командой. Подробно — в разделе README про антивирус.
  • Работоспособность медиа зависит от конкретного DC аккаунта и доступности Telegram/Cloudflare у провайдера.
  • Соединение, открытое клиентом и молчащее дольше IO_TIMEOUT (10 секунд), закрывается. Для клиента, открывающего соединения про запас, это норма; счётчик «промолчали» показывает, как часто это происходит, — раньше такие соединения не попадали никуда.
  • Пулы заранее открытых WebSocket-соединений будут добавлены после измерения, что они не создают лишнюю нагрузку и не ухудшают стабильность.