mirror of
https://github.com/by-sonic/tglock.git
synced 2026-11-05 23:31:58 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ce9355243 | |||
| bc18509b9c | |||
| 098178ead4 | |||
| 7b48f0046b | |||
| 1d96609563 | |||
| 31bb442b0c | |||
| 33322ea3a3 |
@@ -79,7 +79,7 @@ jobs:
|
|||||||
name: Android 15 emulator smoke
|
name: Android 15 emulator smoke
|
||||||
needs: apk
|
needs: apk
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
timeout-minutes: 15
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
@@ -91,20 +91,25 @@ jobs:
|
|||||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||||
sudo udevadm control --reload-rules
|
sudo udevadm control --reload-rules
|
||||||
sudo udevadm trigger --name-match=kvm
|
sudo udevadm trigger --name-match=kvm
|
||||||
|
# The AOSP image has no Play services or Pixel Launcher. Their post-boot
|
||||||
|
# work starved system_server on the 4-vCPU runner and produced foreign ANR
|
||||||
|
# dialogs over the app. The app needs neither; WebView is still present.
|
||||||
- name: Launch and exercise the native app
|
- name: Launch and exercise the native app
|
||||||
uses: reactivecircus/android-emulator-runner@v2
|
uses: reactivecircus/android-emulator-runner@v2
|
||||||
with:
|
with:
|
||||||
api-level: 35
|
api-level: 35
|
||||||
arch: x86_64
|
arch: x86_64
|
||||||
target: google_apis
|
target: default
|
||||||
profile: pixel_2
|
profile: pixel_2
|
||||||
|
cores: 4
|
||||||
|
ram-size: 4096M
|
||||||
disable-animations: true
|
disable-animations: true
|
||||||
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
emulator-options: -no-window -no-snapshot -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||||
script: timeout 180 python3 scripts/android_smoke.py emulator-apk
|
script: timeout 420 python3 scripts/android_smoke.py emulator-apk
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: android-emulator-smoke-evidence
|
name: android-emulator-smoke-evidence-attempt-${{ github.run_attempt }}
|
||||||
path: android-smoke-evidence/
|
path: android-smoke-evidence/
|
||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|||||||
Generated
+1
-1
@@ -3822,7 +3822,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tglock"
|
name = "tglock"
|
||||||
version = "2.0.0-beta.16"
|
version = "2.0.0-beta.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"cipher",
|
"cipher",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tglock"
|
name = "tglock"
|
||||||
version = "2.0.0-beta.16"
|
version = "2.0.0-beta.19"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.88"
|
rust-version = "1.88"
|
||||||
description = "Telegram unblock via local WebSocket tunnel"
|
description = "Telegram unblock via local WebSocket tunnel"
|
||||||
@@ -38,7 +38,7 @@ path = "src/bin/cli.rs"
|
|||||||
required-features = ["cli"]
|
required-features = ["cli"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [], optional = true }
|
tauri = { version = "2", features = ["tray-icon"], optional = true }
|
||||||
tauri-plugin-opener = { version = "2", optional = true }
|
tauri-plugin-opener = { version = "2", optional = true }
|
||||||
clap = { version = "4", features = ["derive"], optional = true }
|
clap = { version = "4", features = ["derive"], optional = true }
|
||||||
# Файл конфигурации CLI. TOML, а не JSON, потому что файл правят руками и в нём
|
# Файл конфигурации CLI. TOML, а не JSON, потому что файл правят руками и в нём
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
<!-- SUPPORT-START -->
|
||||||
|
<p align="center">
|
||||||
|
<b>💛 Поддержать проект</b> · <b>USDT</b> (Tether) в сети <b>TRC20</b>:<br/>
|
||||||
|
<code>TEHHyPSeZ7t5vfVJmjtEBkqJdpfcrm1wqg</code>
|
||||||
|
</p>
|
||||||
|
<!-- SUPPORT-END -->
|
||||||
|
|
||||||
<!-- ════════════════════════ ROSEVPN — sponsor ════════════════════════ -->
|
<!-- ════════════════════════ ROSEVPN — sponsor ════════════════════════ -->
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -210,6 +217,8 @@ worker = ["my-name.workers.dev"]
|
|||||||
|
|
||||||
Полный пример со всеми полями и пояснениями — [`tglock.example.toml`](tglock.example.toml).
|
Полный пример со всеми полями и пояснениями — [`tglock.example.toml`](tglock.example.toml).
|
||||||
|
|
||||||
|
Журнал можно писать в файл, а не в stdout: `log_file` и `log_max_mb` в настройках или `--log-file` и `--log-max-mb`. Заполненный файл переименовывается в `<имя>.1`, больше двух файлов не бывает.
|
||||||
|
|
||||||
Приоритеты: значения по умолчанию → файл → флаги. Флаг всегда сильнее файла. Флаги-переключатели (`--lan`, `--allow-direct`, `--quiet`) могут только включать: их отсутствие означает «взять из файла», а не «выключить».
|
Приоритеты: значения по умолчанию → файл → флаги. Флаг всегда сильнее файла. Флаги-переключатели (`--lan`, `--allow-direct`, `--quiet`) могут только включать: их отсутствие означает «взять из файла», а не «выключить».
|
||||||
|
|
||||||
Опечатка в файле — ошибка при старте с перечислением допустимых полей, а не молча проигнорированная строка. Сервис, который из-за `porrt = 1443` слушает 1080, хуже сервиса, который не запустился.
|
Опечатка в файле — ошибка при старте с перечислением допустимых полей, а не молча проигнорированная строка. Сервис, который из-за `porrt = 1443` слушает 1080, хуже сервиса, который не запустился.
|
||||||
@@ -426,7 +435,7 @@ xattr -cr /Applications/TGLock.app
|
|||||||
|
|
||||||
Это реальный риск, и TGLock 2.0 к нему подготовлен настолько, насколько может.
|
Это реальный риск, и TGLock 2.0 к нему подготовлен настолько, насколько может.
|
||||||
|
|
||||||
Маршрут не один: пробуются закреплённые IP Telegram, дублёры `kwsN-1` и системный DNS. Пока жив хотя бы один — туннель поднимается.
|
Маршрут не один: пробуются закреплённые WebSocket-фронты Telegram, для медиа ещё и его отдельный кластер `kwsN-1`, затем системный DNS, WebSocket на порту 80 и родной MTProto TCP. Пока жив хотя бы один — туннель поднимается.
|
||||||
|
|
||||||
Если у твоего провайдера легли **все** маршруты, есть запасной выход — **свой Cloudflare Worker**. Тогда соединение идёт на твой домен `*.workers.dev`, а воркер доводит его до Telegram; блокировать его провайдеру придётся отдельно. Готовый скрипт и пошаговая установка: **[docs/CLOUDFLARE_WORKER.md](docs/CLOUDFLARE_WORKER.md)**. Нужен только аккаунт Cloudflare, бесплатного тарифа хватает, свой сервер и домен не нужны.
|
Если у твоего провайдера легли **все** маршруты, есть запасной выход — **свой Cloudflare Worker**. Тогда соединение идёт на твой домен `*.workers.dev`, а воркер доводит его до Telegram; блокировать его провайдеру придётся отдельно. Готовый скрипт и пошаговая установка: **[docs/CLOUDFLARE_WORKER.md](docs/CLOUDFLARE_WORKER.md)**. Нужен только аккаунт Cloudflare, бесплатного тарифа хватает, свой сервер и домен не нужны.
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -57,8 +57,13 @@ restart, and explicit force-stop/relaunch. It verifies the foreground service an
|
|||||||
performs a real SOCKS5 greeting through ADB port forwarding to the Rust listener.
|
performs a real SOCKS5 greeting through ADB port forwarding to the Rust listener.
|
||||||
If buttons are inaccessible after a bounded wait, CI explicitly reports the
|
If buttons are inaccessible after a bounded wait, CI explicitly reports the
|
||||||
lifecycle checks as skipped; a launch-only pass is not lifecycle evidence.
|
lifecycle checks as skipped; a launch-only pass is not lifecycle evidence.
|
||||||
`android-emulator-smoke-evidence` retains the exact result, UI dumps, service
|
`android-emulator-smoke-evidence-attempt-N` (one per run attempt) retains the
|
||||||
state and logcat. This does not test automatic low-memory eviction, battery
|
exact result, timed steps, UI dumps, service state, logcat and, on failure, a
|
||||||
|
screenshot. The emulator is the AOSP image without Play services; foreign
|
||||||
|
system ANR dialogs are answered with Wait and recorded, while a crash, ANR or
|
||||||
|
error dialog of TGLock itself fails the job. A tap the WebView dropped (the
|
||||||
|
button is unchanged ten seconds later) is repeated at most twice; the service
|
||||||
|
and listener effects are then asserted with the same strictness. This does not test automatic low-memory eviction, battery
|
||||||
behavior, Telegram connectivity, or a physical phone.
|
behavior, Telegram connectivity, or a physical phone.
|
||||||
|
|
||||||
Checked-in `gen/android` contains the native source and Gradle wrapper. Tauri's
|
Checked-in `gen/android` contains the native source and Gradle wrapper. Tauri's
|
||||||
|
|||||||
+42
-12
@@ -200,25 +200,55 @@ Ping приходит в читающую половину, а отвечает
|
|||||||
Для каждого DC и отдельно для media-соединений строится список маршрутов:
|
Для каждого DC и отдельно для media-соединений строится список маршрутов:
|
||||||
|
|
||||||
1. сохранённый успешный маршрут;
|
1. сохранённый успешный маршрут;
|
||||||
2. точный Telegram IP с `kwsN` или `kwsN-1` в TLS SNI и WebSocket Host;
|
2. основной WebSocket-фронт DC (`web_fronts`) с настоящим именем хоста в TLS SNI
|
||||||
3. явно настроенный пользователем Cloudflare Worker;
|
и WebSocket Host;
|
||||||
4. дополнительные Telegram IP и варианты хоста;
|
3. явно настроенные пользователем CF-прокси и Cloudflare Worker;
|
||||||
5. системный DNS (кроме CDN DC203).
|
4. запасные фронты DC;
|
||||||
|
5. системный DNS;
|
||||||
|
6. WebSocket без TLS на порту 80 адреса DC и родной MTProto TCP, только если всё
|
||||||
|
остальное не ответило.
|
||||||
|
|
||||||
Поддерживаются DC1–5 и CDN DC203. Для DC203 первым используется обычный MTProto
|
Адреса фронтов и родные адреса DC разные. `149.154.167.51`, `.91`, `149.154.175.50`,
|
||||||
TCP к закреплённому `91.105.192.100:443`; этот сервер может принимать MTProto,
|
`.100`, `149.154.171.5` и `91.105.192.100` говорят только на MTProto и на TLS не
|
||||||
не принимая TLS/WebSocket. Резерв через Worker соединяется с тем же IP.
|
отвечают, в том числе из сети без блокировок. WebSocket принимают `149.154.167.220`
|
||||||
WebSocket-попытки также сохраняют закреплённый адрес. DNS fallback на DC2 для DC203 запрещён:
|
и `.99` (DC2, DC4), `149.154.174.100` и `.200`, `149.154.175.209` (DC1, DC3),
|
||||||
успешный WebSocket handshake с другим DC не доставляет CDN-запрос в нужный
|
`149.154.170.100`, `.200` и `.96` (DC5).
|
||||||
датацентр. Worker также получает именно IP DC203.
|
|
||||||
|
Фронт выбирает дата-центр по имени хоста, а не по заголовку клиента, поэтому имя и
|
||||||
|
адрес нельзя менять местами. Какой 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 минут.
|
После ошибки маршрут получает exponential cooldown от 30 секунд до 30 минут.
|
||||||
Успешный маршрут становится первым для следующего соединения того же DC и
|
Успешный маршрут становится первым для следующего соединения того же DC и
|
||||||
типа трафика. Попытки запускаются с интервалом 250 мс, не больше трёх
|
типа трафика. Попытки запускаются с интервалом 250 мс, не больше трёх
|
||||||
одновременно на соединение; остальные отменяются после первого успеха.
|
одновременно на соединение; остальные отменяются после первого успеха.
|
||||||
Если все маршруты на паузе, возвращается причина и время до повторной
|
Если все маршруты на паузе, возвращается причина и время до повторной
|
||||||
попытки. Новое подключение клиента не обходит cooldown. Ошибка upstream
|
попытки. Новое подключение клиента не обходит cooldown. Маршрут тоже теряет
|
||||||
после handshake также снимает предпочтение маршрута и добавляет cooldown.
|
предпочтение и получает cooldown, если Telegram закрыл соединение с кодом ошибки,
|
||||||
|
туннель оборвался, не проработав 15 секунд, или клиент спросил, не дождался ответа
|
||||||
|
за 5 секунд и ушёл сам. Обрыв туннеля, который работал дольше и получал ответы,
|
||||||
|
маршрут не наказывает: это обычное дело, а пауза оставила бы весь DC без связи.
|
||||||
|
|
||||||
## TLS policy
|
## TLS policy
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Свой домен на Cloudflare (CF-прокси)
|
||||||
|
|
||||||
|
Ещё один резервный маршрут на случай, когда провайдер режет все обычные адреса
|
||||||
|
Telegram. В отличие от [Cloudflare Worker](CLOUDFLARE_WORKER.md) здесь нет
|
||||||
|
скрипта и лимитов Worker'ов: Cloudflare сам проксирует WebSocket до Telegram
|
||||||
|
по DNS-записям вашего домена. Нужен только домен (подойдёт любой, обычно это
|
||||||
|
150–300 ₽ в год).
|
||||||
|
|
||||||
|
Схема та же, что у CF-прокси в [Flowseal/tg-ws-proxy](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/RU/CfProxy.md)
|
||||||
|
(идея — [Nekogram/WSProxy](https://github.com/Nekogram/WSProxy)):
|
||||||
|
|
||||||
|
```
|
||||||
|
Telegram → TGLock → wss://kws2.ваш-домен/apiws → Cloudflare → 149.154.167.51 (DC2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Настройка
|
||||||
|
|
||||||
|
1. Добавьте домен в Cloudflare: купите его там или смените NS-серверы у
|
||||||
|
регистратора ([инструкция Cloudflare](https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/)).
|
||||||
|
2. **SSL/TLS → Overview** — режим **Flexible**. До Cloudflare соединение
|
||||||
|
остаётся HTTPS с проверкой сертификата, а от Cloudflare до Telegram идёт по
|
||||||
|
порту 80: у адресов Telegram нет сертификата на ваш домен.
|
||||||
|
3. **DNS → Records** — `A`-записи с включённым проксированием (оранжевое облако):
|
||||||
|
|
||||||
|
| Name | IPv4 |
|
||||||
|
|---|---|
|
||||||
|
| `kws1` | `149.154.175.50` |
|
||||||
|
| `kws2` | `149.154.167.51` |
|
||||||
|
| `kws3` | `149.154.175.100` |
|
||||||
|
| `kws4` | `149.154.167.91` |
|
||||||
|
| `kws5` | `149.154.171.5` |
|
||||||
|
| `kws203` | `91.105.192.100` |
|
||||||
|
|
||||||
|
4. Укажите в TGLock **только домен**, без `kws` и без `https://`:
|
||||||
|
- в приложении: **Настройки → Свой домен на Cloudflare** → `example.com`;
|
||||||
|
- в CLI: `tglock-cli --cf-proxy example.com` или `cf_proxy = ["example.com"]` в `tglock.toml`.
|
||||||
|
|
||||||
|
В журнале появится строка `CF-прокси в списке маршрутов: kws1.example.com …`.
|
||||||
|
Если строка начинается с «не похож на имя домена» — домен вписан со схемой
|
||||||
|
или со слэшем.
|
||||||
|
|
||||||
|
## Что учесть
|
||||||
|
|
||||||
|
- Если у провайдера заблокирована сама сеть Cloudflare, этот маршрут не
|
||||||
|
поможет, как и Worker.
|
||||||
|
- Маршрут участвует в общем переборе вместе с Worker'ом: его проверка
|
||||||
|
начинается сразу после основного адреса Telegram, успешный запоминается.
|
||||||
|
- Шифрование Telegram не меняется: Cloudflare видит только зашифрованный
|
||||||
|
поток MTProto, как и провайдер.
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
|
|
||||||
Если Telegram работает — **ничего настраивать не надо.** Поле Worker в настройках существует для случая, когда обычные маршруты умерли.
|
Если Telegram работает — **ничего настраивать не надо.** Поле Worker в настройках существует для случая, когда обычные маршруты умерли.
|
||||||
|
|
||||||
|
Если есть свой домен, проще [CF-прокси](CF_PROXY.md): тот же маршрут через Cloudflare, но без скрипта и без лимитов Worker'ов.
|
||||||
|
|
||||||
Обрати внимание: Worker не спасает, если Telegram недоступен *с самого воркера*. Он помогает, когда домены Telegram заблокированы **у тебя**, а датацентры Cloudflare до них дотягиваются.
|
Обрати внимание: Worker не спасает, если Telegram недоступен *с самого воркера*. Он помогает, когда домены Telegram заблокированы **у тебя**, а датацентры Cloudflare до них дотягиваются.
|
||||||
|
|
||||||
## Что понадобится
|
## Что понадобится
|
||||||
@@ -37,7 +39,9 @@
|
|||||||
|
|
||||||
### Проверка, что воркер жив
|
### Проверка, что воркер жив
|
||||||
|
|
||||||
Открой в браузере `https://tglock.имя.workers.dev/apiws`. Должно вернуться `expected a websocket upgrade` (HTTP 426). Это проверяет только публикацию скрипта. Соединение Worker → Telegram обычный GET **не проверяет**.
|
Открой в браузере `https://tglock.имя.workers.dev/apiws`. Должно вернуться `expected a websocket upgrade` (HTTP 426) и строка `TGLock Worker 2026-10-26` с датой версии скрипта. Это проверяет только публикацию скрипта. Соединение Worker → Telegram обычный GET **не проверяет**.
|
||||||
|
|
||||||
|
**Строки с версией нет — работает старый скрипт**, даже если в редакторе Cloudflare открыт новый текст. Редактор показывает черновик, а запросы обслуживает последняя *опубликованная* версия: после правки нужно нажать **Deploy**. Старый скрипт на новых compatibility date рвёт каждый туннель; в журнале Worker'а это предупреждение `This TransformStream is being used as a byte stream, but received an object of non-ArrayBuffer/ArrayBufferView type`, в журнале TGLock — `WebSocket закрыт без ответа Telegram`.
|
||||||
|
|
||||||
Если вернулось `not found` — проверь, что путь именно `/apiws`. Если ошибка про `cloudflare:sockets` — у воркера слишком старая дата совместимости, поставь в **Settings → Compatibility date** сегодняшнюю.
|
Если вернулось `not found` — проверь, что путь именно `/apiws`. Если ошибка про `cloudflare:sockets` — у воркера слишком старая дата совместимости, поставь в **Settings → Compatibility date** сегодняшнюю.
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,26 @@ authenticate the responding server, prove its DC identity, log into an account,
|
|||||||
or verify message sending, media downloads or Android lifecycle. In particular,
|
or verify message sending, media downloads or Android lifecycle. In particular,
|
||||||
`requestedDc` describes the request, not an independently confirmed backend.
|
`requestedDc` describes the request, not an independently confirmed backend.
|
||||||
|
|
||||||
|
## Confirm which data centre answers
|
||||||
|
|
||||||
|
`--identity` continues the handshake with `req_DH_params`. The DC number sealed
|
||||||
|
inside it is checked by the server against itself, and a mismatch is answered
|
||||||
|
with transport error `-444`, so this does prove the backend. `resPQ` alone proves
|
||||||
|
nothing here: every server answers it for any DC in the header. The handshake
|
||||||
|
stops before an authorization key exists. CDN DC203 uses its own RSA keys, so
|
||||||
|
the report says `identity: "skipped: CDN keys"` for it.
|
||||||
|
|
||||||
|
`--batched` implies `--identity` and sends `req_DH_params` glued behind another
|
||||||
|
packet in one write, the way real clients batch requests. A WebSocket frontend
|
||||||
|
reads one packet per frame, so a proxy that forwards the write as a single frame
|
||||||
|
times out here while a lone request still passes. Run it before every release:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
for dc in 1 2 -2 3 4 -4 5 203; do
|
||||||
|
node scripts/probe_proxy.mjs --port 18080 --secret-file /private/path/tglock-secret --dc $dc --batched
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
## Isolate CDN transport failures
|
## Isolate CDN transport failures
|
||||||
|
|
||||||
To distinguish an unavailable CDN WebSocket endpoint from an unavailable CDN
|
To distinguish an unavailable CDN WebSocket endpoint from an unavailable CDN
|
||||||
|
|||||||
@@ -77,6 +77,21 @@ chmod 755 /etc/init.d/tglock
|
|||||||
logread -e tglock
|
logread -e tglock
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Состояние меняется часто, и stdout сервиса уходит в системный журнал. Чтобы TGLock
|
||||||
|
его не забивал, задайте отдельный файл в `/etc/tglock/tglock.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# /tmp лежит в оперативной памяти: журнал не изнашивает flash и не переживает
|
||||||
|
# перезагрузку. Заполненный файл переименовывается в tglock.log.1, больше двух
|
||||||
|
# файлов на диске не бывает.
|
||||||
|
log_file = "/tmp/tglock.log"
|
||||||
|
log_max_mb = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Смотреть его: `tail -f /tmp/tglock.log`. То же самое флагами: `--log-file`,
|
||||||
|
`--log-max-mb`. Ошибки запуска по-прежнему попадают в `logread`. Если журнал не
|
||||||
|
нужен совсем, есть `quiet = true`.
|
||||||
|
|
||||||
Обновление: остановите сервис, замените бинарь после проверки контрольной суммы,
|
Обновление: остановите сервис, замените бинарь после проверки контрольной суммы,
|
||||||
сохраните `/etc/tglock`, запустите сервис снова. Для отмены автозапуска используйте
|
сохраните `/etc/tglock`, запустите сервис снова. Для отмены автозапуска используйте
|
||||||
`/etc/init.d/tglock stop` и `/etc/init.d/tglock disable`.
|
`/etc/init.d/tglock stop` и `/etc/init.d/tglock disable`.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "tglock-ui",
|
"name": "tglock-ui",
|
||||||
"version": "2.0.0-beta.16",
|
"version": "2.0.0-beta.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tglock-ui",
|
"name": "tglock-ui",
|
||||||
"version": "2.0.0-beta.16",
|
"version": "2.0.0-beta.19",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.8.0"
|
"@tauri-apps/api": "^2.8.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tglock-ui",
|
"name": "tglock-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.0-beta.16",
|
"version": "2.0.0-beta.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js",
|
"test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js",
|
||||||
|
|||||||
+161
-32
@@ -14,23 +14,103 @@ PACKAGE = "com.bysonic.tglock"
|
|||||||
COMPONENT = f"{PACKAGE}/.MainActivity"
|
COMPONENT = f"{PACKAGE}/.MainActivity"
|
||||||
EVIDENCE = Path("android-smoke-evidence")
|
EVIDENCE = Path("android-smoke-evidence")
|
||||||
EVIDENCE.mkdir(exist_ok=True)
|
EVIDENCE.mkdir(exist_ok=True)
|
||||||
RESULT = {"launch": "not_run", "lifecycle": "not_run"}
|
RESULT = {"launch": "not_run", "lifecycle": "not_run", "foreign_dialogs": [], "taps": {}}
|
||||||
|
STARTED = time.monotonic()
|
||||||
|
|
||||||
|
# Budgets measured on the GitHub-hosted emulator, not on a phone. With the
|
||||||
|
# software GPU the WebView paints about one frame per second, and dumpsys
|
||||||
|
# itself can take seconds while system_server catches up after boot.
|
||||||
|
SETTLE_SECONDS = 120
|
||||||
|
VISIBLE_SECONDS = 60
|
||||||
|
EFFECT_SECONDS = 45
|
||||||
|
# A click that reaches the handler re-renders the button as "Подождите…" or
|
||||||
|
# the opposite action within a frame. Only a button left unchanged this long
|
||||||
|
# was never clicked.
|
||||||
|
RETAP_AFTER_SECONDS = 10
|
||||||
|
MAX_TAPS = 3
|
||||||
|
SYSTEM_DIALOG = re.compile(r"(Application Not Responding|Application Error): ([\w.:]+)")
|
||||||
|
|
||||||
|
|
||||||
def adb(*args, check=True, timeout=20):
|
def adb(*args, check=True, timeout=30):
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
["adb", *args], check=check, capture_output=True, text=True,
|
["adb", *args], check=check, capture_output=True, text=True,
|
||||||
encoding="utf-8", errors="replace", timeout=timeout,
|
encoding="utf-8", errors="replace", timeout=timeout,
|
||||||
).stdout.strip()
|
).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
def wait_for(description, predicate, seconds=20):
|
def step(message):
|
||||||
|
# The same marker in stdout and logcat lines up the job log with evidence.
|
||||||
|
line = f"[{time.monotonic() - STARTED:6.1f}s] {message}"
|
||||||
|
print(line, flush=True)
|
||||||
|
with open(EVIDENCE / "steps.txt", "a", encoding="utf-8") as steps:
|
||||||
|
steps.write(line + "\n")
|
||||||
|
adb("shell", "log", "-t", "tglock-smoke", json.dumps(message), check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def system_dialog():
|
||||||
|
try:
|
||||||
|
windows = adb("shell", "dumpsys", "window", "windows", check=False)
|
||||||
|
except subprocess.SubprocessError:
|
||||||
|
return None
|
||||||
|
match = SYSTEM_DIALOG.search(windows)
|
||||||
|
return (match.group(1), match.group(2)) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def clear_foreign_dialogs():
|
||||||
|
# A loaded emulator regularly declares its own launcher or system apps
|
||||||
|
# unresponsive, and that dialog covers the WebView. Ours is a failure.
|
||||||
|
dialog = system_dialog()
|
||||||
|
if dialog is None:
|
||||||
|
return
|
||||||
|
kind, process = dialog
|
||||||
|
if process.split(":")[0] == PACKAGE:
|
||||||
|
raise AssertionError(f"{kind} dialog for {process}")
|
||||||
|
button = "android:id/aerr_wait" if kind == "Application Not Responding" else "android:id/aerr_close"
|
||||||
|
try:
|
||||||
|
point = node_center(ui_dump(f"dialog-{len(RESULT['foreign_dialogs'])}"),
|
||||||
|
lambda node: node.get("resource-id") == button)
|
||||||
|
except (subprocess.SubprocessError, ET.ParseError):
|
||||||
|
point = None
|
||||||
|
if point is not None:
|
||||||
|
adb("shell", "input", "tap", str(point[0]), str(point[1]))
|
||||||
|
outcome = f"tapped {button}" if point else "button not dumped yet"
|
||||||
|
RESULT["foreign_dialogs"].append(f"{kind}: {process}, {outcome}")
|
||||||
|
step(f"foreign dialog {kind}: {process}, {outcome}")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for(description, predicate, seconds=EFFECT_SECONDS):
|
||||||
deadline = time.monotonic() + seconds
|
deadline = time.monotonic() + seconds
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
|
clear_foreign_dialogs()
|
||||||
if predicate():
|
if predicate():
|
||||||
|
step(f"ok: {description}")
|
||||||
return
|
return
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
raise AssertionError(f"Timed out: {description}")
|
raise AssertionError(f"Timed out after {seconds}s: {description}")
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_idle_share(interval=3):
|
||||||
|
def sample():
|
||||||
|
values = list(map(int, adb("shell", "head", "-1", "/proc/stat").split()[1:]))
|
||||||
|
return values[3] + values[4], sum(values)
|
||||||
|
idle1, total1 = sample()
|
||||||
|
time.sleep(interval)
|
||||||
|
idle2, total2 = sample()
|
||||||
|
return (idle2 - idle1) / max(1, total2 - total1)
|
||||||
|
|
||||||
|
|
||||||
|
def settle():
|
||||||
|
# boot_completed fires long before package, launcher and dexopt work
|
||||||
|
# drains. Start the app on a quiet device instead of racing that work.
|
||||||
|
deadline = time.monotonic() + SETTLE_SECONDS
|
||||||
|
quiet = 0
|
||||||
|
idle = 0.0
|
||||||
|
while time.monotonic() < deadline and quiet < 2:
|
||||||
|
clear_foreign_dialogs()
|
||||||
|
idle = cpu_idle_share()
|
||||||
|
quiet = quiet + 1 if idle >= 0.6 else 0
|
||||||
|
RESULT["settle"] = f"{'quiet' if quiet >= 2 else 'still busy'} at {time.monotonic() - STARTED:.0f}s, {idle:.0%} idle"
|
||||||
|
step(f"settle: {RESULT['settle']}")
|
||||||
|
|
||||||
|
|
||||||
def ui_dump(label):
|
def ui_dump(label):
|
||||||
@@ -47,12 +127,9 @@ def ui_dump(label):
|
|||||||
return ET.fromstring(text)
|
return ET.fromstring(text)
|
||||||
|
|
||||||
|
|
||||||
def label_node(tree, label):
|
def node_center(tree, matches):
|
||||||
for node in tree.iter("node"):
|
for node in tree.iter("node"):
|
||||||
# Android aggregates the decorative arrow into the button's accessible
|
if matches(node):
|
||||||
# text (observed in the API35 CI dump). Accept only that exact suffix.
|
|
||||||
names = (node.get("text", "").strip(), node.get("content-desc", "").strip())
|
|
||||||
if any(name in (label, f"{label} →") for name in names):
|
|
||||||
bounds = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", ""))
|
bounds = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", ""))
|
||||||
if bounds:
|
if bounds:
|
||||||
x1, y1, x2, y2 = map(int, bounds.groups())
|
x1, y1, x2, y2 = map(int, bounds.groups())
|
||||||
@@ -61,17 +138,53 @@ def label_node(tree, label):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def tap_label(label, stage):
|
def label_node(tree, label):
|
||||||
for attempt in range(5):
|
# Android aggregates the decorative arrow into the button's accessible
|
||||||
try:
|
# text (observed in the API35 CI dump). Accept only that exact suffix.
|
||||||
point = label_node(ui_dump(f"{stage}-{attempt}"), label)
|
return node_center(tree, lambda node: any(
|
||||||
except (subprocess.SubprocessError, ET.ParseError) as error:
|
name in (label, f"{label} →")
|
||||||
(EVIDENCE / f"{stage}-{attempt}.error.txt").write_text(str(error), encoding="utf-8")
|
for name in (node.get("text", "").strip(), node.get("content-desc", "").strip())
|
||||||
point = None
|
))
|
||||||
if point is not None:
|
|
||||||
adb("shell", "input", "tap", str(point[0]), str(point[1]))
|
|
||||||
|
def find_label(label, stage):
|
||||||
|
clear_foreign_dialogs()
|
||||||
|
try:
|
||||||
|
return label_node(ui_dump(stage), label)
|
||||||
|
except (subprocess.SubprocessError, ET.ParseError) as error:
|
||||||
|
(EVIDENCE / f"{stage}.error.txt").write_text(str(error), encoding="utf-8")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def press(label, stage):
|
||||||
|
"""Tap a visible WebView action until the page shows it was clicked.
|
||||||
|
|
||||||
|
The page re-renders every second. At one frame per second the touch down
|
||||||
|
and up can straddle that re-render, and Chromium then drops the click: the
|
||||||
|
button stays exactly as it was and the handler never runs. Callers still
|
||||||
|
assert the service and listener effect strictly afterwards.
|
||||||
|
"""
|
||||||
|
deadline = time.monotonic() + VISIBLE_SECONDS
|
||||||
|
taps = 0
|
||||||
|
last_tap = 0.0
|
||||||
|
attempt = 0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
point = find_label(label, f"{stage}-{attempt}")
|
||||||
|
attempt += 1
|
||||||
|
if point is None and taps:
|
||||||
|
RESULT["taps"][stage] = taps
|
||||||
|
step(f"{label}: clicked after {taps} tap(s)")
|
||||||
return
|
return
|
||||||
|
if point is not None and (not taps or time.monotonic() - last_tap >= RETAP_AFTER_SECONDS):
|
||||||
|
if taps == MAX_TAPS:
|
||||||
|
raise AssertionError(f"{label} stayed unchanged after {taps} taps")
|
||||||
|
adb("shell", "input", "tap", str(point[0]), str(point[1]))
|
||||||
|
taps += 1
|
||||||
|
last_tap = time.monotonic()
|
||||||
|
step(f"{label}: tap {taps} at {point}")
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
if taps:
|
||||||
|
raise AssertionError(f"{label} stayed unchanged {VISIBLE_SECONDS}s after {taps} tap(s)")
|
||||||
raise AssertionError(f"Visible action not found: {label}")
|
raise AssertionError(f"Visible action not found: {label}")
|
||||||
|
|
||||||
|
|
||||||
@@ -85,7 +198,7 @@ def proxy_ready():
|
|||||||
# ADB forwards to emulator loopback. A real SOCKS5 greeting proves the
|
# ADB forwards to emulator loopback. A real SOCKS5 greeting proves the
|
||||||
# Rust listener is serving, beyond just a notification being displayed.
|
# Rust listener is serving, beyond just a notification being displayed.
|
||||||
try:
|
try:
|
||||||
with socket.create_connection(("127.0.0.1", 11080), timeout=1) as peer:
|
with socket.create_connection(("127.0.0.1", 11080), timeout=3) as peer:
|
||||||
peer.sendall(bytes([5, 1, 0]))
|
peer.sendall(bytes([5, 1, 0]))
|
||||||
return peer.recv(2) == bytes([5, 0])
|
return peer.recv(2) == bytes([5, 0])
|
||||||
except (OSError, TimeoutError):
|
except (OSError, TimeoutError):
|
||||||
@@ -93,7 +206,7 @@ def proxy_ready():
|
|||||||
|
|
||||||
|
|
||||||
def launch():
|
def launch():
|
||||||
adb("shell", "am", "start", "-W", "-n", COMPONENT)
|
adb("shell", "am", "start", "-W", "-n", COMPONENT, timeout=60)
|
||||||
wait_for("Activity resumed", lambda: any(
|
wait_for("Activity resumed", lambda: any(
|
||||||
"ResumedActivity" in line and PACKAGE in line
|
"ResumedActivity" in line and PACKAGE in line
|
||||||
for line in adb("shell", "dumpsys", "activity", "activities").splitlines()
|
for line in adb("shell", "dumpsys", "activity", "activities").splitlines()
|
||||||
@@ -104,17 +217,20 @@ def launch():
|
|||||||
def main():
|
def main():
|
||||||
apks = sorted(Path(sys.argv[1]).rglob("*.apk"))
|
apks = sorted(Path(sys.argv[1]).rglob("*.apk"))
|
||||||
assert len(apks) == 1, f"Expected one x86_64 APK, got {len(apks)}"
|
assert len(apks) == 1, f"Expected one x86_64 APK, got {len(apks)}"
|
||||||
adb("install", "-r", str(apks[0]), timeout=60)
|
adb("install", "-r", str(apks[0]), timeout=120)
|
||||||
adb("shell", "pm", "grant", PACKAGE, "android.permission.POST_NOTIFICATIONS")
|
adb("shell", "pm", "grant", PACKAGE, "android.permission.POST_NOTIFICATIONS")
|
||||||
adb("logcat", "-c")
|
|
||||||
adb("forward", "tcp:11080", "tcp:1080")
|
adb("forward", "tcp:11080", "tcp:1080")
|
||||||
|
settle()
|
||||||
|
adb("logcat", "-c")
|
||||||
|
step("launch")
|
||||||
launch()
|
launch()
|
||||||
RESULT["launch"] = "passed"
|
RESULT["launch"] = "passed"
|
||||||
deadline = time.monotonic() + 20
|
deadline = time.monotonic() + VISIBLE_SECONDS
|
||||||
tree = None
|
tree = None
|
||||||
start_point = None
|
start_point = None
|
||||||
attempt = 0
|
attempt = 0
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
|
clear_foreign_dialogs()
|
||||||
try:
|
try:
|
||||||
tree = ui_dump(f"launched-{attempt}")
|
tree = ui_dump(f"launched-{attempt}")
|
||||||
start_point = label_node(tree, "Включить защиту")
|
start_point = label_node(tree, "Включить защиту")
|
||||||
@@ -132,23 +248,25 @@ def main():
|
|||||||
for node in tree.iter("node")
|
for node in tree.iter("node")
|
||||||
), "App Activity is resumed but its WebView is absent"
|
), "App Activity is resumed but its WebView is absent"
|
||||||
if start_point is None:
|
if start_point is None:
|
||||||
RESULT["lifecycle"] = "skipped: WebView Start not accessible after 20s"
|
RESULT["lifecycle"] = f"skipped: WebView Start not accessible after {VISIBLE_SECONDS}s"
|
||||||
print("::warning::Activity launch passed; lifecycle skipped because UIAutomator did not expose Start after 20s")
|
print(f"::warning::Activity launch passed; lifecycle skipped because UIAutomator did not expose Start after {VISIBLE_SECONDS}s")
|
||||||
return
|
return
|
||||||
RESULT["lifecycle"] = "failed: lifecycle assertions incomplete"
|
RESULT["lifecycle"] = "failed: lifecycle assertions incomplete"
|
||||||
tap_label("Включить защиту", "before-start")
|
press("Включить защиту", "before-start")
|
||||||
wait_for("foreground service after Start", service_running)
|
wait_for("foreground service after Start", service_running)
|
||||||
wait_for("Rust SOCKS listener after Start", proxy_ready)
|
wait_for("Rust SOCKS listener after Start", proxy_ready)
|
||||||
adb("shell", "input", "keyevent", "KEYCODE_HOME")
|
adb("shell", "input", "keyevent", "KEYCODE_HOME")
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
|
clear_foreign_dialogs()
|
||||||
assert service_running() and proxy_ready(), "Proxy stopped after backgrounding"
|
assert service_running() and proxy_ready(), "Proxy stopped after backgrounding"
|
||||||
|
step("ok: still serving after 5s in background")
|
||||||
launch()
|
launch()
|
||||||
tap_label("Выключить", "before-stop")
|
press("Выключить", "before-stop")
|
||||||
wait_for("foreground service after Stop", lambda: not service_running())
|
wait_for("foreground service after Stop", lambda: not service_running())
|
||||||
wait_for("Rust listener after Stop", lambda: not proxy_ready())
|
wait_for("Rust listener after Stop", lambda: not proxy_ready())
|
||||||
# Explicit restart and user force-stop, then relaunch. This tests the
|
# Explicit restart and user force-stop, then relaunch. This tests the
|
||||||
# user-stop contract, not Android's automatic low-memory process eviction.
|
# user-stop contract, not Android's automatic low-memory process eviction.
|
||||||
tap_label("Включить защиту", "before-restart")
|
press("Включить защиту", "before-restart")
|
||||||
wait_for("Rust listener after restart", proxy_ready)
|
wait_for("Rust listener after restart", proxy_ready)
|
||||||
adb("shell", "am", "force-stop", PACKAGE)
|
adb("shell", "am", "force-stop", PACKAGE)
|
||||||
launch()
|
launch()
|
||||||
@@ -160,10 +278,21 @@ try:
|
|||||||
main()
|
main()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
RESULT["failure"] = str(error)
|
RESULT["failure"] = str(error)
|
||||||
|
for name, args in (("failure-windows.txt", ("dumpsys", "window", "windows")),
|
||||||
|
("failure-activities.txt", ("dumpsys", "activity", "activities"))):
|
||||||
|
try:
|
||||||
|
(EVIDENCE / name).write_text(adb("shell", *args, check=False), encoding="utf-8")
|
||||||
|
except subprocess.SubprocessError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
(EVIDENCE / "failure.png").write_bytes(subprocess.run(
|
||||||
|
["adb", "exec-out", "screencap", "-p"], capture_output=True, timeout=30).stdout)
|
||||||
|
except subprocess.SubprocessError:
|
||||||
|
pass
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
logs = adb("logcat", "-d", "-v", "threadtime")
|
logs = adb("logcat", "-d", "-v", "threadtime", timeout=60)
|
||||||
(EVIDENCE / "logcat.txt").write_text(logs, encoding="utf-8")
|
(EVIDENCE / "logcat.txt").write_text(logs, encoding="utf-8")
|
||||||
crashes = adb("logcat", "-b", "crash", "-d")
|
crashes = adb("logcat", "-b", "crash", "-d")
|
||||||
(EVIDENCE / "crash.txt").write_text(crashes, encoding="utf-8")
|
(EVIDENCE / "crash.txt").write_text(crashes, encoding="utf-8")
|
||||||
@@ -171,9 +300,9 @@ finally:
|
|||||||
RESULT["launch"] = "failed: application crash, ANR, or missing process"
|
RESULT["launch"] = "failed: application crash, ANR, or missing process"
|
||||||
raise AssertionError("Application did not remain healthy")
|
raise AssertionError("Application did not remain healthy")
|
||||||
finally:
|
finally:
|
||||||
(EVIDENCE / "result.json").write_text(json.dumps(RESULT, indent=2), encoding="utf-8")
|
(EVIDENCE / "result.json").write_text(json.dumps(RESULT, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||||
if summary:
|
if summary:
|
||||||
with open(summary, "a", encoding="utf-8") as report:
|
with open(summary, "a", encoding="utf-8") as report:
|
||||||
report.write("\nAndroid emulator smoke: " + json.dumps(RESULT) + "\n")
|
report.write("\nAndroid emulator smoke: " + json.dumps(RESULT, ensure_ascii=False) + "\n")
|
||||||
print(json.dumps(RESULT))
|
print(json.dumps(RESULT, ensure_ascii=False))
|
||||||
|
|||||||
+143
-10
@@ -4,28 +4,34 @@
|
|||||||
// https://core.telegram.org/mtproto/auth_key
|
// https://core.telegram.org/mtproto/auth_key
|
||||||
// https://core.telegram.org/mtproto/mtproto-transports#transport-obfuscation
|
// https://core.telegram.org/mtproto/mtproto-transports#transport-obfuscation
|
||||||
// https://core.telegram.org/mtproto/description#unencrypted-message
|
// https://core.telegram.org/mtproto/description#unencrypted-message
|
||||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
import { createCipheriv, createDecipheriv, createHash, createPublicKey, randomBytes } from "node:crypto";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { createConnection } from "node:net";
|
import { createConnection } from "node:net";
|
||||||
import { setTimeout as sleep } from "node:timers/promises";
|
import { setTimeout as sleep } from "node:timers/promises";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
const MAX_RESPONSE = 2 * 1024 * 1024;
|
const MAX_RESPONSE = 2 * 1024 * 1024;
|
||||||
const HELP = `Usage: node scripts/probe_proxy.mjs --secret-file PATH [--port 1080] [--dc 2] [--timeout-ms 15000] [--fragment-size 0]
|
const HELP = `Usage: node scripts/probe_proxy.mjs --secret-file PATH [--port 1080] [--dc 2] [--timeout-ms 15000] [--fragment-size 0] [--identity] [--batched]
|
||||||
Or: node scripts/probe_proxy.mjs --direct-cdn --dc 203
|
Or: node scripts/probe_proxy.mjs --direct-cdn --dc 203
|
||||||
DC: 1..5 or 203; negative values request the media route.
|
DC: 1..5 or 203; negative values request the media route.
|
||||||
Default connects only to 127.0.0.1. --direct-cdn explicitly probes only 91.105.192.100:443 without a secret.
|
Default connects only to 127.0.0.1. --direct-cdn explicitly probes only 91.105.192.100:443 without a secret.
|
||||||
Reads the local proxy secret from the explicit file; never prints it.
|
Reads the local proxy secret from the explicit file; never prints it.
|
||||||
Sends one req_pq_multi and checks resPQ/nonce. No account, API credentials, login or auth key is created.
|
Sends one req_pq_multi and checks resPQ/nonce. No account, API credentials, login or auth key is created.
|
||||||
This proves a protocol response, not DC identity, account operation or media downloads.
|
Without --identity this proves a protocol response, not DC identity, account operation or media downloads.
|
||||||
|
--identity continues with req_DH_params: the server checks the DC number sealed inside it against itself
|
||||||
|
and answers -444 on a mismatch, so this proves WHICH data centre is behind the route. The key is never finished.
|
||||||
|
--batched sends req_DH_params glued behind another packet in one write, the way real clients batch requests.
|
||||||
|
A WebSocket frontend reads one packet per frame, so a proxy that does not split the stream times out here.
|
||||||
Use --self-test for offline parser checks; --fragment-size 7 sends small writes with 2ms gaps.`;
|
Use --self-test for offline parser checks; --fragment-size 7 sends small writes with 2ms gaps.`;
|
||||||
|
|
||||||
function options(args) {
|
function options(args) {
|
||||||
const result = { port: 1080, dc: 2, timeoutMs: 15000, fragmentSize: 0 };
|
const result = { port: 1080, dc: 2, timeoutMs: 15000, fragmentSize: 0, identity: false, batched: false };
|
||||||
const numeric = { "--port": "port", "--dc": "dc", "--timeout-ms": "timeoutMs", "--fragment-size": "fragmentSize" };
|
const numeric = { "--port": "port", "--dc": "dc", "--timeout-ms": "timeoutMs", "--fragment-size": "fragmentSize" };
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
const name = args[i];
|
const name = args[i];
|
||||||
if (name === "--direct-cdn") { result.directCdn = true; continue; }
|
if (name === "--direct-cdn") { result.directCdn = true; continue; }
|
||||||
|
if (name === "--identity") { result.identity = true; continue; }
|
||||||
|
if (name === "--batched") { result.identity = result.batched = true; continue; }
|
||||||
const value = args[++i];
|
const value = args[++i];
|
||||||
if (value === undefined) throw new Error(`Missing value for ${name}`);
|
if (value === undefined) throw new Error(`Missing value for ${name}`);
|
||||||
if (name === "--secret-file") result.secretFile = value;
|
if (name === "--secret-file") result.secretFile = value;
|
||||||
@@ -41,6 +47,110 @@ function options(args) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Production key from Telegram Desktop (mtproto_dc_options.cpp). CDN data centres
|
||||||
|
// use their own keys, so a CDN simply does not list this fingerprint.
|
||||||
|
const TELEGRAM_RSA_KEY = `-----BEGIN RSA PUBLIC KEY-----
|
||||||
|
MIIBCgKCAQEA6LszBcC1LGzyr992NzE0ieY+BSaOW622Aa9Bd4ZHLl+TuFQ4lo4g
|
||||||
|
5nKaMBwK/BIb9xUfg0Q29/2mgIR6Zr9krM7HjuIcCzFvDtr+L0GQjae9H0pRB2OO
|
||||||
|
62cECs5HKhT5DZ98K33vmWiLowc621dQuwKWSQKjWf50XYFw42h21P2KXUGyp2y/
|
||||||
|
+aEyZ+uVgLLQbRA1dEjSDZ2iGRy12Mk5gpYc397aYp438fsJoHIgJ2lgMv5h7WY9
|
||||||
|
t6N/byY9Nw9p21Og3AoXSL2q/2IJ1WRUhebgAdGVMlV1fkuOQoEzR7EdpqtQD9Cs
|
||||||
|
5+bfo3Nhmcyvk5ftB0WkJ9z6bNZ7yxrP8wIDAQAB
|
||||||
|
-----END RSA PUBLIC KEY-----`;
|
||||||
|
|
||||||
|
const tlBytes = (value) => {
|
||||||
|
const prefixed = Buffer.concat([Buffer.from([value.length]), value]);
|
||||||
|
const long = Buffer.concat([Buffer.from([254, value.length & 255, value.length >> 8, 0]), value]);
|
||||||
|
const raw = value.length < 254 ? prefixed : long;
|
||||||
|
return Buffer.concat([raw, Buffer.alloc((4 - raw.length % 4) % 4)]);
|
||||||
|
};
|
||||||
|
const toBigInt = (bytes) => BigInt(`0x${bytes.toString("hex")}`);
|
||||||
|
const rsaKey = () => {
|
||||||
|
const { n, e } = createPublicKey({ key: TELEGRAM_RSA_KEY, format: "pem", type: "pkcs1" }).export({ format: "jwk" });
|
||||||
|
const modulus = Buffer.from(n, "base64url");
|
||||||
|
const exponent = Buffer.from(e, "base64url");
|
||||||
|
const fingerprint = createHash("sha1").update(tlBytes(modulus)).update(tlBytes(exponent)).digest().subarray(12);
|
||||||
|
return { modulus: toBigInt(modulus), exponent: toBigInt(exponent), fingerprint };
|
||||||
|
};
|
||||||
|
function modPow(base, power, modulus) {
|
||||||
|
let result = 1n;
|
||||||
|
for (let b = base % modulus, e = power; e > 0n; e >>= 1n, b = b * b % modulus) if (e & 1n) result = result * b % modulus;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function factorize(pq) {
|
||||||
|
const gcd = (a, b) => { while (b) [a, b] = [b, a % b]; return a; };
|
||||||
|
for (let c = 1n; ; c++) {
|
||||||
|
const step = (x) => (x * x + c) % pq;
|
||||||
|
let slow = 2n, fast = 2n, divisor = 1n;
|
||||||
|
while (divisor === 1n) { slow = step(slow); fast = step(step(fast)); divisor = gcd(slow > fast ? slow - fast : fast - slow, pq); }
|
||||||
|
if (divisor !== pq) { const other = pq / divisor; return divisor < other ? [divisor, other] : [other, divisor]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function aesIgeEncrypt(data, key) {
|
||||||
|
const output = Buffer.alloc(data.length);
|
||||||
|
let previousCipher = Buffer.alloc(16), previousPlain = Buffer.alloc(16);
|
||||||
|
for (let offset = 0; offset < data.length; offset += 16) {
|
||||||
|
const block = data.subarray(offset, offset + 16), mixed = Buffer.alloc(16);
|
||||||
|
for (let i = 0; i < 16; i++) mixed[i] = block[i] ^ previousCipher[i];
|
||||||
|
const encrypted = createCipheriv("aes-256-ecb", key, null).setAutoPadding(false).update(mixed);
|
||||||
|
for (let i = 0; i < 16; i++) output[offset + i] = encrypted[i] ^ previousPlain[i];
|
||||||
|
previousCipher = output.subarray(offset, offset + 16);
|
||||||
|
previousPlain = block;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
// RSA_PAD from https://core.telegram.org/mtproto/auth_key#41-rsa-paddata-server-public-key-mentioned-above-is-implemented-as-follows
|
||||||
|
function rsaPad(data, key) {
|
||||||
|
const padded = Buffer.concat([data, randomBytes(192 - data.length)]);
|
||||||
|
for (;;) {
|
||||||
|
const tempKey = randomBytes(32);
|
||||||
|
const hashed = Buffer.concat([Buffer.from(padded).reverse(), createHash("sha256").update(tempKey).update(padded).digest()]);
|
||||||
|
const encrypted = aesIgeEncrypt(hashed, tempKey);
|
||||||
|
const mask = createHash("sha256").update(encrypted).digest();
|
||||||
|
const masked = Buffer.from(tempKey.map((byte, index) => byte ^ mask[index]));
|
||||||
|
const value = toBigInt(Buffer.concat([masked, encrypted]));
|
||||||
|
if (value < key.modulus) return Buffer.from(modPow(value, key.exponent, key.modulus).toString(16).padStart(512, "0"), "hex");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function unencryptedPacket(body) {
|
||||||
|
const message = Buffer.alloc(20 + body.length);
|
||||||
|
const now = BigInt(Date.now());
|
||||||
|
message.writeBigUInt64LE(((now / 1000n << 32n) | ((now % 1000n) * (1n << 32n) / 1000n)) & ~3n, 8);
|
||||||
|
message.writeUInt32LE(body.length, 16);
|
||||||
|
body.copy(message, 20);
|
||||||
|
const padded = Buffer.concat([message, randomBytes(7)]);
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32LE(padded.length);
|
||||||
|
return Buffer.concat([length, padded]);
|
||||||
|
}
|
||||||
|
const reqPqBody = (nonce) => {
|
||||||
|
const constructor = Buffer.alloc(4);
|
||||||
|
constructor.writeUInt32LE(0xbe7e8ef1);
|
||||||
|
return Buffer.concat([constructor, nonce]);
|
||||||
|
};
|
||||||
|
function reqDhParams(resPQ, nonce, dc, key) {
|
||||||
|
const [p, q] = factorize(toBigInt(resPQ.pq));
|
||||||
|
const bigEndian = (value) => Buffer.from(value.toString(16).padStart(8, "0"), "hex");
|
||||||
|
const dcField = Buffer.alloc(4);
|
||||||
|
dcField.writeInt32LE(dc);
|
||||||
|
const inner = Buffer.concat([Buffer.from("955ff5a9", "hex"), tlBytes(resPQ.pq), tlBytes(bigEndian(p)), tlBytes(bigEndian(q)),
|
||||||
|
nonce, resPQ.serverNonce, randomBytes(32), dcField]);
|
||||||
|
return Buffer.concat([Buffer.from("bee412d7", "hex"), nonce, resPQ.serverNonce, tlBytes(bigEndian(p)), tlBytes(bigEndian(q)),
|
||||||
|
key.fingerprint, tlBytes(rsaPad(inner, key))]);
|
||||||
|
}
|
||||||
|
function parseDhReply(frame) {
|
||||||
|
if (frame.length < 24 || frame.readBigUInt64LE() !== 0n) {
|
||||||
|
const code = frame.readInt32LE();
|
||||||
|
throw new Error(code === -444
|
||||||
|
? "MTProto transport error -444: this route leads to the wrong data centre"
|
||||||
|
: `MTProto transport error ${code}`);
|
||||||
|
}
|
||||||
|
const constructor = frame.readUInt32LE(20);
|
||||||
|
if (constructor === 0x05162463) return null; // the reply to the packet batched in front
|
||||||
|
if (constructor !== 0xd0e8075c) throw new Error(`Unexpected reply 0x${constructor.toString(16)} to req_DH_params`);
|
||||||
|
return "accepted";
|
||||||
|
}
|
||||||
|
|
||||||
function makeRequest(secret, dc) {
|
function makeRequest(secret, dc) {
|
||||||
let header;
|
let header;
|
||||||
do {
|
do {
|
||||||
@@ -68,7 +178,7 @@ function makeRequest(secret, dc) {
|
|||||||
const padded = Buffer.concat([payload, randomBytes(7)]);
|
const padded = Buffer.concat([payload, randomBytes(7)]);
|
||||||
const length = Buffer.alloc(4);
|
const length = Buffer.alloc(4);
|
||||||
length.writeUInt32LE(padded.length);
|
length.writeUInt32LE(padded.length);
|
||||||
return { wire: Buffer.concat([wireHeader, encrypt.update(Buffer.concat([length, padded]))]), decrypt, nonce };
|
return { wire: Buffer.concat([wireHeader, encrypt.update(Buffer.concat([length, padded]))]), encrypt, decrypt, nonce };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseResPQ(frame, nonce) {
|
function parseResPQ(frame, nonce) {
|
||||||
@@ -90,7 +200,11 @@ function parseResPQ(frame, nonce) {
|
|||||||
// Validate the complete TL object fits; do not mistake padding for corruption.
|
// Validate the complete TL object fits; do not mistake padding for corruption.
|
||||||
if (count < 1 || count > 64 || vectorOffset + 8 + count * 8 > body.length) throw new Error("Invalid RSA fingerprint count");
|
if (count < 1 || count > 64 || vectorOffset + 8 + count * 8 > body.length) throw new Error("Invalid RSA fingerprint count");
|
||||||
// Deliberately do not infer DC identity from these public fingerprints.
|
// Deliberately do not infer DC identity from these public fingerprints.
|
||||||
return { response: "resPQ", nonceMatches: true, rsaFingerprintCount: count };
|
const fingerprints = [];
|
||||||
|
for (let i = 0; i < count; i++) fingerprints.push(body.subarray(vectorOffset + 8 + i * 8, vectorOffset + 16 + i * 8));
|
||||||
|
const handshake = { serverNonce: body.subarray(20, 36), pq: body.subarray(37, 37 + pqLength), fingerprints };
|
||||||
|
return Object.defineProperty({ response: "resPQ", nonceMatches: true, rsaFingerprintCount: count },
|
||||||
|
"handshake", { value: handshake, enumerable: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probe(config) {
|
async function probe(config) {
|
||||||
@@ -101,7 +215,8 @@ async function probe(config) {
|
|||||||
if (!/^[0-9a-f]{32}$/i.test(hex)) throw new Error("Secret file must contain 32 hex characters or dd followed by 32 hex characters");
|
if (!/^[0-9a-f]{32}$/i.test(hex)) throw new Error("Secret file must contain 32 hex characters or dd followed by 32 hex characters");
|
||||||
secret = Buffer.from(hex, "hex");
|
secret = Buffer.from(hex, "hex");
|
||||||
}
|
}
|
||||||
const { wire, decrypt, nonce } = makeRequest(secret, config.dc);
|
const { wire, encrypt, decrypt, nonce } = makeRequest(secret, config.dc);
|
||||||
|
let resPQ = null;
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const socket = createConnection(config.directCdn
|
const socket = createConnection(config.directCdn
|
||||||
@@ -118,7 +233,7 @@ async function probe(config) {
|
|||||||
if (error) reject(error);
|
if (error) reject(error);
|
||||||
else resolve({ transport: config.directCdn ? "direct-cdn-tcp" : "local-proxy", requestedDc: config.dc, ...result, elapsedMs: Date.now() - started });
|
else resolve({ transport: config.directCdn ? "direct-cdn-tcp" : "local-proxy", requestedDc: config.dc, ...result, elapsedMs: Date.now() - started });
|
||||||
};
|
};
|
||||||
const timer = setTimeout(() => finish(new Error("Timed out waiting for resPQ")), config.timeoutMs);
|
const timer = setTimeout(() => finish(new Error(resPQ ? "Timed out waiting for the reply to req_DH_params" : "Timed out waiting for resPQ")), config.timeoutMs);
|
||||||
socket.on("error", (error) => finish(error));
|
socket.on("error", (error) => finish(error));
|
||||||
socket.on("end", () => finish(new Error("Proxy closed before a complete resPQ response")));
|
socket.on("end", () => finish(new Error("Proxy closed before a complete resPQ response")));
|
||||||
socket.on("data", (chunk) => {
|
socket.on("data", (chunk) => {
|
||||||
@@ -138,8 +253,23 @@ async function probe(config) {
|
|||||||
pending = pending.subarray(4 + length); // padded intermediate quick ACK
|
pending = pending.subarray(4 + length); // padded intermediate quick ACK
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
finish(null, parseResPQ(pending.subarray(4, 4 + length), nonce));
|
const frame = pending.subarray(4, 4 + length);
|
||||||
return;
|
pending = pending.subarray(4 + length);
|
||||||
|
if (resPQ) {
|
||||||
|
const verdict = parseDhReply(frame);
|
||||||
|
if (verdict) { finish(null, { ...resPQ, identity: verdict, signedDc: config.dc, batched: config.batched }); return; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resPQ = parseResPQ(frame, nonce);
|
||||||
|
if (!config.identity) { finish(null, resPQ); return; }
|
||||||
|
const key = rsaKey();
|
||||||
|
if (!resPQ.handshake.fingerprints.some((fingerprint) => fingerprint.equals(key.fingerprint))) {
|
||||||
|
finish(null, { ...resPQ, identity: "skipped: CDN keys" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const request = unencryptedPacket(reqDhParams(resPQ.handshake, nonce, config.dc, key));
|
||||||
|
const batch = config.batched ? [unencryptedPacket(reqPqBody(randomBytes(16))), request] : [request];
|
||||||
|
socket.write(encrypt.update(Buffer.concat(batch)));
|
||||||
}
|
}
|
||||||
} catch (error) { finish(error); }
|
} catch (error) { finish(error); }
|
||||||
});
|
});
|
||||||
@@ -177,6 +307,9 @@ function selfTest() {
|
|||||||
oversizedVector.writeUInt32LE(65, 72);
|
oversizedVector.writeUInt32LE(65, 72);
|
||||||
assert.throws(() => parseResPQ(oversizedVector, nonce), /count/);
|
assert.throws(() => parseResPQ(oversizedVector, nonce), /count/);
|
||||||
assert.throws(() => parseResPQ(Buffer.from("6cfeffff", "hex"), nonce), /-404/);
|
assert.throws(() => parseResPQ(Buffer.from("6cfeffff", "hex"), nonce), /-404/);
|
||||||
|
assert.throws(() => parseDhReply(Buffer.from("44feffff", "hex")), /wrong data centre/);
|
||||||
|
assert.equal(rsaKey().fingerprint.toString("hex"), Buffer.from("d09d1d85de64fd85", "hex").reverse().toString("hex"));
|
||||||
|
assert.deepEqual(factorize(0x17ED48941A08F981n), [0x494C553Bn, 0x53911073n]);
|
||||||
console.log("Offline parser checks passed; no network connection made.");
|
console.log("Offline parser checks passed; no network connection made.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+279
-4
@@ -6,12 +6,14 @@
|
|||||||
//! (by-sonic/tglock#10, by-sonic/tglock#17).
|
//! (by-sonic/tglock#10, by-sonic/tglock#17).
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use tglock_lib::cli_settings as config;
|
use tglock_lib::cli_settings as config;
|
||||||
use tglock_lib::{proxy, transport};
|
use tglock_lib::{proxy, transport};
|
||||||
|
|
||||||
@@ -23,11 +25,144 @@ const STATUS_POLL: Duration = Duration::from_secs(1);
|
|||||||
/// пускают в `head`, закрывают терминал, перезапускают сборщик логов. Падать из
|
/// пускают в `head`, закрывают терминал, перезапускают сборщик логов. Падать из
|
||||||
/// за этого туннель не должен — он продолжает работать молча.
|
/// за этого туннель не должен — он продолжает работать молча.
|
||||||
fn say(text: &str) -> bool {
|
fn say(text: &str) -> bool {
|
||||||
use std::io::Write;
|
if let Some(log) = LOG_FILE.get() {
|
||||||
|
// `false` здесь значило бы «писать больше некому», а это не так: диск
|
||||||
|
// могло временно заполнить. Журнал продолжается со следующей строки.
|
||||||
|
log.lock().unwrap().record(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
let mut out = std::io::stdout().lock();
|
let mut out = std::io::stdout().lock();
|
||||||
writeln!(out, "{text}").and_then(|()| out.flush()).is_ok()
|
writeln!(out, "{text}").and_then(|()| out.flush()).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Журнал вместо stdout, если задан `--log-file`.
|
||||||
|
static LOG_FILE: OnceLock<Mutex<LogFile>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Файл журнала с ротацией.
|
||||||
|
///
|
||||||
|
/// На роутере stdout сервиса уходит в системный журнал, и TGLock забивал его
|
||||||
|
/// строками состояния (by-sonic/tglock#53). Заполненный файл переименовывается
|
||||||
|
/// в `<имя>.1`, поэтому на диске лежит не больше двух файлов: flash и память
|
||||||
|
/// роутера не резиновые.
|
||||||
|
struct LogFile {
|
||||||
|
path: PathBuf,
|
||||||
|
/// `None` только внутри ротации: Windows не переименует открытый файл.
|
||||||
|
file: Option<File>,
|
||||||
|
written: u64,
|
||||||
|
limit: u64,
|
||||||
|
/// Об ошибке записи сообщается один раз, а не на каждой строке.
|
||||||
|
failing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogFile {
|
||||||
|
fn open(path: PathBuf, limit: u64) -> std::io::Result<Self> {
|
||||||
|
let file = open_log(&path)?;
|
||||||
|
let written = file.metadata()?.len();
|
||||||
|
Ok(Self {
|
||||||
|
path,
|
||||||
|
file: Some(file),
|
||||||
|
written,
|
||||||
|
limit,
|
||||||
|
failing: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&mut self, text: &str) {
|
||||||
|
match self.write_line(text) {
|
||||||
|
Ok(()) => self.failing = false,
|
||||||
|
Err(error) if !self.failing => {
|
||||||
|
self.failing = true;
|
||||||
|
eprintln!(
|
||||||
|
"tglock-cli: журнал {} не пишется: {error}",
|
||||||
|
self.path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_line(&mut self, text: &str) -> std::io::Result<()> {
|
||||||
|
let line = format!("{} {text}\n", timestamp(now_seconds()));
|
||||||
|
if self.written > 0 && self.written + line.len() as u64 > self.limit {
|
||||||
|
self.rotate()?;
|
||||||
|
}
|
||||||
|
if self.file.is_none() {
|
||||||
|
self.file = Some(open_log(&self.path)?);
|
||||||
|
}
|
||||||
|
let file = self.file.as_mut().ok_or(std::io::ErrorKind::NotFound)?;
|
||||||
|
file.write_all(line.as_bytes())?;
|
||||||
|
file.flush()?;
|
||||||
|
self.written += line.len() as u64;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate(&mut self) -> std::io::Result<()> {
|
||||||
|
let mut backup = self.path.clone().into_os_string();
|
||||||
|
backup.push(".1");
|
||||||
|
self.file = None;
|
||||||
|
// rename заменяет старый бэкап сам, и если он не удался, бэкап цел.
|
||||||
|
let renamed = std::fs::rename(&self.path, &backup);
|
||||||
|
let file = open_log(&self.path)?;
|
||||||
|
// Файл могли удалить или держать другой программой. Тогда предел
|
||||||
|
// важнее старых строк: журнал не должен съесть память роутера.
|
||||||
|
if renamed.is_err() {
|
||||||
|
// Отдельным открытием: файл для дозаписи на Windows нельзя обрезать.
|
||||||
|
File::options()
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(&self.path)?;
|
||||||
|
}
|
||||||
|
self.file = Some(file);
|
||||||
|
self.written = 0;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// В журнал попадает ссылка `tg://proxy` с секретом: читать его может только
|
||||||
|
/// владелец, даже если файл появился раньше и с другими правами.
|
||||||
|
fn open_log(path: &Path) -> std::io::Result<File> {
|
||||||
|
let mut options = std::fs::OpenOptions::new();
|
||||||
|
options.create(true).append(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||||
|
let file = options.open(path)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
file.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_seconds() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `2026-10-31 09:30:00` в UTC. Перевод дней в дату — civil_from_days из
|
||||||
|
/// https://howardhinnant.github.io/date_algorithms.html
|
||||||
|
fn timestamp(seconds: u64) -> String {
|
||||||
|
let days = (seconds / 86_400) as i64 + 719_468;
|
||||||
|
let era = days.div_euclid(146_097);
|
||||||
|
let day_of_era = days.rem_euclid(146_097);
|
||||||
|
let year_of_era =
|
||||||
|
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||||
|
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||||
|
let shifted_month = (5 * day_of_year + 2) / 153;
|
||||||
|
let day = day_of_year - (153 * shifted_month + 2) / 5 + 1;
|
||||||
|
let month = if shifted_month < 10 {
|
||||||
|
shifted_month + 3
|
||||||
|
} else {
|
||||||
|
shifted_month - 9
|
||||||
|
};
|
||||||
|
let year = year_of_era + era * 400 + i64::from(month <= 2);
|
||||||
|
format!(
|
||||||
|
"{year:04}-{month:02}-{day:02} {:02}:{:02}:{:02}",
|
||||||
|
(seconds / 3600) % 24,
|
||||||
|
(seconds / 60) % 60,
|
||||||
|
seconds % 60
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "tglock-cli",
|
name = "tglock-cli",
|
||||||
@@ -55,6 +190,11 @@ struct Args {
|
|||||||
#[arg(long, value_name = "DOMAIN")]
|
#[arg(long, value_name = "DOMAIN")]
|
||||||
worker: Vec<String>,
|
worker: Vec<String>,
|
||||||
|
|
||||||
|
/// Свой домен на Cloudflare для CF-прокси (записи kws1…kws5, kws203 на
|
||||||
|
/// адреса Telegram, docs/CF_PROXY.md). Можно повторять
|
||||||
|
#[arg(long, value_name = "DOMAIN")]
|
||||||
|
cf_proxy: Vec<String>,
|
||||||
|
|
||||||
/// Проксировать и не-Telegram адреса. На сетевом адресе это открытый SOCKS5
|
/// Проксировать и не-Telegram адреса. На сетевом адресе это открытый SOCKS5
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
allow_direct: bool,
|
allow_direct: bool,
|
||||||
@@ -67,6 +207,15 @@ struct Args {
|
|||||||
/// Печатать только ошибки
|
/// Печатать только ошибки
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
|
|
||||||
|
/// Писать журнал в этот файл, а не в stdout. Ошибки запуска всё равно идут
|
||||||
|
/// в stderr. Заполненный файл переименовывается в <имя>.1
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
log_file: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Размер файла журнала в МБ, после которого он сменяется. По умолчанию 5
|
||||||
|
#[arg(long, value_name = "MB")]
|
||||||
|
log_max_mb: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Args {
|
impl Args {
|
||||||
@@ -77,8 +226,11 @@ impl Args {
|
|||||||
lan: self.lan,
|
lan: self.lan,
|
||||||
allow_direct: self.allow_direct,
|
allow_direct: self.allow_direct,
|
||||||
worker: self.worker.clone(),
|
worker: self.worker.clone(),
|
||||||
|
cf_proxy: self.cf_proxy.clone(),
|
||||||
secret_file: self.secret_file.clone(),
|
secret_file: self.secret_file.clone(),
|
||||||
quiet: self.quiet,
|
quiet: self.quiet,
|
||||||
|
log_file: self.log_file.clone(),
|
||||||
|
log_max_mb: self.log_max_mb,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +275,11 @@ async fn serve(args: Args) -> Result<(), String> {
|
|||||||
let listen = settings.listen;
|
let listen = settings.listen;
|
||||||
let stats = settings.stats();
|
let stats = settings.stats();
|
||||||
let quiet = settings.quiet;
|
let quiet = settings.quiet;
|
||||||
|
if let Some(path) = &settings.log_file {
|
||||||
|
let log = LogFile::open(path.clone(), u64::from(settings.log_max_mb) * 1024 * 1024)
|
||||||
|
.map_err(|error| format!("не удалось открыть журнал {}: {error}", path.display()))?;
|
||||||
|
let _ = LOG_FILE.set(Mutex::new(log));
|
||||||
|
}
|
||||||
|
|
||||||
// Bind before printing anything: a busy port must be an error, not a
|
// Bind before printing anything: a busy port must be an error, not a
|
||||||
// daemon that reports success and silently does nothing.
|
// daemon that reports success and silently does nothing.
|
||||||
@@ -168,6 +325,9 @@ async fn serve(args: Args) -> Result<(), String> {
|
|||||||
if !settings.workers.is_empty() {
|
if !settings.workers.is_empty() {
|
||||||
say(&format!("Резервные Worker-домены: {}", settings.workers));
|
say(&format!("Резервные Worker-домены: {}", settings.workers));
|
||||||
}
|
}
|
||||||
|
if !settings.cf_proxies.is_empty() {
|
||||||
|
say(&format!("CF-прокси домены: {}", settings.cf_proxies));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let server_stats = stats.clone();
|
let server_stats = stats.clone();
|
||||||
@@ -282,6 +442,114 @@ mod tests {
|
|||||||
config::resolve(config::FileConfig::default(), parse(args).overrides()).unwrap()
|
config::resolve(config::FileConfig::default(), parse(args).overrides()).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Пустой каталог для одного теста, удаляется вместе с ним.
|
||||||
|
struct TempDir(PathBuf);
|
||||||
|
|
||||||
|
impl TempDir {
|
||||||
|
fn new(name: &str) -> Self {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"tglock-cli-{name}-{}-{:?}",
|
||||||
|
std::process::id(),
|
||||||
|
std::thread::current().id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&path);
|
||||||
|
std::fs::create_dir_all(&path).unwrap();
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_rotation_keeps_the_newest_lines_in_two_bounded_files() {
|
||||||
|
let dir = TempDir::new("rotation");
|
||||||
|
let path = dir.0.join("tglock.log");
|
||||||
|
let mut log = LogFile::open(path.clone(), 200).unwrap();
|
||||||
|
for line in 0..40 {
|
||||||
|
log.write_line(&format!("line {line}")).unwrap();
|
||||||
|
}
|
||||||
|
let current = std::fs::read_to_string(&path).unwrap();
|
||||||
|
let backup = std::fs::read_to_string(dir.0.join("tglock.log.1")).unwrap();
|
||||||
|
assert!(current.contains("line 39"), "{current}");
|
||||||
|
assert!(current.len() <= 200 && backup.len() <= 200);
|
||||||
|
assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_log_deleted_by_hand_is_recreated_and_stays_bounded() {
|
||||||
|
let dir = TempDir::new("deleted");
|
||||||
|
let path = dir.0.join("tglock.log");
|
||||||
|
let mut log = LogFile::open(path.clone(), 200).unwrap();
|
||||||
|
log.write_line("before").unwrap();
|
||||||
|
// На Windows открытый файл не удалить, поэтому он закрывается как при
|
||||||
|
// ротации. На unix запись шла бы в удалённый файл до предела.
|
||||||
|
log.file = None;
|
||||||
|
std::fs::remove_file(&path).unwrap();
|
||||||
|
for line in 0..40 {
|
||||||
|
log.write_line(&format!("line {line}")).unwrap();
|
||||||
|
}
|
||||||
|
let current = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(current.contains("line 39"), "{current}");
|
||||||
|
assert!(current.len() <= 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_rotation_keeps_the_old_backup_and_the_size_limit() {
|
||||||
|
let dir = TempDir::new("failed-rotation");
|
||||||
|
let path = dir.0.join("tglock.log");
|
||||||
|
let mut log = LogFile::open(path.clone(), 200).unwrap();
|
||||||
|
std::fs::write(dir.0.join("tglock.log.1"), "old backup").unwrap();
|
||||||
|
log.write_line("x").unwrap();
|
||||||
|
// Файл исчез до ротации: переименовывать нечего.
|
||||||
|
log.file = None;
|
||||||
|
std::fs::remove_file(&path).unwrap();
|
||||||
|
log.rotate().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(dir.0.join("tglock.log.1")).unwrap(),
|
||||||
|
"old backup"
|
||||||
|
);
|
||||||
|
assert_eq!(log.written, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn an_existing_log_with_wide_permissions_is_tightened() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let dir = TempDir::new("permissions");
|
||||||
|
let path = dir.0.join("tglock.log");
|
||||||
|
std::fs::write(&path, "").unwrap();
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||||
|
LogFile::open(path.clone(), 200).unwrap();
|
||||||
|
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_restarted_daemon_continues_the_log_and_counts_what_is_there() {
|
||||||
|
let dir = TempDir::new("restart");
|
||||||
|
let path = dir.0.join("tglock.log");
|
||||||
|
LogFile::open(path.clone(), 10_000)
|
||||||
|
.unwrap()
|
||||||
|
.write_line("first run")
|
||||||
|
.unwrap();
|
||||||
|
let mut log = LogFile::open(path.clone(), 10_000).unwrap();
|
||||||
|
assert!(log.written > 0);
|
||||||
|
log.write_line("second run").unwrap();
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(text.contains("first run") && text.contains("second run"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timestamps_are_calendar_dates_in_utc() {
|
||||||
|
assert_eq!(timestamp(0), "1970-01-01 00:00:00");
|
||||||
|
assert_eq!(timestamp(951_782_400), "2000-02-29 00:00:00");
|
||||||
|
assert_eq!(timestamp(1_793_442_645), "2026-10-31 10:30:45");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn command_definition_is_valid() {
|
fn command_definition_is_valid() {
|
||||||
Args::command().debug_assert();
|
Args::command().debug_assert();
|
||||||
@@ -338,6 +606,13 @@ mod tests {
|
|||||||
assert_eq!(settings.workers, "a.workers.dev,b.workers.dev");
|
assert_eq!(settings.workers, "a.workers.dev,b.workers.dev");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_cf_proxy_flags_collapse_into_one_list() {
|
||||||
|
let settings = from_flags(&["--cf-proxy", "a.example", "--cf-proxy", "b.example"]);
|
||||||
|
assert_eq!(settings.cf_proxies, "a.example,b.example");
|
||||||
|
assert!(from_flags(&[]).cf_proxies.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_worker_flag_means_no_domains() {
|
fn no_worker_flag_means_no_domains() {
|
||||||
assert!(from_flags(&[]).workers.is_empty());
|
assert!(from_flags(&[]).workers.is_empty());
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ use std::path::{Path, PathBuf};
|
|||||||
/// Имя файла, который ищется рядом с бинарём, если `--config` не задан.
|
/// Имя файла, который ищется рядом с бинарём, если `--config` не задан.
|
||||||
pub const DEFAULT_FILE_NAME: &str = "tglock.toml";
|
pub const DEFAULT_FILE_NAME: &str = "tglock.toml";
|
||||||
|
|
||||||
|
/// Размер журнала, после которого он сменяется. Как в Flowseal/tg-ws-proxy.
|
||||||
|
pub const DEFAULT_LOG_MAX_MB: u32 = 5;
|
||||||
|
|
||||||
/// Содержимое файла конфигурации.
|
/// Содержимое файла конфигурации.
|
||||||
///
|
///
|
||||||
/// `deny_unknown_fields` намеренно: опечатка вроде `porrt = 1080` должна быть
|
/// `deny_unknown_fields` намеренно: опечатка вроде `porrt = 1080` должна быть
|
||||||
@@ -33,9 +36,14 @@ pub struct FileConfig {
|
|||||||
pub lan: Option<bool>,
|
pub lan: Option<bool>,
|
||||||
pub allow_direct: Option<bool>,
|
pub allow_direct: Option<bool>,
|
||||||
pub worker: Option<Vec<String>>,
|
pub worker: Option<Vec<String>>,
|
||||||
|
/// Свои домены на Cloudflare для CF-прокси (docs/CF_PROXY.md).
|
||||||
|
pub cf_proxy: Option<Vec<String>>,
|
||||||
pub secret: Option<String>,
|
pub secret: Option<String>,
|
||||||
pub secret_file: Option<PathBuf>,
|
pub secret_file: Option<PathBuf>,
|
||||||
pub quiet: Option<bool>,
|
pub quiet: Option<bool>,
|
||||||
|
/// Писать журнал в файл, а не в stdout (by-sonic/tglock#53).
|
||||||
|
pub log_file: Option<PathBuf>,
|
||||||
|
pub log_max_mb: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileConfig {
|
impl FileConfig {
|
||||||
@@ -58,8 +66,11 @@ pub struct Overrides {
|
|||||||
pub lan: bool,
|
pub lan: bool,
|
||||||
pub allow_direct: bool,
|
pub allow_direct: bool,
|
||||||
pub worker: Vec<String>,
|
pub worker: Vec<String>,
|
||||||
|
pub cf_proxy: Vec<String>,
|
||||||
pub secret_file: Option<PathBuf>,
|
pub secret_file: Option<PathBuf>,
|
||||||
pub quiet: bool,
|
pub quiet: bool,
|
||||||
|
pub log_file: Option<PathBuf>,
|
||||||
|
pub log_max_mb: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Откуда взять секрет прокси.
|
/// Откуда взять секрет прокси.
|
||||||
@@ -79,8 +90,12 @@ pub struct Resolved {
|
|||||||
pub listen: ListenConfig,
|
pub listen: ListenConfig,
|
||||||
/// Домены Worker'ов в том виде, в каком их ждёт `Stats::set_worker_domain`.
|
/// Домены Worker'ов в том виде, в каком их ждёт `Stats::set_worker_domain`.
|
||||||
pub workers: String,
|
pub workers: String,
|
||||||
|
/// Домены CF-прокси в том виде, в каком их ждёт `Stats::set_cf_proxy_domain`.
|
||||||
|
pub cf_proxies: String,
|
||||||
pub secret: SecretSource,
|
pub secret: SecretSource,
|
||||||
pub quiet: bool,
|
pub quiet: bool,
|
||||||
|
pub log_file: Option<PathBuf>,
|
||||||
|
pub log_max_mb: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Resolved {
|
impl Resolved {
|
||||||
@@ -93,6 +108,7 @@ impl Resolved {
|
|||||||
SecretSource::Ephemeral => proxy::Stats::new(),
|
SecretSource::Ephemeral => proxy::Stats::new(),
|
||||||
};
|
};
|
||||||
stats.set_worker_domain(&self.workers);
|
stats.set_worker_domain(&self.workers);
|
||||||
|
stats.set_cf_proxy_domain(&self.cf_proxies);
|
||||||
stats
|
stats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +143,19 @@ pub fn resolve(file: FileConfig, cli: Overrides) -> Result<Resolved, String> {
|
|||||||
} else {
|
} else {
|
||||||
cli.worker
|
cli.worker
|
||||||
};
|
};
|
||||||
|
let cf_proxies = if cli.cf_proxy.is_empty() {
|
||||||
|
file.cf_proxy.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
cli.cf_proxy
|
||||||
|
};
|
||||||
|
|
||||||
|
let log_max_mb = cli
|
||||||
|
.log_max_mb
|
||||||
|
.or(file.log_max_mb)
|
||||||
|
.unwrap_or(DEFAULT_LOG_MAX_MB);
|
||||||
|
if log_max_mb == 0 {
|
||||||
|
return Err("log_max_mb должен быть не меньше 1".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
// Секрет из файла конфигурации важнее отдельного файла: если человек вписал
|
// Секрет из файла конфигурации важнее отдельного файла: если человек вписал
|
||||||
// его сюда, значит хотел держать всё в одном месте.
|
// его сюда, значит хотел держать всё в одном месте.
|
||||||
@@ -142,8 +171,11 @@ pub fn resolve(file: FileConfig, cli: Overrides) -> Result<Resolved, String> {
|
|||||||
Ok(Resolved {
|
Ok(Resolved {
|
||||||
listen,
|
listen,
|
||||||
workers: workers.join(","),
|
workers: workers.join(","),
|
||||||
|
cf_proxies: cf_proxies.join(","),
|
||||||
secret,
|
secret,
|
||||||
quiet: cli.quiet || file.quiet.unwrap_or(false),
|
quiet: cli.quiet || file.quiet.unwrap_or(false),
|
||||||
|
log_file: cli.log_file.or(file.log_file),
|
||||||
|
log_max_mb,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +209,31 @@ mod tests {
|
|||||||
assert!(!resolved.quiet);
|
assert!(!resolved.quiet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_file_comes_from_the_file_and_a_flag_overrides_it() {
|
||||||
|
let from_file =
|
||||||
|
|| FileConfig::parse("log_file = \"/tmp/tglock.log\"\nlog_max_mb = 2").unwrap();
|
||||||
|
let resolved = resolve(from_file(), cli()).unwrap();
|
||||||
|
assert_eq!(resolved.log_file, Some(PathBuf::from("/tmp/tglock.log")));
|
||||||
|
assert_eq!(resolved.log_max_mb, 2);
|
||||||
|
|
||||||
|
let flags = Overrides {
|
||||||
|
log_file: Some(PathBuf::from("/var/log/t.log")),
|
||||||
|
log_max_mb: Some(1),
|
||||||
|
..cli()
|
||||||
|
};
|
||||||
|
let resolved = resolve(from_file(), flags).unwrap();
|
||||||
|
assert_eq!(resolved.log_file, Some(PathBuf::from("/var/log/t.log")));
|
||||||
|
assert_eq!(resolved.log_max_mb, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_log_limit_is_refused() {
|
||||||
|
// Иначе файл менялся бы на каждой строке.
|
||||||
|
let file = FileConfig::parse("log_max_mb = 0").unwrap();
|
||||||
|
assert!(resolve(file, cli()).unwrap_err().contains("log_max_mb"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn file_values_are_used_when_no_flags_are_given() {
|
fn file_values_are_used_when_no_flags_are_given() {
|
||||||
let file = FileConfig::parse(
|
let file = FileConfig::parse(
|
||||||
@@ -185,6 +242,7 @@ mod tests {
|
|||||||
port = 1443
|
port = 1443
|
||||||
allow_direct = true
|
allow_direct = true
|
||||||
worker = ["a.workers.dev", "b.workers.dev"]
|
worker = ["a.workers.dev", "b.workers.dev"]
|
||||||
|
cf_proxy = ["example.com"]
|
||||||
quiet = true
|
quiet = true
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -193,6 +251,7 @@ mod tests {
|
|||||||
assert_eq!(resolved.listen.addr.to_string(), "10.0.0.5:1443");
|
assert_eq!(resolved.listen.addr.to_string(), "10.0.0.5:1443");
|
||||||
assert!(resolved.listen.allow_direct);
|
assert!(resolved.listen.allow_direct);
|
||||||
assert_eq!(resolved.workers, "a.workers.dev,b.workers.dev");
|
assert_eq!(resolved.workers, "a.workers.dev,b.workers.dev");
|
||||||
|
assert_eq!(resolved.cf_proxies, "example.com");
|
||||||
assert!(resolved.quiet);
|
assert!(resolved.quiet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+125
-3
@@ -8,12 +8,17 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
|||||||
use tauri::{Manager, State};
|
use tauri::{Manager, State};
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
// `default`: a settings file written before a field existed must keep the
|
||||||
|
// values it has. Without it the whole file failed to parse and silently fell
|
||||||
|
// back to defaults, dropping the user's Worker domain with it.
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase", default)]
|
||||||
struct Settings {
|
struct Settings {
|
||||||
lan_mode: bool,
|
lan_mode: bool,
|
||||||
port: u16,
|
port: u16,
|
||||||
worker_domain: String,
|
worker_domain: String,
|
||||||
|
/// Свой домен на Cloudflare для CF-прокси (docs/CF_PROXY.md).
|
||||||
|
cf_proxy_domain: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
@@ -22,6 +27,7 @@ impl Default for Settings {
|
|||||||
lan_mode: false,
|
lan_mode: false,
|
||||||
port: proxy::DEFAULT_PORT,
|
port: proxy::DEFAULT_PORT,
|
||||||
worker_domain: String::new(),
|
worker_domain: String::new(),
|
||||||
|
cf_proxy_domain: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,6 +238,7 @@ async fn start_proxy(
|
|||||||
// A rejected foreground-service start must not leave a hidden proxy running.
|
// A rejected foreground-service start must not leave a hidden proxy running.
|
||||||
set_foreground_service(&app, true)?;
|
set_foreground_service(&app, true)?;
|
||||||
state.stats.set_worker_domain(&settings.worker_domain);
|
state.stats.set_worker_domain(&settings.worker_domain);
|
||||||
|
state.stats.set_cf_proxy_domain(&settings.cf_proxy_domain);
|
||||||
*state.active_port.lock().unwrap() = settings.port;
|
*state.active_port.lock().unwrap() = settings.port;
|
||||||
*state.active_listen.lock().unwrap() = Some(listen);
|
*state.active_listen.lock().unwrap() = Some(listen);
|
||||||
*state.started_at.lock().unwrap() = Some(Instant::now());
|
*state.started_at.lock().unwrap() = Some(Instant::now());
|
||||||
@@ -424,8 +431,19 @@ pub fn run() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
app.manage(state);
|
app.manage(state);
|
||||||
|
#[cfg(desktop)]
|
||||||
|
if let Err(error) = setup_tray(app) {
|
||||||
|
app.state::<AppState>().log(
|
||||||
|
format!("Значок в трее недоступен ({error}). Закрытие окна выключит защиту"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
.on_window_event(|_window, _event| {
|
||||||
|
#[cfg(desktop)]
|
||||||
|
hide_to_tray_on_close(_window, _event);
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_status,
|
get_status,
|
||||||
get_settings,
|
get_settings,
|
||||||
@@ -435,14 +453,118 @@ pub fn run() {
|
|||||||
get_telegram_link,
|
get_telegram_link,
|
||||||
open_telegram
|
open_telegram
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("failed to run TGLock");
|
.expect("failed to build TGLock")
|
||||||
|
.run(|_app, _event| {
|
||||||
|
// В macOS окно, спрятанное в трей, возвращает и клик по значку в Dock.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if let tauri::RunEvent::Reopen { .. } = _event {
|
||||||
|
show_main_window(_app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(desktop)]
|
||||||
|
const TRAY_ID: &str = "main";
|
||||||
|
|
||||||
|
/// Закрытие окна при включённой защите прячет его в трей, и прокси продолжает
|
||||||
|
/// работать, как в tg-ws-proxy. Выйти можно из меню трея (by-sonic/tglock#70).
|
||||||
|
/// Если защита выключена или значка в трее нет, окно закрывается как раньше:
|
||||||
|
/// иначе приложение осталось бы работать без способа до него добраться.
|
||||||
|
#[cfg(desktop)]
|
||||||
|
fn hide_to_tray_on_close(window: &tauri::Window, event: &tauri::WindowEvent) {
|
||||||
|
let tauri::WindowEvent::CloseRequested { api, .. } = event else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let app = window.app_handle();
|
||||||
|
let running = app.state::<AppState>().stats.running.load(Ordering::SeqCst);
|
||||||
|
if running && app.tray_by_id(TRAY_ID).is_some() {
|
||||||
|
api.prevent_close();
|
||||||
|
let _ = window.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(desktop)]
|
||||||
|
fn show_main_window(app: &tauri::AppHandle) {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(desktop)]
|
||||||
|
fn setup_tray(app: &tauri::App) -> Result<(), String> {
|
||||||
|
// В Linux значок держится на libayatana-appindicator, и без неё tray-icon
|
||||||
|
// паникует, а не возвращает ошибку. Без трея окно просто закрывается.
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_tray(app)))
|
||||||
|
.map_err(|_| "нет libayatana-appindicator3".to_string())?
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(desktop)]
|
||||||
|
fn build_tray(app: &tauri::App) -> tauri::Result<()> {
|
||||||
|
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||||
|
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||||
|
|
||||||
|
let show = MenuItem::with_id(app, "show", "Открыть TGLock", true, None::<&str>)?;
|
||||||
|
let telegram = MenuItem::with_id(app, "telegram", "Открыть Telegram", true, None::<&str>)?;
|
||||||
|
let separator = PredefinedMenuItem::separator(app)?;
|
||||||
|
let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&show, &telegram, &separator, &quit])?;
|
||||||
|
|
||||||
|
let mut tray = TrayIconBuilder::with_id(TRAY_ID)
|
||||||
|
.tooltip("TGLock")
|
||||||
|
.menu(&menu)
|
||||||
|
// В Windows и Linux левый клик открывает окно, меню — по правому.
|
||||||
|
// В macOS меню по клику привычнее.
|
||||||
|
.show_menu_on_left_click(cfg!(target_os = "macos"))
|
||||||
|
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||||
|
"show" => show_main_window(app),
|
||||||
|
"telegram" => {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
if let Err(error) = open_telegram_link(app, &state) {
|
||||||
|
state.log(error, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => app.exit(0),
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
show_main_window(tray.app_handle());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(icon) = app.default_window_icon() {
|
||||||
|
tray = tray.icon(icon.clone());
|
||||||
|
}
|
||||||
|
tray.build(app)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_settings_file_from_an_older_version_keeps_its_values() {
|
||||||
|
let old: Settings =
|
||||||
|
serde_json::from_str(r#"{"lanMode":true,"port":1443,"workerDomain":"a.workers.dev"}"#)
|
||||||
|
.unwrap();
|
||||||
|
assert!(old.lan_mode);
|
||||||
|
assert_eq!(old.port, 1443);
|
||||||
|
assert_eq!(old.worker_domain, "a.workers.dev");
|
||||||
|
assert_eq!(old.cf_proxy_domain, "");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nothing_to_share_when_the_proxy_is_off_or_local() {
|
fn nothing_to_share_when_the_proxy_is_off_or_local() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+244
@@ -354,6 +354,138 @@ fn build_crypto_context(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Длина, больше которой пакет считается не настоящим. Файлы грузятся частями
|
||||||
|
/// по 512 КиБ. Предел держит и память: в LAN-режиме чужое устройство могло бы
|
||||||
|
/// заставить копить «пакет» на роутере. Если настоящий пакет окажется длиннее,
|
||||||
|
/// поток просто пойдёт дальше без нарезки.
|
||||||
|
const MAX_PACKET: usize = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Framing {
|
||||||
|
Abridged,
|
||||||
|
Intermediate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Режет поток клиента на транспортные пакеты MTProto.
|
||||||
|
///
|
||||||
|
/// WebSocket-фронт Telegram разбирает один пакет на входящий кадр, а остаток
|
||||||
|
/// кадра держит до следующего. Клиент пишет запросы пачками, и всё, что шло
|
||||||
|
/// вторым в той же записи TCP, застревало на фронте: текст проходил, а файлы
|
||||||
|
/// вставали. Проверено на живом фронте: `req_DH_params` вторым пакетом в кадре
|
||||||
|
/// остаётся без ответа, пока не придёт следующий кадр, а отдельным кадром
|
||||||
|
/// принимается сразу. Так же режет поток Flowseal/tg-ws-proxy.
|
||||||
|
///
|
||||||
|
/// Заголовок читается по копии: ключи `init` открыты, поэтому режим с секретом
|
||||||
|
/// и SOCKS5 разбираются одинаково, а наружу уходят исходные байты.
|
||||||
|
pub struct PacketSplitter {
|
||||||
|
framing: Option<Framing>,
|
||||||
|
decrypt: AesCtr,
|
||||||
|
wire: Vec<u8>,
|
||||||
|
plain: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PacketSplitter {
|
||||||
|
/// Разбирать поток, начатый этим `init`. Неизвестный протокол не режется.
|
||||||
|
pub fn from_init(init: &[u8; INIT_LEN]) -> Self {
|
||||||
|
let key: [u8; 32] = init[KEY_START..KEY_END].try_into().unwrap();
|
||||||
|
let iv: [u8; 16] = init[KEY_END..IV_END].try_into().unwrap();
|
||||||
|
let mut decrypt = AesCtr::new((&key).into(), (&iv).into());
|
||||||
|
let mut plain_init = *init;
|
||||||
|
decrypt.apply_keystream(&mut plain_init);
|
||||||
|
let framing = match [
|
||||||
|
plain_init[TAG_START],
|
||||||
|
plain_init[TAG_START + 1],
|
||||||
|
plain_init[TAG_START + 2],
|
||||||
|
plain_init[TAG_START + 3],
|
||||||
|
] {
|
||||||
|
ABRIDGED => Some(Framing::Abridged),
|
||||||
|
INTERMEDIATE | PADDED_INTERMEDIATE => Some(Framing::Intermediate),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
framing,
|
||||||
|
decrypt,
|
||||||
|
wire: Vec::new(),
|
||||||
|
plain: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Принять зашифрованные байты, вернуть готовые к отправке пакеты.
|
||||||
|
pub fn push(&mut self, wire: &[u8]) -> Vec<Vec<u8>> {
|
||||||
|
let Some(framing) = self.framing else {
|
||||||
|
return vec![wire.to_vec()];
|
||||||
|
};
|
||||||
|
let start = self.plain.len();
|
||||||
|
self.wire.extend_from_slice(wire);
|
||||||
|
self.plain.extend_from_slice(wire);
|
||||||
|
self.decrypt.apply_keystream(&mut self.plain[start..]);
|
||||||
|
|
||||||
|
let mut packets = Vec::new();
|
||||||
|
let mut offset = 0;
|
||||||
|
while offset < self.plain.len() {
|
||||||
|
match packet_len(framing, &self.plain[offset..]) {
|
||||||
|
PacketLen::Incomplete => break,
|
||||||
|
PacketLen::Complete(length) => {
|
||||||
|
if self.plain.len() - offset < length {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
packets.push(self.wire[offset..offset + length].to_vec());
|
||||||
|
offset += length;
|
||||||
|
}
|
||||||
|
// Длина не похожа на настоящую: поток не наш. Дальше он идёт
|
||||||
|
// как есть, иначе данные встанут в ожидании несуществующего конца.
|
||||||
|
PacketLen::Invalid => {
|
||||||
|
packets.push(self.wire[offset..].to_vec());
|
||||||
|
offset = self.plain.len();
|
||||||
|
self.framing = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.wire.drain(..offset);
|
||||||
|
self.plain.drain(..offset);
|
||||||
|
packets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Хвост незаконченного пакета: клиент закрыл соединение на середине.
|
||||||
|
pub fn finish(&mut self) -> Option<Vec<u8>> {
|
||||||
|
self.plain.clear();
|
||||||
|
(!self.wire.is_empty()).then(|| std::mem::take(&mut self.wire))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PacketLen {
|
||||||
|
Incomplete,
|
||||||
|
Complete(usize),
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn packet_len(framing: Framing, plain: &[u8]) -> PacketLen {
|
||||||
|
let (header, payload) = match framing {
|
||||||
|
Framing::Abridged => match plain {
|
||||||
|
[] => return PacketLen::Incomplete,
|
||||||
|
[0x7f | 0xff, length @ ..] => match length {
|
||||||
|
[a, b, c, ..] => (4, u32::from_le_bytes([*a, *b, *c, 0]) as usize * 4),
|
||||||
|
_ => return PacketLen::Incomplete,
|
||||||
|
},
|
||||||
|
[first, ..] => (1, (*first & 0x7f) as usize * 4),
|
||||||
|
},
|
||||||
|
Framing::Intermediate => match plain {
|
||||||
|
[a, b, c, d, ..] => {
|
||||||
|
// Старший бит просит quick ack и к длине не относится.
|
||||||
|
(
|
||||||
|
4,
|
||||||
|
(u32::from_le_bytes([*a, *b, *c, *d]) & 0x7fff_ffff) as usize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => return PacketLen::Incomplete,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if payload == 0 || payload > MAX_PACKET {
|
||||||
|
return PacketLen::Invalid;
|
||||||
|
}
|
||||||
|
PacketLen::Complete(header + payload)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn test_client_init(secret: &[u8; 16], dc_index: i16) -> [u8; INIT_LEN] {
|
pub(crate) fn test_client_init(secret: &[u8; 16], dc_index: i16) -> [u8; INIT_LEN] {
|
||||||
tests::generate_client_init(secret, PADDED_INTERMEDIATE, dc_index)
|
tests::generate_client_init(secret, PADDED_INTERMEDIATE, dc_index)
|
||||||
@@ -423,6 +555,118 @@ pub(crate) fn test_relay_peer(relay_init: &[u8; INIT_LEN]) -> TestPeer {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Поток, каким его увидел бы фронт Telegram, и разделитель для него.
|
||||||
|
fn splitter_for(tag: [u8; 4], plain: &[u8]) -> (PacketSplitter, Vec<u8>) {
|
||||||
|
let init = generate_relay_init(tag, 2);
|
||||||
|
let mut wire = plain.to_vec();
|
||||||
|
test_relay_peer(&init).decrypt(&mut wire);
|
||||||
|
(PacketSplitter::from_init(&init), wire)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn intermediate(payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut packet = (payload.len() as u32).to_le_bytes().to_vec();
|
||||||
|
packet.extend_from_slice(payload);
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn glued_packets_leave_one_per_frame() {
|
||||||
|
let first = intermediate(&[1; 40]);
|
||||||
|
let second = intermediate(&[2; 340]);
|
||||||
|
let plain = [first.clone(), second.clone()].concat();
|
||||||
|
for tag in [INTERMEDIATE, PADDED_INTERMEDIATE] {
|
||||||
|
let (mut splitter, wire) = splitter_for(tag, &plain);
|
||||||
|
let packets = splitter.push(&wire);
|
||||||
|
assert_eq!(packets.len(), 2);
|
||||||
|
assert_eq!(packets[0], wire[..first.len()]);
|
||||||
|
assert_eq!(packets[1], wire[first.len()..]);
|
||||||
|
assert_eq!(splitter.finish(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_packet_is_held_until_it_is_complete() {
|
||||||
|
let packet = intermediate(&[7; 300]);
|
||||||
|
let (mut splitter, wire) = splitter_for(INTERMEDIATE, &packet);
|
||||||
|
// Границы TCP не совпадают с пакетами: режем в том числе внутри длины.
|
||||||
|
assert!(splitter.push(&wire[..2]).is_empty());
|
||||||
|
assert!(splitter.push(&wire[2..150]).is_empty());
|
||||||
|
assert_eq!(splitter.push(&wire[150..]), vec![wire.clone()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tail_of_one_packet_and_the_start_of_the_next_stay_separate() {
|
||||||
|
let first = intermediate(&[1; 40]);
|
||||||
|
let second = intermediate(&[2; 80]);
|
||||||
|
let plain = [first.clone(), second.clone()].concat();
|
||||||
|
let (mut splitter, wire) = splitter_for(INTERMEDIATE, &plain);
|
||||||
|
let packets = splitter.push(&wire[..first.len() + 10]);
|
||||||
|
assert_eq!(packets, vec![wire[..first.len()].to_vec()]);
|
||||||
|
let packets = splitter.push(&wire[first.len() + 10..]);
|
||||||
|
assert_eq!(packets, vec![wire[first.len()..].to_vec()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_ack_bit_is_not_part_of_the_length() {
|
||||||
|
let mut packet = intermediate(&[3; 16]);
|
||||||
|
packet[3] |= 0x80;
|
||||||
|
let (mut splitter, wire) = splitter_for(INTERMEDIATE, &packet);
|
||||||
|
assert_eq!(splitter.push(&wire), vec![wire.clone()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn abridged_lengths_count_in_words_with_either_header() {
|
||||||
|
let short = [vec![2], vec![9; 8]].concat();
|
||||||
|
let long = [vec![0x7f, 0x80, 0, 0], vec![9; 512]].concat();
|
||||||
|
let quick_ack = [vec![0x81], vec![9; 4]].concat();
|
||||||
|
let plain = [short.clone(), long.clone(), quick_ack.clone()].concat();
|
||||||
|
let (mut splitter, wire) = splitter_for(ABRIDGED, &plain);
|
||||||
|
let lengths: Vec<_> = splitter.push(&wire).iter().map(Vec::len).collect();
|
||||||
|
assert_eq!(lengths, [short.len(), long.len(), quick_ack.len()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_length_that_cannot_be_real_stops_the_splitting_without_losing_bytes() {
|
||||||
|
// Если длина мусорная, ждать конца пакета значит заморозить соединение.
|
||||||
|
let mut garbage = 0x7fff_fff0_u32.to_le_bytes().to_vec();
|
||||||
|
garbage.extend_from_slice(&[5; 32]);
|
||||||
|
let (mut splitter, wire) = splitter_for(INTERMEDIATE, &garbage);
|
||||||
|
assert_eq!(splitter.push(&wire), vec![wire.clone()]);
|
||||||
|
assert_eq!(splitter.push(b"more"), vec![b"more".to_vec()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_length_over_the_limit_is_never_buffered() {
|
||||||
|
let mut oversized = ((MAX_PACKET + 1) as u32).to_le_bytes().to_vec();
|
||||||
|
oversized.extend_from_slice(&[5; 32]);
|
||||||
|
let (mut splitter, wire) = splitter_for(INTERMEDIATE, &oversized);
|
||||||
|
assert_eq!(splitter.push(&wire), vec![wire.clone()]);
|
||||||
|
assert_eq!(splitter.finish(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_largest_real_upload_part_still_arrives_whole() {
|
||||||
|
let packet = intermediate(&vec![7; 512 * 1024 + 256]);
|
||||||
|
let (mut splitter, wire) = splitter_for(INTERMEDIATE, &packet);
|
||||||
|
assert!(splitter.push(&wire[..65536]).is_empty());
|
||||||
|
assert_eq!(splitter.push(&wire[65536..]), vec![wire.clone()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_protocol_passes_through_untouched() {
|
||||||
|
let (mut splitter, wire) = splitter_for([0x11; 4], &[0; 16]);
|
||||||
|
assert_eq!(splitter.push(&wire), vec![wire.clone()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_unfinished_tail_is_returned_once() {
|
||||||
|
let packet = intermediate(&[1; 40]);
|
||||||
|
let (mut splitter, wire) = splitter_for(INTERMEDIATE, &packet);
|
||||||
|
assert!(splitter.push(&wire[..10]).is_empty());
|
||||||
|
assert_eq!(splitter.finish(), Some(wire[..10].to_vec()));
|
||||||
|
assert_eq!(splitter.finish(), None);
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn generate_client_init(
|
pub(super) fn generate_client_init(
|
||||||
secret: &[u8; 16],
|
secret: &[u8; 16],
|
||||||
protocol_tag: [u8; 4],
|
protocol_tag: [u8; 4],
|
||||||
|
|||||||
+373
-19
@@ -269,6 +269,26 @@ impl Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Свои домены на Cloudflare для CF-прокси (`kwsN.<домен>`, docs/CF_PROXY.md).
|
||||||
|
pub fn set_cf_proxy_domain(&self, domain: &str) {
|
||||||
|
let domains = domain
|
||||||
|
.split([',', ';', ' '])
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let result = self.transport.set_cf_proxy_domains(&domains);
|
||||||
|
for rejected in &result.rejected {
|
||||||
|
self.note(format!(
|
||||||
|
"CF-прокси «{rejected}» не похож на имя домена — маршрут не добавлен. Нужен только домен, без https:// и без kws: example.com"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for accepted in &result.accepted {
|
||||||
|
self.note(format!(
|
||||||
|
"CF-прокси в списке маршрутов: kws1.{accepted} … kws5.{accepted}, kws203.{accepted}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stop(&self) {
|
pub fn stop(&self) {
|
||||||
if let Some(sender) = self.shutdown.lock().unwrap().take() {
|
if let Some(sender) = self.shutdown.lock().unwrap().take() {
|
||||||
let _ = sender.send(true);
|
let _ = sender.send(true);
|
||||||
@@ -752,6 +772,7 @@ async fn ws_tunnel(
|
|||||||
ws_w.send(tungstenite::Message::Binary(init.to_vec()))
|
ws_w.send(tungstenite::Message::Binary(init.to_vec()))
|
||||||
.await
|
.await
|
||||||
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
|
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
|
||||||
|
let health = TunnelHealth::new();
|
||||||
|
|
||||||
// Ping приходит в половину, которая читает, а отвечать на него должна та,
|
// Ping приходит в половину, которая читает, а отвечать на него должна та,
|
||||||
// которая пишет: владелец у отправляющей половины строго один.
|
// которая пишет: владелец у отправляющей половины строго один.
|
||||||
@@ -765,11 +786,12 @@ async fn ws_tunnel(
|
|||||||
// сессия встаёт при живом туннеле (by-sonic/tglock#42, #32).
|
// сессия встаёт при живом туннеле (by-sonic/tglock#42, #32).
|
||||||
let downstream = async {
|
let downstream = async {
|
||||||
let mut crypto = downstream_crypto;
|
let mut crypto = downstream_crypto;
|
||||||
let mut received_payload = false;
|
|
||||||
while let Some(message) = ws_r.next().await {
|
while let Some(message) = ws_r.next().await {
|
||||||
match message {
|
match message {
|
||||||
Ok(tungstenite::Message::Binary(mut data)) => {
|
Ok(tungstenite::Message::Binary(mut data)) => {
|
||||||
received_payload |= !data.is_empty();
|
if !data.is_empty() {
|
||||||
|
health.note_reply();
|
||||||
|
}
|
||||||
if let Some(crypto) = &mut crypto {
|
if let Some(crypto) = &mut crypto {
|
||||||
crypto.apply(data.as_mut());
|
crypto.apply(data.as_mut());
|
||||||
}
|
}
|
||||||
@@ -793,7 +815,7 @@ async fn ws_tunnel(
|
|||||||
.is_ok_and(|code| code != 0)
|
.is_ok_and(|code| code != 0)
|
||||||
|| frame.code != tungstenite::protocol::frame::coding::CloseCode::Normal
|
|| frame.code != tungstenite::protocol::frame::coding::CloseCode::Normal
|
||||||
});
|
});
|
||||||
if !received_payload || transport_error {
|
if !health.replied() || transport_error {
|
||||||
let reason = match frame {
|
let reason = match frame {
|
||||||
Some(frame) => describe_ws_close(frame.code.into(), &frame.reason),
|
Some(frame) => describe_ws_close(frame.code.into(), &frame.reason),
|
||||||
None => "WebSocket закрыт без ответа Telegram".to_owned(),
|
None => "WebSocket закрыт без ответа Telegram".to_owned(),
|
||||||
@@ -803,17 +825,18 @@ async fn ws_tunnel(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
return Err(tunnel_route_error(
|
return Err(tunnel_broke(
|
||||||
stats,
|
stats,
|
||||||
&connected,
|
&connected,
|
||||||
dc,
|
dc,
|
||||||
&error.to_string(),
|
&error.to_string(),
|
||||||
|
&health,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !received_payload {
|
if !health.replied() {
|
||||||
return Err(tunnel_route_error(
|
return Err(tunnel_route_error(
|
||||||
stats,
|
stats,
|
||||||
&connected,
|
&connected,
|
||||||
@@ -826,32 +849,54 @@ async fn ws_tunnel(
|
|||||||
|
|
||||||
let upstream = async {
|
let upstream = async {
|
||||||
let mut crypto = upstream_crypto;
|
let mut crypto = upstream_crypto;
|
||||||
|
// Worker пишет кадры в обычный TCP до Telegram, там они ничего не значат.
|
||||||
|
// Отдельный кадр на пакет нужен только WebSocket Telegram, на 443 и на 80.
|
||||||
|
let mut splitter = (connected.route.kind != crate::transport::RouteKind::CloudflareWorker)
|
||||||
|
.then(|| crate::mtproto::PacketSplitter::from_init(init));
|
||||||
let mut buf = vec![0u8; 65536];
|
let mut buf = vec![0u8; 65536];
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
read = tcp_r.read(&mut buf) => match read {
|
read = tcp_r.read(&mut buf) => match read {
|
||||||
Ok(0) => break,
|
Ok(0) => {
|
||||||
|
if let Some(tail) = splitter.as_mut().and_then(|splitter| splitter.finish()) {
|
||||||
|
let _ = ws_w.send(tungstenite::Message::Binary(tail)).await;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
Err(error) => return Err(error.into()),
|
Err(error) => return Err(error.into()),
|
||||||
Ok(read) => {
|
Ok(read) => {
|
||||||
|
health.note_request();
|
||||||
if let Some(crypto) = &mut crypto {
|
if let Some(crypto) = &mut crypto {
|
||||||
crypto.apply(&mut buf[..read]);
|
crypto.apply(&mut buf[..read]);
|
||||||
}
|
}
|
||||||
ws_w
|
let packets = match &mut splitter {
|
||||||
.send(tungstenite::Message::Binary(buf[..read].to_vec()))
|
Some(splitter) => splitter.push(&buf[..read]),
|
||||||
.await
|
None => vec![buf[..read].to_vec()],
|
||||||
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
|
};
|
||||||
|
for packet in packets {
|
||||||
|
ws_w.feed(tungstenite::Message::Binary(packet)).await.map_err(|error| {
|
||||||
|
tunnel_broke(stats, &connected, dc, &error.to_string(), &health)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
ws_w.flush().await.map_err(|error| {
|
||||||
|
tunnel_broke(stats, &connected, dc, &error.to_string(), &health)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
payload = pong_rx.recv() => match payload {
|
payload = pong_rx.recv() => match payload {
|
||||||
Some(payload) => {
|
Some(payload) => {
|
||||||
ws_w.send(tungstenite::Message::Pong(payload)).await
|
ws_w.send(tungstenite::Message::Pong(payload)).await.map_err(|error| {
|
||||||
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
|
tunnel_broke(stats, &connected, dc, &error.to_string(), &health)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
None => break,
|
None => break,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = ws_w.close().await;
|
let _ = ws_w.close().await;
|
||||||
|
if health.went_silent() {
|
||||||
|
return Err(tunnel_route_error(stats, &connected, dc, SILENT_ROUTE));
|
||||||
|
}
|
||||||
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
|
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -885,45 +930,49 @@ async fn cdn_tcp_tunnel(
|
|||||||
Some((upstream, downstream)) => (Some(upstream), Some(downstream)),
|
Some((upstream, downstream)) => (Some(upstream), Some(downstream)),
|
||||||
None => (None, None),
|
None => (None, None),
|
||||||
};
|
};
|
||||||
|
let health = TunnelHealth::new();
|
||||||
let upstream = async {
|
let upstream = async {
|
||||||
let mut crypto = upstream_crypto;
|
let mut crypto = upstream_crypto;
|
||||||
let mut buffer = vec![0; 65536];
|
let mut buffer = vec![0; 65536];
|
||||||
loop {
|
loop {
|
||||||
let read = client_r.read(&mut buffer).await?;
|
let read = client_r.read(&mut buffer).await?;
|
||||||
if read == 0 {
|
if read == 0 {
|
||||||
|
if health.went_silent() {
|
||||||
|
return Err(tunnel_route_error(stats, connected, dc, SILENT_ROUTE));
|
||||||
|
}
|
||||||
return Ok::<_, Box<dyn std::error::Error + Send + Sync>>(());
|
return Ok::<_, Box<dyn std::error::Error + Send + Sync>>(());
|
||||||
}
|
}
|
||||||
|
health.note_request();
|
||||||
if let Some(crypto) = &mut crypto {
|
if let Some(crypto) = &mut crypto {
|
||||||
crypto.apply(&mut buffer[..read]);
|
crypto.apply(&mut buffer[..read]);
|
||||||
}
|
}
|
||||||
remote_w
|
remote_w
|
||||||
.write_all(&buffer[..read])
|
.write_all(&buffer[..read])
|
||||||
.await
|
.await
|
||||||
.map_err(|error| tunnel_route_error(stats, connected, dc, &error.to_string()))?;
|
.map_err(|error| tunnel_broke(stats, connected, dc, &error.to_string(), &health))?;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let downstream = async {
|
let downstream = async {
|
||||||
let mut crypto = downstream_crypto;
|
let mut crypto = downstream_crypto;
|
||||||
let mut buffer = vec![0; 65536];
|
let mut buffer = vec![0; 65536];
|
||||||
let mut received_payload = false;
|
|
||||||
loop {
|
loop {
|
||||||
let read = remote_r
|
let read = remote_r
|
||||||
.read(&mut buffer)
|
.read(&mut buffer)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| tunnel_route_error(stats, connected, dc, &error.to_string()))?;
|
.map_err(|error| tunnel_broke(stats, connected, dc, &error.to_string(), &health))?;
|
||||||
if read == 0 {
|
if read == 0 {
|
||||||
return if received_payload {
|
return if health.replied() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(tunnel_route_error(
|
Err(tunnel_route_error(
|
||||||
stats,
|
stats,
|
||||||
connected,
|
connected,
|
||||||
dc,
|
dc,
|
||||||
"CDN TCP закрыт без ответа Telegram",
|
"MTProto TCP закрыт без ответа Telegram",
|
||||||
))
|
))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
received_payload = true;
|
health.note_reply();
|
||||||
if let Some(crypto) = &mut crypto {
|
if let Some(crypto) = &mut crypto {
|
||||||
crypto.apply(&mut buffer[..read]);
|
crypto.apply(&mut buffer[..read]);
|
||||||
}
|
}
|
||||||
@@ -969,6 +1018,92 @@ fn tunnel_route_error(
|
|||||||
message.into()
|
message.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SILENT_ROUTE: &str = "Telegram не ответил на запрос, клиент ушёл сам";
|
||||||
|
|
||||||
|
/// Сколько туннель должен проработать, чтобы его обрыв не считался поломкой
|
||||||
|
/// маршрута. Один ответ ничего не доказывает: DPI режет и после первых байт.
|
||||||
|
const PROVEN_TUNNEL: Duration = if cfg!(test) {
|
||||||
|
Duration::from_millis(500)
|
||||||
|
} else {
|
||||||
|
Duration::from_secs(15)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Сколько Telegram может молчать в ответ на запрос. Он отвечает за доли секунды.
|
||||||
|
const SILENT_TUNNEL: Duration = if cfg!(test) {
|
||||||
|
Duration::from_millis(500)
|
||||||
|
} else {
|
||||||
|
Duration::from_secs(5)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Что туннель показал о своём маршруте.
|
||||||
|
struct TunnelHealth {
|
||||||
|
opened: std::time::Instant,
|
||||||
|
requested: AtomicBool,
|
||||||
|
replied: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TunnelHealth {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
opened: std::time::Instant::now(),
|
||||||
|
requested: AtomicBool::new(false),
|
||||||
|
replied: AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_request(&self) {
|
||||||
|
self.requested.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_reply(&self) {
|
||||||
|
self.replied.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replied(&self) -> bool {
|
||||||
|
self.replied.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Маршрут работал по-настоящему: Telegram отвечал, и не один миг.
|
||||||
|
fn proven(&self) -> bool {
|
||||||
|
self.replied() && self.opened.elapsed() >= PROVEN_TUNNEL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Клиент спросил, Telegram промолчал, и клиент сдался.
|
||||||
|
///
|
||||||
|
/// Раньше такой маршрут оставался предпочтительным: TCP открылся, значит
|
||||||
|
/// успех, а отказа никто не зафиксировал. Запасные маршруты до очереди так
|
||||||
|
/// и не доходили. Быстрый уход не считается: клиент сам закрывает лишние
|
||||||
|
/// соединения из своей гонки.
|
||||||
|
fn went_silent(&self) -> bool {
|
||||||
|
self.requested.load(Ordering::Relaxed)
|
||||||
|
&& !self.replied()
|
||||||
|
&& self.opened.elapsed() >= SILENT_TUNNEL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Соединение оборвалось на полпути.
|
||||||
|
///
|
||||||
|
/// Обрыв долгоживущего туннеля обычен, и пауза на рабочем маршруте оставила бы
|
||||||
|
/// весь дата-центр без связи. Клиент сам откроет новое соединение. Маршрут
|
||||||
|
/// наказывается, если он не успел доказать, что работает.
|
||||||
|
fn tunnel_broke(
|
||||||
|
stats: &Stats,
|
||||||
|
connected: &crate::transport::ConnectedRoute,
|
||||||
|
dc: u16,
|
||||||
|
reason: &str,
|
||||||
|
health: &TunnelHealth,
|
||||||
|
) -> Box<dyn std::error::Error + Send + Sync> {
|
||||||
|
if !health.proven() {
|
||||||
|
return tunnel_route_error(stats, connected, dc, reason);
|
||||||
|
}
|
||||||
|
let message = format!(
|
||||||
|
"Обрыв туннеля DC{dc} через {}: {reason}",
|
||||||
|
connected.route.connect_host
|
||||||
|
);
|
||||||
|
stats.note(message.clone());
|
||||||
|
message.into()
|
||||||
|
}
|
||||||
|
|
||||||
async fn tcp_relay(a: TcpStream, b: TcpStream) {
|
async fn tcp_relay(a: TcpStream, b: TcpStream) {
|
||||||
let (mut ar, mut aw) = tokio::io::split(a);
|
let (mut ar, mut aw) = tokio::io::split(a);
|
||||||
let (mut br, mut bw) = tokio::io::split(b);
|
let (mut br, mut bw) = tokio::io::split(b);
|
||||||
@@ -1706,6 +1841,225 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Точка фронта Telegram: принять WebSocket и вернуть его вместе с `init`.
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
async fn accept_relay(
|
||||||
|
listener: TcpListener,
|
||||||
|
) -> (
|
||||||
|
tokio_tungstenite::WebSocketStream<TcpStream>,
|
||||||
|
[u8; INIT_LEN],
|
||||||
|
) {
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
let (tcp, _) = listener.accept().await.unwrap();
|
||||||
|
let mut ws =
|
||||||
|
tokio_tungstenite::accept_hdr_async(tcp, |_: &Request, mut response: Response| {
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("Sec-WebSocket-Protocol", "binary".parse().unwrap());
|
||||||
|
Ok(response)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let init = match ws.next().await.unwrap().unwrap() {
|
||||||
|
Message::Binary(init) => init,
|
||||||
|
other => panic!("expected init: {other:?}"),
|
||||||
|
};
|
||||||
|
(ws, init.try_into().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_packets_per_frame(kind: crate::transport::RouteKind, expected_frames: usize) {
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
let first = [&44_u32.to_le_bytes()[..], &[1; 44]].concat();
|
||||||
|
let second = [&300_u32.to_le_bytes()[..], &[2; 300]].concat();
|
||||||
|
let request = [first.clone(), second.clone()].concat();
|
||||||
|
let total = request.len();
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let relay_port = listener.local_addr().unwrap().port();
|
||||||
|
let relay = tokio::spawn(async move {
|
||||||
|
let (mut ws, init) = accept_relay(listener).await;
|
||||||
|
let mut peer = crate::mtproto::test_relay_peer(&init);
|
||||||
|
let mut frames = Vec::new();
|
||||||
|
let mut received = 0;
|
||||||
|
while received < total {
|
||||||
|
if let Message::Binary(mut data) = ws.next().await.unwrap().unwrap() {
|
||||||
|
peer.decrypt(&mut data);
|
||||||
|
received += data.len();
|
||||||
|
frames.push(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frames
|
||||||
|
});
|
||||||
|
|
||||||
|
let stats = Stats::new();
|
||||||
|
stats
|
||||||
|
.transport
|
||||||
|
.force_local_route_with(relay_port, kind, "/apiws".to_owned());
|
||||||
|
let (port, server) = start_proxy(stats.clone(), false).await;
|
||||||
|
let init = unambiguous_client_init(&stats.secret, 2);
|
||||||
|
let mut peer = crate::mtproto::test_client_peer(&init, &stats.secret);
|
||||||
|
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||||
|
client.write_all(&init).await.unwrap();
|
||||||
|
// Одна запись TCP: так клиент отправляет msgs_ack вместе с запросом.
|
||||||
|
let mut wire = request.clone();
|
||||||
|
peer.encrypt(&mut wire);
|
||||||
|
client.write_all(&wire).await.unwrap();
|
||||||
|
|
||||||
|
let frames = tokio::time::timeout(Duration::from_secs(5), relay)
|
||||||
|
.await
|
||||||
|
.expect("every packet must reach the relay")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(frames.len(), expected_frames);
|
||||||
|
assert_eq!(frames.concat(), request);
|
||||||
|
if expected_frames == 2 {
|
||||||
|
assert_eq!(frames, vec![first, second]);
|
||||||
|
}
|
||||||
|
stats.stop();
|
||||||
|
drop(client);
|
||||||
|
server.await.unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn websocket_frontend_gets_one_packet_per_frame() {
|
||||||
|
// Фронт разбирает один пакет на кадр, остальное застревает до следующего.
|
||||||
|
check_packets_per_frame(crate::transport::RouteKind::TelegramIp, 2).await;
|
||||||
|
check_packets_per_frame(crate::transport::RouteKind::TelegramPlainWs, 2).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cloudflare_worker_needs_no_splitting() {
|
||||||
|
check_packets_per_frame(crate::transport::RouteKind::CloudflareWorker, 1).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Туннель, который ответил и через `lifetime` оборвался без закрывающего
|
||||||
|
/// кадра: так выглядит «Connection reset without closing handshake».
|
||||||
|
async fn break_tunnel_after(lifetime: Duration) -> Arc<Stats> {
|
||||||
|
use futures_util::SinkExt;
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let relay_port = listener.local_addr().unwrap().port();
|
||||||
|
let relay = tokio::spawn(async move {
|
||||||
|
let (mut ws, _) = accept_relay(listener).await;
|
||||||
|
ws.send(Message::Binary(vec![1, 2, 3, 4])).await.unwrap();
|
||||||
|
tokio::time::sleep(lifetime).await;
|
||||||
|
drop(ws);
|
||||||
|
});
|
||||||
|
let stats = Stats::new();
|
||||||
|
stats.transport.force_local_route(relay_port);
|
||||||
|
let (port, server) = start_proxy(stats.clone(), false).await;
|
||||||
|
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||||
|
client
|
||||||
|
.write_all(&unambiguous_client_init(&stats.secret, 2))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut reply = Vec::new();
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), client.read_to_end(&mut reply))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
relay.await.unwrap();
|
||||||
|
wait_until("connection task finished", || {
|
||||||
|
stats.active.load(Ordering::Relaxed) == 0
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
stats.stop();
|
||||||
|
server.await.unwrap().unwrap();
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_tunnel_that_proved_itself_does_not_penalize_its_route_when_it_breaks() {
|
||||||
|
// Иначе один обрыв рабочего туннеля ставил маршрут на паузу, и DC
|
||||||
|
// оставался без связи.
|
||||||
|
let stats = break_tunnel_after(PROVEN_TUNNEL + Duration::from_millis(100)).await;
|
||||||
|
assert_eq!(stats.transport.route_failures(), 0);
|
||||||
|
assert_eq!(stats.ws_failures.load(Ordering::Relaxed), 1);
|
||||||
|
assert!(stats
|
||||||
|
.drain_events()
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.contains("Обрыв туннеля DC2")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_route_that_answers_once_and_dies_is_still_penalized() {
|
||||||
|
// Иначе он навсегда оставался бы предпочтительным: DPI режет и после
|
||||||
|
// первых байт, и запасные маршруты до очереди не доходили.
|
||||||
|
let stats = break_tunnel_after(Duration::ZERO).await;
|
||||||
|
assert_eq!(stats.transport.route_failures(), 1);
|
||||||
|
assert!(stats
|
||||||
|
.drain_events()
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.contains("Сбой туннеля DC2")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Telegram молчит, клиент спрашивает и уходит через `patience`.
|
||||||
|
async fn leave_silent_route_after(patience: Duration, raw_tcp: bool) -> Arc<Stats> {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let relay_port = listener.local_addr().unwrap().port();
|
||||||
|
let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
|
||||||
|
let relay = tokio::spawn(async move {
|
||||||
|
// Соединение держится открытым и без ответа, пока клиент не уйдёт.
|
||||||
|
if raw_tcp {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let _ = done_rx.await;
|
||||||
|
drop(stream);
|
||||||
|
} else {
|
||||||
|
let (ws, _) = accept_relay(listener).await;
|
||||||
|
let _ = done_rx.await;
|
||||||
|
drop(ws);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let stats = Stats::new();
|
||||||
|
if raw_tcp {
|
||||||
|
stats.transport.force_local_route_with(
|
||||||
|
relay_port,
|
||||||
|
crate::transport::RouteKind::TelegramTcp,
|
||||||
|
String::new(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
stats.transport.force_local_route(relay_port);
|
||||||
|
}
|
||||||
|
let (port, server) = start_proxy(stats.clone(), false).await;
|
||||||
|
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||||
|
let init = unambiguous_client_init(&stats.secret, if raw_tcp { 203 } else { 2 });
|
||||||
|
client.write_all(&init).await.unwrap();
|
||||||
|
client.write_all(&[0; 16]).await.unwrap();
|
||||||
|
tokio::time::sleep(patience).await;
|
||||||
|
drop(client);
|
||||||
|
wait_until("connection task finished", || {
|
||||||
|
stats.active.load(Ordering::Relaxed) == 0
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let _ = done_tx.send(());
|
||||||
|
relay.await.unwrap();
|
||||||
|
stats.stop();
|
||||||
|
server.await.unwrap().unwrap();
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_silent_route_is_penalized_when_the_client_gives_up() {
|
||||||
|
// Иначе CDN, который принял TCP и молчит, оставался первым в списке, и
|
||||||
|
// запасной маршрут через порт 80 не получал ни одного шанса.
|
||||||
|
for raw_tcp in [true, false] {
|
||||||
|
let wait = SILENT_TUNNEL + Duration::from_millis(100);
|
||||||
|
let stats = leave_silent_route_after(wait, raw_tcp).await;
|
||||||
|
assert_eq!(stats.transport.route_failures(), 1, "raw_tcp {raw_tcp}");
|
||||||
|
assert!(stats
|
||||||
|
.drain_events()
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.contains(SILENT_ROUTE)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_client_that_leaves_at_once_does_not_penalize_the_route() {
|
||||||
|
// Клиент сам закрывает лишние соединения из своей гонки.
|
||||||
|
for raw_tcp in [true, false] {
|
||||||
|
let stats = leave_silent_route_after(Duration::ZERO, raw_tcp).await;
|
||||||
|
assert_eq!(stats.transport.route_failures(), 0, "raw_tcp {raw_tcp}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fragmented_encrypted_upload_and_download_preserve_both_streams() {
|
async fn fragmented_encrypted_upload_and_download_preserve_both_streams() {
|
||||||
check_fragmented_encrypted_duplex(false).await;
|
check_fragmented_encrypted_duplex(false).await;
|
||||||
@@ -1745,7 +2099,7 @@ mod tests {
|
|||||||
assert!(stats
|
assert!(stats
|
||||||
.drain_events()
|
.drain_events()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|event| event.contains("CDN TCP закрыт без ответа")));
|
.any(|event| event.contains("MTProto TCP закрыт без ответа")));
|
||||||
relay.await.unwrap();
|
relay.await.unwrap();
|
||||||
stats.stop();
|
stats.stop();
|
||||||
server.await.unwrap().unwrap();
|
server.await.unwrap().unwrap();
|
||||||
|
|||||||
+568
-117
@@ -9,12 +9,15 @@ use tokio::time::Instant;
|
|||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||||
|
|
||||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(4);
|
// Живой адрес под нагрузкой не всегда укладывался в 4 с, а упавший маршрут уходит
|
||||||
|
// на паузу. Столько же ждёт Flowseal/tg-ws-proxy.
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
|
||||||
const FALLBACK_DELAY: Duration = Duration::from_millis(250);
|
const FALLBACK_DELAY: Duration = Duration::from_millis(250);
|
||||||
const MAX_CONNECTING: usize = 3;
|
const MAX_CONNECTING: usize = 3;
|
||||||
const FAILURE_BACKOFF_INITIAL: Duration = Duration::from_secs(30);
|
const FAILURE_BACKOFF_INITIAL: Duration = Duration::from_secs(30);
|
||||||
const FAILURE_BACKOFF_MAX: Duration = Duration::from_secs(30 * 60);
|
const FAILURE_BACKOFF_MAX: Duration = Duration::from_secs(30 * 60);
|
||||||
const HTTPS_PORT: u16 = 443;
|
const HTTPS_PORT: u16 = 443;
|
||||||
|
const HTTP_PORT: u16 = 80;
|
||||||
const CDN203_IP: &str = "91.105.192.100";
|
const CDN203_IP: &str = "91.105.192.100";
|
||||||
|
|
||||||
pub type TelegramWebSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
pub type TelegramWebSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||||
@@ -33,6 +36,13 @@ pub enum RouteKind {
|
|||||||
SystemDns,
|
SystemDns,
|
||||||
CloudflareWorker,
|
CloudflareWorker,
|
||||||
TelegramTcp,
|
TelegramTcp,
|
||||||
|
/// Свой домен на Cloudflare: записи `kwsN` указывают на адреса Telegram,
|
||||||
|
/// и Cloudflare сам доводит WebSocket до дата-центра. Worker и его лимиты
|
||||||
|
/// не нужны — так работает CF-прокси в Flowseal/tg-ws-proxy.
|
||||||
|
CloudflareProxy,
|
||||||
|
/// WebSocket без TLS на порту 80 родного адреса DC. Единственный прямой
|
||||||
|
/// WebSocket к CDN203, у которого нет фронта на 443.
|
||||||
|
TelegramPlainWs,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RouteKind {
|
impl RouteKind {
|
||||||
@@ -43,6 +53,8 @@ impl RouteKind {
|
|||||||
Self::SystemDns => 3,
|
Self::SystemDns => 3,
|
||||||
Self::CloudflareWorker => 4,
|
Self::CloudflareWorker => 4,
|
||||||
Self::TelegramTcp => 5,
|
Self::TelegramTcp => 5,
|
||||||
|
Self::CloudflareProxy => 6,
|
||||||
|
Self::TelegramPlainWs => 7,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +65,8 @@ impl RouteKind {
|
|||||||
3 => Some(Self::SystemDns),
|
3 => Some(Self::SystemDns),
|
||||||
4 => Some(Self::CloudflareWorker),
|
4 => Some(Self::CloudflareWorker),
|
||||||
5 => Some(Self::TelegramTcp),
|
5 => Some(Self::TelegramTcp),
|
||||||
|
6 => Some(Self::CloudflareProxy),
|
||||||
|
7 => Some(Self::TelegramPlainWs),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,7 +78,9 @@ impl RouteKind {
|
|||||||
Self::AlternateTelegramIp => "Запасной Telegram IP",
|
Self::AlternateTelegramIp => "Запасной Telegram IP",
|
||||||
Self::SystemDns => "Системный DNS",
|
Self::SystemDns => "Системный DNS",
|
||||||
Self::CloudflareWorker => "Cloudflare Worker",
|
Self::CloudflareWorker => "Cloudflare Worker",
|
||||||
Self::TelegramTcp => "Telegram CDN TCP",
|
Self::TelegramTcp => "Telegram TCP (MTProto)",
|
||||||
|
Self::CloudflareProxy => "Cloudflare-прокси",
|
||||||
|
Self::TelegramPlainWs => "Telegram WebSocket (порт 80)",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,8 +107,13 @@ pub struct Route {
|
|||||||
|
|
||||||
impl Route {
|
impl Route {
|
||||||
fn cdn_tcp() -> Self {
|
fn cdn_tcp() -> Self {
|
||||||
|
Self::telegram_tcp(CDN203_IP)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native obfuscated2 straight to a pinned Telegram address.
|
||||||
|
fn telegram_tcp(ip: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
connect_host: CDN203_IP.to_owned(),
|
connect_host: ip.to_owned(),
|
||||||
websocket_host: String::new(),
|
websocket_host: String::new(),
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
kind: RouteKind::TelegramTcp,
|
kind: RouteKind::TelegramTcp,
|
||||||
@@ -101,6 +122,23 @@ impl Route {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// WebSocket to a native Telegram address on port 80.
|
||||||
|
///
|
||||||
|
/// There is no TLS here, but no less protection than on `telegram_tcp`: the
|
||||||
|
/// payload is the same obfuscated MTProto stream, and MTProto authenticates
|
||||||
|
/// the server itself. Here the address picks the DC and the Host header does
|
||||||
|
/// not matter, so the address is all that goes in it.
|
||||||
|
fn plain_ws(ip: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
connect_host: ip.to_owned(),
|
||||||
|
websocket_host: ip.to_owned(),
|
||||||
|
path: "/apiws".to_owned(),
|
||||||
|
kind: RouteKind::TelegramPlainWs,
|
||||||
|
port: HTTP_PORT,
|
||||||
|
secure: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A production route: TLS on 443.
|
/// A production route: TLS on 443.
|
||||||
fn https(connect_host: String, websocket_host: String, path: String, kind: RouteKind) -> Self {
|
fn https(connect_host: String, websocket_host: String, path: String, kind: RouteKind) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -150,6 +188,7 @@ struct HealthState {
|
|||||||
pub struct TransportEngine {
|
pub struct TransportEngine {
|
||||||
health: Mutex<HealthState>,
|
health: Mutex<HealthState>,
|
||||||
worker_domains: Mutex<Vec<String>>,
|
worker_domains: Mutex<Vec<String>>,
|
||||||
|
cf_proxy_domains: Mutex<Vec<String>>,
|
||||||
/// Сколько раз отдельный маршрут не ответил.
|
/// Сколько раз отдельный маршрут не ответил.
|
||||||
///
|
///
|
||||||
/// Считается отдельно от `Stats::ws_failures`, который растёт только когда
|
/// Считается отдельно от `Stats::ws_failures`, который растёт только когда
|
||||||
@@ -194,22 +233,16 @@ impl TransportEngine {
|
|||||||
/// проверку, маршрут не появлялся, и «воркер настроен» ничем не отличалось
|
/// проверку, маршрут не появлялся, и «воркер настроен» ничем не отличалось
|
||||||
/// от «воркера нет» (by-sonic/tglock#50).
|
/// от «воркера нет» (by-sonic/tglock#50).
|
||||||
pub fn set_worker_domains(&self, domains: &[String]) -> WorkerDomains {
|
pub fn set_worker_domains(&self, domains: &[String]) -> WorkerDomains {
|
||||||
let mut accepted = Vec::new();
|
let result = split_domains(domains);
|
||||||
let mut rejected = Vec::new();
|
*self.worker_domains.lock().unwrap() = result.accepted.clone();
|
||||||
for domain in domains {
|
result
|
||||||
let trimmed = domain.trim();
|
}
|
||||||
if trimmed.is_empty() {
|
|
||||||
continue;
|
/// Задать свои домены на Cloudflare для CF-прокси (`kwsN.<домен>`).
|
||||||
}
|
pub fn set_cf_proxy_domains(&self, domains: &[String]) -> WorkerDomains {
|
||||||
let normalized = trimmed.to_ascii_lowercase();
|
let result = split_domains(domains);
|
||||||
if !valid_domain(&normalized) {
|
*self.cf_proxy_domains.lock().unwrap() = result.accepted.clone();
|
||||||
rejected.push(trimmed.to_owned());
|
result
|
||||||
} else if !accepted.contains(&normalized) {
|
|
||||||
accepted.push(normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*self.worker_domains.lock().unwrap() = accepted.clone();
|
|
||||||
WorkerDomains { accepted, rejected }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn connect(
|
pub async fn connect(
|
||||||
@@ -235,47 +268,21 @@ impl TransportEngine {
|
|||||||
F: FnMut(Route) -> Fut,
|
F: FnMut(Route) -> Fut,
|
||||||
Fut: std::future::Future<Output = Result<T, String>>,
|
Fut: std::future::Future<Output = Result<T, String>>,
|
||||||
{
|
{
|
||||||
let mut candidates = self.ordered_candidates(key).into_iter().peekable();
|
// Native TCP to an ordinary DC opens the moment the address is
|
||||||
let mut pending = FuturesUnordered::new();
|
// reachable, long before Telegram answers, so racing it would beat
|
||||||
let mut next_start = Instant::now();
|
// every slower working WebSocket. Like the TCP fallback in
|
||||||
|
// Flowseal/tg-ws-proxy it is tried only once every other route failed.
|
||||||
|
let (main, last_resort): (Vec<_>, Vec<_>) = self
|
||||||
|
.ordered_candidates(key)
|
||||||
|
.into_iter()
|
||||||
|
.partition(|route| !is_last_resort(route));
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
|
for candidates in [main, last_resort] {
|
||||||
while candidates.peek().is_some() || !pending.is_empty() {
|
if let Some(winner) = self
|
||||||
if candidates.peek().is_some()
|
.race_candidates(key, candidates, &mut connect, &mut errors)
|
||||||
&& pending.len() < MAX_CONNECTING
|
.await
|
||||||
&& (pending.is_empty() || Instant::now() >= next_start)
|
|
||||||
{
|
{
|
||||||
let route = candidates.next().unwrap();
|
return Ok(winner);
|
||||||
// Another client may have failed this route since we took our
|
|
||||||
// snapshot. Never knowingly bypass its current cooldown.
|
|
||||||
if !self.route_available(&route) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let connection = connect(route.clone());
|
|
||||||
pending.push(async move { (route, connection.await) });
|
|
||||||
next_start = Instant::now() + FALLBACK_DELAY;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
tokio::select! {
|
|
||||||
Some((route, result)) = pending.next(), if !pending.is_empty() => {
|
|
||||||
match result {
|
|
||||||
Ok(connection) => {
|
|
||||||
self.record_success(key, &route);
|
|
||||||
return Ok((connection, ConnectedRoute { route }));
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
self.record_failure(&route);
|
|
||||||
let endpoint = if route.kind == RouteKind::TelegramTcp {
|
|
||||||
format!("{}:{} (MTProto TCP)", route.connect_host, route.port)
|
|
||||||
} else {
|
|
||||||
format!("{} (TLS {})", route.connect_host, route.websocket_host)
|
|
||||||
};
|
|
||||||
errors.push(format!("{endpoint} — {error}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = tokio::time::sleep_until(next_start),
|
|
||||||
if candidates.peek().is_some() && pending.len() < MAX_CONNECTING => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +312,70 @@ impl TransportEngine {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Race one tier of candidates; failures are appended to `errors`.
|
||||||
|
async fn race_candidates<T, F, Fut>(
|
||||||
|
&self,
|
||||||
|
key: DcKey,
|
||||||
|
candidates: Vec<Route>,
|
||||||
|
connect: &mut F,
|
||||||
|
errors: &mut Vec<String>,
|
||||||
|
) -> Option<(T, ConnectedRoute)>
|
||||||
|
where
|
||||||
|
F: FnMut(Route) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<T, String>>,
|
||||||
|
{
|
||||||
|
let mut candidates = candidates.into_iter().peekable();
|
||||||
|
let mut pending = FuturesUnordered::new();
|
||||||
|
let mut next_start = Instant::now();
|
||||||
|
|
||||||
|
while candidates.peek().is_some() || !pending.is_empty() {
|
||||||
|
if candidates.peek().is_some()
|
||||||
|
&& pending.len() < MAX_CONNECTING
|
||||||
|
&& (pending.is_empty() || Instant::now() >= next_start)
|
||||||
|
{
|
||||||
|
let route = candidates.next().unwrap();
|
||||||
|
// Another client may have failed this route since we took our
|
||||||
|
// snapshot. Never knowingly bypass its current cooldown.
|
||||||
|
if !self.route_available(&route) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let connection = connect(route.clone());
|
||||||
|
pending.push(async move { (route, connection.await) });
|
||||||
|
next_start = Instant::now() + FALLBACK_DELAY;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
Some((route, result)) = pending.next(), if !pending.is_empty() => {
|
||||||
|
match result {
|
||||||
|
Ok(connection) => {
|
||||||
|
self.record_success(key, &route);
|
||||||
|
return Some((connection, ConnectedRoute { route }));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.record_failure(&route);
|
||||||
|
let endpoint = match route.kind {
|
||||||
|
RouteKind::TelegramTcp => {
|
||||||
|
format!("{}:{} (MTProto TCP)", route.connect_host, route.port)
|
||||||
|
}
|
||||||
|
RouteKind::TelegramPlainWs => {
|
||||||
|
format!("{}:{} (WebSocket)", route.connect_host, route.port)
|
||||||
|
}
|
||||||
|
_ => format!(
|
||||||
|
"{} (TLS {})",
|
||||||
|
route.connect_host, route.websocket_host
|
||||||
|
),
|
||||||
|
};
|
||||||
|
errors.push(format!("{endpoint} — {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tokio::time::sleep_until(next_start),
|
||||||
|
if candidates.peek().is_some() && pending.len() < MAX_CONNECTING => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn route_available(&self, route: &Route) -> bool {
|
fn route_available(&self, route: &Route) -> bool {
|
||||||
self.health
|
self.health
|
||||||
.lock()
|
.lock()
|
||||||
@@ -333,14 +404,19 @@ impl TransportEngine {
|
|||||||
candidates.sort_by_key(|route| {
|
candidates.sort_by_key(|route| {
|
||||||
let preferred_rank = u8::from(preferred != Some(route));
|
let preferred_rank = u8::from(preferred != Some(route));
|
||||||
let kind_rank = match route.kind {
|
let kind_rank = match route.kind {
|
||||||
RouteKind::TelegramTcp => 0,
|
RouteKind::TelegramTcp if route.connect_host == CDN203_IP => 0,
|
||||||
RouteKind::TelegramIp if Some(route) == all_routes.first() => 0,
|
RouteKind::TelegramIp if Some(route) == all_routes.first() => 0,
|
||||||
// The operator explicitly configured this independent path.
|
// The operator explicitly configured these independent paths.
|
||||||
// Start it after the primary IP, before more potentially
|
// Start them after the primary IP, before more potentially
|
||||||
// blocked Telegram addresses consume the concurrency budget.
|
// blocked Telegram addresses consume the concurrency budget.
|
||||||
RouteKind::CloudflareWorker => 1,
|
RouteKind::CloudflareWorker | RouteKind::CloudflareProxy => 1,
|
||||||
RouteKind::TelegramIp | RouteKind::AlternateTelegramIp => 2,
|
RouteKind::TelegramIp | RouteKind::AlternateTelegramIp => 2,
|
||||||
RouteKind::SystemDns => 3,
|
RouteKind::SystemDns => 3,
|
||||||
|
// Last resort, raced separately; see race_connections(). The
|
||||||
|
// WebSocket goes first: its upgrade is an answer from Telegram,
|
||||||
|
// while TCP only shows that the address accepts connections.
|
||||||
|
RouteKind::TelegramPlainWs => 4,
|
||||||
|
RouteKind::TelegramTcp => 5,
|
||||||
};
|
};
|
||||||
(preferred_rank, kind_rank)
|
(preferred_rank, kind_rank)
|
||||||
});
|
});
|
||||||
@@ -358,6 +434,15 @@ impl TransportEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut routes = routes_for_dc(key.dc, key.media);
|
let mut routes = routes_for_dc(key.dc, key.media);
|
||||||
|
for domain in self.cf_proxy_domains.lock().unwrap().iter() {
|
||||||
|
let host = cf_proxy_host(key.dc, domain);
|
||||||
|
routes.push(Route::https(
|
||||||
|
host.clone(),
|
||||||
|
host,
|
||||||
|
"/apiws".to_owned(),
|
||||||
|
RouteKind::CloudflareProxy,
|
||||||
|
));
|
||||||
|
}
|
||||||
let Some(path) = worker_path(key.dc) else {
|
let Some(path) = worker_path(key.dc) else {
|
||||||
return routes;
|
return routes;
|
||||||
};
|
};
|
||||||
@@ -423,6 +508,20 @@ pub(crate) fn worker_path(dc: u16) -> Option<String> {
|
|||||||
Some(format!("/apiws?dst={}&dc={}", destination, dc))
|
Some(format!("/apiws?dst={}&dc={}", destination, dc))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Host of a CF-proxy domain for a data centre: `kws<dc>.<domain>`, with DNS
|
||||||
|
/// records the operator points at the Telegram addresses (docs/CF_PROXY.md).
|
||||||
|
/// CDN203 keeps its own record; it must never be folded into DC2.
|
||||||
|
pub(crate) fn cf_proxy_host(dc: u16, domain: &str) -> String {
|
||||||
|
format!("kws{}.{}", dc, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes tried only once everything else failed: port 80, and native TCP to
|
||||||
|
/// an ordinary data centre. The CDN's native TCP is its primary route.
|
||||||
|
fn is_last_resort(route: &Route) -> bool {
|
||||||
|
route.kind == RouteKind::TelegramPlainWs
|
||||||
|
|| (route.kind == RouteKind::TelegramTcp && route.connect_host != CDN203_IP)
|
||||||
|
}
|
||||||
|
|
||||||
/// Every address a Worker may be asked to reach, so a deployment can refuse
|
/// Every address a Worker may be asked to reach, so a deployment can refuse
|
||||||
/// anything else instead of becoming an open TCP proxy.
|
/// anything else instead of becoming an open TCP proxy.
|
||||||
pub fn worker_allowed_destinations() -> Vec<&'static str> {
|
pub fn worker_allowed_destinations() -> Vec<&'static str> {
|
||||||
@@ -435,14 +534,118 @@ pub fn worker_allowed_destinations() -> Vec<&'static str> {
|
|||||||
all
|
all
|
||||||
}
|
}
|
||||||
|
|
||||||
fn canonical_dc(dc: u16) -> u16 {
|
/// Настоящий WebSocket-фронт Telegram: адрес и имена, которые он обслуживает.
|
||||||
if dc == 203 {
|
struct WebFront {
|
||||||
2
|
ip: &'static str,
|
||||||
} else {
|
/// Основной кластер. Принимает и обычные соединения, и медиа.
|
||||||
dc
|
host: &'static str,
|
||||||
|
/// Медиа-кластер (`-1`), только для медиа; см. `routes_for_dc`.
|
||||||
|
media_host: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn front(ip: &'static str, host: &'static str, media_host: &'static str) -> WebFront {
|
||||||
|
WebFront {
|
||||||
|
ip,
|
||||||
|
host,
|
||||||
|
media_host,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Где Telegram принимает WebSocket. Первый фронт каждого DC — тот, на который
|
||||||
|
/// указывает `kwsN.web.telegram.org`.
|
||||||
|
///
|
||||||
|
/// Это не адреса из `telegram_ips`: те говорят только на родном MTProto и на
|
||||||
|
/// TLS не отвечают из любой сети. Фронт выбирает DC по имени хоста, а не по
|
||||||
|
/// заголовку клиента, так что имя и адрес здесь нельзя менять местами: `.220`
|
||||||
|
/// на `kws1` отвечает редиректом. Каждая пара проверена запросом `req_DH_params`:
|
||||||
|
/// сервер принимает только свой номер DC, поэтому за фронтом стоит именно он.
|
||||||
|
fn web_fronts(dc: u16) -> &'static [WebFront] {
|
||||||
|
const DC1: &[WebFront] = &[
|
||||||
|
front(
|
||||||
|
"149.154.174.100",
|
||||||
|
"kws1.web.telegram.org",
|
||||||
|
"kws1-1.web.telegram.org",
|
||||||
|
),
|
||||||
|
front(
|
||||||
|
"149.154.174.200",
|
||||||
|
"zws1.web.telegram.org",
|
||||||
|
"zws1-1.web.telegram.org",
|
||||||
|
),
|
||||||
|
front(
|
||||||
|
"149.154.175.209",
|
||||||
|
"pluto.web.telegram.org",
|
||||||
|
"pluto-1.web.telegram.org",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const DC2: &[WebFront] = &[
|
||||||
|
front(
|
||||||
|
"149.154.167.220",
|
||||||
|
"kws2.web.telegram.org",
|
||||||
|
"kws2-1.web.telegram.org",
|
||||||
|
),
|
||||||
|
front(
|
||||||
|
"149.154.167.99",
|
||||||
|
"kws2.web.telegram.org",
|
||||||
|
"kws2-1.web.telegram.org",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const DC3: &[WebFront] = &[
|
||||||
|
front(
|
||||||
|
"149.154.174.100",
|
||||||
|
"kws3.web.telegram.org",
|
||||||
|
"kws3-1.web.telegram.org",
|
||||||
|
),
|
||||||
|
front(
|
||||||
|
"149.154.174.200",
|
||||||
|
"zws3.web.telegram.org",
|
||||||
|
"zws3-1.web.telegram.org",
|
||||||
|
),
|
||||||
|
front(
|
||||||
|
"149.154.175.209",
|
||||||
|
"aurora.web.telegram.org",
|
||||||
|
"aurora-1.web.telegram.org",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const DC4: &[WebFront] = &[
|
||||||
|
front(
|
||||||
|
"149.154.167.220",
|
||||||
|
"kws4.web.telegram.org",
|
||||||
|
"kws4-1.web.telegram.org",
|
||||||
|
),
|
||||||
|
front(
|
||||||
|
"149.154.167.99",
|
||||||
|
"kws4.web.telegram.org",
|
||||||
|
"kws4-1.web.telegram.org",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const DC5: &[WebFront] = &[
|
||||||
|
front(
|
||||||
|
"149.154.170.100",
|
||||||
|
"kws5.web.telegram.org",
|
||||||
|
"kws5-1.web.telegram.org",
|
||||||
|
),
|
||||||
|
front(
|
||||||
|
"149.154.170.200",
|
||||||
|
"zws5.web.telegram.org",
|
||||||
|
"zws5-1.web.telegram.org",
|
||||||
|
),
|
||||||
|
front(
|
||||||
|
"149.154.170.96",
|
||||||
|
"flora.web.telegram.org",
|
||||||
|
"flora-1.web.telegram.org",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
match dc {
|
||||||
|
1 => DC1,
|
||||||
|
2 => DC2,
|
||||||
|
3 => DC3,
|
||||||
|
4 => DC4,
|
||||||
|
5 => DC5,
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Родные адреса MTProto: сюда ходит прямой TCP и Worker. TLS здесь нет.
|
||||||
fn telegram_ips(dc: u16) -> &'static [&'static str] {
|
fn telegram_ips(dc: u16) -> &'static [&'static str] {
|
||||||
match dc {
|
match dc {
|
||||||
1 => &["149.154.175.50"],
|
1 => &["149.154.175.50"],
|
||||||
@@ -456,32 +659,33 @@ fn telegram_ips(dc: u16) -> &'static [&'static str] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> {
|
pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> {
|
||||||
if telegram_ips(dc).is_empty() {
|
if dc == 203 {
|
||||||
|
// CDN203 has no WebSocket frontend of its own: its address speaks only
|
||||||
|
// native obfuscated2 on 443, and kws2 on any frontend is plain DC2, where
|
||||||
|
// a CDN session has neither its keys nor its data. Port 80 of the same
|
||||||
|
// address does lead to the CDN. Worker and CF proxy routes are added on
|
||||||
|
// top in routes_for_key.
|
||||||
|
return vec![Route::cdn_tcp(), Route::plain_ws(CDN203_IP)];
|
||||||
|
}
|
||||||
|
let fronts = web_fronts(dc);
|
||||||
|
if fronts.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let websocket_dc = canonical_dc(dc);
|
|
||||||
let primary = format!("kws{}.web.telegram.org", websocket_dc);
|
|
||||||
let secondary = format!("kws{}-1.web.telegram.org", websocket_dc);
|
|
||||||
let websocket_hosts = if media {
|
|
||||||
[secondary, primary]
|
|
||||||
} else {
|
|
||||||
[primary, secondary]
|
|
||||||
};
|
|
||||||
let ips = telegram_ips(dc);
|
|
||||||
let mut routes = Vec::new();
|
|
||||||
if dc == 203 {
|
|
||||||
// CDN203 speaks native obfuscated2 on this exact IP:443. A WebSocket
|
|
||||||
// handshake on the same address can time out even while MTProto is
|
|
||||||
// healthy. Keep the destination unchanged and use its native wire
|
|
||||||
// transport; never redirect CDN authorization to ordinary DC2.
|
|
||||||
routes.push(Route::cdn_tcp());
|
|
||||||
}
|
|
||||||
|
|
||||||
for websocket_host in &websocket_hosts {
|
// Only media may use the `-1` hosts. That cluster rejects an ordinary
|
||||||
for (index, ip) in ips.iter().enumerate() {
|
// connection's key exchange with -444 (measured on kws2-1 and kws4-1), and
|
||||||
|
// Telegram Desktop answers -444 by turning the proxy off as misconfigured.
|
||||||
|
let clusters: &[fn(&WebFront) -> &'static str] = if media {
|
||||||
|
&[|front| front.media_host, |front| front.host]
|
||||||
|
} else {
|
||||||
|
&[|front| front.host]
|
||||||
|
};
|
||||||
|
let mut routes = Vec::new();
|
||||||
|
for host in clusters {
|
||||||
|
for (index, front) in fronts.iter().enumerate() {
|
||||||
routes.push(Route::https(
|
routes.push(Route::https(
|
||||||
(*ip).to_owned(),
|
front.ip.to_owned(),
|
||||||
websocket_host.clone(),
|
host(front).to_owned(),
|
||||||
"/apiws".to_owned(),
|
"/apiws".to_owned(),
|
||||||
if index == 0 {
|
if index == 0 {
|
||||||
RouteKind::TelegramIp
|
RouteKind::TelegramIp
|
||||||
@@ -490,18 +694,18 @@ pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> {
|
|||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// DC203 uses the DC2 hostname only for TLS/HTTP virtual hosting.
|
let name = host(&fronts[0]).to_owned();
|
||||||
// Resolving it would connect to DC2 itself and send CDN sessions to
|
routes.push(Route::https(
|
||||||
// the wrong data centre. Its fallback must retain the CDN address.
|
name.clone(),
|
||||||
if dc != 203 {
|
name,
|
||||||
routes.push(Route::https(
|
"/apiws".to_owned(),
|
||||||
websocket_host.clone(),
|
RouteKind::SystemDns,
|
||||||
websocket_host.clone(),
|
));
|
||||||
"/apiws".to_owned(),
|
|
||||||
RouteKind::SystemDns,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Where the frontends are out of reach, the DC's own address may still
|
||||||
|
// work. Raced only after everything above failed (race_connections).
|
||||||
|
routes.push(Route::plain_ws(telegram_ips(dc)[0]));
|
||||||
|
routes.push(Route::telegram_tcp(telegram_ips(dc)[0]));
|
||||||
routes
|
routes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +743,14 @@ async fn connect_route_with_config(
|
|||||||
// Native TCP is a narrowly scoped CDN route, not a general proxy escape
|
// Native TCP is a narrowly scoped CDN route, not a general proxy escape
|
||||||
// hatch. Reject malformed raw routes before dialing anything.
|
// hatch. Reject malformed raw routes before dialing anything.
|
||||||
if route.kind == RouteKind::TelegramTcp && !allowed_tcp_route(route) {
|
if route.kind == RouteKind::TelegramTcp && !allowed_tcp_route(route) {
|
||||||
return Err("native MTProto TCP разрешён только для закреплённого CDN203".to_owned());
|
return Err(
|
||||||
|
"native MTProto TCP разрешён только до закреплённых адресов Telegram".to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if route.kind == RouteKind::TelegramPlainWs && !allowed_plain_ws_route(route) {
|
||||||
|
return Err(
|
||||||
|
"WebSocket без TLS разрешён только до закреплённых адресов Telegram".to_owned(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let tcp = tokio::time::timeout(
|
let tcp = tokio::time::timeout(
|
||||||
CONNECT_TIMEOUT,
|
CONNECT_TIMEOUT,
|
||||||
@@ -569,8 +780,8 @@ async fn connect_route_with_config(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if !route.secure {
|
if !route.secure {
|
||||||
// Raw TCP has already returned above. Plain WebSocket is only used by
|
// Raw TCP has already returned above. Besides local fixtures, plain
|
||||||
// local fixtures; production WebSocket routes are always HTTPS.
|
// WebSocket is only allowed to pinned Telegram addresses, checked above.
|
||||||
return tokio::time::timeout(
|
return tokio::time::timeout(
|
||||||
CONNECT_TIMEOUT,
|
CONNECT_TIMEOUT,
|
||||||
tokio_tungstenite::client_async(request, MaybeTlsStream::Plain(tcp)),
|
tokio_tungstenite::client_async(request, MaybeTlsStream::Plain(tcp)),
|
||||||
@@ -596,18 +807,48 @@ async fn connect_route_with_config(
|
|||||||
.map_err(|error| format!("TLS/WebSocket handshake: {}", error))
|
.map_err(|error| format!("TLS/WebSocket handshake: {}", error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn allowed_plain_ws_route(route: &Route) -> bool {
|
||||||
|
#[cfg(test)]
|
||||||
|
if route.connect_host == "127.0.0.1" {
|
||||||
|
return !route.secure;
|
||||||
|
}
|
||||||
|
worker_allowed_destinations().contains(&route.connect_host.as_str())
|
||||||
|
&& route.port == HTTP_PORT
|
||||||
|
&& route.websocket_host == route.connect_host
|
||||||
|
&& !route.secure
|
||||||
|
}
|
||||||
|
|
||||||
fn allowed_tcp_route(route: &Route) -> bool {
|
fn allowed_tcp_route(route: &Route) -> bool {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
if route.connect_host == "127.0.0.1" && !route.secure {
|
if route.connect_host == "127.0.0.1" && !route.secure {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
route.connect_host == CDN203_IP
|
worker_allowed_destinations().contains(&route.connect_host.as_str())
|
||||||
&& route.port == HTTPS_PORT
|
&& route.port == HTTPS_PORT
|
||||||
&& route.websocket_host.is_empty()
|
&& route.websocket_host.is_empty()
|
||||||
&& route.path.is_empty()
|
&& route.path.is_empty()
|
||||||
&& !route.secure
|
&& !route.secure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Нормализовать список доменов, отделив непохожие на имя хоста.
|
||||||
|
fn split_domains(domains: &[String]) -> WorkerDomains {
|
||||||
|
let mut accepted = Vec::new();
|
||||||
|
let mut rejected = Vec::new();
|
||||||
|
for domain in domains {
|
||||||
|
let trimmed = domain.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let normalized = trimmed.to_ascii_lowercase();
|
||||||
|
if !valid_domain(&normalized) {
|
||||||
|
rejected.push(trimmed.to_owned());
|
||||||
|
} else if !accepted.contains(&normalized) {
|
||||||
|
accepted.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WorkerDomains { accepted, rejected }
|
||||||
|
}
|
||||||
|
|
||||||
fn valid_domain(domain: &str) -> bool {
|
fn valid_domain(domain: &str) -> bool {
|
||||||
if domain.is_empty() || domain.len() > 253 || !domain.contains('.') {
|
if domain.is_empty() || domain.len() > 253 || !domain.contains('.') {
|
||||||
return false;
|
return false;
|
||||||
@@ -631,7 +872,73 @@ mod tests {
|
|||||||
fn media_prefers_dash_one_websocket_host() {
|
fn media_prefers_dash_one_websocket_host() {
|
||||||
let routes = routes_for_dc(2, true);
|
let routes = routes_for_dc(2, true);
|
||||||
assert_eq!(routes[0].websocket_host, "kws2-1.web.telegram.org");
|
assert_eq!(routes[0].websocket_host, "kws2-1.web.telegram.org");
|
||||||
assert_eq!(routes[0].connect_host, "149.154.167.51");
|
assert_eq!(routes[0].connect_host, "149.154.167.220");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_media_may_reach_the_dash_one_cluster() {
|
||||||
|
// That cluster answers an ordinary key exchange with -444, and Telegram
|
||||||
|
// Desktop reacts by disabling the proxy as misconfigured.
|
||||||
|
for dc in [1, 2, 3, 4, 5] {
|
||||||
|
let dash_one = |route: &Route| route.websocket_host.contains("-1.");
|
||||||
|
assert!(
|
||||||
|
!routes_for_dc(dc, false).iter().any(dash_one),
|
||||||
|
"DC{dc} ordinary connection on a media-only cluster"
|
||||||
|
);
|
||||||
|
let media = routes_for_dc(dc, true);
|
||||||
|
assert!(dash_one(&media[0]), "DC{dc}: {:?}", media[0]);
|
||||||
|
// The main cluster accepts media too, so it stays as a fallback.
|
||||||
|
assert!(media
|
||||||
|
.iter()
|
||||||
|
.any(|route| route.websocket_host == format!("kws{dc}.web.telegram.org")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_routes_only_dial_real_frontends() {
|
||||||
|
// The native addresses speak MTProto and answer TLS with garbage from
|
||||||
|
// any network, so a WebSocket route there can never connect.
|
||||||
|
for dc in [1, 2, 3, 4, 5, 203] {
|
||||||
|
for media in [false, true] {
|
||||||
|
for route in routes_for_dc(dc, media) {
|
||||||
|
if matches!(
|
||||||
|
route.kind,
|
||||||
|
RouteKind::TelegramTcp | RouteKind::TelegramPlainWs | RouteKind::SystemDns
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
web_fronts(dc)
|
||||||
|
.iter()
|
||||||
|
.any(|front| front.ip == route.connect_host),
|
||||||
|
"DC{dc}: {route:?}"
|
||||||
|
);
|
||||||
|
assert!(route.secure, "DC{dc}: {route:?}");
|
||||||
|
assert_ne!(route.connect_host, telegram_ips(dc)[0], "DC{dc}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontends_are_only_dialled_under_a_name_they_serve() {
|
||||||
|
// A frontend picks the DC from the Host header, not from the client's
|
||||||
|
// init: kws2 is DC2 whatever the header says, and .220 redirects kws1.
|
||||||
|
for (dc, name) in [
|
||||||
|
(1, "kws1."),
|
||||||
|
(2, "kws2."),
|
||||||
|
(3, "kws3."),
|
||||||
|
(4, "kws4."),
|
||||||
|
(5, "kws5."),
|
||||||
|
] {
|
||||||
|
let routes = routes_for_dc(dc, false);
|
||||||
|
assert!(routes[0].websocket_host.starts_with(name), "{routes:?}");
|
||||||
|
let front = &web_fronts(dc)[0];
|
||||||
|
assert_eq!(routes[0].connect_host, front.ip);
|
||||||
|
}
|
||||||
|
assert!(routes_for_dc(1, false)
|
||||||
|
.iter()
|
||||||
|
.all(|route| route.connect_host != "149.154.167.220"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -641,7 +948,8 @@ mod tests {
|
|||||||
assert!(routes[0].websocket_host.is_empty());
|
assert!(routes[0].websocket_host.is_empty());
|
||||||
assert!(!routes[0].secure);
|
assert!(!routes[0].secure);
|
||||||
assert_eq!(routes[0].connect_host, "91.105.192.100");
|
assert_eq!(routes[0].connect_host, "91.105.192.100");
|
||||||
assert_eq!(routes[1].websocket_host, "kws2.web.telegram.org");
|
assert_eq!(routes[1], Route::plain_ws("91.105.192.100"));
|
||||||
|
assert_eq!(routes.len(), 2, "CDN203 has no WebSocket frontend on 443");
|
||||||
assert!(routes
|
assert!(routes
|
||||||
.iter()
|
.iter()
|
||||||
.all(|route| route.connect_host == "91.105.192.100"));
|
.all(|route| route.connect_host == "91.105.192.100"));
|
||||||
@@ -714,27 +1022,149 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn production_websockets_use_tls_and_native_tcp_is_restricted_to_cdn203() {
|
fn production_websockets_use_tls_and_native_tcp_only_reaches_pinned_telegram_ips() {
|
||||||
let engine = TransportEngine::new();
|
let engine = TransportEngine::new();
|
||||||
engine.set_worker_domains(&["fallback.workers.dev".to_owned()]);
|
engine.set_worker_domains(&["fallback.workers.dev".to_owned()]);
|
||||||
|
engine.set_cf_proxy_domains(&["example.com".to_owned()]);
|
||||||
for dc in [1, 2, 3, 4, 5, 203] {
|
for dc in [1, 2, 3, 4, 5, 203] {
|
||||||
for media in [false, true] {
|
for media in [false, true] {
|
||||||
let routes = engine.routes_for_key(DcKey { dc, media });
|
let routes = engine.routes_for_key(DcKey { dc, media });
|
||||||
assert!(!routes.is_empty(), "DC{dc} must have at least one route");
|
assert!(!routes.is_empty(), "DC{dc} must have at least one route");
|
||||||
for route in routes {
|
for route in routes {
|
||||||
assert_eq!(route.port, 443, "{route:?}");
|
match route.kind {
|
||||||
if route.kind == RouteKind::TelegramTcp {
|
RouteKind::TelegramTcp => {
|
||||||
assert_eq!(dc, 203);
|
assert_eq!(route.port, 443, "{route:?}");
|
||||||
assert_eq!(route.connect_host, CDN203_IP);
|
assert!(
|
||||||
assert!(allowed_tcp_route(&route));
|
telegram_ips(dc).contains(&route.connect_host.as_str()),
|
||||||
} else {
|
"{route:?}"
|
||||||
assert!(route.secure, "{route:?}");
|
);
|
||||||
|
assert!(allowed_tcp_route(&route));
|
||||||
|
}
|
||||||
|
// The only route without TLS: pinned address, and no
|
||||||
|
// Telegram name on the wire.
|
||||||
|
RouteKind::TelegramPlainWs => {
|
||||||
|
assert_eq!(route.port, 80, "{route:?}");
|
||||||
|
assert_eq!(route.connect_host, telegram_ips(dc)[0]);
|
||||||
|
assert_eq!(route.websocket_host, route.connect_host);
|
||||||
|
assert!(allowed_plain_ws_route(&route));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
assert_eq!(route.port, 443, "{route:?}");
|
||||||
|
assert!(route.secure, "{route:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_tcp_to_an_ordinary_dc_is_a_last_resort_but_cdn203_leads() {
|
||||||
|
let engine = TransportEngine::new();
|
||||||
|
engine.set_cf_proxy_domains(&["example.com".to_owned()]);
|
||||||
|
for dc in [1, 2, 3, 4, 5] {
|
||||||
|
let ordered = engine.ordered_candidates(DcKey { dc, media: true });
|
||||||
|
let first_fallback = ordered
|
||||||
|
.iter()
|
||||||
|
.position(is_last_resort)
|
||||||
|
.expect("every DC has a last-resort route");
|
||||||
|
assert_eq!(ordered[first_fallback].kind, RouteKind::TelegramPlainWs);
|
||||||
|
assert_eq!(ordered[first_fallback + 1].kind, RouteKind::TelegramTcp);
|
||||||
|
assert_eq!(ordered.len(), first_fallback + 2, "DC{dc}: {ordered:?}");
|
||||||
|
}
|
||||||
|
let cdn = engine.ordered_candidates(DcKey {
|
||||||
|
dc: 203,
|
||||||
|
media: true,
|
||||||
|
});
|
||||||
|
assert_eq!(cdn[0], Route::cdn_tcp());
|
||||||
|
assert!(!is_last_resort(&cdn[0]));
|
||||||
|
assert_eq!(cdn.last(), Some(&Route::plain_ws(CDN203_IP)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plain_websocket_refuses_anything_but_a_pinned_address() {
|
||||||
|
for route in [
|
||||||
|
Route::plain_ws("203.0.113.5"),
|
||||||
|
Route {
|
||||||
|
websocket_host: "kws2.web.telegram.org".to_owned(),
|
||||||
|
..Route::plain_ws("149.154.167.51")
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
port: 8080,
|
||||||
|
..Route::plain_ws("149.154.167.51")
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let error = connect_route(&route).await.unwrap_err();
|
||||||
|
assert!(error.contains("без TLS"), "{route:?}: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn native_tcp_is_dialed_only_after_every_other_route_failed() {
|
||||||
|
use std::sync::Mutex as StdMutex;
|
||||||
|
let key = DcKey { dc: 2, media: true };
|
||||||
|
|
||||||
|
// A slow but working WebSocket wins; TCP is never dialed.
|
||||||
|
let engine = TransportEngine::new();
|
||||||
|
let dialed = Arc::new(StdMutex::new(Vec::new()));
|
||||||
|
let log = dialed.clone();
|
||||||
|
let (_, connected) = engine
|
||||||
|
.race_connections(key, move |route: Route| {
|
||||||
|
log.lock().unwrap().push(route.kind);
|
||||||
|
async move {
|
||||||
|
if route.kind == RouteKind::TelegramIp {
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
Ok(())
|
||||||
|
} else if route.kind == RouteKind::TelegramTcp {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("blocked".to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(connected.route.kind, RouteKind::TelegramIp);
|
||||||
|
assert!(!dialed.lock().unwrap().contains(&RouteKind::TelegramTcp));
|
||||||
|
|
||||||
|
// Every WebSocket fails: the pinned TCP route carries the tunnel.
|
||||||
|
let engine = TransportEngine::new();
|
||||||
|
let (_, connected) = engine
|
||||||
|
.race_connections(key, |route: Route| async move {
|
||||||
|
if route.kind == RouteKind::TelegramTcp {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("blocked".to_owned())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(connected.route.kind, RouteKind::TelegramTcp);
|
||||||
|
assert!(telegram_ips(2).contains(&connected.route.connect_host.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cf_proxy_domains_become_kws_routes_and_keep_cdn203_separate() {
|
||||||
|
let engine = TransportEngine::new();
|
||||||
|
let result = engine.set_cf_proxy_domains(&[
|
||||||
|
" Example.COM ".to_owned(),
|
||||||
|
"https://bad.example/".to_owned(),
|
||||||
|
]);
|
||||||
|
assert_eq!(result.accepted, ["example.com"]);
|
||||||
|
assert_eq!(result.rejected, ["https://bad.example/"]);
|
||||||
|
for (dc, host) in [(2, "kws2.example.com"), (203, "kws203.example.com")] {
|
||||||
|
let routes = engine.routes_for_key(DcKey { dc, media: true });
|
||||||
|
let proxy = routes
|
||||||
|
.iter()
|
||||||
|
.find(|route| route.kind == RouteKind::CloudflareProxy)
|
||||||
|
.expect("CF proxy route");
|
||||||
|
assert_eq!(proxy.connect_host, host);
|
||||||
|
assert_eq!(proxy.websocket_host, host);
|
||||||
|
assert_eq!(proxy.path, "/apiws");
|
||||||
|
assert!(proxy.secure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_data_center_offers_a_pinned_ip_and_a_dns_route() {
|
fn every_data_center_offers_a_pinned_ip_and_a_dns_route() {
|
||||||
for dc in [1, 2, 3, 4, 5] {
|
for dc in [1, 2, 3, 4, 5] {
|
||||||
@@ -975,6 +1405,8 @@ mod tests {
|
|||||||
RouteKind::SystemDns,
|
RouteKind::SystemDns,
|
||||||
RouteKind::CloudflareWorker,
|
RouteKind::CloudflareWorker,
|
||||||
RouteKind::TelegramTcp,
|
RouteKind::TelegramTcp,
|
||||||
|
RouteKind::CloudflareProxy,
|
||||||
|
RouteKind::TelegramPlainWs,
|
||||||
] {
|
] {
|
||||||
assert_eq!(RouteKind::from_ui_code(kind.ui_code()), Some(kind));
|
assert_eq!(RouteKind::from_ui_code(kind.ui_code()), Some(kind));
|
||||||
assert_eq!(route_label(kind.ui_code()), kind.label());
|
assert_eq!(route_label(kind.ui_code()), kind.label());
|
||||||
@@ -1270,11 +1702,30 @@ mod tests {
|
|||||||
let mut route = Route::cdn_tcp();
|
let mut route = Route::cdn_tcp();
|
||||||
route.connect_host = "127.0.0.2".to_owned();
|
route.connect_host = "127.0.0.2".to_owned();
|
||||||
let error = connect_route(&route).await.unwrap_err();
|
let error = connect_route(&route).await.unwrap_err();
|
||||||
assert!(error.contains("только для закреплённого CDN203"), "{error}");
|
assert!(
|
||||||
for dc in [1, 2, 3, 4, 5, 999] {
|
error.contains("только до закреплённых адресов Telegram"),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
assert!(routes_for_dc(999, false).is_empty());
|
||||||
|
for dc in [1, 2, 3, 4, 5] {
|
||||||
assert!(routes_for_dc(dc, false)
|
assert!(routes_for_dc(dc, false)
|
||||||
.iter()
|
.iter()
|
||||||
.all(|route| route.kind != RouteKind::TelegramTcp));
|
.filter(|route| route.kind == RouteKind::TelegramTcp)
|
||||||
|
.all(|route| route.connect_host == telegram_ips(dc)[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires live Telegram network access"]
|
||||||
|
async fn port_80_answers_on_every_native_address() {
|
||||||
|
for dc in [1, 2, 3, 4, 5, 203] {
|
||||||
|
let route = Route::plain_ws(telegram_ips(dc)[0]);
|
||||||
|
match connect_route(&route).await {
|
||||||
|
Ok(TelegramConnection::WebSocket(mut websocket)) => {
|
||||||
|
websocket.close(None).await.unwrap()
|
||||||
|
}
|
||||||
|
other => panic!("DC{dc}: {other:?}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "TGLock",
|
"productName": "TGLock",
|
||||||
"version": "2.0.0-beta.16",
|
"version": "2.0.0-beta.19",
|
||||||
"identifier": "com.bysonic.tglock",
|
"identifier": "com.bysonic.tglock",
|
||||||
"mainBinaryName": "tglock",
|
"mainBinaryName": "tglock",
|
||||||
"build": {
|
"build": {
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
# docs/CLOUDFLARE_WORKER.md
|
# docs/CLOUDFLARE_WORKER.md
|
||||||
# worker = ["my-name.workers.dev"]
|
# worker = ["my-name.workers.dev"]
|
||||||
|
|
||||||
|
# Свой домен на Cloudflare (CF-прокси): записи kws1…kws5 и kws203 указывают
|
||||||
|
# на адреса Telegram, Worker не нужен. Настройка описана в docs/CF_PROXY.md
|
||||||
|
# cf_proxy = ["example.com"]
|
||||||
|
|
||||||
# Пропускать и не-Telegram адреса.
|
# Пропускать и не-Telegram адреса.
|
||||||
# На loopback это включено само. На сетевом адресе означает открытый
|
# На loopback это включено само. На сетевом адресе означает открытый
|
||||||
# SOCKS5-прокси для всего интернета — включайте осознанно.
|
# SOCKS5-прокси для всего интернета — включайте осознанно.
|
||||||
@@ -40,3 +44,8 @@
|
|||||||
|
|
||||||
# Печатать только ошибки.
|
# Печатать только ошибки.
|
||||||
# quiet = false
|
# quiet = false
|
||||||
|
|
||||||
|
# Журнал в файл вместо stdout: на роутере stdout сервиса уходит в системный журнал.
|
||||||
|
# Заполненный файл переименовывается в <имя>.1, больше двух файлов не бывает.
|
||||||
|
# log_file = "/tmp/tglock.log"
|
||||||
|
# log_max_mb = 5
|
||||||
|
|||||||
+109
-24
@@ -34,6 +34,7 @@ type Settings = {
|
|||||||
lanMode: boolean;
|
lanMode: boolean;
|
||||||
port: number;
|
port: number;
|
||||||
workerDomain: string;
|
workerDomain: string;
|
||||||
|
cfProxyDomain: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const root = document.querySelector<HTMLDivElement>("#app")!;
|
const root = document.querySelector<HTMLDivElement>("#app")!;
|
||||||
@@ -55,7 +56,7 @@ let status: Status = {
|
|||||||
shareAddress: null,
|
shareAddress: null,
|
||||||
logs: [],
|
logs: [],
|
||||||
};
|
};
|
||||||
let settings: Settings = { lanMode: false, port: 1080, workerDomain: "" };
|
let settings: Settings = { lanMode: false, port: 1080, workerDomain: "", cfProxyDomain: "" };
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let toastTimer: number | undefined;
|
let toastTimer: number | undefined;
|
||||||
let toastMessage = "";
|
let toastMessage = "";
|
||||||
@@ -112,6 +113,47 @@ function connectionState(): { title: string; subtitle: string; className: string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Статус приходит раз в секунду. Если каждый раз переписывать root.innerHTML,
|
||||||
|
// кнопки становятся новыми узлами, и тап, начатый на старой кнопке и
|
||||||
|
// законченный на новой, Chromium не превращает в click. Поэтому разметка
|
||||||
|
// накладывается на уже существующие узлы: меняются только текст и атрибуты,
|
||||||
|
// а элемент заменяется лишь там, где на его месте теперь другой тег или id.
|
||||||
|
function patchRoot(html: string): void {
|
||||||
|
const next = document.createElement("template");
|
||||||
|
next.innerHTML = html;
|
||||||
|
patchChildren(root, next.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchChildren(current: Node, next: Node): void {
|
||||||
|
const nextChildren = Array.from(next.childNodes);
|
||||||
|
nextChildren.forEach((nextChild, index) => {
|
||||||
|
const currentChild = current.childNodes[index];
|
||||||
|
if (!currentChild) current.appendChild(nextChild);
|
||||||
|
else if (!sameSlot(currentChild, nextChild)) current.replaceChild(nextChild, currentChild);
|
||||||
|
else patchNode(currentChild, nextChild);
|
||||||
|
});
|
||||||
|
while (current.childNodes.length > nextChildren.length) current.lastChild?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSlot(current: Node, next: Node): boolean {
|
||||||
|
if (current.nodeType !== next.nodeType || current.nodeName !== next.nodeName) return false;
|
||||||
|
return !(current instanceof Element && next instanceof Element) || current.id === next.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchNode(current: Node, next: Node): void {
|
||||||
|
if (!(current instanceof Element && next instanceof Element)) {
|
||||||
|
if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const { name } of Array.from(current.attributes)) {
|
||||||
|
if (!next.hasAttribute(name)) current.removeAttribute(name);
|
||||||
|
}
|
||||||
|
for (const { name, value } of Array.from(next.attributes)) {
|
||||||
|
if (current.getAttribute(name) !== value) current.setAttribute(name, value);
|
||||||
|
}
|
||||||
|
patchChildren(current, next);
|
||||||
|
}
|
||||||
|
|
||||||
function shell(content: string, pageClass = ""): string {
|
function shell(content: string, pageClass = ""): string {
|
||||||
return `
|
return `
|
||||||
<main class="app-shell ${pageClass}">
|
<main class="app-shell ${pageClass}">
|
||||||
@@ -124,7 +166,7 @@ function shell(content: string, pageClass = ""): string {
|
|||||||
|
|
||||||
function renderHome(): void {
|
function renderHome(): void {
|
||||||
const connection = connectionState();
|
const connection = connectionState();
|
||||||
root.innerHTML = shell(`
|
patchRoot(shell(`
|
||||||
<section class="home">
|
<section class="home">
|
||||||
<header class="brand">
|
<header class="brand">
|
||||||
<div class="wordmark"><span>TG</span>Lock</div>
|
<div class="wordmark"><span>TG</span>Lock</div>
|
||||||
@@ -190,14 +232,7 @@ function renderHome(): void {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
`, "home-page");
|
`, "home-page"));
|
||||||
|
|
||||||
document.querySelector("#power")?.addEventListener("click", toggleProtection);
|
|
||||||
document.querySelector("#open-telegram")?.addEventListener("click", openTelegram);
|
|
||||||
document.querySelector("#copy-link")?.addEventListener("click", copyTelegramLink);
|
|
||||||
document.querySelector("#copy-address")?.addEventListener("click", copyShareAddress);
|
|
||||||
document.querySelector("#settings-nav")?.addEventListener("click", () => navigate("settings"));
|
|
||||||
document.querySelector("#diagnostics-nav")?.addEventListener("click", () => navigate("diagnostics"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageHeader(title: string, subtitle: string): string {
|
function pageHeader(title: string, subtitle: string): string {
|
||||||
@@ -213,7 +248,7 @@ function pageHeader(title: string, subtitle: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSettings(): void {
|
function renderSettings(): void {
|
||||||
root.innerHTML = shell(`
|
patchRoot(shell(`
|
||||||
<section class="subpage">
|
<section class="subpage">
|
||||||
${pageHeader("Настройки", "Подключение и локальная сеть")}
|
${pageHeader("Настройки", "Подключение и локальная сеть")}
|
||||||
|
|
||||||
@@ -232,6 +267,20 @@ function renderSettings(): void {
|
|||||||
<p class="field-hint">Необязательно. Используется как резервный маршрут.</p>
|
<p class="field-hint">Необязательно. Используется как резервный маршрут.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-card">
|
||||||
|
<label class="field-label" for="cf-proxy">Свой домен на Cloudflare</label>
|
||||||
|
<input
|
||||||
|
id="cf-proxy"
|
||||||
|
name="cf-proxy"
|
||||||
|
type="text"
|
||||||
|
value="${escapeHtml(settings.cfProxyDomain)}"
|
||||||
|
placeholder="example.com"
|
||||||
|
autocomplete="off"
|
||||||
|
${status.running ? "disabled" : ""}
|
||||||
|
/>
|
||||||
|
<p class="field-hint">Необязательно. CF-прокси без Worker: записи kws1…kws5 и kws203 на адреса Telegram.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="setting-list">
|
<div class="setting-list">
|
||||||
<label class="setting-row" for="lan-mode">
|
<label class="setting-row" for="lan-mode">
|
||||||
<span>
|
<span>
|
||||||
@@ -260,15 +309,12 @@ function renderSettings(): void {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
`, "subpage-shell");
|
`, "subpage-shell"));
|
||||||
|
|
||||||
document.querySelector("#back")?.addEventListener("click", () => navigate("home"));
|
|
||||||
document.querySelector("#settings-form")?.addEventListener("submit", saveSettings);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDiagnostics(): void {
|
function renderDiagnostics(): void {
|
||||||
const recentLogs = status.logs.slice(-4).reverse();
|
const recentLogs = status.logs.slice(-4).reverse();
|
||||||
root.innerHTML = shell(`
|
patchRoot(shell(`
|
||||||
<section class="subpage">
|
<section class="subpage">
|
||||||
${pageHeader("Диагностика", "Состояние маршрута в реальном времени")}
|
${pageHeader("Диагностика", "Состояние маршрута в реальном времени")}
|
||||||
|
|
||||||
@@ -357,12 +403,31 @@ function renderDiagnostics(): void {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`, "subpage-shell");
|
`, "subpage-shell"));
|
||||||
|
|
||||||
document.querySelector("#copy-diagnostics")?.addEventListener("click", copyDiagnostics);
|
|
||||||
document.querySelector("#back")?.addEventListener("click", () => navigate("home"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обработчики висят на #app, а не на кнопках: patchRoot сохраняет кнопки,
|
||||||
|
// но новые (например, «Открыть Telegram» после включения) создаёт сам.
|
||||||
|
const clickActions: Record<string, () => void> = {
|
||||||
|
power: toggleProtection,
|
||||||
|
"open-telegram": openTelegram,
|
||||||
|
"copy-link": copyTelegramLink,
|
||||||
|
"copy-address": copyShareAddress,
|
||||||
|
"settings-nav": () => navigate("settings"),
|
||||||
|
"diagnostics-nav": () => navigate("diagnostics"),
|
||||||
|
"copy-diagnostics": copyDiagnostics,
|
||||||
|
back: () => navigate("home"),
|
||||||
|
};
|
||||||
|
|
||||||
|
root.addEventListener("click", (event) => {
|
||||||
|
const button = (event.target as Element).closest<HTMLButtonElement>("button[id]");
|
||||||
|
if (button && !button.disabled) clickActions[button.id]?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
root.addEventListener("submit", (event) => {
|
||||||
|
if ((event.target as Element).id === "settings-form") void saveSettings(event);
|
||||||
|
});
|
||||||
|
|
||||||
function render(): void {
|
function render(): void {
|
||||||
if (view === "settings") renderSettings();
|
if (view === "settings") renderSettings();
|
||||||
else if (view === "diagnostics") renderDiagnostics();
|
else if (view === "diagnostics") renderDiagnostics();
|
||||||
@@ -403,9 +468,10 @@ async function saveSettings(event: Event): Promise<void> {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (busy || status.running) return;
|
if (busy || status.running) return;
|
||||||
const worker = document.querySelector<HTMLInputElement>("#worker");
|
const worker = document.querySelector<HTMLInputElement>("#worker");
|
||||||
|
const cfProxy = document.querySelector<HTMLInputElement>("#cf-proxy");
|
||||||
const lanMode = document.querySelector<HTMLInputElement>("#lan-mode");
|
const lanMode = document.querySelector<HTMLInputElement>("#lan-mode");
|
||||||
const port = document.querySelector<HTMLInputElement>("#port");
|
const port = document.querySelector<HTMLInputElement>("#port");
|
||||||
if (!worker || !lanMode || !port) return;
|
if (!worker || !cfProxy || !lanMode || !port) return;
|
||||||
|
|
||||||
const parsedPort = Number(port.value);
|
const parsedPort = Number(port.value);
|
||||||
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
||||||
@@ -416,6 +482,7 @@ async function saveSettings(event: Event): Promise<void> {
|
|||||||
busy = true;
|
busy = true;
|
||||||
settings = {
|
settings = {
|
||||||
workerDomain: worker.value.trim(),
|
workerDomain: worker.value.trim(),
|
||||||
|
cfProxyDomain: cfProxy.value.trim(),
|
||||||
lanMode: lanMode.checked,
|
lanMode: lanMode.checked,
|
||||||
port: parsedPort,
|
port: parsedPort,
|
||||||
};
|
};
|
||||||
@@ -446,12 +513,29 @@ async function copyTelegramLink(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отчёт публикуют в issue: домен Worker'а и свой домен CF-прокси туда не идут,
|
||||||
|
// Worker можно задать и адресом, поэтому оставляются только адреса Telegram.
|
||||||
|
// Они и имена Telegram нужны, чтобы понять, какой маршрут не ответил.
|
||||||
|
function publicLogLine(message: string): string {
|
||||||
|
return message.replace(/[\w-]+(?:\.[\w-]+)+/g, (host) =>
|
||||||
|
/^(149\.154|91\.105|91\.108)\.[\d.]+$/.test(host) || host.endsWith(".telegram.org") ? host : "<домен>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function failureLog(): string[] {
|
||||||
|
const failure = /^(Не поднялся туннель|Сбой туннеля|Обрыв туннеля)/;
|
||||||
|
const paused = /на паузе после ошибок/;
|
||||||
|
return status.logs
|
||||||
|
.filter(({ message }) => failure.test(message) && !paused.test(message))
|
||||||
|
.slice(-20)
|
||||||
|
.map(({ timestamp, message }) => `${timestamp} ${publicLogLine(message)}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function copyDiagnostics(): Promise<void> {
|
async function copyDiagnostics(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Public reports exclude proxy links, worker domains and free-form logs.
|
// Public reports exclude proxy links and secrets.
|
||||||
const report = {
|
const report = {
|
||||||
application: "TGLock",
|
application: "TGLock",
|
||||||
version: "2.0.0-beta.16",
|
version: "2.0.0-beta.19",
|
||||||
running: status.running,
|
running: status.running,
|
||||||
activeConnections: status.activeConnections,
|
activeConnections: status.activeConnections,
|
||||||
tunnels: status.tunnels,
|
tunnels: status.tunnels,
|
||||||
@@ -465,9 +549,10 @@ async function copyDiagnostics(): Promise<void> {
|
|||||||
uptimeSeconds: status.uptimeSeconds,
|
uptimeSeconds: status.uptimeSeconds,
|
||||||
port: status.port,
|
port: status.port,
|
||||||
lanMode: settings.lanMode,
|
lanMode: settings.lanMode,
|
||||||
|
failureLog: failureLog(),
|
||||||
};
|
};
|
||||||
await navigator.clipboard.writeText(JSON.stringify(report, null, 2));
|
await navigator.clipboard.writeText(JSON.stringify(report, null, 2));
|
||||||
showToast("Диагностика скопирована без адресов и секретов");
|
showToast("Диагностика скопирована без секретов и своих доменов");
|
||||||
} catch { showToast("Буфер обмена недоступен", true); }
|
} catch { showToast("Буфер обмена недоступен", true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -34,6 +34,12 @@ const CONNECT_TIMEOUT_MS = 3000;
|
|||||||
const MAX_PENDING_BYTES = 1024 * 1024;
|
const MAX_PENDING_BYTES = 1024 * 1024;
|
||||||
const MAX_PENDING_MESSAGES = 256;
|
const MAX_PENDING_MESSAGES = 256;
|
||||||
|
|
||||||
|
// Видна в ответе на проверку из браузера (/apiws без WebSocket). Редактор
|
||||||
|
// Cloudflare показывает сохранённый черновик, а работает опубликованная
|
||||||
|
// версия: без этой строки старый скрипт за новым текстом в редакторе было не
|
||||||
|
// отличить (by-sonic/tglock#53). Меняется при каждом изменении скрипта.
|
||||||
|
const WORKER_VERSION = "2026-10-26";
|
||||||
|
|
||||||
// Размер бинарного кадра и способ достать его байты; null — текстовый кадр.
|
// Размер бинарного кадра и способ достать его байты; null — текстовый кадр.
|
||||||
//
|
//
|
||||||
// С compatibility date 2026-03-17 бинарные кадры приходят как Blob, если
|
// С compatibility date 2026-03-17 бинарные кадры приходят как Blob, если
|
||||||
@@ -68,7 +74,10 @@ export default {
|
|||||||
return new Response("not found", { status: 404 });
|
return new Response("not found", { status: 404 });
|
||||||
}
|
}
|
||||||
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
|
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
|
||||||
return new Response("expected a websocket upgrade", { status: 426 });
|
return new Response(`expected a websocket upgrade\nTGLock Worker ${WORKER_VERSION}\n`, {
|
||||||
|
status: 426,
|
||||||
|
headers: { "X-TGLock-Worker": WORKER_VERSION },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Необязательный общий секрет: задай переменную TGLOCK_TOKEN в настройках
|
// Необязательный общий секрет: задай переменную TGLOCK_TOKEN в настройках
|
||||||
// воркера, и посторонние подключиться не смогут.
|
// воркера, и посторонние подключиться не смогут.
|
||||||
|
|||||||
@@ -77,7 +77,11 @@ async function fixture({ opened = Promise.resolve(), write, timeout = false } =
|
|||||||
|
|
||||||
test("HTTP deployment check does not claim Telegram connectivity", async () => {
|
test("HTTP deployment check does not claim Telegram connectivity", async () => {
|
||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
assert.equal((await f.fetch("/apiws", { Upgrade: "" })).status, 426);
|
const check = await f.fetch("/apiws", { Upgrade: "" });
|
||||||
|
assert.equal(check.status, 426);
|
||||||
|
// The browser check names the published version (#53).
|
||||||
|
assert.match(check.body, /^expected a websocket upgrade\nTGLock Worker \d{4}-\d{2}-\d{2}\n$/);
|
||||||
|
assert.match(check.headers["X-TGLock-Worker"], /^\d{4}-\d{2}-\d{2}$/);
|
||||||
assert.equal(f.state.connects, 0);
|
assert.equal(f.state.connects, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user