Files
Никита Sonic 8ce9355243 fix: медиа и «прокси не настроен»: пакет на кадр, настоящие фронты, kwsN-1 только для медиа; beta.19 (#75)
* fix(tunnel): медиа и «прокси не настроен»: один пакет на кадр, настоящие фронты, kwsN-1 только для медиа

Три причины, каждая проверена на живом Telegram запросом req_DH_params:
сервер сверяет номер DC внутри него с собой и отвечает -444 на чужой.

- WebSocket-фронт разбирает один пакет на входящий кадр, остаток ждёт
  следующего. Клиент пишет запросы пачками, TGLock слал их одним кадром,
  и всё, что шло вторым, застревало: текст проходил, файлы вставали.
  Теперь поток режется на пакеты, как в Flowseal/tg-ws-proxy. Пачка через
  beta.18 висит на всех DC, через эту сборку принимается на всех.
- Кластер kwsN-1 отвергает обычное соединение с -444, а Telegram Desktop
  в ответ выключает прокси как настроенный неверно. При сбое kwsN основное
  соединение откатывалось именно туда. Теперь kwsN-1 только для медиа.
- Адреса из списка DC не говорят на TLS ни из какой сети (проверено с чистой
  сети), поэтому первый маршрут каждого DC не работал никогда. Закреплены
  настоящие фронты, и DC1, DC3, DC5 стали доступны по WebSocket, чего не было.
  У CDN203 своего фронта нет: остались TCP, Worker и CF-прокси.

Обрыв туннеля, через который уже шли данные, больше не ставит маршрут на
паузу. Таймаут соединения 8 с, как в tg-ws-proxy. probe_proxy.mjs получил
--identity и --batched: они и нашли эти баги.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: журнал ошибок в диагностике, --log-file для роутеров; 2.0.0-beta.19

«Скопировать диагностику» отдавала только счётчики, и по ней нельзя было
понять, какой маршрут не ответил. Теперь в отчёте есть строки сбоев туннелей,
а домены Worker и CF-прокси в них заменены на <домен>.

--log-file и --log-max-mb (log_file, log_max_mb в настройках), как в
tg-ws-proxy: журнал идёт в файл с ротацией, а не в системный журнал роутера.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(routes): запасной WebSocket на порту 80 родных адресов, включая CDN203

Родные адреса Telegram принимают WebSocket без TLS на порту 80. Измерено
req_DH_params: DC определяется адресом, имя хоста не важно, а 91.105.192.100
отдаёт ключи CDN. Для CDN203, откуда часто идёт видео, это первый прямой
WebSocket: на 443 фронта у него нет, и там, где провайдер закрыл только 443,
до него раньше было не достать без Worker.

Защита та же, что у родного TCP: внутри тот же обфусцированный MTProto. В Host
идёт адрес, имени Telegram в открытом виде нет. Маршрут разрешён только до
закреплённых адресов и пробуется последним.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): паузы маршрутов по результатам ревью, пределы и журнал

- Обрыв не наказывает маршрут, только если туннель работал и получал ответы
  15 с. Одного ответа мало: маршрут, который ответил раз и умер, оставался
  предпочтительным навсегда.
- Клиент спросил, не дождался ответа за 5 с и ушёл: маршрут считается
  неработающим. Иначе CDN, принявший TCP и молчащий, держал первое место, и
  очередь до порта 80 не доходила.
- Предел пакета 2 МиБ вместо 16: чужое устройство в LAN-режиме не заставит
  роутер копить десятки мегабайт на соединение.
- Журнал: разовая ошибка записи его больше не выключает, неудачная ротация
  не теряет бэкап и держит предел, права 0600 и на уже существующем файле.
- В отчёт диагностики не попадает Worker, заданный адресом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 07:32:03 +00:00

325 lines
18 KiB
JavaScript

