/** * 通过 RunForge-ProxyServer 的 /Proxy/Forward 发起出站 HTTP 请求。 */ const axios = require('axios') const { getRuntimeConfig } = require('./RuntimeConfig') async function getServerConfig() { let cfg = null try { cfg = await getRuntimeConfig('proxyForwardServer', { required: false, defaultValue: null }) } catch (_) { return { url: '', enabled: false } } if (!cfg || typeof cfg !== 'object') return { url: '', enabled: false } return { url: String(cfg.url || '').trim().replace(/\/+$/, ''), enabled: cfg.enabled !== false, defaultTimeout: Number(cfg.timeout) || 120000 } } async function isProxyForwardEnabled() { const { url, enabled } = await getServerConfig() return enabled && url.length > 0 } function debugProxyEnabled() { return String(process.env.LEPAO_DEBUG_PROXY || '').trim() === '1' } function debugProxyAxiosFragment() { const host = process.env.LEPAO_DEBUG_PROXY_HOST || '127.0.0.1' const port = Number(process.env.LEPAO_DEBUG_PROXY_PORT || 9000) return { proxy: { host, port, protocol: 'http' } } } function briefUrlPath(fullUrl) { try { const u = new URL(fullUrl) return `${u.pathname}${u.search}` } catch { return fullUrl } } /** 转为可 JSON 序列化的纯对象,避免 AxiosHeaders 等类型丢失字段 */ function normalizeForwardHeaders(headers) { if (headers == null) return {} let source = headers if (typeof headers === 'string') { try { source = JSON.parse(headers) } catch { return {} } } if (typeof source !== 'object' || Array.isArray(source)) return {} if (typeof source.toJSON === 'function') source = source.toJSON() const out = {} for (const [key, value] of Object.entries(source)) { if (value == null) continue out[String(key)] = Array.isArray(value) ? value.join(', ') : String(value) } return out } /** * URLSearchParams / Buffer 等无法被 JSON.stringify 正确序列化,需先转换再发给代理服务。 */ function serializeForwardBody(data) { if (data == null || data === '') return null if (typeof data === 'string') return data if (Buffer.isBuffer(data)) { return { __encoding: 'base64', data: data.toString('base64') } } if (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) { return data.toString() } if (typeof data === 'object' && !Array.isArray(data)) return data return String(data) } function buildDirectAxiosConfig(extra = {}) { if (debugProxyEnabled()) { const { HttpsProxyAgent } = require('https-proxy-agent') const dbg = debugProxyAxiosFragment() const { host, port } = dbg.proxy const agent = new HttpsProxyAgent(`http://${host}:${port}`) return { proxy: false, httpAgent: agent, httpsAgent: agent, ...extra } } return { proxy: false, ...extra } } async function execDirect({ method, url, data, headers, timeout, validateStatus, responseType, transformResponse }) { const m = String(method || 'get').toLowerCase() const merged = { headers: headers || {}, timeout, ...buildDirectAxiosConfig(), ...(validateStatus ? { validateStatus } : {}), ...(responseType ? { responseType } : {}), ...(transformResponse ? { transformResponse } : {}) } return axios.request({ ...merged, method: m, url, data }) } function assertProxyModeOk(outboundMode, meta) { if (outboundMode !== 'proxy') return if (meta?.fallback_direct) { const err = new Error('代理模式发生直连回退,任务中止') err.code = 'PROXY_REQUIRED_FALLBACK_DIRECT' err.retryable = true throw err } if (!meta?.used_proxy) { const err = new Error('代理模式未使用代理节点') err.code = 'PROXY_REQUIRED_NO_PROXY_USED' err.retryable = true throw err } } function toAxiosResponse(forwardData, responseType) { if (!forwardData || typeof forwardData !== 'object') return null const status = forwardData.status ?? forwardData.statusCode if (status == null) return null let data = forwardData.body if (data && typeof data === 'object' && data.__encoding === 'base64' && data.data) { data = Buffer.from(String(data.data), 'base64') } return { status, statusText: String(status), headers: forwardData.headers || {}, data, config: {} } } function isValidForwardPayload(json) { return json && json.code === 0 && json.data != null && (json.data.status ?? json.data.statusCode) != null } /** * @param {{ * method?: string * url: string * data?: any * headers?: object * timeout?: number * outboundMode?: 'auto'|'direct'|'proxy' * validateStatus?: Function * responseType?: string * transformResponse?: Function[] * logger?: object * logPrefix?: string * scene?: string * }} opts */ async function forwardRequest(opts) { const { method = 'get', url, data, headers = {}, timeout = 15000, outboundMode = 'auto', validateStatus, responseType, transformResponse, logger, logPrefix = '[ProxyForwardClient]', scene = 'outbound' } = opts const m = String(method).toLowerCase() const path = briefUrlPath(url) if (outboundMode === 'direct') { logger?.info?.(`${logPrefix} (${scene}) 直连 ${m.toUpperCase()} ${path}`) return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse }) } if (!await isProxyForwardEnabled()) { logger?.info?.(`${logPrefix} (${scene}) 未配置代理服务,直连 ${path}`) if (outboundMode === 'proxy') { const err = new Error('未配置 proxyForwardServer.url') err.code = 'PROXY_REQUIRED_NOT_CONFIGURED' err.retryable = false throw err } return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse }) } const { url: serverUrl, defaultTimeout } = await getServerConfig() logger?.info?.(`${logPrefix} (${scene}) 经代理服务 ${m.toUpperCase()} ${path}`) const reqHeaders = normalizeForwardHeaders(headers) const reqBody = serializeForwardBody(data) let forwardResp try { forwardResp = await axios.post( `${serverUrl}/Proxy/Forward`, { url, method: m.toUpperCase(), headers: reqHeaders, body: reqBody, timeout, ...(responseType ? { responseType } : {}) }, { timeout: Math.min(defaultTimeout, timeout + 10000), validateStatus: () => true, headers: { 'Content-Type': 'application/json' } } ) } catch (e) { if (outboundMode === 'proxy') { const err = new Error(`代理服务不可达: ${e.message || e}`) err.code = 'PROXY_SERVER_UNREACHABLE' err.retryable = true throw err } logger?.warn?.(`${logPrefix} (${scene}) 代理服务请求失败,改直连: ${e.message || e}`) return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse }) } const json = forwardResp.data || {} if (!isValidForwardPayload(json)) { const reason = json.code !== 0 ? (json.msg || 'unknown') : '代理响应缺少 status' if (outboundMode === 'proxy') { const err = new Error(json.msg || reason || '代理转发失败') err.code = 'PROXY_FORWARD_FAILED' err.retryable = true throw err } logger?.warn?.(`${logPrefix} (${scene}) 代理响应无效,改直连: ${reason}`) return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse }) } const meta = json.data?.meta || {} assertProxyModeOk(outboundMode, meta) logger?.info?.( `${logPrefix} (${scene}) 完成 status=${json.data?.status ?? json.data?.statusCode} used_proxy=${meta.used_proxy} fallback=${meta.fallback_direct} ${meta.duration_ms}ms` ) const axiosResp = toAxiosResponse(json.data, responseType) if (!axiosResp) { if (outboundMode === 'proxy') { const err = new Error('代理响应缺少 HTTP status') err.code = 'PROXY_FORWARD_BAD_RESPONSE' err.retryable = true throw err } logger?.warn?.(`${logPrefix} (${scene}) 代理响应缺少 status,改直连`) return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse }) } if (validateStatus && !validateStatus(axiosResp.status)) { const err = new Error(`Request failed with status code ${axiosResp.status}`) err.response = axiosResp err.isAxiosError = true throw err } return axiosResp } module.exports = { forwardRequest, isProxyForwardEnabled, debugProxyEnabled, debugProxyAxiosFragment, briefUrlPath, buildDirectAxiosConfig, execDirect, getServerConfig, normalizeForwardHeaders, serializeForwardBody }