fix: маршруты, медиа и Worker; Android APK и статический ARM64 CLI (#60)

* fix: address routing, media, worker and platform issues

* ci: use available Android tools and verify Windows CLI artifact

* fix(gui): return a result from asynchronous stop command

* test(android): verify installed APK and proxy lifecycle on emulator

* fix(media): use native MTProto for CDN203 and retain recent diagnostics

* Fix owned WebSocket split after transport boxing

* Clarify CDN transport and connection status guarantees

* Recognize accessible Android power button labels

* chore(release): prepare 2.0.0-beta.15 with verified draft publication

---------

Co-authored-by: babin <Thartewerner536e@engineer.com>
This commit is contained in:
Никита Sonic
2026-09-19 16:04:39 +03:00
committed by GitHub
parent 8617d25f3a
commit 05ce9566a8
78 changed files with 4752 additions and 765 deletions
+71 -15
View File
@@ -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();
})();
+196
View File
@@ -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");
});
+3
View File
@@ -0,0 +1,3 @@
name = "tglock"
main = "tglock-worker.js"
compatibility_date = "2026-09-19"