Compare commits

..

9 Commits

Author SHA1 Message Date
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
20 changed files with 918 additions and 156 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.18"
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.18"
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, потому что файл правят руками и в нём
+7
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">
+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
+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; локальные тесты ничего не публикуют.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "tglock-ui",
"version": "2.0.0-beta.15",
"version": "2.0.0-beta.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tglock-ui",
"version": "2.0.0-beta.15",
"version": "2.0.0-beta.18",
"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.18",
"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))
+16
View File
@@ -55,6 +55,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,
@@ -77,6 +82,7 @@ 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,
}
@@ -168,6 +174,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();
@@ -338,6 +347,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());
+14
View File
@@ -33,6 +33,8 @@ 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>,
@@ -58,6 +60,7 @@ 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,
}
@@ -79,6 +82,8 @@ 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,
}
@@ -93,6 +98,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 +133,11 @@ 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
};
// Секрет из файла конфигурации важнее отдельного файла: если человек вписал
// его сюда, значит хотел держать всё в одном месте.
@@ -142,6 +153,7 @@ 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),
})
@@ -185,6 +197,7 @@ mod tests {
port = 1443
allow_direct = true
worker = ["a.workers.dev", "b.workers.dev"]
cf_proxy = ["example.com"]
quiet = true
"#,
)
@@ -193,6 +206,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!(
+52 -5
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);
@@ -795,9 +815,7 @@ async fn ws_tunnel(
});
if !received_payload || 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));
@@ -921,7 +939,7 @@ async fn cdn_tcp_tunnel(
stats,
connected,
dc,
"CDN TCP закрыт без ответа Telegram",
"MTProto TCP закрыт без ответа Telegram",
))
};
}
@@ -938,6 +956,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,
@@ -969,6 +1005,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
@@ -1718,7 +1765,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();
+262 -69
View File
@@ -33,6 +33,10 @@ pub enum RouteKind {
SystemDns,
CloudflareWorker,
TelegramTcp,
/// Свой домен на Cloudflare: записи `kwsN` указывают на адреса Telegram,
/// и Cloudflare сам доводит WebSocket до дата-центра. Worker и его лимиты
/// не нужны — так работает CF-прокси в Flowseal/tg-ws-proxy.
CloudflareProxy,
}
impl RouteKind {
@@ -43,6 +47,7 @@ impl RouteKind {
Self::SystemDns => 3,
Self::CloudflareWorker => 4,
Self::TelegramTcp => 5,
Self::CloudflareProxy => 6,
}
}
@@ -53,6 +58,7 @@ impl RouteKind {
3 => Some(Self::SystemDns),
4 => Some(Self::CloudflareWorker),
5 => Some(Self::TelegramTcp),
6 => Some(Self::CloudflareProxy),
_ => None,
}
}
@@ -64,7 +70,8 @@ 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-прокси",
}
}
}
@@ -91,8 +98,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,
@@ -150,6 +162,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 +207,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 +242,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 +286,63 @@ 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 = 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 => {}
}
}
None
}
fn route_available(&self, route: &Route) -> bool {
self.health
.lock()
@@ -333,14 +371,16 @@ 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().
RouteKind::TelegramTcp => 4,
};
(preferred_rank, kind_rank)
});
@@ -358,6 +398,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 +472,19 @@ 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)
}
/// Native TCP to an ordinary data centre — every pinned address except the
/// CDN, whose native transport is its primary route.
fn is_last_resort(route: &Route) -> bool {
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> {
@@ -502,6 +564,16 @@ pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> {
));
}
}
// Where the WebSocket frontends are filtered but the address itself is
// reachable, plain MTProto to the DC's primary address still works —
// measured from a Russian ISP where TLS to .51/.91/.50/.100/.5 was cut
// while native TCP to each of them answered req_pq (main and media).
// 149.154.167.220 is a WebSocket frontend only and does not speak native
// MTProto, so it gets no TCP route. Raced only after everything above
// failed (race_connections).
if dc != 203 {
routes.push(Route::telegram_tcp(ips[0]));
}
routes
}
@@ -539,7 +611,9 @@ 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(),
);
}
let tcp = tokio::time::timeout(
CONNECT_TIMEOUT,
@@ -601,13 +675,32 @@ fn allowed_tcp_route(route: &Route) -> bool {
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;
@@ -714,9 +807,10 @@ 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 });
@@ -724,8 +818,10 @@ mod tests {
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!(
telegram_ips(dc).contains(&route.connect_host.as_str()),
"{route:?}"
);
assert!(allowed_tcp_route(&route));
} else {
assert!(route.secure, "{route:?}");
@@ -735,6 +831,98 @@ mod tests {
}
}
#[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_tcp = ordered
.iter()
.position(|route| route.kind == RouteKind::TelegramTcp)
.expect("every DC has a native TCP fallback");
assert!(
ordered[first_tcp..].iter().all(is_last_resort),
"DC{dc}: {ordered:?}"
);
assert!(ordered[..first_tcp]
.iter()
.all(|route| !is_last_resort(route)));
}
let cdn = engine.ordered_candidates(DcKey {
dc: 203,
media: true,
});
assert_eq!(cdn[0], Route::cdn_tcp());
assert!(!is_last_resort(&cdn[0]));
}
#[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] {
@@ -1270,11 +1458,16 @@ 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]));
}
}
+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.18",
"identifier": "com.bysonic.tglock",
"mainBinaryName": "tglock",
"build": {
+4
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-прокси для всего интернета — включайте осознанно.
+89 -22
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,
};
@@ -451,7 +518,7 @@ async function copyDiagnostics(): Promise<void> {
// Public reports exclude proxy links, worker domains and free-form logs.
const report = {
application: "TGLock",
version: "2.0.0-beta.15",
version: "2.0.0-beta.18",
running: status.running,
activeConnections: status.activeConnections,
tunnels: status.tunnels,
+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");