mirror of
https://github.com/by-sonic/tglock.git
synced 2026-11-05 15:21:55 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc18509b9c | |||
| 098178ead4 | |||
| 7b48f0046b | |||
| 1d96609563 | |||
| 31bb442b0c | |||
| 33322ea3a3 | |||
| 3aa6f413a3 |
@@ -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
@@ -3822,7 +3822,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tglock"
|
||||
version = "2.0.0-beta.16"
|
||||
version = "2.0.0-beta.18"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cipher",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tglock"
|
||||
version = "2.0.0-beta.16"
|
||||
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, потому что файл правят руками и в нём
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Свой домен на Cloudflare (CF-прокси)
|
||||
|
||||
Ещё один резервный маршрут на случай, когда провайдер режет все обычные адреса
|
||||
Telegram. В отличие от [Cloudflare Worker](CLOUDFLARE_WORKER.md) здесь нет
|
||||
скрипта и лимитов Worker'ов: Cloudflare сам проксирует WebSocket до Telegram
|
||||
по DNS-записям вашего домена. Нужен только домен (подойдёт любой, обычно это
|
||||
150–300 ₽ в год).
|
||||
|
||||
Схема та же, что у CF-прокси в [Flowseal/tg-ws-proxy](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/RU/CfProxy.md)
|
||||
(идея — [Nekogram/WSProxy](https://github.com/Nekogram/WSProxy)):
|
||||
|
||||
```
|
||||
Telegram → TGLock → wss://kws2.ваш-домен/apiws → Cloudflare → 149.154.167.51 (DC2)
|
||||
```
|
||||
|
||||
## Настройка
|
||||
|
||||
1. Добавьте домен в Cloudflare: купите его там или смените NS-серверы у
|
||||
регистратора ([инструкция Cloudflare](https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/)).
|
||||
2. **SSL/TLS → Overview** — режим **Flexible**. До Cloudflare соединение
|
||||
остаётся HTTPS с проверкой сертификата, а от Cloudflare до Telegram идёт по
|
||||
порту 80: у адресов Telegram нет сертификата на ваш домен.
|
||||
3. **DNS → Records** — `A`-записи с включённым проксированием (оранжевое облако):
|
||||
|
||||
| Name | IPv4 |
|
||||
|---|---|
|
||||
| `kws1` | `149.154.175.50` |
|
||||
| `kws2` | `149.154.167.51` |
|
||||
| `kws3` | `149.154.175.100` |
|
||||
| `kws4` | `149.154.167.91` |
|
||||
| `kws5` | `149.154.171.5` |
|
||||
| `kws203` | `91.105.192.100` |
|
||||
|
||||
4. Укажите в TGLock **только домен**, без `kws` и без `https://`:
|
||||
- в приложении: **Настройки → Свой домен на Cloudflare** → `example.com`;
|
||||
- в CLI: `tglock-cli --cf-proxy example.com` или `cf_proxy = ["example.com"]` в `tglock.toml`.
|
||||
|
||||
В журнале появится строка `CF-прокси в списке маршрутов: kws1.example.com …`.
|
||||
Если строка начинается с «не похож на имя домена» — домен вписан со схемой
|
||||
или со слэшем.
|
||||
|
||||
## Что учесть
|
||||
|
||||
- Если у провайдера заблокирована сама сеть Cloudflare, этот маршрут не
|
||||
поможет, как и Worker.
|
||||
- Маршрут участвует в общем переборе вместе с Worker'ом: его проверка
|
||||
начинается сразу после основного адреса Telegram, успешный запоминается.
|
||||
- Шифрование Telegram не меняется: Cloudflare видит только зашифрованный
|
||||
поток MTProto, как и провайдер.
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
Если Telegram работает — **ничего настраивать не надо.** Поле Worker в настройках существует для случая, когда обычные маршруты умерли.
|
||||
|
||||
Если есть свой домен, проще [CF-прокси](CF_PROXY.md): тот же маршрут через Cloudflare, но без скрипта и без лимитов Worker'ов.
|
||||
|
||||
Обрати внимание: Worker не спасает, если Telegram недоступен *с самого воркера*. Он помогает, когда домены Telegram заблокированы **у тебя**, а датацентры Cloudflare до них дотягиваются.
|
||||
|
||||
## Что понадобится
|
||||
@@ -37,7 +39,9 @@
|
||||
|
||||
### Проверка, что воркер жив
|
||||
|
||||
Открой в браузере `https://tglock.имя.workers.dev/apiws`. Должно вернуться `expected a websocket upgrade` (HTTP 426). Это проверяет только публикацию скрипта. Соединение Worker → Telegram обычный GET **не проверяет**.
|
||||
Открой в браузере `https://tglock.имя.workers.dev/apiws`. Должно вернуться `expected a websocket upgrade` (HTTP 426) и строка `TGLock Worker 2026-10-26` с датой версии скрипта. Это проверяет только публикацию скрипта. Соединение Worker → Telegram обычный GET **не проверяет**.
|
||||
|
||||
**Строки с версией нет — работает старый скрипт**, даже если в редакторе Cloudflare открыт новый текст. Редактор показывает черновик, а запросы обслуживает последняя *опубликованная* версия: после правки нужно нажать **Deploy**. Старый скрипт на новых compatibility date рвёт каждый туннель; в журнале Worker'а это предупреждение `This TransformStream is being used as a byte stream, but received an object of non-ArrayBuffer/ArrayBufferView type`, в журнале TGLock — `WebSocket закрыт без ответа Telegram`.
|
||||
|
||||
Если вернулось `not found` — проверь, что путь именно `/apiws`. Если ошибка про `cloudflare:sockets` — у воркера слишком старая дата совместимости, поставь в **Settings → Compatibility date** сегодняшнюю.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "tglock-ui",
|
||||
"version": "2.0.0-beta.16",
|
||||
"version": "2.0.0-beta.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tglock-ui",
|
||||
"version": "2.0.0-beta.16",
|
||||
"version": "2.0.0-beta.18",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tglock-ui",
|
||||
"private": true,
|
||||
"version": "2.0.0-beta.16",
|
||||
"version": "2.0.0-beta.18",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js",
|
||||
|
||||
+161
-32
@@ -14,23 +14,103 @@ PACKAGE = "com.bysonic.tglock"
|
||||
COMPONENT = f"{PACKAGE}/.MainActivity"
|
||||
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):
|
||||
@@ -47,12 +127,9 @@ def ui_dump(label):
|
||||
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())
|
||||
@@ -61,17 +138,53 @@ def label_node(tree, label):
|
||||
return None
|
||||
|
||||
|
||||
def tap_label(label, stage):
|
||||
for attempt in range(5):
|
||||
try:
|
||||
point = label_node(ui_dump(f"{stage}-{attempt}"), label)
|
||||
except (subprocess.SubprocessError, ET.ParseError) as error:
|
||||
(EVIDENCE / f"{stage}-{attempt}.error.txt").write_text(str(error), encoding="utf-8")
|
||||
point = None
|
||||
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}")
|
||||
|
||||
|
||||
@@ -85,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):
|
||||
@@ -93,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()
|
||||
@@ -104,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, "Включить защиту")
|
||||
@@ -132,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()
|
||||
@@ -160,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")
|
||||
@@ -171,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))
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
+22
-2
@@ -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);
|
||||
@@ -919,7 +939,7 @@ async fn cdn_tcp_tunnel(
|
||||
stats,
|
||||
connected,
|
||||
dc,
|
||||
"CDN TCP закрыт без ответа Telegram",
|
||||
"MTProto TCP закрыт без ответа Telegram",
|
||||
))
|
||||
};
|
||||
}
|
||||
@@ -1745,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
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "TGLock",
|
||||
"version": "2.0.0-beta.16",
|
||||
"version": "2.0.0-beta.18",
|
||||
"identifier": "com.bysonic.tglock",
|
||||
"mainBinaryName": "tglock",
|
||||
"build": {
|
||||
|
||||
@@ -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
@@ -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.16",
|
||||
version: "2.0.0-beta.18",
|
||||
running: status.running,
|
||||
activeConnections: status.activeConnections,
|
||||
tunnels: status.tunnels,
|
||||
|
||||
+56
-14
@@ -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 в настройках
|
||||
// воркера, и посторонние подключиться не смогут.
|
||||
@@ -114,23 +149,16 @@ export default {
|
||||
let pending = Promise.resolve();
|
||||
let pendingBytes = 0;
|
||||
let pendingMessages = 0;
|
||||
let uploaded = 0;
|
||||
|
||||
server.addEventListener("message", (event) => {
|
||||
if (closed) return;
|
||||
const data = event.data;
|
||||
// С compatibility date 2026-03-17 бинарные кадры приходят как Blob, если
|
||||
// binaryType не "arraybuffer". Свойство выставлено выше; Blob всё равно
|
||||
// принимается — на случай рантайма, который его не учитывает. Раньше такой
|
||||
// кадр закрывал туннель кодом 1003 сразу после рукопожатия.
|
||||
const blob = typeof Blob !== "undefined" && data instanceof Blob;
|
||||
if (!blob && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
|
||||
const frame = binaryFrame(event.data);
|
||||
if (!frame) {
|
||||
shutdown(1003, "binary messages required");
|
||||
return;
|
||||
}
|
||||
const chunk = blob ? null : data instanceof ArrayBuffer
|
||||
? new Uint8Array(data)
|
||||
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
const size = blob ? data.size : chunk.byteLength;
|
||||
const size = frame.size;
|
||||
if (pendingBytes + size > MAX_PENDING_BYTES || pendingMessages >= MAX_PENDING_MESSAGES) {
|
||||
shutdown(1009, "Telegram write queue full");
|
||||
return;
|
||||
@@ -140,9 +168,12 @@ export default {
|
||||
pending = pending
|
||||
.then(async () => {
|
||||
if (closed) return;
|
||||
const bytes = chunk || new Uint8Array(await data.arrayBuffer());
|
||||
const bytes = await frame.read();
|
||||
await writer.ready;
|
||||
if (!closed) await writer.write(bytes);
|
||||
if (!closed) {
|
||||
await writer.write(bytes);
|
||||
uploaded += bytes.byteLength;
|
||||
}
|
||||
})
|
||||
.catch(() => shutdown(1011, "Telegram write failed"))
|
||||
.finally(() => {
|
||||
@@ -157,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 {
|
||||
@@ -168,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();
|
||||
})();
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ 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; state.binaryTypeOnAccept = this.binaryType; },
|
||||
addEventListener(name, callback) { events.set(name, callback); },
|
||||
@@ -41,7 +41,7 @@ async function fixture({ opened = Promise.resolve(), write, timeout = false } =
|
||||
},
|
||||
};
|
||||
const context = vm.createContext({
|
||||
URL, Uint8Array, ArrayBuffer, Blob, 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);
|
||||
});
|
||||
|
||||
@@ -208,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");
|
||||
|
||||
Reference in New Issue
Block a user