#!/usr/bin/env node
// Manual, account-free probe of an ALREADY RUNNING local TGLock instance.
// Protocol sources (this does not import the Rust implementation):
// https://core.telegram.org/mtproto/auth_key
// https://core.telegram.org/mtproto/mtproto-transports#transport-obfuscation
// https://core.telegram.org/mtproto/description#unencrypted-message
import { createCipheriv, createDecipheriv, createHash, createPublicKey, randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createConnection } from "node:net";
import { setTimeout as sleep } from "node:timers/promises";
import assert from "node:assert/strict";
const MAX_RESPONSE = 2 * 1024 * 1024;
const HELP = `Usage: node scripts/probe_proxy.mjs --secret-file PATH [--port 1080] [--dc 2] [--timeout-ms 15000] [--fragment-size 0] [--identity] [--batched]
Or: node scripts/probe_proxy.mjs --direct-cdn --dc 203
DC: 1..5 or 203; negative values request the media route.
Default connects only to 127.0.0.1. --direct-cdn explicitly probes only 91.105.192.100:443 without a secret.
Reads the local proxy secret from the explicit file; never prints it.
Sends one req_pq_multi and checks resPQ/nonce. No account, API credentials, login or auth key is created.
Without --identity this proves a protocol response, not DC identity, account operation or media downloads.
--identity continues with req_DH_params: the server checks the DC number sealed inside it against itself
and answers -444 on a mismatch, so this proves WHICH data centre is behind the route. The key is never finished.
--batched sends req_DH_params glued behind another packet in one write, the way real clients batch requests.
A WebSocket frontend reads one packet per frame, so a proxy that does not split the stream times out here.
Use --self-test for offline parser checks; --fragment-size 7 sends small writes with 2ms gaps.`;
function options(args) {
const result = { port: 1080, dc: 2, timeoutMs: 15000, fragmentSize: 0, identity: false, batched: false };
const numeric = { "--port": "port", "--dc": "dc", "--timeout-ms": "timeoutMs", "--fragment-size": "fragmentSize" };
for (let i = 0; i < args.length; i++) {
const name = args[i];
if (name === "--direct-cdn") { result.directCdn = true; continue; }
if (name === "--identity") { result.identity = true; continue; }
if (name === "--batched") { result.identity = result.batched = true; continue; }
const value = args[++i];
if (value === undefined) throw new Error(`Missing value for ${name}`);
if (name === "--secret-file") result.secretFile = value;
else if (numeric[name] && /^-?\d+$/.test(value)) result[numeric[name]] = Number(value);
else throw new Error(`Invalid option ${name}`);
}
if (!result.secretFile && !result.directCdn) throw new Error("--secret-file is required");
if (result.directCdn && result.dc !== 203) throw new Error("Direct CDN probe requires --dc 203");
if (!Number.isInteger(result.port) || result.port < 1 || result.port > 65535) throw new Error("Invalid port");
if (![1, 2, 3, 4, 5, 203].includes(Math.abs(result.dc))) throw new Error("Unsupported DC");
if (result.timeoutMs < 100 || result.timeoutMs > 120000) throw new Error("Timeout must be 100..120000ms");
if (result.fragmentSize < 0 || result.fragmentSize > 65536) throw new Error("Fragment size must be 0..65536");
return result;
}
// Production key from Telegram Desktop (mtproto_dc_options.cpp). CDN data centres
// use their own keys, so a CDN simply does not list this fingerprint.
const TELEGRAM_RSA_KEY = `-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEA6LszBcC1LGzyr992NzE0ieY+BSaOW622Aa9Bd4ZHLl+TuFQ4lo4g
5nKaMBwK/BIb9xUfg0Q29/2mgIR6Zr9krM7HjuIcCzFvDtr+L0GQjae9H0pRB2OO
62cECs5HKhT5DZ98K33vmWiLowc621dQuwKWSQKjWf50XYFw42h21P2KXUGyp2y/
+aEyZ+uVgLLQbRA1dEjSDZ2iGRy12Mk5gpYc397aYp438fsJoHIgJ2lgMv5h7WY9
t6N/byY9Nw9p21Og3AoXSL2q/2IJ1WRUhebgAdGVMlV1fkuOQoEzR7EdpqtQD9Cs
5+bfo3Nhmcyvk5ftB0WkJ9z6bNZ7yxrP8wIDAQAB
-----END RSA PUBLIC KEY-----`;
const tlBytes = (value) => {
const prefixed = Buffer.concat([Buffer.from([value.length]), value]);
const long = Buffer.concat([Buffer.from([254, value.length & 255, value.length >> 8, 0]), value]);
const raw = value.length < 254 ? prefixed : long;
return Buffer.concat([raw, Buffer.alloc((4 - raw.length % 4) % 4)]);
};
const toBigInt = (bytes) => BigInt(`0x${bytes.toString("hex")}`);
const rsaKey = () => {
const { n, e } = createPublicKey({ key: TELEGRAM_RSA_KEY, format: "pem", type: "pkcs1" }).export({ format: "jwk" });
const modulus = Buffer.from(n, "base64url");
const exponent = Buffer.from(e, "base64url");
const fingerprint = createHash("sha1").update(tlBytes(modulus)).update(tlBytes(exponent)).digest().subarray(12);
return { modulus: toBigInt(modulus), exponent: toBigInt(exponent), fingerprint };
};
function modPow(base, power, modulus) {
let result = 1n;
for (let b = base % modulus, e = power; e > 0n; e >>= 1n, b = b * b % modulus) if (e & 1n) result = result * b % modulus;
return result;
}
function factorize(pq) {
const gcd = (a, b) => { while (b) [a, b] = [b, a % b]; return a; };
for (let c = 1n; ; c++) {
const step = (x) => (x * x + c) % pq;
let slow = 2n, fast = 2n, divisor = 1n;
while (divisor === 1n) { slow = step(slow); fast = step(step(fast)); divisor = gcd(slow > fast ? slow - fast : fast - slow, pq); }
if (divisor !== pq) { const other = pq / divisor; return divisor < other ? [divisor, other] : [other, divisor]; }
}
}
function aesIgeEncrypt(data, key) {
const output = Buffer.alloc(data.length);
let previousCipher = Buffer.alloc(16), previousPlain = Buffer.alloc(16);
for (let offset = 0; offset < data.length; offset += 16) {
const block = data.subarray(offset, offset + 16), mixed = Buffer.alloc(16);
for (let i = 0; i < 16; i++) mixed[i] = block[i] ^ previousCipher[i];
const encrypted = createCipheriv("aes-256-ecb", key, null).setAutoPadding(false).update(mixed);
for (let i = 0; i < 16; i++) output[offset + i] = encrypted[i] ^ previousPlain[i];
previousCipher = output.subarray(offset, offset + 16);
previousPlain = block;
}
return output;
}
// RSA_PAD from https://core.telegram.org/mtproto/auth_key#41-rsa-paddata-server-public-key-mentioned-above-is-implemented-as-follows
function rsaPad(data, key) {
const padded = Buffer.concat([data, randomBytes(192 - data.length)]);
for (;;) {
const tempKey = randomBytes(32);
const hashed = Buffer.concat([Buffer.from(padded).reverse(), createHash("sha256").update(tempKey).update(padded).digest()]);
const encrypted = aesIgeEncrypt(hashed, tempKey);
const mask = createHash("sha256").update(encrypted).digest();
const masked = Buffer.from(tempKey.map((byte, index) => byte ^ mask[index]));
const value = toBigInt(Buffer.concat([masked, encrypted]));
if (value < key.modulus) return Buffer.from(modPow(value, key.exponent, key.modulus).toString(16).padStart(512, "0"), "hex");
}
}
function unencryptedPacket(body) {
const message = Buffer.alloc(20 + body.length);
const now = BigInt(Date.now());
message.writeBigUInt64LE(((now / 1000n << 32n) | ((now % 1000n) * (1n << 32n) / 1000n)) & ~3n, 8);
message.writeUInt32LE(body.length, 16);
body.copy(message, 20);
const padded = Buffer.concat([message, randomBytes(7)]);
const length = Buffer.alloc(4);
length.writeUInt32LE(padded.length);
return Buffer.concat([length, padded]);
}
const reqPqBody = (nonce) => {
const constructor = Buffer.alloc(4);
constructor.writeUInt32LE(0xbe7e8ef1);
return Buffer.concat([constructor, nonce]);
};
function reqDhParams(resPQ, nonce, dc, key) {
const [p, q] = factorize(toBigInt(resPQ.pq));
const bigEndian = (value) => Buffer.from(value.toString(16).padStart(8, "0"), "hex");
const dcField = Buffer.alloc(4);
dcField.writeInt32LE(dc);
const inner = Buffer.concat([Buffer.from("955ff5a9", "hex"), tlBytes(resPQ.pq), tlBytes(bigEndian(p)), tlBytes(bigEndian(q)),
nonce, resPQ.serverNonce, randomBytes(32), dcField]);
return Buffer.concat([Buffer.from("bee412d7", "hex"), nonce, resPQ.serverNonce, tlBytes(bigEndian(p)), tlBytes(bigEndian(q)),
key.fingerprint, tlBytes(rsaPad(inner, key))]);
}
function parseDhReply(frame) {
if (frame.length < 24 || frame.readBigUInt64LE() !== 0n) {
const code = frame.readInt32LE();
throw new Error(code === -444
? "MTProto transport error -444: this route leads to the wrong data centre"
: `MTProto transport error ${code}`);
}
const constructor = frame.readUInt32LE(20);
if (constructor === 0x05162463) return null; // the reply to the packet batched in front
if (constructor !== 0xd0e8075c) throw new Error(`Unexpected reply 0x${constructor.toString(16)} to req_DH_params`);
return "accepted";
}
function makeRequest(secret, dc) {
let header;
do {
header = randomBytes(64);
} while (header[0] === 0xef || ["HEAD", "POST", "GET ", "OPTI"].includes(header.toString("ascii", 0, 4))
|| [0xeeeeeeee, 0xdddddddd, 0x02010316].includes(header.readUInt32LE()) || header.readUInt32LE(4) === 0);
header.fill(0xdd, 56, 60); // padded intermediate transport
header.writeInt16LE(dc, 60);
const salted = (key) => secret ? createHash("sha256").update(key).update(secret).digest() : Buffer.from(key);
const encrypt = createCipheriv("aes-256-ctr", salted(header.subarray(8, 40)), header.subarray(40, 56));
const reversed = Buffer.from(header.subarray(8, 56)).reverse();
const decrypt = createDecipheriv("aes-256-ctr", salted(reversed.subarray(0, 32)), reversed.subarray(32));
const wireHeader = Buffer.from(header);
encrypt.update(header).copy(wireHeader, 56, 56); // advances outgoing CTR by all 64 bytes
const nonce = randomBytes(16);
const payload = Buffer.alloc(40);
// auth_key_id = 0, client message ID divisible by four, TL payload length = 20.
const now = BigInt(Date.now());
const messageId = ((now / 1000n << 32n) | ((now % 1000n) * (1n << 32n) / 1000n)) & ~3n;
payload.writeBigUInt64LE(messageId, 8);
payload.writeUInt32LE(20, 16);
payload.writeUInt32LE(0xbe7e8ef1, 20);
nonce.copy(payload, 24);
const padded = Buffer.concat([payload, randomBytes(7)]);
const length = Buffer.alloc(4);
length.writeUInt32LE(padded.length);
return { wire: Buffer.concat([wireHeader, encrypt.update(Buffer.concat([length, padded]))]), encrypt, decrypt, nonce };
}
function parseResPQ(frame, nonce) {
if (frame.length === 4) throw new Error(`MTProto transport error ${frame.readInt32LE()}`);
if (frame.length < 20 || frame.readBigUInt64LE() !== 0n) throw new Error("Expected an unencrypted MTProto response");
const length = frame.readUInt32LE(16);
if (length < 48 || length % 4 !== 0 || length > frame.length - 20) throw new Error("Invalid MTProto message length");
const body = frame.subarray(20, 20 + length);
if (body.readUInt32LE() !== 0x05162463) throw new Error("Response is not resPQ");
if (!body.subarray(4, 20).equals(nonce)) throw new Error("resPQ nonce does not match request");
// pq is at most eight bytes for this handshake, so its TL string uses the
// one-byte length encoding followed by padding to a four-byte boundary.
const pqLength = body[36];
if (pqLength < 1 || pqLength > 8) throw new Error("Invalid resPQ pq length");
const vectorOffset = 36 + Math.ceil((1 + pqLength) / 4) * 4;
if (vectorOffset + 8 > body.length || body.readUInt32LE(vectorOffset) !== 0x1cb5c415) throw new Error("Invalid RSA fingerprint vector");
const count = body.readUInt32LE(vectorOffset + 4);
// A CDN may include trailing random bytes in its declared message length.
// Validate the complete TL object fits; do not mistake padding for corruption.
if (count < 1 || count > 64 || vectorOffset + 8 + count * 8 > body.length) throw new Error("Invalid RSA fingerprint count");
// Deliberately do not infer DC identity from these public fingerprints.
const fingerprints = [];
for (let i = 0; i < count; i++) fingerprints.push(body.subarray(vectorOffset + 8 + i * 8, vectorOffset + 16 + i * 8));
const handshake = { serverNonce: body.subarray(20, 36), pq: body.subarray(37, 37 + pqLength), fingerprints };
return Object.defineProperty({ response: "resPQ", nonceMatches: true, rsaFingerprintCount: count },
"handshake", { value: handshake, enumerable: false });
}
async function probe(config) {
let secret = null;
if (!config.directCdn) {
let hex = (await readFile(config.secretFile, "utf8")).trim();
if (/^dd[0-9a-f]{32}$/i.test(hex)) hex = hex.slice(2);
if (!/^[0-9a-f]{32}$/i.test(hex)) throw new Error("Secret file must contain 32 hex characters or dd followed by 32 hex characters");
secret = Buffer.from(hex, "hex");
}
const { wire, encrypt, decrypt, nonce } = makeRequest(secret, config.dc);
let resPQ = null;
const started = Date.now();
return new Promise((resolve, reject) => {
const socket = createConnection(config.directCdn
? { host: "91.105.192.100", port: 443 }
: { host: "127.0.0.1", port: config.port });
let pending = Buffer.alloc(0);
let received = 0;
let finished = false;
const finish = (error, result) => {
if (finished) return;
finished = true;
clearTimeout(timer);
socket.destroy();
if (error) reject(error);
else resolve({ transport: config.directCdn ? "direct-cdn-tcp" : "local-proxy", requestedDc: config.dc, ...result, elapsedMs: Date.now() - started });
};
const timer = setTimeout(() => finish(new Error(resPQ ? "Timed out waiting for the reply to req_DH_params" : "Timed out waiting for resPQ")), config.timeoutMs);
socket.on("error", (error) => finish(error));
socket.on("end", () => finish(new Error("Proxy closed before a complete resPQ response")));
socket.on("data", (chunk) => {
try {
received += chunk.length;
if (received > MAX_RESPONSE) throw new Error("Response exceeded the 2MiB limit");
pending = Buffer.concat([pending, decrypt.update(chunk)]);
while (pending.length >= 4) {
const length = pending.readUInt32LE();
if (length & 0x80000000) { // optional intermediate quick acknowledgment
pending = pending.subarray(4);
continue;
}
if (length < 4 || length > MAX_RESPONSE - 4) throw new Error("Invalid intermediate frame length");
if (pending.length < 4 + length) return;
if (length >= 8 && length <= 16 && pending.readUInt32LE(4) === 0xffffffff) {
pending = pending.subarray(4 + length); // padded intermediate quick ACK
continue;
}
const frame = pending.subarray(4, 4 + length);
pending = pending.subarray(4 + length);
if (resPQ) {
const verdict = parseDhReply(frame);
if (verdict) { finish(null, { ...resPQ, identity: verdict, signedDc: config.dc, batched: config.batched }); return; }
continue;
}
resPQ = parseResPQ(frame, nonce);
if (!config.identity) { finish(null, resPQ); return; }
const key = rsaKey();
if (!resPQ.handshake.fingerprints.some((fingerprint) => fingerprint.equals(key.fingerprint))) {
finish(null, { ...resPQ, identity: "skipped: CDN keys" });
return;
}
const request = unencryptedPacket(reqDhParams(resPQ.handshake, nonce, config.dc, key));
const batch = config.batched ? [unencryptedPacket(reqPqBody(randomBytes(16))), request] : [request];
socket.write(encrypt.update(Buffer.concat(batch)));
}
} catch (error) { finish(error); }
});
socket.on("connect", async () => {
try {
socket.setNoDelay(true);
const size = config.fragmentSize || wire.length;
for (let offset = 0; offset < wire.length && !finished; offset += size) {
socket.write(wire.subarray(offset, offset + size));
if (config.fragmentSize) await sleep(2);
}
} catch (error) { finish(error); }
});
});
}
function selfTest() {
// Fixed TL fixture: resPQ, request nonce 00..0f, server nonce 10..1f,
// eight-byte pq, a one-element vector of public RSA fingerprints.
const body = Buffer.from("63241605000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f08112233445566778800000015c4b51c010000008877665544332211", "hex");
const envelope = Buffer.alloc(20);
envelope.writeUInt32LE(body.length, 16);
const frame = Buffer.concat([envelope, body, Buffer.from([1, 2, 3])]);
const nonce = Buffer.from("000102030405060708090a0b0c0d0e0f", "hex");
assert.equal(parseResPQ(frame, nonce).rsaFingerprintCount, 1);
const paddedEnvelope = Buffer.from(envelope);
paddedEnvelope.writeUInt32LE(body.length + 128, 16);
assert.equal(parseResPQ(Buffer.concat([paddedEnvelope, body, Buffer.alloc(128, 0x42)]), nonce).rsaFingerprintCount, 1);
assert.throws(() => parseResPQ(frame, Buffer.alloc(16)), /nonce/);
assert.throws(() => parseResPQ(frame.subarray(0, 30), nonce), /length/);
const wrongConstructor = Buffer.from(frame);
wrongConstructor[20] = 0;
assert.throws(() => parseResPQ(wrongConstructor, nonce), /not resPQ/);
const oversizedVector = Buffer.from(frame);
oversizedVector.writeUInt32LE(65, 72);
assert.throws(() => parseResPQ(oversizedVector, nonce), /count/);
assert.throws(() => parseResPQ(Buffer.from("6cfeffff", "hex"), nonce), /-404/);
assert.throws(() => parseDhReply(Buffer.from("44feffff", "hex")), /wrong data centre/);
assert.equal(rsaKey().fingerprint.toString("hex"), Buffer.from("d09d1d85de64fd85", "hex").reverse().toString("hex"));
assert.deepEqual(factorize(0x17ED48941A08F981n), [0x494C553Bn, 0x53911073n]);
console.log("Offline parser checks passed; no network connection made.");
}
try {
const args = process.argv.slice(2);
if (args.length === 1 && args[0] === "--self-test") selfTest();
else if (args.length === 1 && ["--help", "-h"].includes(args[0])) console.log(HELP);
else console.log(JSON.stringify(await probe(options(args)), null, 2));
} catch (error) {
console.error(`Probe failed: ${error.message}`);
process.exitCode = 1;
}