343 lines
14 KiB
JavaScript
343 lines
14 KiB
JavaScript
import * as http from 'node:http';
|
|
import * as https from 'node:https';
|
|
import { promisify } from 'node:util';
|
|
import { brotliDecompress, gunzip, inflate } from 'node:zlib';
|
|
import { ProxyAgent } from 'proxy-agent';
|
|
import { createProxyChainRelay, validateLocalProxyUrl } from './proxy-chain.mjs';
|
|
|
|
const SUPPORTED_PROTOCOLS = new Set(['http:', 'https:', 'socks:', 'socks4:', 'socks4a:', 'socks5:', 'socks5h:']);
|
|
const runtime = new Map();
|
|
let cursor = 0;
|
|
const responseDecoders = new Map([
|
|
['gzip', promisify(gunzip)],
|
|
['deflate', promisify(inflate)],
|
|
['br', promisify(brotliDecompress)],
|
|
]);
|
|
|
|
function hostPort(value) {
|
|
const match = String(value || '').trim().match(/^(.+):(\d{1,5})$/);
|
|
if (!match) return null;
|
|
const port = Number(match[2]);
|
|
return port >= 1 && port <= 65535 ? { host: match[1], port } : null;
|
|
}
|
|
|
|
function normalizeHost(value) {
|
|
const host = String(value || '').trim();
|
|
return host.replace(/^\[|\]$/g, '');
|
|
}
|
|
|
|
function hostForUrl(value) {
|
|
const host = normalizeHost(value);
|
|
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
}
|
|
|
|
function encodePart(value) {
|
|
return encodeURIComponent(String(value || ''));
|
|
}
|
|
|
|
function proxyUrlFromParts(protocol, host, port, username = '', password = '') {
|
|
const auth = username || password ? `${encodePart(username)}:${encodePart(password)}@` : '';
|
|
return `${protocol}//${auth}${hostForUrl(host)}:${port}`;
|
|
}
|
|
|
|
function credentialsFrom(value) {
|
|
const source = String(value || '').trim();
|
|
const separator = source.indexOf(':');
|
|
if (separator < 0) return [source, ''];
|
|
return [source.slice(0, separator).trim(), source.slice(separator + 1).trim()];
|
|
}
|
|
|
|
function normalizedProtocol(protocol) {
|
|
const value = String(protocol || 'http:').toLowerCase().replace(/:$/, '');
|
|
// SOCKS5H keeps destination DNS resolution inside the proxy. Treat the
|
|
// common SOCKS5 spellings as that mode so a proxy cannot resolve targets
|
|
// locally by accident.
|
|
if (value === 'socks' || value === 'socks5' || value === 'socks5h') return 'socks5h:';
|
|
return `${value}:`;
|
|
}
|
|
|
|
function sourceTypeForHost(host) {
|
|
const value = String(host || '').toLowerCase();
|
|
return /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|::1|\[::1\])$/.test(value) ? 'local' : 'residential';
|
|
}
|
|
|
|
export function parseProxyInput(input) {
|
|
// Proxy lists are often copied from shell snippets where @ is escaped.
|
|
// Remove only backslashes immediately before an authority separator so
|
|
// credentials containing other backslashes remain unchanged.
|
|
let value = String(input || '').trim().replace(/\\+(?=@)/g, '');
|
|
if (!value) throw new Error('proxy_required');
|
|
let protocol = 'http:';
|
|
let host;
|
|
let port;
|
|
let username = '';
|
|
let password = '';
|
|
|
|
if (/^[a-z][a-z\d+.-]*:\/\//i.test(value)) {
|
|
const parsed = new URL(value);
|
|
protocol = normalizedProtocol(parsed.protocol);
|
|
if (!SUPPORTED_PROTOCOLS.has(protocol)) throw new Error('unsupported_proxy_protocol');
|
|
host = normalizeHost(parsed.hostname);
|
|
port = Number(parsed.port || (protocol === 'http:' || protocol === 'https:' ? 8080 : 1080));
|
|
try {
|
|
username = decodeURIComponent(parsed.username || '');
|
|
password = decodeURIComponent(parsed.password || '');
|
|
} catch {
|
|
throw new Error('invalid_proxy_credentials');
|
|
}
|
|
} else {
|
|
const at = value.lastIndexOf('@');
|
|
if (at > 0) {
|
|
const left = value.slice(0, at).trim();
|
|
const right = value.slice(at + 1).trim();
|
|
const leftHost = hostPort(left);
|
|
const rightHost = hostPort(right);
|
|
if (leftHost && !rightHost) {
|
|
host = normalizeHost(leftHost.host); port = leftHost.port;
|
|
[username, password] = credentialsFrom(right);
|
|
} else if (rightHost) {
|
|
host = normalizeHost(rightHost.host); port = rightHost.port;
|
|
[username, password] = credentialsFrom(left);
|
|
} else throw new Error('invalid_proxy_host');
|
|
} else {
|
|
const parts = value.split(':').map((part) => part.trim());
|
|
if (parts.length >= 4 && /^\d+$/.test(parts[1])) {
|
|
host = normalizeHost(parts.shift()); port = Number(parts.shift()); username = parts.shift() || ''; password = parts.join(':');
|
|
} else {
|
|
const parsedHost = hostPort(value);
|
|
if (!parsedHost) throw new Error('invalid_proxy_format');
|
|
host = normalizeHost(parsedHost.host); port = parsedHost.port;
|
|
}
|
|
}
|
|
}
|
|
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error('invalid_proxy_host');
|
|
const url = proxyUrlFromParts(protocol, host, port, username, password);
|
|
return { url, protocol: protocol.slice(0, -1), host, port, username, password, sourceType: sourceTypeForHost(host) };
|
|
}
|
|
|
|
export function maskProxyUrl(value) {
|
|
try {
|
|
const parsed = new URL(String(value));
|
|
const auth = parsed.username || parsed.password ? '***:***@' : '';
|
|
return `${parsed.protocol}//${auth}${parsed.hostname}:${parsed.port || (parsed.protocol.startsWith('socks') ? 1080 : 8080)}`;
|
|
} catch { return '无效代理'; }
|
|
}
|
|
|
|
function proxyEntrySummary(entry) {
|
|
return {
|
|
id: entry.id,
|
|
label: entry.label || '',
|
|
display: entry.display || maskProxyUrl(entry.url),
|
|
protocol: entry.protocol || '',
|
|
host: entry.host || '',
|
|
port: Number(entry.port) || null,
|
|
sourceType: entry.sourceType || sourceTypeForHost(entry.host),
|
|
hasAuth: Boolean(entry.username || entry.password),
|
|
healthy: runtime.get(entry.id)?.healthy !== false,
|
|
failures: runtime.get(entry.id)?.failures || 0,
|
|
cooldownUntil: runtime.get(entry.id)?.cooldownUntil || null,
|
|
};
|
|
}
|
|
|
|
export function publicProxySettings(settings = {}) {
|
|
const proxy = settings.proxy || {};
|
|
const timeoutMs = Number(proxy.timeoutMs);
|
|
const maxRetries = Number(proxy.maxRetries);
|
|
return {
|
|
enabled: proxy.enabled === true,
|
|
localProxyEnabled: proxy.localProxyEnabled === true,
|
|
localProxyUrl: proxy.localProxyUrl || 'http://127.0.0.1:7897',
|
|
strategy: proxy.strategy === 'round_robin' ? 'round_robin' : 'failover',
|
|
timeoutMs: Number.isFinite(timeoutMs) ? Math.min(120000, Math.max(1000, timeoutMs)) : 15000,
|
|
maxRetries: Number.isFinite(maxRetries) ? Math.min(5, Math.max(0, maxRetries)) : 2,
|
|
entries: Array.isArray(proxy.entries) ? proxy.entries.map(proxyEntrySummary) : [],
|
|
};
|
|
}
|
|
|
|
function usableEntries(config = {}, excluded = new Set()) {
|
|
const entries = Array.isArray(config.entries) ? config.entries : [];
|
|
const now = Date.now();
|
|
return entries.filter((entry) => entry?.url && !excluded.has(entry.id) && (!runtime.get(entry.id)?.cooldownUntil || runtime.get(entry.id).cooldownUntil <= now));
|
|
}
|
|
|
|
function selectEntry(config, excluded) {
|
|
const available = usableEntries(config, excluded);
|
|
if (!available.length) {
|
|
const fallback = (Array.isArray(config.entries) ? config.entries : []).filter((entry) => entry?.url && !excluded.has(entry.id));
|
|
return fallback.sort((a, b) => (runtime.get(a.id)?.cooldownUntil || 0) - (runtime.get(b.id)?.cooldownUntil || 0))[0] || null;
|
|
}
|
|
if (config.strategy !== 'round_robin') return available[0];
|
|
const entry = available[cursor % available.length];
|
|
cursor = (cursor + 1) % Math.max(1, available.length);
|
|
return entry;
|
|
}
|
|
|
|
function markSuccess(entry) {
|
|
if (!entry?.id) return;
|
|
runtime.set(entry.id, { healthy: true, failures: 0, cooldownUntil: null, lastUsedAt: Date.now() });
|
|
}
|
|
|
|
function markFailure(entry) {
|
|
if (!entry?.id) return;
|
|
const previous = runtime.get(entry.id) || {};
|
|
const failures = (previous.failures || 0) + 1;
|
|
runtime.set(entry.id, { healthy: false, failures, cooldownUntil: Date.now() + Math.min(120000, 5000 * (2 ** Math.min(failures - 1, 4))), lastUsedAt: Date.now() });
|
|
}
|
|
|
|
function retryableStatus(status) {
|
|
return status === 407 || status === 408 || status === 429 || status >= 500;
|
|
}
|
|
|
|
async function decodeProxyResponse(body, responseHeaders) {
|
|
const headers = { ...responseHeaders };
|
|
const encodings = String(headers['content-encoding'] || '').split(',').map((encoding) => encoding.trim().toLowerCase()).filter((encoding) => encoding && encoding !== 'identity');
|
|
for (const encoding of encodings.reverse()) {
|
|
const decode = responseDecoders.get(encoding);
|
|
if (typeof decode !== 'function') {
|
|
const error = new Error('unsupported proxy response content encoding');
|
|
error.code = 'PROXY_UNSUPPORTED_CONTENT_ENCODING';
|
|
throw error;
|
|
}
|
|
try {
|
|
body = await decode(body);
|
|
} catch (cause) {
|
|
const error = new Error(`proxy response ${encoding} decompression failed`, { cause });
|
|
error.code = cause.code || 'PROXY_RESPONSE_DECODE_FAILED';
|
|
throw error;
|
|
}
|
|
}
|
|
if (encodings.length) {
|
|
delete headers['content-encoding'];
|
|
delete headers['content-length'];
|
|
}
|
|
return { body, headers };
|
|
}
|
|
|
|
function requestViaProxy(url, options = {}, proxyUrl) {
|
|
const target = new URL(url);
|
|
const transport = target.protocol === 'https:' ? https : http;
|
|
const timeoutMs = Math.max(1000, Number(options.proxyTimeoutMs || options.timeoutMs) || 15000);
|
|
const headers = { ...(options.headers || {}) };
|
|
let body = options.body;
|
|
if (body instanceof URLSearchParams) body = body.toString();
|
|
if (body !== undefined && body !== null && !headers['content-length'] && !headers['Content-Length']) headers['content-length'] = Buffer.byteLength(String(body));
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
let req;
|
|
let response;
|
|
let timer;
|
|
// CONNECT can stall before its socket is assigned to ClientRequest. Give
|
|
// this request its own abortable agent so destroying it also ends that
|
|
// handshake, without interrupting other requests using the same proxy.
|
|
const connection = new AbortController();
|
|
const agent = new ProxyAgent({ getProxyForUrl: () => proxyUrl, signal: connection.signal, timeout: timeoutMs });
|
|
const onAbort = () => fail(options.signal.reason || new Error('aborted'));
|
|
const cleanup = () => {
|
|
clearTimeout(timer);
|
|
options.signal?.removeEventListener('abort', onAbort);
|
|
if (req?.socket) req.socket.destroy();
|
|
else connection.abort();
|
|
agent.destroy();
|
|
};
|
|
const fail = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
response?.destroy();
|
|
req?.destroy();
|
|
reject(error);
|
|
};
|
|
if (options.signal?.aborted) { onAbort(); return; }
|
|
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
timer = setTimeout(() => {
|
|
const error = new Error('proxy request timeout');
|
|
error.name = 'TimeoutError';
|
|
// Settle directly: a pending proxy handshake may never emit req.error.
|
|
fail(error);
|
|
}, timeoutMs);
|
|
try {
|
|
req = transport.request(target, { method: options.method || 'GET', headers, agent, timeout: timeoutMs }, (incoming) => {
|
|
response = incoming;
|
|
if (settled) { response.destroy(); return; }
|
|
const chunks = [];
|
|
response.on('data', (chunk) => { if (!settled) chunks.push(chunk); });
|
|
response.once('error', fail);
|
|
response.once('end', async () => {
|
|
if (settled) return;
|
|
try {
|
|
const status = response.statusCode || 0;
|
|
const bodyless = options.method?.toUpperCase() === 'HEAD' || [204, 205, 304].includes(status);
|
|
const decoded = bodyless ? { body: null, headers: response.headers } : await decodeProxyResponse(Buffer.concat(chunks), response.headers);
|
|
if (settled) return;
|
|
const result = new Response(decoded.body, { status, statusText: response.statusMessage || '', headers: decoded.headers });
|
|
settled = true;
|
|
cleanup();
|
|
resolve(result);
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
});
|
|
req.once('error', fail);
|
|
if (body !== undefined && body !== null) req.write(body);
|
|
req.end();
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function createProxyFetch(getSettings) {
|
|
return async function proxyFetch(url, options = {}) {
|
|
const config = getSettings?.() || {};
|
|
const proxyEnabled = config.enabled === true;
|
|
const localProxyUrl = config.localProxyEnabled === true ? validateLocalProxyUrl(config.localProxyUrl) : '';
|
|
if (proxyEnabled && (!Array.isArray(config.entries) || !config.entries.some((entry) => entry?.url))) {
|
|
throw new Error('proxy_entries_required');
|
|
}
|
|
const attempts = proxyEnabled ? Math.min(6, Math.max(1, Number(config.maxRetries) + 1 || 1)) : 1;
|
|
const excluded = new Set();
|
|
let lastError = null;
|
|
let lastResponse = null;
|
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
const entry = proxyEnabled ? selectEntry(config, excluded) : null;
|
|
if (proxyEnabled && !entry) {
|
|
if (lastResponse) return lastResponse;
|
|
throw lastError || new Error('proxy_unavailable');
|
|
}
|
|
let relay;
|
|
try {
|
|
const requestOptions = entry || localProxyUrl
|
|
? {
|
|
...options,
|
|
proxyTimeoutMs: Math.max(1000, Number(config.timeoutMs) || 15000),
|
|
// The proxy timeout is user-configurable; replace the caller's fixed
|
|
// business timeout so a configured 30-120s window is honored.
|
|
signal: AbortSignal.timeout(Math.max(1000, Number(config.timeoutMs) || 15000)),
|
|
}
|
|
: options;
|
|
if (entry && localProxyUrl) {
|
|
relay = await createProxyChainRelay({ localProxyUrl, upstreamProxyUrl: entry.url, timeoutMs: requestOptions.proxyTimeoutMs });
|
|
}
|
|
const proxyUrl = relay?.url || entry?.url || localProxyUrl;
|
|
const response = proxyUrl ? await requestViaProxy(url, requestOptions, proxyUrl) : await fetch(url, requestOptions);
|
|
lastResponse = response;
|
|
lastError = null;
|
|
const usableResponse = response.status >= 200 && response.status < 400;
|
|
if (entry && usableResponse) markSuccess(entry);
|
|
if (entry && !usableResponse && retryableStatus(response.status) && attempt < attempts - 1) { markFailure(entry); excluded.add(entry.id); continue; }
|
|
if (entry && !usableResponse && retryableStatus(response.status)) markFailure(entry);
|
|
return response;
|
|
} catch (error) {
|
|
lastError = error;
|
|
lastResponse = null;
|
|
if (entry) { markFailure(entry); excluded.add(entry.id); }
|
|
if (attempt >= attempts - 1) throw error;
|
|
} finally {
|
|
await relay?.close();
|
|
}
|
|
}
|
|
throw lastError || new Error('proxy_request_failed');
|
|
};
|
|
}
|