// Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import net, { type TcpSocketConnectOpts } from 'node:net'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { SocksProxyAgent } from 'socks-proxy-agent'; import { URL } from 'node:url'; import type { LookupOptions, LookupAddress } from 'node:dns'; import { lookup } from 'node:dns/promises'; import { createLogger } from '../logging/log.std.ts'; import { happyEyeballs } from './createHTTPSAgent.node.ts'; import type { ConnectOptionsType } from './createHTTPSAgent.node.ts'; import { explodePromise } from './explodePromise.std.ts'; import { SECOND } from './durations/index.std.ts'; import { drop } from './drop.std.ts'; const log = createLogger('createProxyAgent'); // Warning threshold const CONNECT_THRESHOLD_MS = SECOND; const SOCKS_PROTOCOLS = new Set([ 'socks:', 'socks4:', 'socks4a:', 'socks5:', 'socks5h:', ]); export type ProxyAgent = | HttpsProxyAgent<'http:'> | HttpsProxyAgent<'https:'> | SocksProxyAgent; export async function createProxyAgent(proxyUrl: string): Promise { const { port: portStr, hostname: proxyHost, protocol } = new URL(proxyUrl); let defaultPort: number | undefined; let agentClass: typeof HttpsProxyAgent | typeof SocksProxyAgent; if (protocol === 'http:') { defaultPort = 80; agentClass = HttpsProxyAgent; } else if (protocol === 'https:') { defaultPort = 443; agentClass = HttpsProxyAgent; } else if (SOCKS_PROTOCOLS.has(protocol)) { defaultPort = 1080; agentClass = SocksProxyAgent; } else { throw new Error(`Unsupported proxy protocol: ${protocol}`); } const port = portStr ? parseInt(portStr, 10) : defaultPort; async function happyLookup(host: string): Promise { const addresses = await lookup(host, { all: true }); // SOCKS 4/5 resolve target host before sending it to the proxy. if (host !== proxyHost) { const idx = Math.floor(Math.random() * addresses.length); // oxlint-disable-next-line typescript/no-non-null-assertion return addresses[idx]!; } const start = Date.now(); const { socket, address, v4Attempts, v6Attempts } = await happyEyeballs({ addresses, port, connect, }); const duration = Date.now() - start; const logLine = `createProxyAgent.lookup(${host}): connected to ` + `IPv${address.family} addr after ${duration}ms ` + `(attempts v4=${v4Attempts} v6=${v6Attempts})`; if (v4Attempts + v6Attempts > 1 || duration > CONNECT_THRESHOLD_MS) { log.warn(logLine); } else { log.info(logLine); } // Sadly we can't return socket to proxy-agent socket.destroy(); return address; } type CoercedCallbackType = ( err: NodeJS.ErrnoException | null, address: string | Array, family?: number ) => void; async function happyLookupWithCallback( host: string, opts: LookupOptions, callback: CoercedCallbackType ): Promise { try { const addr = await happyLookup(host); if (opts.all) { callback(null, [addr]); } else { const { address, family } = addr; callback(null, address, family); } } catch (error) { callback(error, '', -1); } } return new agentClass(proxyUrl, { lookup: port !== undefined ? (host, opts, callback) => drop( happyLookupWithCallback( host, opts, callback as CoercedCallbackType ) ) : undefined, } satisfies Pick); } async function connect({ port, address, abortSignal, }: ConnectOptionsType): Promise { const socket = net.connect({ port, host: address, signal: abortSignal, }); const { promise: onConnect, resolve, reject } = explodePromise(); socket.once('connect', resolve); socket.once('error', reject); try { await onConnect; } finally { socket.removeListener('connect', resolve); socket.removeListener('error', reject); } return socket; }