* 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>
26 KiB
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— headlesstglock-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-соединений строится список маршрутов:
- сохранённый успешный маршрут;
- основной WebSocket-фронт DC (
web_fronts) с настоящим именем хоста в TLS SNI и WebSocket Host; - явно настроенные пользователем CF-прокси и Cloudflare Worker;
- запасные фронты DC;
- системный DNS;
- 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-соединений будут добавлены после измерения, что они не создают лишнюю нагрузку и не ухудшают стабильность.