@@ -186,6 +193,8 @@ function renderHome(): void {
`, "home-page");
document.querySelector("#power")?.addEventListener("click", toggleProtection);
+ document.querySelector("#open-telegram")?.addEventListener("click", openTelegram);
+ document.querySelector("#copy-link")?.addEventListener("click", copyTelegramLink);
document.querySelector("#copy-address")?.addEventListener("click", copyShareAddress);
document.querySelector("#settings-nav")?.addEventListener("click", () => navigate("settings"));
document.querySelector("#diagnostics-nav")?.addEventListener("click", () => navigate("diagnostics"));
@@ -326,6 +335,8 @@ function renderDiagnostics(): void {
настроен неверно», а адрес такого клиента появится в журнале ниже.
+
Последние события
@@ -348,6 +359,7 @@ function renderDiagnostics(): void {
`, "subpage-shell");
+ document.querySelector("#copy-diagnostics")?.addEventListener("click", copyDiagnostics);
document.querySelector("#back")?.addEventListener("click", () => navigate("home"));
}
@@ -419,6 +431,46 @@ async function saveSettings(event: Event): Promise {
}
}
+async function openTelegram(): Promise {
+ try { await invoke("open_telegram"); }
+ catch (error) { showToast(String(error), true); }
+}
+
+async function copyTelegramLink(): Promise {
+ try {
+ const link = await invoke("get_telegram_link");
+ await navigator.clipboard.writeText(link);
+ showToast("Ссылка скопирована; она содержит секрет прокси");
+ } catch (error) {
+ showToast(`Не удалось скопировать ссылку: ${String(error)}`, true);
+ }
+}
+
+async function copyDiagnostics(): Promise {
+ try {
+ // Public reports exclude proxy links, worker domains and free-form logs.
+ const report = {
+ application: "TGLock",
+ version: "2.0.0-beta.14",
+ running: status.running,
+ activeConnections: status.activeConnections,
+ tunnels: status.tunnels,
+ dataCenter: status.dataCenter,
+ route: status.route,
+ failures: status.failures,
+ routeFailures: status.routeFailures,
+ blocked: status.blocked,
+ unknownClients: status.unknownClients,
+ silentClients: status.silentClients,
+ uptimeSeconds: status.uptimeSeconds,
+ port: status.port,
+ lanMode: settings.lanMode,
+ };
+ await navigator.clipboard.writeText(JSON.stringify(report, null, 2));
+ showToast("Диагностика скопирована без адресов и секретов");
+ } catch { showToast("Буфер обмена недоступен", true); }
+}
+
async function copyShareAddress(): Promise {
const address = status.shareAddress;
if (!address) return;
@@ -433,12 +485,17 @@ async function copyShareAddress(): Promise {
function showToast(message: string, error = false): void {
window.clearTimeout(toastTimer);
+ toastMessage = message.replace(/^["']|["']$/g, "");
+ toastError = error;
window.requestAnimationFrame(() => {
const toast = document.querySelector("#toast");
if (!toast) return;
toast.textContent = message.replace(/^["']|["']$/g, "");
toast.className = `toast visible${error ? " error" : ""}`;
- toastTimer = window.setTimeout(() => toast.classList.remove("visible"), 2800);
+ toastTimer = window.setTimeout(() => {
+ toastMessage = "";
+ document.querySelector("#toast")?.classList.remove("visible");
+ }, 4500);
});
}
diff --git a/ui/styles.css b/ui/styles.css
index d2afd58..5291344 100644
--- a/ui/styles.css
+++ b/ui/styles.css
@@ -897,3 +897,21 @@ input:disabled {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
}
+
+/* Keep actions reachable with mobile safe areas and accessibility text scaling. */
+.app-shell { overflow-y: auto; }
+.home, .subpage { height: auto; min-height: 100%; }
+.connection-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 8px;
+ margin-top: 12px;
+}
+.connection-actions .nav-button { min-height: 44px; padding: 8px 12px; }
+@media (max-width: 500px) {
+ .home { padding: max(32px, env(safe-area-inset-top)) 20px max(24px, env(safe-area-inset-bottom)); }
+ .subpage { padding-left: 20px; padding-right: 20px; padding-top: max(28px, env(safe-area-inset-top)); }
+ .drag-region { display: none; }
+ .share-address { overflow-wrap: anywhere; }
+}
diff --git a/worker/tglock-worker.js b/worker/tglock-worker.js
index 43a24fb..732e41e 100644
--- a/worker/tglock-worker.js
+++ b/worker/tglock-worker.js
@@ -28,9 +28,14 @@ const ALLOWED_DESTINATIONS = new Set([
]);
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) {
+ async fetch(request, env = {}) {
const url = new URL(request.url);
if (url.pathname !== "/apiws") {
@@ -50,19 +55,48 @@ export default {
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.accept();
- const upstream = connect({ hostname: destination, port: TELEGRAM_PORT });
const writer = upstream.writable.getWriter();
let closed = false;
- const shutdown = () => {
+ const shutdown = (code = 1000, reason = "") => {
if (closed) return;
closed = true;
- writer.close().catch(() => {});
+ // close() cancels both directions, including a blocked write/read.
+ upstream.close().catch(() => {});
try {
- server.close();
+ server.close(code, reason);
} catch {
// соединение уже закрыто
}
@@ -77,19 +111,39 @@ export default {
// проявлялось. Как только голодание починили, в воркер пошёл настоящий
// поток (by-sonic/tglock#42).
let pending = Promise.resolve();
+ let pendingBytes = 0;
+ let pendingMessages = 0;
server.addEventListener("message", (event) => {
- const chunk =
- event.data instanceof ArrayBuffer
- ? new Uint8Array(event.data)
- : event.data;
+ if (closed) return;
+ if (!(event.data instanceof ArrayBuffer) && !ArrayBuffer.isView(event.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) {
+ shutdown(1009, "Telegram write queue full");
+ return;
+ }
+ pendingBytes += chunk.byteLength;
+ pendingMessages += 1;
pending = pending
- .then(() => writer.ready)
- .then(() => writer.write(chunk))
- .catch(shutdown);
+ .then(async () => {
+ if (closed) return;
+ await writer.ready;
+ if (!closed) await writer.write(chunk);
+ })
+ .catch(() => shutdown(1011, "Telegram write failed"))
+ .finally(() => {
+ pendingBytes -= chunk.byteLength;
+ pendingMessages -= 1;
+ });
});
- server.addEventListener("close", shutdown);
- server.addEventListener("error", shutdown);
+ server.addEventListener("close", () => shutdown());
+ server.addEventListener("error", () => shutdown(1011, "WebSocket failed"));
+ upstream.closed.catch(() => shutdown(1011, "Telegram socket failed"));
// Обратное направление: всё, что приходит от Telegram, уходит клиенту.
(async () => {
@@ -101,7 +155,9 @@ export default {
server.send(value);
}
} catch {
- // разрыв соединения — обычная ситуация, не ошибка
+ shutdown(1011, "Telegram read failed");
+ } finally {
+ reader.releaseLock();
}
shutdown();
})();
diff --git a/worker/tglock-worker.test.js b/worker/tglock-worker.test.js
new file mode 100644
index 0000000..26ba404
--- /dev/null
+++ b/worker/tglock-worker.test.js
@@ -0,0 +1,196 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+import vm from "node:vm";
+
+const source = await readFile(new URL("./tglock-worker.js", import.meta.url), "utf8");
+const tick = () => new Promise((resolve) => setImmediate(resolve));
+function deferred() {
+ let resolve, reject;
+ const promise = new Promise((a, b) => { resolve = a; reject = b; });
+ return { promise, resolve, reject };
+}
+
+// Execute the actual standalone deployment file, replacing only Cloudflare's
+// platform primitives. No test-only implementation of the relay is used.
+async function fixture({ opened = Promise.resolve(), write, timeout = false } = {}) {
+ let readController;
+ const readable = new ReadableStream({ start(controller) { readController = controller; } });
+ const reads = { reject(error) { readController.error(error); } };
+ const closed = deferred();
+ const writes = [];
+ const events = new Map();
+ const state = { connects: 0, accepted: false, socketCloses: 0, sent: [], closes: [] };
+ const server = {
+ accept() { state.accepted = true; },
+ addEventListener(name, callback) { events.set(name, callback); },
+ close(code, reason) { state.closes.push({ code, reason }); },
+ send(chunk) { state.sent.push(Array.from(chunk)); },
+ };
+ const socket = {
+ opened, closed: closed.promise,
+ writable: { getWriter: () => ({
+ ready: Promise.resolve(),
+ async write(chunk) { writes.push(Array.from(chunk)); if (write) await write(chunk); },
+ }) },
+ readable,
+ async close() {
+ state.socketCloses++;
+ try { readController.close(); } catch { /* already closed or errored */ }
+ closed.resolve();
+ },
+ };
+ const context = vm.createContext({
+ URL, Uint8Array, ArrayBuffer, setTimeout: timeout ? (fn) => setTimeout(fn, 0) : setTimeout, clearTimeout,
+ Response: class {
+ constructor(body, options) { this.body = body; Object.assign(this, options); }
+ },
+ WebSocketPair: class { constructor() { this[0] = {}; this[1] = server; } },
+ });
+ const sockets = new vm.SyntheticModule(["connect"], function () {
+ this.setExport("connect", (address) => {
+ state.connects++;
+ state.address = address;
+ return socket;
+ });
+ }, { context });
+ const module = new vm.SourceTextModule(source, { context });
+ await module.link((name) => {
+ assert.equal(name, "cloudflare:sockets");
+ return sockets;
+ });
+ await module.evaluate();
+ return {
+ ...state,
+ state, writes, reads, closed, socket,
+ download(chunk) { readController.enqueue(chunk); },
+ eof() { readController.close(); },
+ message(data) { events.get("message")({ data }); },
+ event(name) { events.get(name)({}); },
+ fetch(path = "/apiws?dst=149.154.167.51&dc=2", headers = {}, env = {}) {
+ return module.namespace.default.fetch(new Request(`https://example.workers.dev${path}`, {
+ headers: { Upgrade: "websocket", "Sec-WebSocket-Protocol": "binary", ...headers },
+ }), env);
+ },
+ };
+}
+
+test("HTTP deployment check does not claim Telegram connectivity", async () => {
+ const f = await fixture();
+ assert.equal((await f.fetch("/apiws", { Upgrade: "" })).status, 426);
+ assert.equal(f.state.connects, 0);
+});
+
+test("reject unauthorized destinations, token and subprotocol before opening TCP", async () => {
+ const f = await fixture();
+ assert.equal((await f.fetch("/wrong")).status, 404);
+ assert.equal((await f.fetch("/apiws?dst=127.0.0.1")).status, 403);
+ assert.equal((await f.fetch(undefined, {}, { TGLOCK_TOKEN: "test-token" })).status, 403);
+ assert.equal((await f.fetch(undefined, { "Sec-WebSocket-Protocol": "chat" })).status, 400);
+ assert.equal(f.state.connects, 0);
+});
+
+test("wait for upstream TCP before accepting WebSocket", async () => {
+ const opened = deferred();
+ const f = await fixture({ opened: opened.promise });
+ const response = f.fetch();
+ await tick();
+ assert.equal(f.state.accepted, false);
+ opened.resolve();
+ const result = await response;
+ assert.equal(result.status, 101);
+ assert.equal(result.headers["Sec-WebSocket-Protocol"], "binary");
+ assert.equal(f.state.accepted, true);
+ f.event("close");
+});
+
+test("failed upstream returns HTTP 502, never a successful tunnel", async () => {
+ const f = await fixture({ opened: Promise.reject(new Error("unreachable")) });
+ assert.equal((await f.fetch()).status, 502);
+ assert.equal(f.state.accepted, false);
+ assert.equal(f.state.socketCloses, 1);
+});
+
+test("upstream timeout returns 504 and cancels socket", async () => {
+ const f = await fixture({ opened: new Promise(() => {}), timeout: true });
+ assert.equal((await f.fetch()).status, 504);
+ assert.equal(f.state.accepted, false);
+ assert.equal(f.state.socketCloses, 1);
+});
+
+test("serialize binary writes, preserve bytes and view offsets", async () => {
+ const firstWrite = deferred();
+ let calls = 0;
+ const f = await fixture({ write: async () => { if (++calls === 1) await firstWrite.promise; } });
+ await f.fetch();
+ f.message(new Uint8Array([1, 2]).buffer);
+ f.message(new Uint8Array([99, 3, 4, 88]).subarray(1, 3));
+ await tick();
+ assert.deepEqual(f.writes, [[1, 2]]);
+ firstWrite.resolve();
+ await tick();
+ assert.deepEqual(f.writes, [[1, 2], [3, 4]]);
+ f.event("close");
+});
+
+test("overflow closes socket and discards queued writes", async () => {
+ const f = await fixture();
+ await f.fetch();
+ f.message(new Uint8Array(1024 * 1024).buffer);
+ f.message(new Uint8Array([1]).buffer);
+ await tick();
+ assert.equal(f.state.closes[0].code, 1009);
+ assert.equal(f.state.socketCloses, 1);
+ assert.equal(f.writes.length, 0);
+});
+
+test("empty-message floods have a bounded queue too", async () => {
+ const f = await fixture();
+ await f.fetch();
+ for (let i = 0; i < 257; i++) f.message(new ArrayBuffer(0));
+ await tick();
+ assert.equal(f.state.closes[0].code, 1009);
+ assert.equal(f.state.socketCloses, 1);
+});
+
+test("text and write failures get diagnostic close codes", async () => {
+ const f = await fixture();
+ await f.fetch();
+ f.message("not binary");
+ assert.equal(f.state.closes[0].code, 1003);
+ const failing = await fixture({ write: async () => { throw new Error("reset"); } });
+ await failing.fetch();
+ failing.message(new Uint8Array([1]).buffer);
+ await tick();
+ assert.equal(failing.state.closes[0].code, 1011);
+ assert.equal(failing.state.socketCloses, 1);
+});
+
+test("read errors propagate and close both socket directions", async () => {
+ const f = await fixture();
+ await f.fetch();
+ f.reads.reject(new Error("reset"));
+ await tick();
+ assert.equal(f.state.closes[0].code, 1011);
+ assert.equal(f.state.socketCloses, 1);
+});
+
+test("Telegram bytes reach WebSocket unchanged and EOF closes both directions", async () => {
+ const f = await fixture();
+ await f.fetch();
+ f.download(new Uint8Array([0, 255, 128]));
+ f.download(new Uint8Array([1, 2]));
+ f.eof();
+ await tick();
+ assert.deepEqual(f.state.sent, [[0, 255, 128], [1, 2]]);
+ assert.equal(f.state.closes[0].code, 1000);
+ assert.equal(f.state.socketCloses, 1);
+});
+
+test("CDN worker keeps the requested DC203 destination", async () => {
+ const f = await fixture();
+ await f.fetch("/apiws?dst=91.105.192.100&dc=203");
+ assert.equal(f.state.address.hostname, "91.105.192.100");
+ assert.equal(f.state.address.port, 443);
+ f.event("close");
+});
diff --git a/worker/wrangler.toml b/worker/wrangler.toml
new file mode 100644
index 0000000..321995c
--- /dev/null
+++ b/worker/wrangler.toml
@@ -0,0 +1,3 @@
+name = "tglock"
+main = "tglock-worker.js"
+compatibility_date = "2026-09-19"