Compare commits

..

11 Commits

Author SHA1 Message Date
Никита Sonic 8ce9355243 fix: медиа и «прокси не настроен»: пакет на кадр, настоящие фронты, kwsN-1 только для медиа; beta.19 (#75)
* fix(tunnel): медиа и «прокси не настроен»: один пакет на кадр, настоящие фронты, kwsN-1 только для медиа

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 07:32:03 +00:00
by-sonic bc18509b9c README: поддержать проект — USDT TRC20
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:54:55 +03:00
Никита Sonic 098178ead4 feat(gui): закрытие окна при включённой защите сворачивает TGLock в трей; 2.0.0-beta.18 (#74)
Раньше закрытие окна завершало приложение вместе с прокси, и Telegram
терял соединение. Теперь, пока защита включена, окно прячется в трей,
а прокси продолжает работать, как в tg-ws-proxy. Меню трея: открыть
TGLock, открыть Telegram, выход. Левый клик по значку открывает окно
(в macOS меню по клику, окно возвращает и клик по значку в Dock).

Если защита выключена или значок в трее создать не удалось (в Linux
нет libayatana-appindicator3), окно закрывается как раньше, чтобы не
оставлять приложение без способа до него добраться.

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:48:04 +00:00
Никита Sonic 7b48f0046b fix(ui): status refresh no longer recreates the power button (#71)
refreshStatus() runs every second and render() used to rewrite
root.innerHTML, so #power became a new DOM node each tick. A tap whose
press and release landed on different nodes produced no click in
Chromium/WebView; on a slow WebView the "Выключить" tap was lost.

render now patches the existing DOM in place (text and attributes only;
a node is replaced only where the tag or id at that position differs),
and click/submit handlers are delegated from #app instead of being
reattached to fresh nodes on every render. Texts, classes, ids and
accessible labels are unchanged.

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:24:09 +00:00
Никита Sonic 1d96609563 feat: запасной MTProto TCP и CF-прокси через свой домен (по образцу Flowseal/tg-ws-proxy); 2.0.0-beta.17 (#73)
* feat: native MTProto TCP fallback and CF-proxy domains, after Flowseal/tg-ws-proxy; 2.0.0-beta.17

Two routes Flowseal/tg-ws-proxy has and TGLock lacked.

Native TCP fallback. When every WebSocket route for a DC fails, TGLock now
tries plain obfuscated2 MTProto to the DC's primary address, which the
CDN203 route already used. Measured from a Russian ISP: TLS to .51, .91,
.50, .100 and .5 was cut, while native TCP to each answered req_pq_multi,
for main and media DCs alike. 149.154.167.220 is a WebSocket frontend only
and gets no TCP route. TCP opens before Telegram answers, so it is raced
only after all other routes failed; it can never displace a slower working
WebSocket. Raw TCP stays restricted to the pinned Telegram addresses.

CF proxy. A user's own Cloudflare domain with kws1..kws5/kws203 records
pointing at Telegram proxies the WebSocket without a Worker or its limits
(--cf-proxy, cf_proxy in tglock.toml, a GUI field; docs/CF_PROXY.md).

GUI settings now deserialize with serde(default): a file written before a
field existed used to fail to parse and silently reset to defaults.

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

* style: cargo fmt

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

---------

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:24:38 +00:00
Никита Sonic 31bb442b0c fix(worker): name the published script version in the browser check (#72)
The dashboard editor shows a saved draft while requests are served by the
last deployed version. A user with the new text in the editor was running
the pre-beta.15 script, which writes Blob frames into the socket on current
compatibility dates (Cloudflare warns "This TransformStream is being used as
a byte stream ...") and drops every tunnel (#53). Reproduced on local
workerd: the old script logs exactly that warning, the current one does not.

GET /apiws now answers "TGLock Worker <version>" and X-TGLock-Worker, and the
docs say a missing line means an old script is still deployed.

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:17:04 +00:00
Никита Sonic 33322ea3a3 ci(android): make the emulator smoke survive a slow emulator, not app failures (#68)
Evidence from runs 39866280662, 39870244857 and 40091145294 shows the red
smoke runs were the emulator, not TGLock: on the software GPU the WebView
paints about one frame per second while the page re-renders every second,
so a tap's down/up can straddle a re-render and Chromium drops the click
(no setEnabled call at all in the failed Stop run). Play services and Pixel
Launcher post-boot work also starved system_server into foreign ANR dialogs.

- AOSP image, 4 cores, 4 GB RAM, no snapshot save; wait for CPU to settle
  after boot before launching.
- Answer foreign "isn't responding" dialogs with Wait and record them; a
  TGLock ANR/error dialog, crash or missing process still fails.
- Re-tap only when the button is unchanged 10s after a tap (at most 3 taps);
  foreground-service and SOCKS listener effects stay strictly asserted with
  a 45s budget sized for this emulator.
- Timed step markers in stdout and logcat, failure screenshot and window
  dump, one evidence artifact per run attempt.

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:37:41 +00:00
Никита Sonic 3aa6f413a3 fix(worker): type-clean frame handling; name a Telegram close before any reply (#67)
Cloudflare's dashboard editor type-checks the script and underlined four
lines of the Blob handling, because a separate `blob` flag did not narrow
`event.data` (#66). binaryFrame() narrows each branch with instanceof; the
file now type-checks cleanly against current @cloudflare/workers-types.

When Telegram closes TCP without sending a byte, the Worker used to end the
WebSocket with a plain 1000. It now closes with 1011 "Telegram closed before
replying" and logs the destination and the bytes the client sent, so the
client log and the Cloudflare log both say where the tunnel broke (#53).

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:03:26 +00:00
Никита Sonic f82543e44e ci(android): retry UIAutomator dumps that write nothing (#64)
On the loaded API 35 emulator `uiautomator dump` intermittently writes no
file ("could not get idle state"). tap_label passed that straight to
`cat` and failed the whole smoke on the first miss, while the launch loop
already retried. The beta.16 commit failed four runs in a row this way;
the identical tree passed on its PR. Only the dump is retried — crash,
ANR, service and listener assertions are unchanged.

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:12:23 +00:00
Никита Sonic 9529df3fdf fix(worker): туннели через Worker рвались с 1003 на новых compatibility date; 2.0.0-beta.16 (#63)
* worker: set binaryType = "arraybuffer" to fix 1003 "binary messages required"

Since compatibility date 2026-03-17 Cloudflare delivers binary WebSocket messages as Blob (websocket_standard_binary_type). The message handler accepts only ArrayBuffer and closes every tunnel with 1003. Setting binaryType before accept() restores ArrayBuffer delivery regardless of the compatibility date.

* fix(worker): accept Blob frames and name an outdated Worker in the log; 2.0.0-beta.16

Since compatibility date 2026-03-17 Cloudflare delivers binary WebSocket
frames as Blob unless binaryType is "arraybuffer", and the Worker closed
every tunnel with 1003 right after the handshake. wrangler.toml itself pins
2026-09-19, so every fresh deployment was broken (#58, #50, #53).

On top of #61 (binaryType before accept) the Worker now also relays Blob
frames in order, and the client log tells the user to redeploy the script
when a Worker closes with "1003 binary messages required".

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

---------

Co-authored-by: Dmitry Sankevich <16187308+avost@users.noreply.github.com>
Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:37:15 +00:00
Никита Sonic 05ce9566a8 fix: маршруты, медиа и Worker; Android APK и статический ARM64 CLI (#60)
* fix: address routing, media, worker and platform issues

* ci: use available Android tools and verify Windows CLI artifact

* fix(gui): return a result from asynchronous stop command

* test(android): verify installed APK and proxy lifecycle on emulator

* fix(media): use native MTProto for CDN203 and retain recent diagnostics

* Fix owned WebSocket split after transport boxing

* Clarify CDN transport and connection status guarantees

* Recognize accessible Android power button labels

* chore(release): prepare 2.0.0-beta.15 with verified draft publication

---------

Co-authored-by: babin <Thartewerner536e@engineer.com>
2026-09-19 16:04:39 +03:00
25 changed files with 2375 additions and 250 deletions
+10 -5
View File
@@ -79,7 +79,7 @@ jobs:
name: Android 15 emulator smoke
needs: apk
runs-on: ubuntu-22.04
timeout-minutes: 15
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
- 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
sudo udevadm control --reload-rules
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
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 35
arch: x86_64
target: google_apis
target: default
profile: pixel_2
cores: 4
ram-size: 4096M
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
script: timeout 180 python3 scripts/android_smoke.py emulator-apk
emulator-options: -no-window -no-snapshot -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
script: timeout 420 python3 scripts/android_smoke.py emulator-apk
- uses: actions/upload-artifact@v4
if: always()
with:
name: android-emulator-smoke-evidence
name: android-emulator-smoke-evidence-attempt-${{ github.run_attempt }}
path: android-smoke-evidence/
if-no-files-found: warn
retention-days: 14
Generated
+1 -1
View File
@@ -3822,7 +3822,7 @@ dependencies = [
[[package]]
name = "tglock"
version = "2.0.0-beta.15"
version = "2.0.0-beta.19"
dependencies = [
"aes",
"cipher",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "tglock"
version = "2.0.0-beta.15"
version = "2.0.0-beta.19"
edition = "2021"
rust-version = "1.88"
description = "Telegram unblock via local WebSocket tunnel"
@@ -38,7 +38,7 @@ path = "src/bin/cli.rs"
required-features = ["cli"]
[dependencies]
tauri = { version = "2", features = [], optional = true }
tauri = { version = "2", features = ["tray-icon"], optional = true }
tauri-plugin-opener = { version = "2", optional = true }
clap = { version = "4", features = ["derive"], optional = true }
# Файл конфигурации CLI. TOML, а не JSON, потому что файл правят руками и в нём
+10 -1
View File
@@ -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 ════════════════════════ -->
<p align="center">
@@ -210,6 +217,8 @@ worker = ["my-name.workers.dev"]
Полный пример со всеми полями и пояснениями — [`tglock.example.toml`](tglock.example.toml).
Журнал можно писать в файл, а не в stdout: `log_file` и `log_max_mb` в настройках или `--log-file` и `--log-max-mb`. Заполненный файл переименовывается в `<имя>.1`, больше двух файлов не бывает.
Приоритеты: значения по умолчанию → файл → флаги. Флаг всегда сильнее файла. Флаги-переключатели (`--lan`, `--allow-direct`, `--quiet`) могут только включать: их отсутствие означает «взять из файла», а не «выключить».
Опечатка в файле — ошибка при старте с перечислением допустимых полей, а не молча проигнорированная строка. Сервис, который из-за `porrt = 1443` слушает 1080, хуже сервиса, который не запустился.
@@ -426,7 +435,7 @@ xattr -cr /Applications/TGLock.app
Это реальный риск, и 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, бесплатного тарифа хватает, свой сервер и домен не нужны.
+7 -2
View File
@@ -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.
If buttons are inaccessible after a bounded wait, CI explicitly reports the
lifecycle checks as skipped; a launch-only pass is not lifecycle evidence.
`android-emulator-smoke-evidence` retains the exact result, UI dumps, service
state and logcat. This does not test automatic low-memory eviction, battery
`android-emulator-smoke-evidence-attempt-N` (one per run attempt) retains the
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.
Checked-in `gen/android` contains the native source and Gradle wrapper. Tauri's
+42 -12
View File
@@ -200,25 +200,55 @@ Ping приходит в читающую половину, а отвечает
Для каждого DC и отдельно для media-соединений строится список маршрутов:
1. сохранённый успешный маршрут;
2. точный Telegram IP с `kwsN` или `kwsN-1` в TLS SNI и WebSocket Host;
3. явно настроенный пользователем Cloudflare Worker;
4. дополнительные Telegram IP и варианты хоста;
5. системный DNS (кроме CDN DC203).
2. основной WebSocket-фронт DC (`web_fronts`) с настоящим именем хоста в TLS SNI
и WebSocket Host;
3. явно настроенные пользователем CF-прокси и Cloudflare Worker;
4. запасные фронты DC;
5. системный DNS;
6. WebSocket без TLS на порту 80 адреса DC и родной MTProto TCP, только если всё
остальное не ответило.
Поддерживаются DC1–5 и CDN DC203. Для DC203 первым используется обычный MTProto
TCP к закреплённому `91.105.192.100:443`; этот сервер может принимать MTProto,
не принимая TLS/WebSocket. Резерв через Worker соединяется с тем же IP.
WebSocket-попытки также сохраняют закреплённый адрес. DNS fallback на DC2 для DC203 запрещён:
успешный WebSocket handshake с другим DC не доставляет CDN-запрос в нужный
датацентр. Worker также получает именно IP DC203.
Адреса фронтов и родные адреса DC разные. `149.154.167.51`, `.91`, `149.154.175.50`,
`.100`, `149.154.171.5` и `91.105.192.100` говорят только на MTProto и на TLS не
отвечают, в том числе из сети без блокировок. WebSocket принимают `149.154.167.220`
и `.99` (DC2, DC4), `149.154.174.100` и `.200`, `149.154.175.209` (DC1, DC3),
`149.154.170.100`, `.200` и `.96` (DC5).
Фронт выбирает дата-центр по имени хоста, а не по заголовку клиента, поэтому имя и
адрес нельзя менять местами. Какой DC стоит за маршрутом, проверяет
`probe_proxy.mjs --identity`: сервер сверяет номер DC внутри `req_DH_params` с собой
и отвечает `-444`, если номер чужой. `resPQ` этого не показывает, на него отвечает
любой сервер при любом номере в заголовке.
Кластер `kwsN-1` только для медиа: обычное соединение он отвергает с `-444`, а
Telegram Desktop на этот код выключает прокси как настроенный неверно. Основной
кластер `kwsN` принимает оба вида соединений и остаётся запасным для медиа.
Фронт разбирает один транспортный пакет на входящий кадр, остаток ждёт следующего
кадра. Клиенты пишут запросы пачками, поэтому поток режется на пакеты
(`PacketSplitter`), и каждый уходит отдельным кадром. Для Worker это не нужно: он
пишет кадры в обычный TCP.
На порту 80 родные адреса принимают WebSocket без TLS. Там DC определяет адрес, а
не имя хоста, поэтому в `Host` идёт сам адрес и имя Telegram в открытом виде не
появляется. Защита та же, что у родного TCP: внутри тот же обфусцированный MTProto,
а сервер подтверждает себя в самом протоколе. Маршрут разрешён только до
закреплённых адресов.
Поддерживаются DC1–5 и CDN DC203. У DC203 нет фронта на 443, а `kws2` на любом
фронте это обычный DC2, поэтому его маршруты: MTProto TCP к закреплённому
`91.105.192.100:443`, Worker, CF-прокси и порт 80 того же адреса.
После ошибки маршрут получает exponential cooldown от 30 секунд до 30 минут.
Успешный маршрут становится первым для следующего соединения того же DC и
типа трафика. Попытки запускаются с интервалом 250 мс, не больше трёх
одновременно на соединение; остальные отменяются после первого успеха.
Если все маршруты на паузе, возвращается причина и время до повторной
попытки. Новое подключение клиента не обходит cooldown. Ошибка upstream
после handshake также снимает предпочтение маршрута и добавляет cooldown.
попытки. Новое подключение клиента не обходит cooldown. Маршрут тоже теряет
предпочтение и получает cooldown, если Telegram закрыл соединение с кодом ошибки,
туннель оборвался, не проработав 15 секунд, или клиент спросил, не дождался ответа
за 5 секунд и ушёл сам. Обрыв туннеля, который работал дольше и получал ответы,
маршрут не наказывает: это обычное дело, а пауза оставила бы весь DC без связи.
## TLS policy
+49
View File
@@ -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, как и провайдер.
+7 -1
View File
@@ -17,6 +17,8 @@
Если Telegram работает — **ничего настраивать не надо.** Поле Worker в настройках существует для случая, когда обычные маршруты умерли.
Если есть свой домен, проще [CF-прокси](CF_PROXY.md): тот же маршрут через Cloudflare, но без скрипта и без лимитов Worker'ов.
Обрати внимание: Worker не спасает, если Telegram недоступен *с самого воркера*. Он помогает, когда домены Telegram заблокированы **у тебя**, а датацентры Cloudflare до них дотягиваются.
## Что понадобится
@@ -37,10 +39,14 @@
### Проверка, что воркер жив
Открой в браузере `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** сегодняшнюю.
> **Обнови скрипт, если Worker создан или менял compatibility date после 17 марта 2026.** С этой даты Cloudflare отдаёт бинарные WebSocket-кадры как `Blob`, и скрипт до beta.16 закрывал каждый туннель сразу после рукопожатия. В журнале TGLock это строка `WebSocket закрыт: 1003 binary messages required`, в диагностике туннели мигают 0 → 1 → 0. Текущий скрипт выставляет `binaryType = "arraybuffer"` и принимает `Blob` (исправление от [@avost](https://github.com/by-sonic/tglock/pull/61)).
> **Обнови скрипт при переходе с beta.14.** Теперь Worker ждёт открытия TCP к Telegram перед ответом 101: отказ возвращает 502, таймаут за 3 секунды — 504. Очередь записи ограничена 1 МиБ / 256 сообщениями; переполнение закрывает соединение с кодом 1009, ошибки сокета — 1011. Старый Worker мог показать успешное соединение ещё до попытки подключения к Telegram и не ограничивал очередь сообщений.
Для установки через Wrangler есть [`worker/wrangler.toml`](../worker/wrangler.toml). Из каталога `worker` можно выполнить `npx wrangler deploy` в своём Cloudflare-аккаунте. Эта команда публикует Worker; локальные тесты ничего не публикуют.
+20
View File
@@ -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,
`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
To distinguish an unavailable CDN WebSocket endpoint from an unavailable CDN
+15
View File
@@ -77,6 +77,21 @@ chmod 755 /etc/init.d/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/init.d/tglock stop` и `/etc/init.d/tglock disable`.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "tglock-ui",
"version": "2.0.0-beta.15",
"version": "2.0.0-beta.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tglock-ui",
"version": "2.0.0-beta.15",
"version": "2.0.0-beta.19",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tglock-ui",
"private": true,
"version": "2.0.0-beta.15",
"version": "2.0.0-beta.19",
"type": "module",
"scripts": {
"test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js",
+166 -29
View File
@@ -14,41 +14,122 @@ PACKAGE = "com.bysonic.tglock"
COMPONENT = f"{PACKAGE}/.MainActivity"
EVIDENCE = Path("android-smoke-evidence")
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(
["adb", *args], check=check, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout,
).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
while time.monotonic() < deadline:
clear_foreign_dialogs()
if predicate():
step(f"ok: {description}")
return
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):
# Dump first, then read the file; never tap coordinates inferred from a
# screenshot, a previous Activity, or assumed phone dimensions.
adb("shell", "rm", "-f", "/sdcard/tglock-ui.xml")
adb("shell", "uiautomator", "dump", "/sdcard/tglock-ui.xml")
status = adb("shell", "uiautomator", "dump", "/sdcard/tglock-ui.xml", check=False)
if not adb("shell", "ls", "/sdcard/tglock-ui.xml", check=False).endswith("tglock-ui.xml"):
# On a loaded emulator UIAutomator intermittently reports "could not get
# idle state" and writes nothing. That is the tool, not the app.
raise subprocess.SubprocessError(f"uiautomator dump failed: {status}")
text = adb("shell", "cat", "/sdcard/tglock-ui.xml")
(EVIDENCE / f"{label}.xml").write_text(text, encoding="utf-8")
return ET.fromstring(text)
def label_node(tree, label):
def node_center(tree, matches):
for node in tree.iter("node"):
# Android aggregates the decorative arrow into the button's accessible
# 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):
if matches(node):
bounds = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", ""))
if bounds:
x1, y1, x2, y2 = map(int, bounds.groups())
@@ -57,13 +138,53 @@ def label_node(tree, label):
return None
def tap_label(label, stage):
for attempt in range(3):
point = label_node(ui_dump(f"{stage}-{attempt}"), label)
if point is not None:
adb("shell", "input", "tap", str(point[0]), str(point[1]))
def label_node(tree, label):
# Android aggregates the decorative arrow into the button's accessible
# text (observed in the API35 CI dump). Accept only that exact suffix.
return node_center(tree, lambda node: any(
name in (label, f"{label} →")
for name in (node.get("text", "").strip(), node.get("content-desc", "").strip())
))
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
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)
if taps:
raise AssertionError(f"{label} stayed unchanged {VISIBLE_SECONDS}s after {taps} tap(s)")
raise AssertionError(f"Visible action not found: {label}")
@@ -77,7 +198,7 @@ def proxy_ready():
# ADB forwards to emulator loopback. A real SOCKS5 greeting proves the
# Rust listener is serving, beyond just a notification being displayed.
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]))
return peer.recv(2) == bytes([5, 0])
except (OSError, TimeoutError):
@@ -85,7 +206,7 @@ def proxy_ready():
def launch():
adb("shell", "am", "start", "-W", "-n", COMPONENT)
adb("shell", "am", "start", "-W", "-n", COMPONENT, timeout=60)
wait_for("Activity resumed", lambda: any(
"ResumedActivity" in line and PACKAGE in line
for line in adb("shell", "dumpsys", "activity", "activities").splitlines()
@@ -96,17 +217,20 @@ def launch():
def main():
apks = sorted(Path(sys.argv[1]).rglob("*.apk"))
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("logcat", "-c")
adb("forward", "tcp:11080", "tcp:1080")
settle()
adb("logcat", "-c")
step("launch")
launch()
RESULT["launch"] = "passed"
deadline = time.monotonic() + 20
deadline = time.monotonic() + VISIBLE_SECONDS
tree = None
start_point = None
attempt = 0
while time.monotonic() < deadline:
clear_foreign_dialogs()
try:
tree = ui_dump(f"launched-{attempt}")
start_point = label_node(tree, "Включить защиту")
@@ -124,23 +248,25 @@ def main():
for node in tree.iter("node")
), "App Activity is resumed but its WebView is absent"
if start_point is None:
RESULT["lifecycle"] = "skipped: WebView Start not accessible after 20s"
print("::warning::Activity launch passed; lifecycle skipped because UIAutomator did not expose Start after 20s")
RESULT["lifecycle"] = f"skipped: WebView Start not accessible after {VISIBLE_SECONDS}s"
print(f"::warning::Activity launch passed; lifecycle skipped because UIAutomator did not expose Start after {VISIBLE_SECONDS}s")
return
RESULT["lifecycle"] = "failed: lifecycle assertions incomplete"
tap_label("Включить защиту", "before-start")
press("Включить защиту", "before-start")
wait_for("foreground service after Start", service_running)
wait_for("Rust SOCKS listener after Start", proxy_ready)
adb("shell", "input", "keyevent", "KEYCODE_HOME")
time.sleep(5)
clear_foreign_dialogs()
assert service_running() and proxy_ready(), "Proxy stopped after backgrounding"
step("ok: still serving after 5s in background")
launch()
tap_label("Выключить", "before-stop")
press("Выключить", "before-stop")
wait_for("foreground service after Stop", lambda: not service_running())
wait_for("Rust listener after Stop", lambda: not proxy_ready())
# Explicit restart and user force-stop, then relaunch. This tests the
# 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)
adb("shell", "am", "force-stop", PACKAGE)
launch()
@@ -152,10 +278,21 @@ try:
main()
except Exception as 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
finally:
try:
logs = adb("logcat", "-d", "-v", "threadtime")
logs = adb("logcat", "-d", "-v", "threadtime", timeout=60)
(EVIDENCE / "logcat.txt").write_text(logs, encoding="utf-8")
crashes = adb("logcat", "-b", "crash", "-d")
(EVIDENCE / "crash.txt").write_text(crashes, encoding="utf-8")
@@ -163,9 +300,9 @@ finally:
RESULT["launch"] = "failed: application crash, ANR, or missing process"
raise AssertionError("Application did not remain healthy")
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")
if summary:
with open(summary, "a", encoding="utf-8") as report:
report.write("\nAndroid emulator smoke: " + json.dumps(RESULT) + "\n")
print(json.dumps(RESULT))
report.write("\nAndroid emulator smoke: " + json.dumps(RESULT, ensure_ascii=False) + "\n")
print(json.dumps(RESULT, ensure_ascii=False))
+143 -10
View File
@@ -4,28 +4,34 @@
// https://core.telegram.org/mtproto/auth_key
// https://core.telegram.org/mtproto/mtproto-transports#transport-obfuscation
// 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 { createConnection } from "node:net";
import { setTimeout as sleep } from "node:timers/promises";
import assert from "node:assert/strict";
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
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.
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.
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.`;
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" };
for (let i = 0; i < args.length; i++) {
const name = args[i];
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];
if (value === undefined) throw new Error(`Missing value for ${name}`);
if (name === "--secret-file") result.secretFile = value;
@@ -41,6 +47,110 @@ function options(args) {
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) {
let header;
do {
@@ -68,7 +178,7 @@ function makeRequest(secret, dc) {
const padded = Buffer.concat([payload, randomBytes(7)]);
const length = Buffer.alloc(4);
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) {
@@ -90,7 +200,11 @@ function parseResPQ(frame, nonce) {
// 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");
// 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) {
@@ -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");
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();
return new Promise((resolve, reject) => {
const socket = createConnection(config.directCdn
@@ -118,7 +233,7 @@ async function probe(config) {
if (error) reject(error);
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("end", () => finish(new Error("Proxy closed before a complete resPQ response")));
socket.on("data", (chunk) => {
@@ -138,8 +253,23 @@ async function probe(config) {
pending = pending.subarray(4 + length); // padded intermediate quick ACK
continue;
}
finish(null, parseResPQ(pending.subarray(4, 4 + length), nonce));
return;
const frame = pending.subarray(4, 4 + length);
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); }
});
@@ -177,6 +307,9 @@ function selfTest() {
oversizedVector.writeUInt32LE(65, 72);
assert.throws(() => parseResPQ(oversizedVector, nonce), /count/);
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.");
}
+279 -4
View File
@@ -6,12 +6,14 @@
//! (by-sonic/tglock#10, by-sonic/tglock#17).
use clap::Parser;
use std::fs::File;
use std::io::Write;
use std::net::IpAddr;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tglock_lib::cli_settings as config;
use tglock_lib::{proxy, transport};
@@ -23,11 +25,144 @@ const STATUS_POLL: Duration = Duration::from_secs(1);
/// пускают в `head`, закрывают терминал, перезапускают сборщик логов. Падать из
/// за этого туннель не должен — он продолжает работать молча.
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();
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)]
#[command(
name = "tglock-cli",
@@ -55,6 +190,11 @@ struct Args {
#[arg(long, value_name = "DOMAIN")]
worker: Vec<String>,
/// Свой домен на Cloudflare для CF-прокси (записи kws1…kws5, kws203 на
/// адреса Telegram, docs/CF_PROXY.md). Можно повторять
#[arg(long, value_name = "DOMAIN")]
cf_proxy: Vec<String>,
/// Проксировать и не-Telegram адреса. На сетевом адресе это открытый SOCKS5
#[arg(long)]
allow_direct: bool,
@@ -67,6 +207,15 @@ struct Args {
/// Печатать только ошибки
#[arg(short, long)]
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 {
@@ -77,8 +226,11 @@ impl Args {
lan: self.lan,
allow_direct: self.allow_direct,
worker: self.worker.clone(),
cf_proxy: self.cf_proxy.clone(),
secret_file: self.secret_file.clone(),
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 stats = settings.stats();
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
// daemon that reports success and silently does nothing.
@@ -168,6 +325,9 @@ async fn serve(args: Args) -> Result<(), String> {
if !settings.workers.is_empty() {
say(&format!("Резервные Worker-домены: {}", settings.workers));
}
if !settings.cf_proxies.is_empty() {
say(&format!("CF-прокси домены: {}", settings.cf_proxies));
}
}
let server_stats = stats.clone();
@@ -282,6 +442,114 @@ mod tests {
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]
fn command_definition_is_valid() {
Args::command().debug_assert();
@@ -338,6 +606,13 @@ mod tests {
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]
fn no_worker_flag_means_no_domains() {
assert!(from_flags(&[]).workers.is_empty());
+59
View File
@@ -20,6 +20,9 @@ use std::path::{Path, PathBuf};
/// Имя файла, который ищется рядом с бинарём, если `--config` не задан.
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` должна быть
@@ -33,9 +36,14 @@ pub struct FileConfig {
pub lan: Option<bool>,
pub allow_direct: Option<bool>,
pub worker: Option<Vec<String>>,
/// Свои домены на Cloudflare для CF-прокси (docs/CF_PROXY.md).
pub cf_proxy: Option<Vec<String>>,
pub secret: Option<String>,
pub secret_file: Option<PathBuf>,
pub quiet: Option<bool>,
/// Писать журнал в файл, а не в stdout (by-sonic/tglock#53).
pub log_file: Option<PathBuf>,
pub log_max_mb: Option<u32>,
}
impl FileConfig {
@@ -58,8 +66,11 @@ pub struct Overrides {
pub lan: bool,
pub allow_direct: bool,
pub worker: Vec<String>,
pub cf_proxy: Vec<String>,
pub secret_file: Option<PathBuf>,
pub quiet: bool,
pub log_file: Option<PathBuf>,
pub log_max_mb: Option<u32>,
}
/// Откуда взять секрет прокси.
@@ -79,8 +90,12 @@ pub struct Resolved {
pub listen: ListenConfig,
/// Домены Worker'ов в том виде, в каком их ждёт `Stats::set_worker_domain`.
pub workers: String,
/// Домены CF-прокси в том виде, в каком их ждёт `Stats::set_cf_proxy_domain`.
pub cf_proxies: String,
pub secret: SecretSource,
pub quiet: bool,
pub log_file: Option<PathBuf>,
pub log_max_mb: u32,
}
impl Resolved {
@@ -93,6 +108,7 @@ impl Resolved {
SecretSource::Ephemeral => proxy::Stats::new(),
};
stats.set_worker_domain(&self.workers);
stats.set_cf_proxy_domain(&self.cf_proxies);
stats
}
}
@@ -127,6 +143,19 @@ pub fn resolve(file: FileConfig, cli: Overrides) -> Result<Resolved, String> {
} else {
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 {
listen,
workers: workers.join(","),
cf_proxies: cf_proxies.join(","),
secret,
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);
}
#[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]
fn file_values_are_used_when_no_flags_are_given() {
let file = FileConfig::parse(
@@ -185,6 +242,7 @@ mod tests {
port = 1443
allow_direct = true
worker = ["a.workers.dev", "b.workers.dev"]
cf_proxy = ["example.com"]
quiet = true
"#,
)
@@ -193,6 +251,7 @@ mod tests {
assert_eq!(resolved.listen.addr.to_string(), "10.0.0.5:1443");
assert!(resolved.listen.allow_direct);
assert_eq!(resolved.workers, "a.workers.dev,b.workers.dev");
assert_eq!(resolved.cf_proxies, "example.com");
assert!(resolved.quiet);
}
+125 -3
View File
@@ -8,12 +8,17 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tauri::{Manager, State};
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)]
#[serde(rename_all = "camelCase")]
#[serde(rename_all = "camelCase", default)]
struct Settings {
lan_mode: bool,
port: u16,
worker_domain: String,
/// Свой домен на Cloudflare для CF-прокси (docs/CF_PROXY.md).
cf_proxy_domain: String,
}
impl Default for Settings {
@@ -22,6 +27,7 @@ impl Default for Settings {
lan_mode: false,
port: proxy::DEFAULT_PORT,
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.
set_foreground_service(&app, true)?;
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_listen.lock().unwrap() = Some(listen);
*state.started_at.lock().unwrap() = Some(Instant::now());
@@ -424,8 +431,19 @@ pub fn run() {
);
}
app.manage(state);
#[cfg(desktop)]
if let Err(error) = setup_tray(app) {
app.state::<AppState>().log(
format!("Значок в трее недоступен ({error}). Закрытие окна выключит защиту"),
true,
);
}
Ok(())
})
.on_window_event(|_window, _event| {
#[cfg(desktop)]
hide_to_tray_on_close(_window, _event);
})
.invoke_handler(tauri::generate_handler![
get_status,
get_settings,
@@ -435,14 +453,118 @@ pub fn run() {
get_telegram_link,
open_telegram
])
.run(tauri::generate_context!())
.expect("failed to run TGLock");
.build(tauri::generate_context!())
.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)]
mod tests {
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]
fn nothing_to_share_when_the_proxy_is_off_or_local() {
assert_eq!(
+244
View File
@@ -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)]
pub(crate) fn test_client_init(secret: &[u8; 16], dc_index: i16) -> [u8; INIT_LEN] {
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 {
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(
secret: &[u8; 16],
protocol_tag: [u8; 4],
+403 -22
View File
@@ -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) {
if let Some(sender) = self.shutdown.lock().unwrap().take() {
let _ = sender.send(true);
@@ -752,6 +772,7 @@ async fn ws_tunnel(
ws_w.send(tungstenite::Message::Binary(init.to_vec()))
.await
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
let health = TunnelHealth::new();
// Ping приходит в половину, которая читает, а отвечать на него должна та,
// которая пишет: владелец у отправляющей половины строго один.
@@ -765,11 +786,12 @@ async fn ws_tunnel(
// сессия встаёт при живом туннеле (by-sonic/tglock#42, #32).
let downstream = async {
let mut crypto = downstream_crypto;
let mut received_payload = false;
while let Some(message) = ws_r.next().await {
match message {
Ok(tungstenite::Message::Binary(mut data)) => {
received_payload |= !data.is_empty();
if !data.is_empty() {
health.note_reply();
}
if let Some(crypto) = &mut crypto {
crypto.apply(data.as_mut());
}
@@ -793,11 +815,9 @@ async fn ws_tunnel(
.is_ok_and(|code| code != 0)
|| frame.code != tungstenite::protocol::frame::coding::CloseCode::Normal
});
if !received_payload || transport_error {
if !health.replied() || transport_error {
let reason = match frame {
Some(frame) => {
format!("WebSocket закрыт: {} {}", frame.code, frame.reason)
}
Some(frame) => describe_ws_close(frame.code.into(), &frame.reason),
None => "WebSocket закрыт без ответа Telegram".to_owned(),
};
return Err(tunnel_route_error(stats, &connected, dc, &reason));
@@ -805,17 +825,18 @@ async fn ws_tunnel(
return Ok(());
}
Err(error) => {
return Err(tunnel_route_error(
return Err(tunnel_broke(
stats,
&connected,
dc,
&error.to_string(),
&health,
));
}
Ok(_) => {}
}
}
if !received_payload {
if !health.replied() {
return Err(tunnel_route_error(
stats,
&connected,
@@ -828,32 +849,54 @@ async fn ws_tunnel(
let upstream = async {
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];
loop {
tokio::select! {
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()),
Ok(read) => {
health.note_request();
if let Some(crypto) = &mut crypto {
crypto.apply(&mut buf[..read]);
}
ws_w
.send(tungstenite::Message::Binary(buf[..read].to_vec()))
.await
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
let packets = match &mut splitter {
Some(splitter) => splitter.push(&buf[..read]),
None => vec![buf[..read].to_vec()],
};
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 {
Some(payload) => {
ws_w.send(tungstenite::Message::Pong(payload)).await
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
ws_w.send(tungstenite::Message::Pong(payload)).await.map_err(|error| {
tunnel_broke(stats, &connected, dc, &error.to_string(), &health)
})?;
}
None => break,
},
}
}
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>>(())
};
@@ -887,45 +930,49 @@ async fn cdn_tcp_tunnel(
Some((upstream, downstream)) => (Some(upstream), Some(downstream)),
None => (None, None),
};
let health = TunnelHealth::new();
let upstream = async {
let mut crypto = upstream_crypto;
let mut buffer = vec![0; 65536];
loop {
let read = client_r.read(&mut buffer).await?;
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>>(());
}
health.note_request();
if let Some(crypto) = &mut crypto {
crypto.apply(&mut buffer[..read]);
}
remote_w
.write_all(&buffer[..read])
.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 mut crypto = downstream_crypto;
let mut buffer = vec![0; 65536];
let mut received_payload = false;
loop {
let read = remote_r
.read(&mut buffer)
.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 {
return if received_payload {
return if health.replied() {
Ok(())
} else {
Err(tunnel_route_error(
stats,
connected,
dc,
"CDN TCP закрыт без ответа Telegram",
"MTProto TCP закрыт без ответа Telegram",
))
};
}
received_payload = true;
health.note_reply();
if let Some(crypto) = &mut crypto {
crypto.apply(&mut buffer[..read]);
}
@@ -938,6 +985,24 @@ async fn cdn_tcp_tunnel(
}
}
/// Текст для журнала, когда WebSocket закрыла другая сторона.
///
/// `1003 binary messages required` отвечает только старый скрипт Worker'а: с
/// compatibility date 2026-03-17 Cloudflare отдаёт бинарные кадры как Blob, и
/// такой скрипт рвал каждый туннель сразу после рукопожатия (by-sonic/tglock#61).
/// Обновление TGLock код в чужом Cloudflare-аккаунте не меняет, поэтому
/// пользователю надо сказать прямо, что делать.
fn describe_ws_close(code: u16, reason: &str) -> String {
let mut text = format!("WebSocket закрыт: {code} {reason}");
if code == 1003 && reason.contains("binary messages required") {
text.push_str(
" — скрипт Cloudflare Worker устарел: опубликуйте заново \
worker/tglock-worker.js из репозитория",
);
}
text
}
fn tunnel_route_error(
stats: &Stats,
connected: &crate::transport::ConnectedRoute,
@@ -953,6 +1018,92 @@ fn tunnel_route_error(
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) {
let (mut ar, mut aw) = tokio::io::split(a);
let (mut br, mut bw) = tokio::io::split(b);
@@ -969,6 +1120,17 @@ mod tests {
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
use tokio_tungstenite::tungstenite::Message;
#[test]
fn an_outdated_worker_close_says_to_redeploy_it() {
let outdated = describe_ws_close(1003, "binary messages required");
assert!(outdated.starts_with("WebSocket закрыт: 1003 binary messages required"));
assert!(outdated.contains("worker/tglock-worker.js"));
assert_eq!(
describe_ws_close(1011, "Telegram socket failed"),
"WebSocket закрыт: 1011 Telegram socket failed"
);
}
/// Start the proxy on a kernel-assigned port.
///
/// Binding first and reading the port back removes the reserve-then-rebind
@@ -1679,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]
async fn fragmented_encrypted_upload_and_download_preserve_both_streams() {
check_fragmented_encrypted_duplex(false).await;
@@ -1718,7 +2099,7 @@ mod tests {
assert!(stats
.drain_events()
.iter()
.any(|event| event.contains("CDN TCP закрыт без ответа")));
.any(|event| event.contains("MTProto TCP закрыт без ответа")));
relay.await.unwrap();
stats.stop();
server.await.unwrap().unwrap();
+568 -117
View File
@@ -9,12 +9,15 @@ use tokio::time::Instant;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
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 MAX_CONNECTING: usize = 3;
const FAILURE_BACKOFF_INITIAL: Duration = Duration::from_secs(30);
const FAILURE_BACKOFF_MAX: Duration = Duration::from_secs(30 * 60);
const HTTPS_PORT: u16 = 443;
const HTTP_PORT: u16 = 80;
const CDN203_IP: &str = "91.105.192.100";
pub type TelegramWebSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
@@ -33,6 +36,13 @@ pub enum RouteKind {
SystemDns,
CloudflareWorker,
TelegramTcp,
/// Свой домен на Cloudflare: записи `kwsN` указывают на адреса Telegram,
/// и Cloudflare сам доводит WebSocket до дата-центра. Worker и его лимиты
/// не нужны — так работает CF-прокси в Flowseal/tg-ws-proxy.
CloudflareProxy,
/// WebSocket без TLS на порту 80 родного адреса DC. Единственный прямой
/// WebSocket к CDN203, у которого нет фронта на 443.
TelegramPlainWs,
}
impl RouteKind {
@@ -43,6 +53,8 @@ impl RouteKind {
Self::SystemDns => 3,
Self::CloudflareWorker => 4,
Self::TelegramTcp => 5,
Self::CloudflareProxy => 6,
Self::TelegramPlainWs => 7,
}
}
@@ -53,6 +65,8 @@ impl RouteKind {
3 => Some(Self::SystemDns),
4 => Some(Self::CloudflareWorker),
5 => Some(Self::TelegramTcp),
6 => Some(Self::CloudflareProxy),
7 => Some(Self::TelegramPlainWs),
_ => None,
}
}
@@ -64,7 +78,9 @@ impl RouteKind {
Self::AlternateTelegramIp => "Запасной Telegram IP",
Self::SystemDns => "Системный DNS",
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 {
fn cdn_tcp() -> Self {
Self::telegram_tcp(CDN203_IP)
}
/// Native obfuscated2 straight to a pinned Telegram address.
fn telegram_tcp(ip: &str) -> Self {
Self {
connect_host: CDN203_IP.to_owned(),
connect_host: ip.to_owned(),
websocket_host: String::new(),
path: String::new(),
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.
fn https(connect_host: String, websocket_host: String, path: String, kind: RouteKind) -> Self {
Self {
@@ -150,6 +188,7 @@ struct HealthState {
pub struct TransportEngine {
health: Mutex<HealthState>,
worker_domains: Mutex<Vec<String>>,
cf_proxy_domains: Mutex<Vec<String>>,
/// Сколько раз отдельный маршрут не ответил.
///
/// Считается отдельно от `Stats::ws_failures`, который растёт только когда
@@ -194,22 +233,16 @@ impl TransportEngine {
/// проверку, маршрут не появлялся, и «воркер настроен» ничем не отличалось
/// от «воркера нет» (by-sonic/tglock#50).
pub fn set_worker_domains(&self, 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);
}
}
*self.worker_domains.lock().unwrap() = accepted.clone();
WorkerDomains { accepted, rejected }
let result = split_domains(domains);
*self.worker_domains.lock().unwrap() = result.accepted.clone();
result
}
/// Задать свои домены на Cloudflare для CF-прокси (`kwsN.<домен>`).
pub fn set_cf_proxy_domains(&self, domains: &[String]) -> WorkerDomains {
let result = split_domains(domains);
*self.cf_proxy_domains.lock().unwrap() = result.accepted.clone();
result
}
pub async fn connect(
@@ -235,47 +268,21 @@ impl TransportEngine {
F: FnMut(Route) -> Fut,
Fut: std::future::Future<Output = Result<T, String>>,
{
let mut candidates = self.ordered_candidates(key).into_iter().peekable();
let mut pending = FuturesUnordered::new();
let mut next_start = Instant::now();
// Native TCP to an ordinary DC opens the moment the address is
// reachable, long before Telegram answers, so racing it would beat
// 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();
while candidates.peek().is_some() || !pending.is_empty() {
if candidates.peek().is_some()
&& pending.len() < MAX_CONNECTING
&& (pending.is_empty() || Instant::now() >= next_start)
for candidates in [main, last_resort] {
if let Some(winner) = self
.race_candidates(key, candidates, &mut connect, &mut errors)
.await
{
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 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 => {}
return Ok(winner);
}
}
@@ -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 {
self.health
.lock()
@@ -333,14 +404,19 @@ impl TransportEngine {
candidates.sort_by_key(|route| {
let preferred_rank = u8::from(preferred != Some(route));
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,
// The operator explicitly configured this independent path.
// Start it after the primary IP, before more potentially
// The operator explicitly configured these independent paths.
// Start them after the primary IP, before more potentially
// blocked Telegram addresses consume the concurrency budget.
RouteKind::CloudflareWorker => 1,
RouteKind::CloudflareWorker | RouteKind::CloudflareProxy => 1,
RouteKind::TelegramIp | RouteKind::AlternateTelegramIp => 2,
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)
});
@@ -358,6 +434,15 @@ impl TransportEngine {
}
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 {
return routes;
};
@@ -423,6 +508,20 @@ pub(crate) fn worker_path(dc: u16) -> Option<String> {
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
/// anything else instead of becoming an open TCP proxy.
pub fn worker_allowed_destinations() -> Vec<&'static str> {
@@ -435,14 +534,118 @@ pub fn worker_allowed_destinations() -> Vec<&'static str> {
all
}
fn canonical_dc(dc: u16) -> u16 {
if dc == 203 {
2
} else {
dc
/// Настоящий WebSocket-фронт Telegram: адрес и имена, которые он обслуживает.
struct WebFront {
ip: &'static str,
/// Основной кластер. Принимает и обычные соединения, и медиа.
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] {
match dc {
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> {
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();
}
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 {
for (index, ip) in ips.iter().enumerate() {
// Only media may use the `-1` hosts. That cluster rejects an ordinary
// 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(
(*ip).to_owned(),
websocket_host.clone(),
front.ip.to_owned(),
host(front).to_owned(),
"/apiws".to_owned(),
if index == 0 {
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.
// Resolving it would connect to DC2 itself and send CDN sessions to
// the wrong data centre. Its fallback must retain the CDN address.
if dc != 203 {
routes.push(Route::https(
websocket_host.clone(),
websocket_host.clone(),
"/apiws".to_owned(),
RouteKind::SystemDns,
));
}
let name = host(&fronts[0]).to_owned();
routes.push(Route::https(
name.clone(),
name,
"/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
}
@@ -539,7 +743,14 @@ async fn connect_route_with_config(
// Native TCP is a narrowly scoped CDN route, not a general proxy escape
// hatch. Reject malformed raw routes before dialing anything.
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(
CONNECT_TIMEOUT,
@@ -569,8 +780,8 @@ async fn connect_route_with_config(
);
if !route.secure {
// Raw TCP has already returned above. Plain WebSocket is only used by
// local fixtures; production WebSocket routes are always HTTPS.
// Raw TCP has already returned above. Besides local fixtures, plain
// WebSocket is only allowed to pinned Telegram addresses, checked above.
return tokio::time::timeout(
CONNECT_TIMEOUT,
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))
}
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 {
#[cfg(test)]
if route.connect_host == "127.0.0.1" && !route.secure {
return true;
}
route.connect_host == CDN203_IP
worker_allowed_destinations().contains(&route.connect_host.as_str())
&& route.port == HTTPS_PORT
&& route.websocket_host.is_empty()
&& route.path.is_empty()
&& !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 {
if domain.is_empty() || domain.len() > 253 || !domain.contains('.') {
return false;
@@ -631,7 +872,73 @@ mod tests {
fn media_prefers_dash_one_websocket_host() {
let routes = routes_for_dc(2, true);
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]
@@ -641,7 +948,8 @@ mod tests {
assert!(routes[0].websocket_host.is_empty());
assert!(!routes[0].secure);
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
.iter()
.all(|route| route.connect_host == "91.105.192.100"));
@@ -714,27 +1022,149 @@ mod tests {
}
#[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();
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 media in [false, true] {
let routes = engine.routes_for_key(DcKey { dc, media });
assert!(!routes.is_empty(), "DC{dc} must have at least one route");
for route in routes {
assert_eq!(route.port, 443, "{route:?}");
if route.kind == RouteKind::TelegramTcp {
assert_eq!(dc, 203);
assert_eq!(route.connect_host, CDN203_IP);
assert!(allowed_tcp_route(&route));
} else {
assert!(route.secure, "{route:?}");
match route.kind {
RouteKind::TelegramTcp => {
assert_eq!(route.port, 443, "{route:?}");
assert!(
telegram_ips(dc).contains(&route.connect_host.as_str()),
"{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]
fn every_data_center_offers_a_pinned_ip_and_a_dns_route() {
for dc in [1, 2, 3, 4, 5] {
@@ -975,6 +1405,8 @@ mod tests {
RouteKind::SystemDns,
RouteKind::CloudflareWorker,
RouteKind::TelegramTcp,
RouteKind::CloudflareProxy,
RouteKind::TelegramPlainWs,
] {
assert_eq!(RouteKind::from_ui_code(kind.ui_code()), Some(kind));
assert_eq!(route_label(kind.ui_code()), kind.label());
@@ -1270,11 +1702,30 @@ mod tests {
let mut route = Route::cdn_tcp();
route.connect_host = "127.0.0.2".to_owned();
let error = connect_route(&route).await.unwrap_err();
assert!(error.contains("только для закреплённого CDN203"), "{error}");
for dc in [1, 2, 3, 4, 5, 999] {
assert!(
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)
.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
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "TGLock",
"version": "2.0.0-beta.15",
"version": "2.0.0-beta.19",
"identifier": "com.bysonic.tglock",
"mainBinaryName": "tglock",
"build": {
+9
View File
@@ -33,6 +33,10 @@
# docs/CLOUDFLARE_WORKER.md
# worker = ["my-name.workers.dev"]
# Свой домен на Cloudflare (CF-прокси): записи kws1…kws5 и kws203 указывают
# на адреса Telegram, Worker не нужен. Настройка описана в docs/CF_PROXY.md
# cf_proxy = ["example.com"]
# Пропускать и не-Telegram адреса.
# На loopback это включено само. На сетевом адресе означает открытый
# SOCKS5-прокси для всего интернета — включайте осознанно.
@@ -40,3 +44,8 @@
# Печатать только ошибки.
# quiet = false
# Журнал в файл вместо stdout: на роутере stdout сервиса уходит в системный журнал.
# Заполненный файл переименовывается в <имя>.1, больше двух файлов не бывает.
# log_file = "/tmp/tglock.log"
# log_max_mb = 5
+109 -24
View File
@@ -34,6 +34,7 @@ type Settings = {
lanMode: boolean;
port: number;
workerDomain: string;
cfProxyDomain: string;
};
const root = document.querySelector<HTMLDivElement>("#app")!;
@@ -55,7 +56,7 @@ let status: Status = {
shareAddress: null,
logs: [],
};
let settings: Settings = { lanMode: false, port: 1080, workerDomain: "" };
let settings: Settings = { lanMode: false, port: 1080, workerDomain: "", cfProxyDomain: "" };
let busy = false;
let toastTimer: number | undefined;
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 {
return `
<main class="app-shell ${pageClass}">
@@ -124,7 +166,7 @@ function shell(content: string, pageClass = ""): string {
function renderHome(): void {
const connection = connectionState();
root.innerHTML = shell(`
patchRoot(shell(`
<section class="home">
<header class="brand">
<div class="wordmark"><span>TG</span>Lock</div>
@@ -190,14 +232,7 @@ function renderHome(): void {
</button>
</nav>
</section>
`, "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"));
`, "home-page"));
}
function pageHeader(title: string, subtitle: string): string {
@@ -213,7 +248,7 @@ function pageHeader(title: string, subtitle: string): string {
}
function renderSettings(): void {
root.innerHTML = shell(`
patchRoot(shell(`
<section class="subpage">
${pageHeader("Настройки", "Подключение и локальная сеть")}
@@ -232,6 +267,20 @@ function renderSettings(): void {
<p class="field-hint">Необязательно. Используется как резервный маршрут.</p>
</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">
<label class="setting-row" for="lan-mode">
<span>
@@ -260,15 +309,12 @@ function renderSettings(): void {
</button>
</form>
</section>
`, "subpage-shell");
document.querySelector("#back")?.addEventListener("click", () => navigate("home"));
document.querySelector("#settings-form")?.addEventListener("submit", saveSettings);
`, "subpage-shell"));
}
function renderDiagnostics(): void {
const recentLogs = status.logs.slice(-4).reverse();
root.innerHTML = shell(`
patchRoot(shell(`
<section class="subpage">
${pageHeader("Диагностика", "Состояние маршрута в реальном времени")}
@@ -357,12 +403,31 @@ function renderDiagnostics(): void {
</div>
</div>
</section>
`, "subpage-shell");
document.querySelector("#copy-diagnostics")?.addEventListener("click", copyDiagnostics);
document.querySelector("#back")?.addEventListener("click", () => navigate("home"));
`, "subpage-shell"));
}
// Обработчики висят на #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 {
if (view === "settings") renderSettings();
else if (view === "diagnostics") renderDiagnostics();
@@ -403,9 +468,10 @@ async function saveSettings(event: Event): Promise<void> {
event.preventDefault();
if (busy || status.running) return;
const worker = document.querySelector<HTMLInputElement>("#worker");
const cfProxy = document.querySelector<HTMLInputElement>("#cf-proxy");
const lanMode = document.querySelector<HTMLInputElement>("#lan-mode");
const port = document.querySelector<HTMLInputElement>("#port");
if (!worker || !lanMode || !port) return;
if (!worker || !cfProxy || !lanMode || !port) return;
const parsedPort = Number(port.value);
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
@@ -416,6 +482,7 @@ async function saveSettings(event: Event): Promise<void> {
busy = true;
settings = {
workerDomain: worker.value.trim(),
cfProxyDomain: cfProxy.value.trim(),
lanMode: lanMode.checked,
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> {
try {
// Public reports exclude proxy links, worker domains and free-form logs.
// Public reports exclude proxy links and secrets.
const report = {
application: "TGLock",
version: "2.0.0-beta.15",
version: "2.0.0-beta.19",
running: status.running,
activeConnections: status.activeConnections,
tunnels: status.tunnels,
@@ -465,9 +549,10 @@ async function copyDiagnostics(): Promise<void> {
uptimeSeconds: status.uptimeSeconds,
port: status.port,
lanMode: settings.lanMode,
failureLog: failureLog(),
};
await navigator.clipboard.writeText(JSON.stringify(report, null, 2));
showToast("Диагностика скопирована без адресов и секретов");
showToast("Диагностика скопирована без секретов и своих доменов");
} catch { showToast("Буфер обмена недоступен", true); }
}
+60 -9
View File
@@ -34,6 +34,38 @@ const CONNECT_TIMEOUT_MS = 3000;
const MAX_PENDING_BYTES = 1024 * 1024;
const MAX_PENDING_MESSAGES = 256;
// Видна в ответе на проверку из браузера (/apiws без WebSocket). Редактор
// Cloudflare показывает сохранённый черновик, а работает опубликованная
// версия: без этой строки старый скрипт за новым текстом в редакторе было не
// отличить (by-sonic/tglock#53). Меняется при каждом изменении скрипта.
const WORKER_VERSION = "2026-10-26";
// Размер бинарного кадра и способ достать его байты; null — текстовый кадр.
//
// С compatibility date 2026-03-17 бинарные кадры приходят как Blob, если
// binaryType не "arraybuffer". Свойство выставлено при accept(); Blob всё
// равно принимается — на случай рантайма, который его не учитывает. Раньше
// такой кадр закрывал туннель кодом 1003 сразу после рукопожатия (#61).
//
// Каждая ветка проверяет тип напрямую, чтобы проверка типов в редакторе
// Cloudflare не подчёркивала доступ к полям (by-sonic/tglock#66).
/** @param {unknown} data */
function binaryFrame(data) {
if (typeof Blob !== "undefined" && data instanceof Blob) {
const blob = data;
return { size: blob.size, read: async () => new Uint8Array(await blob.arrayBuffer()) };
}
if (data instanceof ArrayBuffer) {
const bytes = new Uint8Array(data);
return { size: bytes.byteLength, read: async () => bytes };
}
if (ArrayBuffer.isView(data)) {
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
return { size: bytes.byteLength, read: async () => bytes };
}
return null;
}
export default {
async fetch(request, env = {}) {
const url = new URL(request.url);
@@ -42,7 +74,10 @@ export default {
return new Response("not found", { status: 404 });
}
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 в настройках
// воркера, и посторонние подключиться не смогут.
@@ -85,6 +120,7 @@ export default {
}
const [client, server] = Object.values(new WebSocketPair());
server.binaryType = "arraybuffer";
server.accept();
const writer = upstream.writable.getWriter();
@@ -113,31 +149,35 @@ export default {
let pending = Promise.resolve();
let pendingBytes = 0;
let pendingMessages = 0;
let uploaded = 0;
server.addEventListener("message", (event) => {
if (closed) return;
if (!(event.data instanceof ArrayBuffer) && !ArrayBuffer.isView(event.data)) {
const frame = binaryFrame(event.data);
if (!frame) {
shutdown(1003, "binary messages required");
return;
}
const chunk = event.data instanceof ArrayBuffer
? new Uint8Array(event.data)
: new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength);
if (pendingBytes + chunk.byteLength > MAX_PENDING_BYTES || pendingMessages >= MAX_PENDING_MESSAGES) {
const size = frame.size;
if (pendingBytes + size > MAX_PENDING_BYTES || pendingMessages >= MAX_PENDING_MESSAGES) {
shutdown(1009, "Telegram write queue full");
return;
}
pendingBytes += chunk.byteLength;
pendingBytes += size;
pendingMessages += 1;
pending = pending
.then(async () => {
if (closed) return;
const bytes = await frame.read();
await writer.ready;
if (!closed) await writer.write(chunk);
if (!closed) {
await writer.write(bytes);
uploaded += bytes.byteLength;
}
})
.catch(() => shutdown(1011, "Telegram write failed"))
.finally(() => {
pendingBytes -= chunk.byteLength;
pendingBytes -= size;
pendingMessages -= 1;
});
});
@@ -148,10 +188,12 @@ export default {
// Обратное направление: всё, что приходит от Telegram, уходит клиенту.
(async () => {
const reader = upstream.readable.getReader();
let downloaded = 0;
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
downloaded += value.byteLength;
server.send(value);
}
} catch {
@@ -159,6 +201,15 @@ export default {
} finally {
reader.releaseLock();
}
// Telegram закрыл TCP, не ответив ни байтом. Раньше это было обычное
// закрытие 1000 без причины, и в журнале клиента не было видно, кто и на
// каком шаге оборвал туннель (by-sonic/tglock#53). Строка уходит и в
// журнал Worker'а в панели Cloudflare.
if (!downloaded && !closed) {
console.warn(`Telegram ${destination} closed TCP before replying (client sent ${uploaded} bytes)`);
shutdown(1011, "Telegram closed before replying");
return;
}
shutdown();
})();
+43 -4
View File
@@ -20,9 +20,9 @@ async function fixture({ opened = Promise.resolve(), write, timeout = false } =
const closed = deferred();
const writes = [];
const events = new Map();
const state = { connects: 0, accepted: false, socketCloses: 0, sent: [], closes: [] };
const state = { connects: 0, accepted: false, socketCloses: 0, sent: [], closes: [], warnings: [] };
const server = {
accept() { state.accepted = true; },
accept() { state.accepted = true; state.binaryTypeOnAccept = this.binaryType; },
addEventListener(name, callback) { events.set(name, callback); },
close(code, reason) { state.closes.push({ code, reason }); },
send(chunk) { state.sent.push(Array.from(chunk)); },
@@ -41,7 +41,7 @@ async function fixture({ opened = Promise.resolve(), write, timeout = false } =
},
};
const context = vm.createContext({
URL, Uint8Array, ArrayBuffer, setTimeout: timeout ? (fn) => setTimeout(fn, 0) : setTimeout, clearTimeout,
URL, Uint8Array, ArrayBuffer, Blob, console: { warn: (line) => state.warnings.push(line) }, setTimeout: timeout ? (fn) => setTimeout(fn, 0) : setTimeout, clearTimeout,
Response: class {
constructor(body, options) { this.body = body; Object.assign(this, options); }
},
@@ -77,7 +77,11 @@ async function fixture({ opened = Promise.resolve(), write, timeout = false } =
test("HTTP deployment check does not claim Telegram connectivity", async () => {
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);
});
@@ -133,6 +137,27 @@ test("serialize binary writes, preserve bytes and view offsets", async () => {
f.event("close");
});
test("binary frames arrive as ArrayBuffer on new compatibility dates", async () => {
// With compatibility date 2026-03-17+ Cloudflare delivers Blob unless
// binaryType is set before accept(); the Worker then closed every tunnel
// with 1003 right after the handshake (#61).
const f = await fixture();
await f.fetch();
assert.equal(f.state.binaryTypeOnAccept, "arraybuffer");
f.event("close");
});
test("Blob frames are still relayed in order, never closed as text", async () => {
const f = await fixture();
await f.fetch();
f.message(new Blob([new Uint8Array([7, 8])]));
f.message(new Uint8Array([9]).buffer);
for (let i = 0; i < 5; i++) await tick();
assert.deepEqual(f.writes, [[7, 8], [9]]);
assert.deepEqual(f.state.closes, []);
f.event("close");
});
test("overflow closes socket and discards queued writes", async () => {
const f = await fixture();
await f.fetch();
@@ -187,6 +212,20 @@ test("Telegram bytes reach WebSocket unchanged and EOF closes both directions",
assert.equal(f.state.socketCloses, 1);
});
test("Telegram closing TCP before any reply is named, not a silent 1000", async () => {
// A tunnel that never carried a byte from Telegram used to end as a plain
// 1000 close, which hid where it broke (#53). The reason also reaches the
// Worker log in the Cloudflare dashboard.
const f = await fixture();
await f.fetch();
f.message(new Uint8Array([1, 2, 3]).buffer);
await tick();
f.eof();
for (let i = 0; i < 5; i++) await tick();
assert.deepEqual(f.state.closes[0], { code: 1011, reason: "Telegram closed before replying" });
assert.match(f.state.warnings[0], /149\.154\.167\.51 closed TCP before replying \(client sent 3 bytes\)/);
});
test("CDN worker keeps the requested DC203 destination", async () => {
const f = await fixture();
await f.fetch("/apiws?dst=91.105.192.100&dc=203");