mirror of
https://github.com/by-sonic/tglock.git
synced 2026-11-05 23:31:58 +03:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d95948dbba | |||
| 3aa6f413a3 | |||
| f82543e44e | |||
| 9529df3fdf | |||
| 05ce9566a8 |
@@ -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.15"
|
||||
version = "2.0.0-beta.16"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cipher",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tglock"
|
||||
version = "2.0.0-beta.15"
|
||||
version = "2.0.0-beta.16"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
description = "Telegram unblock via local WebSocket tunnel"
|
||||
|
||||
+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
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
|
||||
Если вернулось `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; локальные тесты ничего не публикуют.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "tglock-ui",
|
||||
"version": "2.0.0-beta.15",
|
||||
"version": "2.0.0-beta.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tglock-ui",
|
||||
"version": "2.0.0-beta.15",
|
||||
"version": "2.0.0-beta.16",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tglock-ui",
|
||||
"private": true,
|
||||
"version": "2.0.0-beta.15",
|
||||
"version": "2.0.0-beta.16",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js",
|
||||
|
||||
+166
-29
@@ -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))
|
||||
|
||||
+30
-3
@@ -795,9 +795,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));
|
||||
@@ -938,6 +936,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 +985,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
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "TGLock",
|
||||
"version": "2.0.0-beta.15",
|
||||
"version": "2.0.0-beta.16",
|
||||
"identifier": "com.bysonic.tglock",
|
||||
"mainBinaryName": "tglock",
|
||||
"build": {
|
||||
|
||||
+1
-1
@@ -451,7 +451,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.16",
|
||||
running: status.running,
|
||||
activeConnections: status.activeConnections,
|
||||
tunnels: status.tunnels,
|
||||
|
||||
+50
-8
@@ -34,6 +34,32 @@ const CONNECT_TIMEOUT_MS = 3000;
|
||||
const MAX_PENDING_BYTES = 1024 * 1024;
|
||||
const MAX_PENDING_MESSAGES = 256;
|
||||
|
||||
// Размер бинарного кадра и способ достать его байты; 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);
|
||||
@@ -85,6 +111,7 @@ export default {
|
||||
}
|
||||
|
||||
const [client, server] = Object.values(new WebSocketPair());
|
||||
server.binaryType = "arraybuffer";
|
||||
server.accept();
|
||||
|
||||
const writer = upstream.writable.getWriter();
|
||||
@@ -113,31 +140,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 +179,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 +192,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,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); }
|
||||
},
|
||||
@@ -133,6 +133,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 +208,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