ProxyForwardClient.js 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. /**
  2. * 通过 RunForge-ProxyServer 的 /Proxy/Forward 发起出站 HTTP 请求。
  3. */
  4. const axios = require('axios')
  5. const config = require('../config.json')
  6. function getServerConfig() {
  7. const cfg = config.proxyForwardServer
  8. if (!cfg || typeof cfg !== 'object') return { url: '', enabled: false }
  9. return {
  10. url: String(cfg.url || '').trim().replace(/\/+$/, ''),
  11. enabled: cfg.enabled !== false,
  12. defaultTimeout: Number(cfg.timeout) || 120000
  13. }
  14. }
  15. function isProxyForwardEnabled() {
  16. const { url, enabled } = getServerConfig()
  17. return enabled && url.length > 0
  18. }
  19. function debugProxyEnabled() {
  20. return String(process.env.LEPAO_DEBUG_PROXY || '').trim() === '1'
  21. }
  22. function debugProxyAxiosFragment() {
  23. const host = process.env.LEPAO_DEBUG_PROXY_HOST || '127.0.0.1'
  24. const port = Number(process.env.LEPAO_DEBUG_PROXY_PORT || 9000)
  25. return {
  26. proxy: {
  27. host,
  28. port,
  29. protocol: 'http'
  30. }
  31. }
  32. }
  33. function briefUrlPath(fullUrl) {
  34. try {
  35. const u = new URL(fullUrl)
  36. return `${u.pathname}${u.search}`
  37. } catch {
  38. return fullUrl
  39. }
  40. }
  41. /** 转为可 JSON 序列化的纯对象,避免 AxiosHeaders 等类型丢失字段 */
  42. function normalizeForwardHeaders(headers) {
  43. if (headers == null) return {}
  44. let source = headers
  45. if (typeof headers === 'string') {
  46. try {
  47. source = JSON.parse(headers)
  48. } catch {
  49. return {}
  50. }
  51. }
  52. if (typeof source !== 'object' || Array.isArray(source)) return {}
  53. if (typeof source.toJSON === 'function') source = source.toJSON()
  54. const out = {}
  55. for (const [key, value] of Object.entries(source)) {
  56. if (value == null) continue
  57. out[String(key)] = Array.isArray(value) ? value.join(', ') : String(value)
  58. }
  59. return out
  60. }
  61. /**
  62. * URLSearchParams / Buffer 等无法被 JSON.stringify 正确序列化,需先转换再发给代理服务。
  63. */
  64. function serializeForwardBody(data) {
  65. if (data == null || data === '') return null
  66. if (typeof data === 'string') return data
  67. if (Buffer.isBuffer(data)) {
  68. return { __encoding: 'base64', data: data.toString('base64') }
  69. }
  70. if (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) {
  71. return data.toString()
  72. }
  73. if (typeof data === 'object' && !Array.isArray(data)) return data
  74. return String(data)
  75. }
  76. function buildDirectAxiosConfig(extra = {}) {
  77. if (debugProxyEnabled()) {
  78. const { HttpsProxyAgent } = require('https-proxy-agent')
  79. const dbg = debugProxyAxiosFragment()
  80. const { host, port } = dbg.proxy
  81. const agent = new HttpsProxyAgent(`http://${host}:${port}`)
  82. return { proxy: false, httpAgent: agent, httpsAgent: agent, ...extra }
  83. }
  84. return { proxy: false, ...extra }
  85. }
  86. async function execDirect({ method, url, data, headers, timeout, validateStatus, responseType, transformResponse }) {
  87. const m = String(method || 'get').toLowerCase()
  88. const merged = {
  89. headers: headers || {},
  90. timeout,
  91. ...buildDirectAxiosConfig(),
  92. ...(validateStatus ? { validateStatus } : {}),
  93. ...(responseType ? { responseType } : {}),
  94. ...(transformResponse ? { transformResponse } : {})
  95. }
  96. return axios.request({ ...merged, method: m, url, data })
  97. }
  98. function assertProxyModeOk(outboundMode, meta) {
  99. if (outboundMode !== 'proxy') return
  100. if (meta?.fallback_direct) {
  101. const err = new Error('代理模式发生直连回退,任务中止')
  102. err.code = 'PROXY_REQUIRED_FALLBACK_DIRECT'
  103. err.retryable = true
  104. throw err
  105. }
  106. if (!meta?.used_proxy) {
  107. const err = new Error('代理模式未使用代理节点')
  108. err.code = 'PROXY_REQUIRED_NO_PROXY_USED'
  109. err.retryable = true
  110. throw err
  111. }
  112. }
  113. function toAxiosResponse(forwardData, responseType) {
  114. if (!forwardData || typeof forwardData !== 'object') return null
  115. const status = forwardData.status ?? forwardData.statusCode
  116. if (status == null) return null
  117. let data = forwardData.body
  118. if (data && typeof data === 'object' && data.__encoding === 'base64' && data.data) {
  119. data = Buffer.from(String(data.data), 'base64')
  120. }
  121. return {
  122. status,
  123. statusText: String(status),
  124. headers: forwardData.headers || {},
  125. data,
  126. config: {}
  127. }
  128. }
  129. function isValidForwardPayload(json) {
  130. return json && json.code === 0 && json.data != null && (json.data.status ?? json.data.statusCode) != null
  131. }
  132. /**
  133. * @param {{
  134. * method?: string
  135. * url: string
  136. * data?: any
  137. * headers?: object
  138. * timeout?: number
  139. * outboundMode?: 'auto'|'direct'|'proxy'
  140. * validateStatus?: Function
  141. * responseType?: string
  142. * transformResponse?: Function[]
  143. * logger?: object
  144. * logPrefix?: string
  145. * scene?: string
  146. * }} opts
  147. */
  148. async function forwardRequest(opts) {
  149. const {
  150. method = 'get',
  151. url,
  152. data,
  153. headers = {},
  154. timeout = 15000,
  155. outboundMode = 'auto',
  156. validateStatus,
  157. responseType,
  158. transformResponse,
  159. logger,
  160. logPrefix = '[ProxyForwardClient]',
  161. scene = 'outbound'
  162. } = opts
  163. const m = String(method).toLowerCase()
  164. const path = briefUrlPath(url)
  165. if (outboundMode === 'direct') {
  166. logger?.info?.(`${logPrefix} (${scene}) 直连 ${m.toUpperCase()} ${path}`)
  167. return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
  168. }
  169. if (!isProxyForwardEnabled()) {
  170. logger?.info?.(`${logPrefix} (${scene}) 未配置代理服务,直连 ${path}`)
  171. if (outboundMode === 'proxy') {
  172. const err = new Error('未配置 proxyForwardServer.url')
  173. err.code = 'PROXY_REQUIRED_NOT_CONFIGURED'
  174. err.retryable = false
  175. throw err
  176. }
  177. return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
  178. }
  179. const { url: serverUrl, defaultTimeout } = getServerConfig()
  180. logger?.info?.(`${logPrefix} (${scene}) 经代理服务 ${m.toUpperCase()} ${path}`)
  181. const reqHeaders = normalizeForwardHeaders(headers)
  182. const reqBody = serializeForwardBody(data)
  183. let forwardResp
  184. try {
  185. forwardResp = await axios.post(
  186. `${serverUrl}/Proxy/Forward`,
  187. {
  188. url,
  189. method: m.toUpperCase(),
  190. headers: reqHeaders,
  191. body: reqBody,
  192. timeout,
  193. ...(responseType ? { responseType } : {})
  194. },
  195. {
  196. timeout: Math.min(defaultTimeout, timeout + 10000),
  197. validateStatus: () => true,
  198. headers: { 'Content-Type': 'application/json' }
  199. }
  200. )
  201. } catch (e) {
  202. if (outboundMode === 'proxy') {
  203. const err = new Error(`代理服务不可达: ${e.message || e}`)
  204. err.code = 'PROXY_SERVER_UNREACHABLE'
  205. err.retryable = true
  206. throw err
  207. }
  208. logger?.warn?.(`${logPrefix} (${scene}) 代理服务请求失败,改直连: ${e.message || e}`)
  209. return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
  210. }
  211. const json = forwardResp.data || {}
  212. if (!isValidForwardPayload(json)) {
  213. const reason = json.code !== 0 ? (json.msg || 'unknown') : '代理响应缺少 status'
  214. if (outboundMode === 'proxy') {
  215. const err = new Error(json.msg || reason || '代理转发失败')
  216. err.code = 'PROXY_FORWARD_FAILED'
  217. err.retryable = true
  218. throw err
  219. }
  220. logger?.warn?.(`${logPrefix} (${scene}) 代理响应无效,改直连: ${reason}`)
  221. return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
  222. }
  223. const meta = json.data?.meta || {}
  224. assertProxyModeOk(outboundMode, meta)
  225. logger?.info?.(
  226. `${logPrefix} (${scene}) 完成 status=${json.data?.status ?? json.data?.statusCode} used_proxy=${meta.used_proxy} fallback=${meta.fallback_direct} ${meta.duration_ms}ms`
  227. )
  228. const axiosResp = toAxiosResponse(json.data, responseType)
  229. if (!axiosResp) {
  230. if (outboundMode === 'proxy') {
  231. const err = new Error('代理响应缺少 HTTP status')
  232. err.code = 'PROXY_FORWARD_BAD_RESPONSE'
  233. err.retryable = true
  234. throw err
  235. }
  236. logger?.warn?.(`${logPrefix} (${scene}) 代理响应缺少 status,改直连`)
  237. return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
  238. }
  239. if (validateStatus && !validateStatus(axiosResp.status)) {
  240. const err = new Error(`Request failed with status code ${axiosResp.status}`)
  241. err.response = axiosResp
  242. err.isAxiosError = true
  243. throw err
  244. }
  245. return axiosResp
  246. }
  247. module.exports = {
  248. forwardRequest,
  249. isProxyForwardEnabled,
  250. debugProxyEnabled,
  251. debugProxyAxiosFragment,
  252. briefUrlPath,
  253. buildDirectAxiosConfig,
  254. execDirect,
  255. getServerConfig,
  256. normalizeForwardHeaders,
  257. serializeForwardBody
  258. }