Files
tglock/worker/tglock-worker.js
T
Никита Sonic 9529df3fdf fix(worker): туннели через Worker рвались с 1003 на новых compatibility date; 2.0.0-beta.16 (#63)
* worker: set binaryType = "arraybuffer" to fix 1003 "binary messages required"

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

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

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

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

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

---------

Co-authored-by: Dmitry Sankevich <16187308+avost@users.noreply.github.com>
Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:37:15 +00:00

183 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Резервный маршрут TGLock через Cloudflare Worker.
//
// Нужен в одном случае: провайдер заблокировал саму веб-инфраструктуру
// Telegram, и все обычные маршруты TGLock перестали отвечать. Тогда соединение
// идёт на твой домен *.workers.dev, а воркер доводит его до Telegram.
//
// Инструкция по установке: docs/CLOUDFLARE_WORKER.md
//
// Контракт, который ожидает клиент (src/transport.rs):
// wss://<домен>/apiws?dst=<telegram-ip>&dc=<номер-dc>
// заголовок Sec-WebSocket-Protocol: binary — его обязательно нужно
// подтвердить в ответе, иначе клиент разорвёт рукопожатие;
// бинарные frames в обе стороны, без обёрток.
import { connect } from "cloudflare:sockets";
// Только те адреса, которые запрашивает сам TGLock. Без этого списка любой,
// кто узнает адрес воркера, получит через твой аккаунт произвольный
// TCP-прокси.
const ALLOWED_DESTINATIONS = new Set([
"91.105.192.100",
"149.154.167.51",
"149.154.167.91",
"149.154.167.220",
"149.154.171.5",
"149.154.175.50",
"149.154.175.100",
]);
const TELEGRAM_PORT = 443;
const CONNECT_TIMEOUT_MS = 3000;
// WebSocket events cannot be paused. Bound the outstanding writes instead of
// retaining an unlimited chain of promises when Telegram stops reading.
const MAX_PENDING_BYTES = 1024 * 1024;
const MAX_PENDING_MESSAGES = 256;
export default {
async fetch(request, env = {}) {
const url = new URL(request.url);
if (url.pathname !== "/apiws") {
return new Response("not found", { status: 404 });
}
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
return new Response("expected a websocket upgrade", { status: 426 });
}
// Необязательный общий секрет: задай переменную TGLOCK_TOKEN в настройках
// воркера, и посторонние подключиться не смогут.
if (env.TGLOCK_TOKEN && url.searchParams.get("token") !== env.TGLOCK_TOKEN) {
return new Response("forbidden", { status: 403 });
}
const destination = url.searchParams.get("dst");
if (!destination || !ALLOWED_DESTINATIONS.has(destination)) {
return new Response("destination not allowed", { status: 403 });
}
if (!request.headers.get("Sec-WebSocket-Protocol")?.split(",").some((p) => p.trim() === "binary")) {
return new Response("binary websocket subprotocol required", { status: 400 });
}
let upstream;
let timer;
let timedOut = false;
try {
upstream = connect({ hostname: destination, port: TELEGRAM_PORT });
// Both promises can reject on connect failure; observe closed immediately.
upstream.closed.catch(() => {});
await Promise.race([
upstream.opened,
new Promise((_, reject) => {
timer = setTimeout(() => {
timedOut = true;
reject(new Error("connect timeout"));
}, CONNECT_TIMEOUT_MS);
}),
]);
} catch {
if (upstream) await upstream.close().catch(() => {});
return new Response(timedOut ? "Telegram TCP connect timeout" : "Telegram TCP connect failed", {
status: timedOut ? 504 : 502,
});
} finally {
clearTimeout(timer);
}
const [client, server] = Object.values(new WebSocketPair());
server.binaryType = "arraybuffer";
server.accept();
const writer = upstream.writable.getWriter();
let closed = false;
const shutdown = (code = 1000, reason = "") => {
if (closed) return;
closed = true;
// close() cancels both directions, including a blocked write/read.
upstream.close().catch(() => {});
try {
server.close(code, reason);
} catch {
// соединение уже закрыто
}
};
// Запись сериализуется: следующий чанк уходит только после того, как
// записан предыдущий, и только когда писатель к этому готов.
//
// Раньше `write()` вызывался поверх незавершённого, а `writer.ready` не
// спрашивался вовсе — backpressure не применялся. Пока в клиенте отправка
// голодала, поверх воркера настоящего потока вверх не бывало и это не
// проявлялось. Как только голодание починили, в воркер пошёл настоящий
// поток (by-sonic/tglock#42).
let pending = Promise.resolve();
let pendingBytes = 0;
let pendingMessages = 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)) {
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;
if (pendingBytes + size > MAX_PENDING_BYTES || pendingMessages >= MAX_PENDING_MESSAGES) {
shutdown(1009, "Telegram write queue full");
return;
}
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(bytes);
})
.catch(() => shutdown(1011, "Telegram write failed"))
.finally(() => {
pendingBytes -= size;
pendingMessages -= 1;
});
});
server.addEventListener("close", () => shutdown());
server.addEventListener("error", () => shutdown(1011, "WebSocket failed"));
upstream.closed.catch(() => shutdown(1011, "Telegram socket failed"));
// Обратное направление: всё, что приходит от Telegram, уходит клиенту.
(async () => {
const reader = upstream.readable.getReader();
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
server.send(value);
}
} catch {
shutdown(1011, "Telegram read failed");
} finally {
reader.releaseLock();
}
shutdown();
})();
return new Response(null, {
status: 101,
webSocket: client,
// Обязательно: клиент запрашивает подпротокол binary и без
// подтверждения рвёт рукопожатие.
headers: { "Sec-WebSocket-Protocol": "binary" },
});
},
};