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

320 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 на:
```text
/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-соединений будут добавлены после измерения,
что они не создают лишнюю нагрузку и не ухудшают стабильность.