mirror of
https://github.com/by-sonic/tglock.git
synced 2026-10-06 09:16:00 +03:00
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:
+71
-15
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
name = "tglock"
|
||||
main = "tglock-worker.js"
|
||||
compatibility_date = "2026-09-19"
|
||||
Reference in New Issue
Block a user