import net from 'node:net'; import tls from 'node:tls'; import { Buffer } from 'node:buffer'; /** * Opt-in outbound proxy for the one hop that is filtered in Iran: this process → api.telegram.org. * * Node's built-in `fetch` only honours `HTTPS_PROXY` on Node 24+ and only behind `NODE_USE_ENV_PROXY`, which * makes "does the proxy apply?" depend on the runtime. Tunnelling the socket here instead keeps the behaviour * identical on every supported Node and needs no dependency: an HTTP `CONNECT` tunnel or a SOCKS5 handshake, * both of which any local VPN/proxy client (or a proxy container on the VPS) exposes. * * With no proxy configured nothing in this file runs — the request goes out directly, as before. */ const SOCKS_VERSION = 0x05; const SOCKS_NO_AUTH = 0x00; const SOCKS_USER_PASS = 0x02; const SOCKS_CMD_CONNECT = 0x01; const SOCKS_ATYP_DOMAIN = 0x03; /** Parses a proxy URL into the shape the connectors need, or throws with a usable message. */ export function parseProxyUrl(raw) { let url; try { url = new URL(raw); } catch { throw new Error(`invalid proxy URL "${raw}" — expected e.g. http://127.0.0.1:10809 or socks5://127.0.0.1:10808`); } const scheme = url.protocol.replace(':', '').toLowerCase(); if (!['http', 'https', 'socks', 'socks5', 'socks5h'].includes(scheme)) { throw new Error(`unsupported proxy scheme "${scheme}" — use http, https, or socks5`); } const port = url.port ? Number(url.port) : scheme === 'https' ? 443 : scheme === 'http' ? 8080 : 1080; return { scheme, host: url.hostname, port, username: url.username ? decodeURIComponent(url.username) : '', password: url.password ? decodeURIComponent(url.password) : '', // What to print in logs — never the credentials. label: `${scheme}://${url.hostname}:${port}`, }; } /** Returns a socket already tunnelled to `host:port` through the proxy. The caller TLS-wraps it. */ export function connectThroughProxy(proxy, host, port, timeoutMs) { const connect = proxy.scheme === 'http' || proxy.scheme === 'https' ? httpConnect : socks5Connect; return withTimeout(connect(proxy, host, port), timeoutMs, `proxy ${proxy.label} did not connect`); } function openToProxy(proxy) { return proxy.scheme === 'https' ? tls.connect({ host: proxy.host, port: proxy.port, servername: proxy.host }) : net.connect({ host: proxy.host, port: proxy.port }); } /** RFC 7231 `CONNECT` tunnel — what an HTTP proxy exposes for TLS traffic. */ function httpConnect(proxy, host, port) { return new Promise((resolve, reject) => { const socket = openToProxy(proxy); const fail = (error) => { socket.destroy(); reject(error); }; socket.once('error', fail); socket.once(proxy.scheme === 'https' ? 'secureConnect' : 'connect', () => { const lines = [`CONNECT ${host}:${port} HTTP/1.1`, `Host: ${host}:${port}`]; if (proxy.username) { const credentials = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64'); lines.push(`Proxy-Authorization: Basic ${credentials}`); } socket.write(`${lines.join('\r\n')}\r\n\r\n`); }); let buffer = Buffer.alloc(0); const onData = (chunk) => { buffer = Buffer.concat([buffer, chunk]); const headerEnd = buffer.indexOf('\r\n\r\n'); if (headerEnd === -1) return; socket.removeListener('data', onData); const statusLine = buffer.subarray(0, buffer.indexOf('\r\n')).toString('latin1'); if (!/^HTTP\/1\.[01] 200/.test(statusLine)) { fail(new Error(`proxy refused CONNECT: ${statusLine}`)); return; } // A compliant proxy sends nothing after the blank line, but push anything it did back for the TLS layer. const leftover = buffer.subarray(headerEnd + 4); if (leftover.length) socket.unshift(leftover); socket.removeListener('error', fail); resolve(socket); }; socket.on('data', onData); }); } /** RFC 1928 SOCKS5 (+ RFC 1929 username/password) — what most local proxy clients expose. */ async function socks5Connect(proxy, host, port) { const socket = openToProxy(proxy); try { await once(socket, 'connect'); const { read, release } = reader(socket); const methods = proxy.username ? [SOCKS_NO_AUTH, SOCKS_USER_PASS] : [SOCKS_NO_AUTH]; socket.write(Buffer.from([SOCKS_VERSION, methods.length, ...methods])); const greeting = await read(2); if (greeting[0] !== SOCKS_VERSION) throw new Error('proxy is not SOCKS5'); if (greeting[1] === SOCKS_USER_PASS) { const user = Buffer.from(proxy.username); const pass = Buffer.from(proxy.password); socket.write(Buffer.concat([ Buffer.from([0x01, user.length]), user, Buffer.from([pass.length]), pass, ])); const auth = await read(2); if (auth[1] !== 0x00) throw new Error('SOCKS5 proxy rejected the credentials'); } else if (greeting[1] !== SOCKS_NO_AUTH) { throw new Error('SOCKS5 proxy demands an unsupported authentication method'); } // ATYP = domain, so the *proxy* resolves api.telegram.org — local DNS is filtered too. const target = Buffer.from(host); const request = Buffer.concat([ Buffer.from([SOCKS_VERSION, SOCKS_CMD_CONNECT, 0x00, SOCKS_ATYP_DOMAIN, target.length]), target, Buffer.from([(port >> 8) & 0xff, port & 0xff]), ]); socket.write(request); const reply = await read(4); if (reply[1] !== 0x00) throw new Error(`SOCKS5 proxy refused CONNECT (code ${reply[1]})`); // Drain the bound address so the stream starts at the tunnelled payload. const boundLength = reply[3] === 0x01 ? 4 : reply[3] === 0x04 ? 16 : (await read(1))[0]; await read(boundLength + 2); return release(); } catch (error) { socket.destroy(); throw error; } } /** * Reads exactly N bytes at a time during the handshake. `release()` hands the socket back with any * already-buffered bytes pushed in front, so the TLS layer sees an untouched stream. */ function reader(socket) { let buffer = Buffer.alloc(0); let pending = null; const pump = () => { if (!pending || buffer.length < pending.size) return; const { size, resolve } = pending; pending = null; const chunk = buffer.subarray(0, size); buffer = buffer.subarray(size); resolve(chunk); }; socket.on('data', (chunk) => { buffer = Buffer.concat([buffer, chunk]); pump(); }); const read = (size) => new Promise((resolve, reject) => { pending = { size, resolve }; socket.once('error', reject); pump(); }); const release = () => { socket.removeAllListeners('data'); socket.removeAllListeners('error'); if (buffer.length) socket.unshift(buffer); return socket; }; return { read, release }; } function once(emitter, event) { return new Promise((resolve, reject) => { emitter.once(event, resolve); emitter.once('error', reject); }); } function withTimeout(promise, timeoutMs, message) { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${message} within ${timeoutMs}ms`)), timeoutMs); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); }