205 lines
7.1 KiB
JavaScript
205 lines
7.1 KiB
JavaScript
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));
|
|
}
|