Files

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');
};
}