Files

309 lines
13 KiB
JavaScript

import http from 'node:http';
import net from 'node:net';
import tls from 'node:tls';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { SocksClient } from 'socks';
const LOCAL_PROTOCOLS = new Set(['http:', 'socks5:', 'socks5h:']);
const UPSTREAM_PROTOCOLS = new Set(['http:', 'https:', 'socks:', 'socks5:', 'socks5h:']);
const HOP_HEADERS = ['connection', 'proxy-connection', 'proxy-authorization', 'proxy-authenticate', 'keep-alive', 'te', 'trailer', 'transfer-encoding', 'upgrade'];
function failure(code) {
return Object.assign(new Error(code), { code });
}
function bareHost(host) {
return host.replace(/^\[|\]$/g, '');
}
function authority(host, port) {
return `${net.isIPv6(host) ? `[${host}]` : host}:${port}`;
}
function proxyConfig(value, local = false) {
const code = local ? 'PROXY_CHAIN_INVALID_LOCAL_PROXY' : 'PROXY_CHAIN_INVALID_UPSTREAM_PROXY';
try {
const url = new URL(String(value || '').trim());
if (!(local ? LOCAL_PROTOCOLS : UPSTREAM_PROTOCOLS).has(url.protocol)
|| !url.hostname || (url.pathname && url.pathname !== '/') || url.search || url.hash) throw failure(code);
let host = bareHost(url.hostname);
if (local) {
if (url.username || url.password) throw failure(code);
if (host === 'localhost') host = '127.0.0.1';
if (host !== '::1' && !(net.isIPv4(host) && host.startsWith('127.'))) throw failure(code);
url.hostname = net.isIPv6(host) ? `[${host}]` : host;
}
const username = decodeURIComponent(url.username);
const password = decodeURIComponent(url.password);
if (/[\r\n\0]/.test(username + password)) throw failure(code);
const port = Number(url.port || (url.protocol.startsWith('socks') ? 1080 : url.protocol === 'https:' ? 443 : 80));
if (!Number.isInteger(port) || port < 1 || port > 65535) throw failure(code);
// Keep an explicit port: the browser configuration parser has different
// defaults for HTTP proxies, so stripping :80 would change the route.
const normalized = local ? `${url.protocol}//${authority(host, port)}` : url.href.replace(/\/$/, '');
return { url: normalized, host, port, username, password, protocol: url.protocol };
} catch {
throw failure(code);
}
}
export function validateLocalProxyUrl(value) {
return proxyConfig(value, true).url;
}
export const normalizeLocalProxyUrl = validateLocalProxyUrl;
function waitForSocket(socket, event, code) {
return new Promise((resolve, reject) => {
const done = (error) => {
socket.off(event, ready);
socket.off('error', failed);
socket.off('close', failed);
error ? reject(failure(code)) : resolve(socket);
};
const ready = () => done();
const failed = () => done(true);
socket.once(event, ready);
socket.once('error', failed);
socket.once('close', failed);
});
}
function httpConnect(socket, proxy, destination, stage) {
return new Promise((resolve, reject) => {
let response = Buffer.alloc(0);
const cleanup = () => {
socket.off('data', data);
socket.off('error', failed);
socket.off('close', failed);
};
const failed = () => {
cleanup();
reject(failure(`PROXY_CHAIN_${stage}_CONNECT_FAILED`));
};
const data = (chunk) => {
response = Buffer.concat([response, chunk]);
const end = response.indexOf('\r\n\r\n');
if (end < 0 && response.length <= 32768) return;
socket.pause();
cleanup();
const status = Number(response.toString('latin1', 0, end < 0 ? response.length : end).match(/^HTTP\/1\.[01] (\d{3})(?: |\r\n)/)?.[1]);
if (end < 0 || end > 32768 || status < 200 || status >= 300 || !status) {
reject(failure(`PROXY_CHAIN_${stage}_${status === 407 ? 'AUTH_REQUIRED' : 'REJECTED'}`));
return;
}
const remaining = response.subarray(end + 4);
if (remaining.length) socket.unshift(remaining);
resolve(socket);
};
socket.on('data', data);
socket.once('error', failed);
socket.once('close', failed);
const host = authority(destination.host, destination.port);
const auth = proxy.username || proxy.password
? `Proxy-Authorization: Basic ${Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64')}\r\n` : '';
socket.write(`CONNECT ${host} HTTP/1.1\r\nHost: ${host}\r\n${auth}\r\n`);
socket.resume();
});
}
async function proxyConnect(socket, proxy, destination, stage, remainingMs) {
if (!proxy.protocol.startsWith('socks')) return httpConnect(socket, proxy, destination, stage);
try {
// Pass hostnames to SOCKS5; DNS belongs to the proxy at this hop.
const result = SocksClient.createConnection({
command: 'connect', existing_socket: socket, timeout: remainingMs,
proxy: { host: proxy.host, port: proxy.port, type: 5, userId: proxy.username, password: proxy.password },
destination,
});
socket.resume();
return (await result).socket;
} catch {
throw failure(`PROXY_CHAIN_${stage}_SOCKS_FAILED`);
}
}
function destinationFromConnect(value) {
try {
const url = new URL(`http://${value}`);
if (!url.hostname || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error();
return { host: bareHost(url.hostname), port: Number(url.port || 80) };
} catch {
throw failure('PROXY_CHAIN_INVALID_TARGET');
}
}
function cleanHeaders(headers) {
const cleaned = { ...headers };
const nominated = String(headers.connection || '').toLowerCase().split(',').map((name) => name.trim());
for (const name of [...HOP_HEADERS, ...nominated]) delete cleaned[name];
return cleaned;
}
/**
* A private HTTP proxy for one local-proxy -> upstream-proxy route. Both
* CONNECT and plain HTTP use the same two hops. No branch connects directly
* to the upstream or target. Keep this relay alive while its browser runs.
*/
export async function createProxyChainRelay({ localProxyUrl, upstreamProxyUrl, timeoutMs = 15000 } = {}) {
const local = proxyConfig(localProxyUrl, true);
const upstream = proxyConfig(upstreamProxyUrl);
const deadlineMs = Math.min(120000, Math.max(100, Number(timeoutMs) || 15000));
const sockets = new Set();
const operations = new Set();
let closing = false;
let closePromise;
const credentials = `chain:${randomBytes(24).toString('hex')}`;
const expectedAuth = Buffer.from(`Basic ${Buffer.from(credentials).toString('base64')}`);
const authenticated = (req) => {
const supplied = Buffer.from(String(req.headers['proxy-authorization'] || ''));
return supplied.length === expectedAuth.length && timingSafeEqual(supplied, expectedAuth);
};
const track = (socket) => {
sockets.add(socket);
socket.on('error', () => {});
socket.once('close', () => sockets.delete(socket));
if (closing) socket.destroy();
return socket;
};
async function openTunnel(destination, client) {
if (closing || client.destroyed) throw failure('PROXY_CHAIN_CLOSED');
const owned = new Set();
let cancelled = false;
let rejectAbort;
const aborted = new Promise((_, reject) => { rejectAbort = reject; });
const abort = (code = 'PROXY_CHAIN_CLOSED') => {
cancelled = true;
rejectAbort(failure(code));
for (const socket of owned) socket.destroy();
};
const own = (socket) => {
if (cancelled || closing) { socket.destroy(); throw failure('PROXY_CHAIN_CLOSED'); }
owned.add(socket);
return track(socket);
};
const started = Date.now();
const remainingMs = () => Math.max(1, deadlineMs - (Date.now() - started));
const clientClosed = () => abort('PROXY_CHAIN_CLIENT_CLOSED');
client.once('close', clientClosed);
operations.add(abort);
const timer = setTimeout(() => abort('PROXY_CHAIN_TIMEOUT'), deadlineMs);
timer.unref();
try {
const work = (async () => {
let socket = own(net.connect({ host: local.host, port: local.port }));
await waitForSocket(socket, 'connect', 'PROXY_CHAIN_LOCAL_CONNECT_FAILED');
socket = await proxyConnect(socket, local, { host: upstream.host, port: upstream.port }, 'LOCAL', remainingMs());
if (cancelled || closing) throw failure('PROXY_CHAIN_CLOSED');
if (upstream.protocol === 'https:') {
socket = own(tls.connect({ socket, servername: net.isIP(upstream.host) ? undefined : upstream.host, rejectUnauthorized: true }));
await waitForSocket(socket, 'secureConnect', 'PROXY_CHAIN_UPSTREAM_TLS_FAILED');
}
socket = await proxyConnect(socket, upstream, destination, 'UPSTREAM', remainingMs());
if (cancelled || closing) throw failure('PROXY_CHAIN_CLOSED');
return socket;
})();
return await Promise.race([work, aborted]);
} catch (error) {
for (const socket of owned) socket.destroy();
throw failure(/^PROXY_CHAIN_[A-Z_]+$/.test(error?.code || '') ? error.code : 'PROXY_CHAIN_CONNECT_FAILED');
} finally {
clearTimeout(timer);
client.off('close', clientClosed);
operations.delete(abort);
}
}
const server = http.createServer(async (req, res) => {
if (!authenticated(req)) {
res.writeHead(407, { 'proxy-authenticate': 'Basic realm="auto-team-chain"', connection: 'close' });
res.end('PROXY_CHAIN_AUTH_REQUIRED');
return;
}
let remote;
let agent;
let request;
try {
const target = new URL(req.url);
if (target.protocol !== 'http:' || target.username || target.password) throw failure('PROXY_CHAIN_INVALID_TARGET');
remote = await openTunnel({ host: bareHost(target.hostname), port: Number(target.port || 80) }, req.socket);
if (req.destroyed || res.destroyed) { remote.destroy(); return; }
agent = new http.Agent({ keepAlive: false });
// The only Agent connection is our established tunnel. It must never
// resolve or open the destination itself.
agent.createConnection = () => remote;
const headers = { ...cleanHeaders(req.headers), host: target.host, connection: 'close' };
request = http.request({ hostname: target.hostname, port: target.port || 80, path: target.pathname + target.search, method: req.method, headers, agent }, (response) => {
res.writeHead(response.statusCode, { ...cleanHeaders(response.headers), connection: 'close' });
response.on('error', () => res.destroy());
response.pipe(res);
});
request.on('error', () => {
if (!res.headersSent) { res.writeHead(502, { connection: 'close' }); res.end('PROXY_CHAIN_REQUEST_FAILED'); }
else res.destroy();
remote.destroy();
agent.destroy();
});
request.setTimeout(deadlineMs, () => request.destroy(failure('PROXY_CHAIN_TIMEOUT')));
req.once('aborted', () => request.destroy());
res.once('close', () => { request.destroy(); remote.destroy(); agent.destroy(); });
req.pipe(request);
remote.resume();
} catch (error) {
remote?.destroy();
agent?.destroy();
if (!res.destroyed) {
res.writeHead(502, { connection: 'close' });
res.end(/^PROXY_CHAIN_[A-Z_]+$/.test(error?.code || '') ? error.code : 'PROXY_CHAIN_REQUEST_FAILED');
}
}
});
server.on('connection', track);
server.on('clientError', (_error, socket) => socket.destroy());
server.on('upgrade', (_req, socket) => socket.destroy());
server.on('connect', async (req, client, head) => {
if (!authenticated(req)) {
client.end('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="auto-team-chain"\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
return;
}
client.pause();
try {
const remote = await openTunnel(destinationFromConnect(req.url), client);
if (client.destroyed) { remote.destroy(); return; }
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
if (head.length) remote.write(head);
client.once('close', () => remote.destroy());
remote.once('close', () => client.destroy());
client.pipe(remote);
remote.pipe(client);
client.resume();
remote.resume();
} catch (error) {
if (!client.destroyed) {
const body = /^PROXY_CHAIN_[A-Z_]+$/.test(error?.code || '') ? error.code : 'PROXY_CHAIN_CONNECT_FAILED';
client.end(`HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
}
}
});
server.headersTimeout = deadlineMs;
server.requestTimeout = deadlineMs;
await new Promise((resolve, reject) => {
server.once('error', () => reject(failure('PROXY_CHAIN_LISTEN_FAILED')));
server.listen(0, '127.0.0.1', resolve);
});
server.unref();
const port = server.address().port;
return {
url: `http://${credentials}@127.0.0.1:${port}`,
close() {
if (closePromise) return closePromise;
closing = true;
for (const abort of operations) abort();
for (const socket of sockets) socket.destroy();
closePromise = new Promise((resolve) => server.close(resolve));
return closePromise;
},
};
}