Files
tglock/worker/tglock-worker.js
T
Никита Sonic 3aa6f413a3 fix(worker): type-clean frame handling; name a Telegram close before any reply (#67)
Cloudflare's dashboard editor type-checks the script and underlined four
lines of the Blob handling, because a separate `blob` flag did not narrow
`event.data` (#66). binaryFrame() narrows each branch with instanceof; the
file now type-checks cleanly against current @cloudflare/workers-types.

When Telegram closes TCP without sending a byte, the Worker used to end the
WebSocket with a plain 1000. It now closes with 1011 "Telegram closed before
replying" and logs the destination and the bytes the client sent, so the
client log and the Cloudflare log both say where the tunnel broke (#53).

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:03:26 +00:00

216 lines
9.0 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;
// Размер бинарного кадра и способ достать его байты; 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);
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;
let uploaded = 0;
server.addEventListener("message", (event) => {
if (closed) return;
const frame = binaryFrame(event.data);
if (!frame) {
shutdown(1003, "binary messages required");
return;
}
const size = frame.size;
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 = await frame.read();
await writer.ready;
if (!closed) {
await writer.write(bytes);
uploaded += bytes.byteLength;
}
})
.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();
let downloaded = 0;
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
downloaded += value.byteLength;
server.send(value);
}
} catch {
shutdown(1011, "Telegram read failed");
} 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();
})();
return new Response(null, {
status: 101,
webSocket: client,
// Обязательно: клиент запрашивает подпротокол binary и без
// подтверждения рвёт рукопожатие.
headers: { "Sec-WebSocket-Protocol": "binary" },
});
},
};