'use strict'; const net = require('node:net'); const tls = require('node:tls'); const https = require('node:https'); const zlib = require('node:zlib'); function parseProxyConfig(raw) { if (!raw) return null; // Standard URL format: http://user:pass@host:port or https://user:pass@host:port try { const u = new URL(raw); if (u.hostname) { return { host: u.hostname, port: parseInt(u.port, 10), auth: u.username ? `${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}` : null, tls: u.protocol === 'https:', }; } } catch { /* fall through */ } // Froxy/OREF format: user:pass@host:port if (raw.includes('@')) { const atIdx = raw.lastIndexOf('@'); const auth = raw.slice(0, atIdx); const hostPort = raw.slice(atIdx + 1); const colonIdx = hostPort.lastIndexOf(':'); if (colonIdx !== -1) { const host = hostPort.slice(0, colonIdx); const port = parseInt(hostPort.slice(colonIdx + 1), 10); if (host && port && auth) return { host, port, auth, tls: true }; } } // Decodo/Smartproxy format: host:port:user:pass const parts = raw.split(':'); if (parts.length >= 4) { const host = parts[0]; const port = parseInt(parts[1], 10); const user = parts[2]; const pass = parts.slice(3).join(':'); if (host && port && user) return { host, port, auth: `${user}:${pass}`, tls: true }; } return null; } /** * Resolve proxy from PROXY_URL only. Returns { host, port, auth } or null. * Use this for sources where OREF (IL-exit) proxy must NOT be used (e.g. USNI). */ function resolveProxyConfig() { return parseProxyConfig(process.env.PROXY_URL || ''); } /** * Resolve proxy from PROXY_URL only. * OREF_PROXY_AUTH is IL-exit and expensive — reserved exclusively for OREF alerts. */ function resolveProxyConfigWithFallback() { return parseProxyConfig(process.env.PROXY_URL || ''); } /** * Returns proxy as "user:pass@host:port" string for use with curl -x. * Decodo: gate.decodo.com → us.decodo.com (curl endpoint differs from CONNECT endpoint). * Returns empty string if no proxy configured. */ function resolveProxyString() { const cfg = resolveProxyConfigWithFallback(); if (!cfg) return ''; const host = cfg.host.replace(/^gate\./, 'us.'); return cfg.auth ? `${cfg.auth}@${host}:${cfg.port}` : `${host}:${cfg.port}`; } /** * Returns proxy as "user:pass@host:port" string for use with HTTP CONNECT tunneling. * Does NOT replace gate.decodo.com → us.decodo.com; CONNECT endpoint is gate.decodo.com. * When PROXY_URL uses https:// (TLS proxy), returns "https://user:pass@host:port" so * httpsProxyFetchJson uses tls.connect to the proxy instead of plain net.connect. * Returns empty string if no proxy configured. */ function resolveProxyStringConnect() { const cfg = resolveProxyConfigWithFallback(); if (!cfg) return ''; const base = cfg.auth ? `${cfg.auth}@${cfg.host}:${cfg.port}` : `${cfg.host}:${cfg.port}`; return cfg.tls ? `https://${base}` : base; } function proxyConnectTunnel(targetHostname, proxyConfig, { timeoutMs = 20_000, targetPort = 443, signal } = {}) { return new Promise((resolve, reject) => { if (signal && signal.aborted) { return reject(signal.reason || new Error('aborted')); } let proxySock; let settled = false; let onAbort = null; const cleanup = () => { clearTimeout(timer); if (signal && onAbort) signal.removeEventListener('abort', onAbort); }; const resolveOnce = (val) => { if (settled) return; settled = true; cleanup(); resolve(val); }; const rejectOnce = (err) => { if (settled) return; settled = true; cleanup(); reject(err); }; const timer = setTimeout(() => { if (proxySock) proxySock.destroy(); rejectOnce(new Error('CONNECT tunnel timeout')); }, timeoutMs); if (signal) { onAbort = () => { if (proxySock) proxySock.destroy(); rejectOnce(signal.reason || new Error('aborted')); }; signal.addEventListener('abort', onAbort, { once: true }); } const onError = (e) => rejectOnce(e); const connectCb = () => { const authHeader = proxyConfig.auth ? `\r\nProxy-Authorization: Basic ${Buffer.from(proxyConfig.auth).toString('base64')}` : ''; proxySock.write( `CONNECT ${targetHostname}:${targetPort} HTTP/1.1\r\nHost: ${targetHostname}:${targetPort}${authHeader}\r\n\r\n` ); let buf = ''; const onData = (chunk) => { buf += chunk.toString('ascii'); if (!buf.includes('\r\n\r\n')) return; proxySock.removeListener('data', onData); const statusLine = buf.split('\r\n')[0]; if (!statusLine.startsWith('HTTP/1.1 200') && !statusLine.startsWith('HTTP/1.0 200')) { proxySock.destroy(); return rejectOnce( Object.assign(new Error(`Proxy CONNECT: ${statusLine}`), { status: parseInt(statusLine.split(' ')[1]) || 0, }) ); } proxySock.pause(); const tlsSocket = tls.connect( { socket: proxySock, servername: targetHostname, ALPNProtocols: ['http/1.1'] }, () => { proxySock.resume(); resolveOnce({ socket: tlsSocket, destroy: () => { tlsSocket.destroy(); proxySock.destroy(); }, }); } ); tlsSocket.on('error', onError); }; proxySock.on('data', onData); }; if (proxyConfig.tls) { proxySock = tls.connect( { host: proxyConfig.host, port: proxyConfig.port, servername: proxyConfig.host, ALPNProtocols: ['http/1.1'] }, connectCb ); } else { proxySock = net.connect({ host: proxyConfig.host, port: proxyConfig.port }, connectCb); } proxySock.on('error', onError); }); } function proxyFetch(url, proxyConfig, { accept = '*/*', headers = {}, method = 'GET', body = null, timeoutMs = 20_000, signal, } = {}) { const targetUrl = new URL(url); if (signal && signal.aborted) { return Promise.reject(signal.reason || new Error('aborted')); } return proxyConnectTunnel(targetUrl.hostname, proxyConfig, { timeoutMs, signal }).then(({ socket: tlsSocket, destroy }) => { return new Promise((resolve, reject) => { let settled = false; let onAbort = null; const cleanup = () => { clearTimeout(timer); if (signal && onAbort) signal.removeEventListener('abort', onAbort); }; // Both terminal paths destroy the TLS tunnel (mirrors the original // behavior where success + failure both released the socket). const resolveOnce = (v) => { if (settled) return; settled = true; cleanup(); destroy(); resolve(v); }; const rejectOnce = (e) => { if (settled) return; settled = true; cleanup(); destroy(); reject(e); }; const timer = setTimeout(() => rejectOnce(new Error('proxy fetch timeout')), timeoutMs); if (signal) { onAbort = () => rejectOnce(signal.reason || new Error('aborted')); signal.addEventListener('abort', onAbort, { once: true }); } const reqHeaders = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', Accept: accept, 'Accept-Encoding': 'gzip, deflate', ...headers, }; if (body != null && !Object.keys(reqHeaders).some((k) => k.toLowerCase() === 'content-length')) { reqHeaders['Content-Length'] = Buffer.byteLength(body); } const req = https.request({ hostname: targetUrl.hostname, path: targetUrl.pathname + targetUrl.search, method, headers: reqHeaders, createConnection: () => tlsSocket, }, (resp) => { let stream = resp; const enc = (resp.headers['content-encoding'] || '').trim().toLowerCase(); if (enc === 'gzip') stream = resp.pipe(zlib.createGunzip()); else if (enc === 'deflate') stream = resp.pipe(zlib.createInflate()); const chunks = []; stream.on('data', (c) => chunks.push(c)); stream.on('end', () => { resolveOnce({ ok: resp.statusCode >= 200 && resp.statusCode < 300, status: resp.statusCode, buffer: Buffer.concat(chunks), contentType: resp.headers['content-type'] || '', }); }); stream.on('error', rejectOnce); }); req.on('error', rejectOnce); if (body != null) req.write(body); req.end(); }); }); } module.exports = { parseProxyConfig, resolveProxyConfig, resolveProxyConfigWithFallback, resolveProxyString, resolveProxyStringConnect, proxyConnectTunnel, proxyFetch };