mirror of
https://github.com/by-sonic/tglock.git
synced 2026-09-05 18:16:09 +03:00
fe9aad5ee9
`TransportEngine::connect` собирает подробный перечень попыток — какой адрес не
ответил, где истёк TLS, что вернул воркер, — и возвращает его в `Err`. Дальше
этот `Err` доходил до `serve`, где выбрасывался: `let _ = handle(...)`.
Увеличивался только счётчик.
Снаружи это выглядит как `туннелей 0 · сбоев 249 · падений маршрутов 395` без
единого слова о том, почему их ноль. Отличить «провайдер режет закреплённые
адреса» от «воркер отвечает отказом» нечем, хотя рядом есть журнал событий, в
который пишутся куда менее важные вещи.
Теперь причина попадает в журнал строкой вида:
Не поднялся туннель до DC2: 149.154.167.51 — не отвечает (таймаут TCP);
kws2.web.telegram.org — таймаут TLS/WebSocket
Дедупликация журнала делает её разовой: набор маршрутов у DC стабилен.
Там же вторая слепая зона. Домен воркера, не похожий на имя хоста, отбрасывался
молча: `https://name.workers.dev/` со схемой или слэшем не проходит
`valid_domain`, маршрут не появляется, и «воркер настроен» неотличимо от
«воркера нет». `set_worker_domains` теперь возвращает принятые и отвергнутые
по отдельности, отвергнутые называются вместе с причиной, принятые
подтверждаются.
Тексты отказов переведены на русский: их читает не разработчик, а человек,
который прислал скриншот и ждёт ответа.
Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
260 lines
20 KiB
Markdown
260 lines
20 KiB
Markdown
# 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. точный Telegram IP с `kwsN` или `kwsN-1` в TLS SNI и WebSocket Host;
|
||
3. дополнительный Telegram IP, если он определён;
|
||
4. системный DNS;
|
||
5. явно настроенный пользователем Cloudflare Worker.
|
||
|
||
Поддерживаются DC1–5 и media/CDN DC203. DC203 использует WebSocket-host DC2,
|
||
но подключается к собственному IP.
|
||
|
||
После ошибки маршрут получает exponential cooldown от 30 секунд до 30 минут.
|
||
Успешный маршрут становится первым для следующего соединения того же DC и
|
||
типа трафика.
|
||
|
||
## TLS policy
|
||
|
||
Проверка сертификатов и hostname никогда не отключается. При подключении к
|
||
заданному Telegram IP TCP destination отделён от URI host: TLS продолжает
|
||
проверять сертификат настоящего `kws*.web.telegram.org`.
|
||
|
||
Cloudflare Worker принимается только как пользовательская настройка. TGLock
|
||
не загружает и не скрывает публичные списки чужих доменов.
|
||
|
||
## Cloudflare Worker contract
|
||
|
||
Worker должен принимать WebSocket на:
|
||
|
||
```text
|
||
/apiws?dst=<telegram-ip>&dc=<dc-id>
|
||
```
|
||
|
||
и проксировать бинарные frames в TCP `<telegram-ip>:443`. Рекомендуется
|
||
добавить собственную авторизацию до стабильного релиза; поэтому Worker
|
||
остаётся расширенной опцией alpha-версии.
|
||
|
||
## 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 у провайдера.
|
||
- Пулы заранее открытых WebSocket-соединений будут добавлены после измерения,
|
||
что они не создают лишнюю нагрузку и не ухудшают стабильность.
|