Files
tglock/worker/tglock-worker.js
T
Никита Sonic 31bb442b0c fix(worker): name the published script version in the browser check (#72)
The dashboard editor shows a saved draft while requests are served by the
last deployed version. A user with the new text in the editor was running
the pre-beta.15 script, which writes Blob frames into the socket on current
compatibility dates (Cloudflare warns "This TransformStream is being used as
a byte stream ...") and drops every tunnel (#53). Reproduced on local
workerd: the old script logs exactly that warning, the current one does not.

GET /apiws now answers "TGLock Worker <version>" and X-TGLock-Worker, and the
docs say a missing line means an old script is still deployed.

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:17:04 +00:00

225 lines
9.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;
// Видна в ответе на проверку из браузера (/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);
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\nTGLock Worker ${WORKER_VERSION}\n`, {
status: 426,
headers: { "X-TGLock-Worker": WORKER_VERSION },
});
}
// Необязательный общий секрет: задай переменную 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" },
});
},
};