From 9529df3fdf122269909974046050d8475d900205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=B8=D1=82=D0=B0=20Sonic?= Date: Thu, 22 Oct 2026 22:37:15 +0000 Subject: [PATCH] =?UTF-8?q?fix(worker):=20=D1=82=D1=83=D0=BD=D0=BD=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Worker=20=D1=80?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D0=B8=D1=81=D1=8C=20=D1=81=201003=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20compatibility=20date;?= =?UTF-8?q?=202.0.0-beta.16=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * worker: set binaryType = "arraybuffer" to fix 1003 "binary messages required" Since compatibility date 2026-03-17 Cloudflare delivers binary WebSocket messages as Blob (websocket_standard_binary_type). The message handler accepts only ArrayBuffer and closes every tunnel with 1003. Setting binaryType before accept() restores ArrayBuffer delivery regardless of the compatibility date. * fix(worker): accept Blob frames and name an outdated Worker in the log; 2.0.0-beta.16 Since compatibility date 2026-03-17 Cloudflare delivers binary WebSocket frames as Blob unless binaryType is "arraybuffer", and the Worker closed every tunnel with 1003 right after the handshake. wrangler.toml itself pins 2026-09-19, so every fresh deployment was broken (#58, #50, #53). On top of #61 (binaryType before accept) the Worker now also relays Blob frames in order, and the client log tells the user to redeploy the script when a Worker closes with "1003 binary messages required". Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitry Sankevich <16187308+avost@users.noreply.github.com> Co-authored-by: babin Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- docs/CLOUDFLARE_WORKER.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- src/proxy.rs | 33 ++++++++++++++++++++++++++++++--- tauri.conf.json | 2 +- ui/main.ts | 2 +- worker/tglock-worker.js | 25 +++++++++++++++++-------- worker/tglock-worker.test.js | 25 +++++++++++++++++++++++-- 10 files changed, 79 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7804947..632cd9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3822,7 +3822,7 @@ dependencies = [ [[package]] name = "tglock" -version = "2.0.0-beta.15" +version = "2.0.0-beta.16" dependencies = [ "aes", "cipher", diff --git a/Cargo.toml b/Cargo.toml index 147801b..270b51c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/docs/CLOUDFLARE_WORKER.md b/docs/CLOUDFLARE_WORKER.md index 82b85b0..9cbacba 100644 --- a/docs/CLOUDFLARE_WORKER.md +++ b/docs/CLOUDFLARE_WORKER.md @@ -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; локальные тесты ничего не публикуют. diff --git a/package-lock.json b/package-lock.json index b5603f4..7b272ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index f582150..5d6f94d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/proxy.rs b/src/proxy.rs index f7fdc36..ac07917 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -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 diff --git a/tauri.conf.json b/tauri.conf.json index f818063..d0937cc 100644 --- a/tauri.conf.json +++ b/tauri.conf.json @@ -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": { diff --git a/ui/main.ts b/ui/main.ts index 5e5bde8..c232669 100644 --- a/ui/main.ts +++ b/ui/main.ts @@ -451,7 +451,7 @@ async function copyDiagnostics(): Promise { // 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, diff --git a/worker/tglock-worker.js b/worker/tglock-worker.js index 732e41e..9c96dd8 100644 --- a/worker/tglock-worker.js +++ b/worker/tglock-worker.js @@ -85,6 +85,7 @@ export default { } const [client, server] = Object.values(new WebSocketPair()); + server.binaryType = "arraybuffer"; server.accept(); const writer = upstream.writable.getWriter(); @@ -116,28 +117,36 @@ export default { server.addEventListener("message", (event) => { if (closed) return; - if (!(event.data instanceof ArrayBuffer) && !ArrayBuffer.isView(event.data)) { + 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)) { 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 chunk = blob ? null : data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const size = blob ? data.size : chunk.byteLength; + 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 = chunk || new Uint8Array(await data.arrayBuffer()); await writer.ready; - if (!closed) await writer.write(chunk); + if (!closed) await writer.write(bytes); }) .catch(() => shutdown(1011, "Telegram write failed")) .finally(() => { - pendingBytes -= chunk.byteLength; + pendingBytes -= size; pendingMessages -= 1; }); }); diff --git a/worker/tglock-worker.test.js b/worker/tglock-worker.test.js index 26ba404..24c6449 100644 --- a/worker/tglock-worker.test.js +++ b/worker/tglock-worker.test.js @@ -22,7 +22,7 @@ async function fixture({ opened = Promise.resolve(), write, timeout = false } = const events = new Map(); const state = { connects: 0, accepted: false, socketCloses: 0, sent: [], closes: [] }; 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, 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();