diff --git a/bun.lock b/bun.lock index 25184aa0..e23117d6 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "mime-types": "^3.0.1", "multer": "^2.3.0", "proxy-agent": "^5.0.0", + "socks": "^2.8.9", "tiktoken": "^1.0.21", }, "devDependencies": { diff --git a/package.json b/package.json index 6cef2c57..89963945 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "mime-types": "^3.0.1", "multer": "^2.3.0", "proxy-agent": "^5.0.0", + "socks": "^2.8.9", "tiktoken": "^1.0.21" }, "devDependencies": { diff --git a/src/controllers/chat.image.video.js b/src/controllers/chat.image.video.js index 4a3e49d3..84426e96 100644 --- a/src/controllers/chat.image.video.js +++ b/src/controllers/chat.image.video.js @@ -632,7 +632,7 @@ const sendOpenAIErrorResponse = (res, error) => { * @returns {Promise} Base64 内容 */ const downloadAssetAsBase64 = async (contentUrl, account) => { - const proxyAgent = getProxyAgent(account) + const proxyAgent = await getProxyAgent(account) const requestConfig = { responseType: 'arraybuffer', timeout: 1000 * 60 * 2 @@ -1003,7 +1003,7 @@ const getChatDetail = async (chatID, token) => { const chatBaseUrl = getChatBaseUrl() // 通过 token 反查 account 解析账号级代理(找不到则回退到全局 PROXY_URL) const account = accountManager.getAccountByToken(token) - const proxyAgent = getProxyAgent(account) + const proxyAgent = await getProxyAgent(account) // Antidetect: per-account fingerprint headers replace static block const ssxmod = getSsxmodForAccount(account) const headers = buildRequestHeaders(account, { @@ -1334,7 +1334,7 @@ const generateImageVideoResult = async (payload) => { } const chatBaseUrl = getChatBaseUrl() - const proxyAgent = getProxyAgent(account) + const proxyAgent = await getProxyAgent(account) // Antidetect: per-account fingerprint headers replace static block const ssxmod = getSsxmodForAccount(account) const headers = buildRequestHeaders(account, { @@ -1695,7 +1695,7 @@ const getVideoTaskStatus = async (videoTaskID, token) => { try { const chatBaseUrl = getChatBaseUrl() const account = accountManager.getAccountByToken(token) - const proxyAgent = getProxyAgent(account) + const proxyAgent = await getProxyAgent(account) // Antidetect: per-account fingerprint headers replace static block const ssxmod = getSsxmodForAccount(account) const headers = buildRequestHeaders(account, { diff --git a/src/controllers/cli.chat.js b/src/controllers/cli.chat.js index f63a37f3..69878eec 100644 --- a/src/controllers/cli.chat.js +++ b/src/controllers/cli.chat.js @@ -295,7 +295,7 @@ const handleCliChatCompletion = async (req, res) => { req.account.cli_info.request_number++ const cliBaseUrl = getCliBaseUrl() - const proxyAgent = getProxyAgent(req.account) + const proxyAgent = await getProxyAgent(req.account) // 设置请求配置 const axiosConfig = { diff --git a/src/models/models-map.js b/src/models/models-map.js index cbab7dd9..05640e3c 100644 --- a/src/models/models-map.js +++ b/src/models/models-map.js @@ -30,7 +30,7 @@ const getLatestModels = async (force = false) => { const chatBaseUrl = getChatBaseUrl() // 一次取出账户对象,token 与 proxy 走同一个账号,避免 round-robin 错位 const account = accountManager.getAccount() - const proxyAgent = getProxyAgent(account) + const proxyAgent = await getProxyAgent(account) // Antidetect: per-account fingerprint headers replace static block const ssxmod = getSsxmodForAccount(account) diff --git a/src/utils/cli.manager.js b/src/utils/cli.manager.js index 84634820..eed0156c 100644 --- a/src/utils/cli.manager.js +++ b/src/utils/cli.manager.js @@ -86,7 +86,7 @@ class CliAuthManager { body: bodyData, } - applyProxyToFetchOptions(fetchOptions, account) + await applyProxyToFetchOptions(fetchOptions, account) try { const response = await fetch(`${chatBaseUrl}/api/v1/oauth2/device/code`, fetchOptions) @@ -147,7 +147,7 @@ class CliAuthManager { }) } - applyProxyToFetchOptions(fetchOptions, account) + await applyProxyToFetchOptions(fetchOptions, account) const response = await fetch(`${chatBaseUrl}/api/v2/oauth2/authorize`, fetchOptions) @@ -200,7 +200,7 @@ class CliAuthManager { body: bodyData, } - applyProxyToFetchOptions(fetchOptions, account) + await applyProxyToFetchOptions(fetchOptions, account) try { const response = await fetch(`${chatBaseUrl}/api/v1/oauth2/token`, fetchOptions) @@ -317,7 +317,7 @@ class CliAuthManager { body: bodyData } - applyProxyToFetchOptions(fetchOptions, account) + await applyProxyToFetchOptions(fetchOptions, account) const response = await fetch(`${chatBaseUrl}/api/v1/oauth2/token`, fetchOptions) diff --git a/src/utils/proxy-helper.js b/src/utils/proxy-helper.js index 838176b9..ad1b9bf1 100644 --- a/src/utils/proxy-helper.js +++ b/src/utils/proxy-helper.js @@ -1,5 +1,6 @@ const config = require('../config/index.js') const { HttpsProxyAgent } = require('https-proxy-agent') +const { ensureSocksBridge } = require('./socks-bridge') // Per-account agent cache keyed by `${proxyUrl}::${email}`. // LRU eviction when cache exceeds MAX_AGENT_CACHE_SIZE. @@ -68,16 +69,22 @@ const buildAgentCacheKey = (url, account) => { * Separate TCP pools per account even when sharing the same proxy. * @param {string|null} url * @param {Object} [account] - * @returns {HttpsProxyAgent|undefined} + * @returns {Promise} */ -const getOrCreateAgent = (url, account) => { +const getOrCreateAgent = async (url, account) => { if (!url) return undefined const key = buildAgentCacheKey(url, account) let agent = proxyAgents.get(key) if (!agent) { - agent = new HttpsProxyAgent(url) - proxyAgents.set(key, agent) - evictOldestAgent() + // socks5 goes through the loopback CONNECT bridge (Bun's fetch only speaks http(s) proxies). + const agentUrl = /^socks5:/i.test(url) ? await ensureSocksBridge(url) : url + // A concurrent caller may have created the agent while the bridge was starting. + agent = proxyAgents.get(key) + if (!agent) { + agent = new HttpsProxyAgent(agentUrl) + proxyAgents.set(key, agent) + evictOldestAgent() + } } else { // Move to end (most recently used) by deleting and re-inserting proxyAgents.delete(key) @@ -89,9 +96,9 @@ const getOrCreateAgent = (url, account) => { /** * Get proxy agent for an account. * @param {Object} [account] - Account object (optional). Falls back to global PROXY_URL - * @returns {HttpsProxyAgent|undefined} + * @returns {Promise} */ -const getProxyAgent = (account) => { +const getProxyAgent = async (account) => { return getOrCreateAgent(resolveProxyUrl(account), account) } @@ -133,10 +140,10 @@ const getCliBaseUrl = () => config.qwenCliProxyUrl * Note: account as second optional param for backward compatibility. * @param {Object} [requestConfig] * @param {Object} [account] - * @returns {Object} + * @returns {Promise} */ -const applyProxyToAxiosConfig = (requestConfig = {}, account) => { - const proxyAgent = getProxyAgent(account) +const applyProxyToAxiosConfig = async (requestConfig = {}, account) => { + const proxyAgent = await getProxyAgent(account) if (proxyAgent) { requestConfig.httpsAgent = proxyAgent requestConfig.proxy = false @@ -148,10 +155,10 @@ const applyProxyToAxiosConfig = (requestConfig = {}, account) => { * Apply proxy settings to fetch options. * @param {Object} [fetchOptions] * @param {Object} [account] - * @returns {Object} + * @returns {Promise} */ -const applyProxyToFetchOptions = (fetchOptions = {}, account) => { - const proxyAgent = getProxyAgent(account) +const applyProxyToFetchOptions = async (fetchOptions = {}, account) => { + const proxyAgent = await getProxyAgent(account) if (proxyAgent) { fetchOptions.agent = proxyAgent } diff --git a/src/utils/request.js b/src/utils/request.js index 821347b7..07ca8103 100644 --- a/src/utils/request.js +++ b/src/utils/request.js @@ -667,7 +667,7 @@ const sendChatRequest = async (body, options = {}) => { } const chatBaseUrl = getChatBaseUrl() - const proxyAgent = getProxyAgent(currentAccount) + const proxyAgent = await getProxyAgent(currentAccount) // Antidetect: per-account fingerprint headers replace static block const ssxmod = getSsxmodForAccount(currentAccount) @@ -843,7 +843,7 @@ const sendChatRequest = async (body, options = {}) => { const generateChatID = async (currentToken, model, account, chatType = 't2t') => { try { const chatBaseUrl = getChatBaseUrl() - const proxyAgent = getProxyAgent(account) + const proxyAgent = await getProxyAgent(account) // Antidetect: per-account fingerprint headers replace static block const ssxmod = getSsxmodForAccount(account) diff --git a/src/utils/socks-bridge.js b/src/utils/socks-bridge.js new file mode 100644 index 00000000..e01e62b6 --- /dev/null +++ b/src/utils/socks-bridge.js @@ -0,0 +1,122 @@ +const net = require('net') +const { SocksClient } = require('socks') + +// Bun's node:http forwards an Agent's `proxy` URL to its native fetch, which only speaks +// http(s) proxies and rejects socks5:// with UnsupportedProxyProtocol; a custom agent's +// createConnection is never called. So for socks5 URLs we run one loopback HTTP CONNECT +// proxy per socks URL and point HttpsProxyAgent at it. The bridge dials the real SOCKS5 +// server and pipes bytes both ways. Works the same under Node. +// 每个 socks URL 对应一个仅监听 127.0.0.1 的 CONNECT 桥;Bun 的 fetch 只支持 http(s) 代理 +const bridges = new Map() // socksUrl -> Promise ('http://127.0.0.1:') +const servers = new Map() // socksUrl -> net.Server +const MAX_HEADER_BYTES = 8 * 1024 +const DEFAULT_SOCKS_PORT = 1080 + +/** + * Map a socks5:// URL to `socks` client proxy options. + * @param {string} url + * @returns {{host: string, port: number, type: 5, userId?: string, password?: string}} + */ +const parseSocksUrl = (url) => { + const parsed = new URL(url) + const proxy = { + host: parsed.hostname.replace(/^\[|\]$/g, ''), + port: parsed.port ? Number(parsed.port) : DEFAULT_SOCKS_PORT, + type: 5 + } + if (parsed.username) proxy.userId = decodeURIComponent(parsed.username) + if (parsed.password) proxy.password = decodeURIComponent(parsed.password) + return proxy +} + +const parseConnectTarget = (target) => { + const match = /^\[([^\]]+)\]:(\d{1,5})$/.exec(target) || /^([^:\s]+):(\d{1,5})$/.exec(target) + if (!match) return null + const port = Number(match[2]) + if (port <= 0 || port >= 65536) return null + return { host: match[1], port } +} + +const reply = (socket, status) => { + socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`) +} + +const handleConnection = (proxy, socket) => { + let head = Buffer.alloc(0) + let clientClosed = false + socket.on('error', () => socket.destroy()) + socket.once('close', () => { clientClosed = true }) + + const onData = (chunk) => { + head = Buffer.concat([head, chunk]) + const headerEnd = head.indexOf('\r\n\r\n') + if (headerEnd === -1) { + if (head.length > MAX_HEADER_BYTES) reply(socket, '431 Request Header Fields Too Large') + return + } + socket.off('data', onData) + socket.pause() + + // Only the request line matters; Bun and https-proxy-agent both add Host/Proxy-Connection. + const requestLine = head.subarray(0, head.indexOf('\r\n')).toString('latin1') + const leftover = head.subarray(headerEnd + 4) + const match = /^(\S+) (\S+) HTTP\/1\.[01]$/.exec(requestLine) + if (!match) return reply(socket, '400 Bad Request') + if (match[1] !== 'CONNECT') return reply(socket, '405 Method Not Allowed') + const destination = parseConnectTarget(match[2]) + if (!destination) return reply(socket, '400 Bad Request') + + SocksClient.createConnection({ proxy, command: 'connect', destination }) + .then(({ socket: upstream }) => { + if (clientClosed) return upstream.destroy() + upstream.on('error', () => socket.destroy()) + upstream.once('close', () => socket.destroy()) + socket.once('close', () => upstream.destroy()) + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + if (leftover.length) upstream.write(leftover) + socket.pipe(upstream).pipe(socket) + }) + .catch(() => { + if (!clientClosed) reply(socket, '502 Bad Gateway') + }) + } + socket.on('data', onData) +} + +/** + * Start (once) the loopback CONNECT bridge for a socks5 URL. + * @param {string} socksUrl + * @returns {Promise} http://127.0.0.1: to hand to HttpsProxyAgent + */ +const ensureSocksBridge = (socksUrl) => { + const existing = bridges.get(socksUrl) + if (existing) return existing + const pending = new Promise((resolve, reject) => { + const proxy = parseSocksUrl(socksUrl) + const server = net.createServer((socket) => handleConnection(proxy, socket)) + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + server.unref() + servers.set(socksUrl, server) + resolve(`http://127.0.0.1:${server.address().port}`) + }) + }) + bridges.set(socksUrl, pending) + // A failed start must not be cached; the next caller retries. + pending.catch(() => bridges.delete(socksUrl)) + return pending +} + +/** Close every bridge server (tests / shutdown). */ +const closeSocksBridges = async () => { + const closing = [...servers.values()].map((server) => new Promise((resolve) => server.close(() => resolve()))) + servers.clear() + bridges.clear() + await Promise.all(closing) +} + +module.exports = { + parseSocksUrl, + ensureSocksBridge, + closeSocksBridges +} diff --git a/src/utils/token-manager.js b/src/utils/token-manager.js index 9b602158..42f74c94 100644 --- a/src/utils/token-manager.js +++ b/src/utils/token-manager.js @@ -33,7 +33,7 @@ class TokenManager { */ async login(email, password, account) { try { - const proxyAgent = getProxyAgent(account) + const proxyAgent = await getProxyAgent(account) // Use per-account fingerprint UA when available; fall back to legacy Edge UA const ua = (account && account.fingerprint) ? buildUserAgent(account.fingerprint) : this.defaultHeaders['User-Agent'] const requestConfig = { diff --git a/src/utils/upload.js b/src/utils/upload.js index 1699225f..3b8462f9 100644 --- a/src/utils/upload.js +++ b/src/utils/upload.js @@ -103,7 +103,7 @@ const requestStsToken = async (filename, filesize, filetypeSimple, authToken, re const requestId = generateUUID() const bearerToken = authToken.startsWith('Bearer ') ? authToken : `Bearer ${authToken}` - const proxyAgent = getProxyAgent(account) + const proxyAgent = await getProxyAgent(account) // Antidetect: per-account fingerprint headers replace static UA const baseHeaders = buildRequestHeaders(account, { @@ -369,7 +369,7 @@ const parseUploadedTextFile = async (fileId, authToken, account, options = {}) = if (!fileId || !authToken) throw new Error('解析文档缺少 fileId 或认证 Token') const baseUrl = getChatBaseUrl() - const requestConfig = applyProxyToAxiosConfig({ + const requestConfig = await applyProxyToAxiosConfig({ headers: createAuthorizedHeaders(authToken, account), timeout: Math.max(1000, Number(options.timeoutMs) || 30000) }, account) diff --git a/tests/expected-counts.json b/tests/expected-counts.json index 9fd75782..4931948e 100644 --- a/tests/expected-counts.json +++ b/tests/expected-counts.json @@ -1,5 +1,5 @@ { - "tests": 1108, + "tests": 1116, "suites": 128, "note": "Authoritative count. Verify with the per-file sum in AGENTS.md (\"The test gate\"). The -a on that grep is load-bearing: tool-prompt.test.js emits bytes that make grep call the stream binary, and without -a its whole summary line — 133 tests — is silently dropped from the sum.", "updated": "2026-09-11" diff --git a/tests/socks-bridge.test.js b/tests/socks-bridge.test.js new file mode 100644 index 00000000..eb8a636c --- /dev/null +++ b/tests/socks-bridge.test.js @@ -0,0 +1,172 @@ +// Loopback CONNECT -> SOCKS5 bridge (src/utils/socks-bridge.js) and its wiring in proxy-helper. +// Bun's fetch only accepts http(s) proxies, so socks5 account proxies are reached through the bridge. +const test = require('node:test') +const assert = require('node:assert/strict') +const net = require('node:net') +const { HttpsProxyAgent } = require('https-proxy-agent') + +const config = require('../src/config/index.js') +config.proxyUrl = null // no global PROXY_URL leaking in from the environment +const { parseSocksUrl, ensureSocksBridge, closeSocksBridges } = require('../src/utils/socks-bridge.js') +const { getProxyAgent } = require('../src/utils/proxy-helper.js') + +const openSockets = new Set() +const track = (socket) => { + openSockets.add(socket) + socket.once('close', () => openSockets.delete(socket)) + return socket +} +const listen = (server) => new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port))) +const closeServer = (server) => new Promise((resolve) => server.close(() => resolve())) +const onceData = (socket) => new Promise((resolve) => socket.once('data', resolve)) +const onceClose = (socket) => new Promise((resolve) => (socket.closed ? resolve() : socket.once('close', resolve))) + +// Echo target behind the fake SOCKS server. +const startTarget = async () => { + const server = net.createServer((socket) => { track(socket).pipe(socket) }) + return { server, port: await listen(server) } +} + +// Minimal SOCKS5 server, no auth. Records each CONNECT destination; `fail` answers "connection refused". +const startFakeSocks = async ({ targetPort, fail = false }) => { + const requests = [] + const server = net.createServer((socket) => { + track(socket) + socket.on('error', () => {}) + let stage = 'greeting' + socket.on('data', (chunk) => { + if (stage === 'greeting') { + stage = 'request' + socket.write(Buffer.from([0x05, 0x00])) + return + } + if (stage !== 'request') return + stage = 'relay' + const atyp = chunk[3] + let host + let offset + if (atyp === 0x01) { + host = Array.from(chunk.subarray(4, 8)).join('.') + offset = 8 + } else if (atyp === 0x03) { + host = chunk.subarray(5, 5 + chunk[4]).toString() + offset = 5 + chunk[4] + } else { + host = chunk.subarray(4, 20).toString('hex') + offset = 20 + } + requests.push({ atyp, host, port: chunk.readUInt16BE(offset) }) + if (fail) { + socket.end(Buffer.from([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + return + } + const upstream = track(net.connect(targetPort, '127.0.0.1', () => { + socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + socket.pipe(upstream).pipe(socket) + })) + upstream.on('error', () => socket.destroy()) + }) + }) + return { server, port: await listen(server), requests } +} + +// Raw client: send `text` to the bridge, resolve with the first response chunk. +const rawConnect = async (bridgeUrl, text) => { + const { hostname, port } = new URL(bridgeUrl) + const socket = track(net.connect(Number(port), hostname)) + socket.on('error', () => {}) + await new Promise((resolve) => socket.once('connect', resolve)) + socket.write(text) + const response = (await onceData(socket)).toString('latin1') + return { socket, response } +} + +let target +let socks +let socksFail +let socksUrl +let socksFailUrl +let bridgeUrl +let bridgeFailUrl + +test.before(async () => { + target = await startTarget() + socks = await startFakeSocks({ targetPort: target.port }) + socksFail = await startFakeSocks({ targetPort: target.port, fail: true }) + socksUrl = `socks5://127.0.0.1:${socks.port}` + socksFailUrl = `socks5://127.0.0.1:${socksFail.port}` + bridgeUrl = await ensureSocksBridge(socksUrl) + bridgeFailUrl = await ensureSocksBridge(socksFailUrl) +}) + +test.after(async () => { + for (const socket of openSockets) socket.destroy() + await closeSocksBridges() + await Promise.all([target, socks, socksFail].map((fixture) => closeServer(fixture.server))) +}) + +test('parseSocksUrl maps socks5:// URLs to socks client options', () => { + assert.deepEqual(parseSocksUrl('socks5://proxy.test:1081'), { host: 'proxy.test', port: 1081, type: 5 }) + assert.deepEqual(parseSocksUrl('socks5://proxy.test'), { host: 'proxy.test', port: 1080, type: 5 }) + assert.deepEqual(parseSocksUrl('socks5://u:p%40s@[::1]:1080'), + { host: '::1', port: 1080, type: 5, userId: 'u', password: 'p@s' }) +}) + +test('CONNECT to a hostname tunnels through SOCKS5 and relays bytes both ways', async () => { + const { socket, response } = await rawConnect(bridgeUrl, + 'CONNECT example.test:8443 HTTP/1.1\r\nHost: example.test:8443\r\nProxy-Connection: Keep-Alive\r\n\r\n') + assert.match(response, /^HTTP\/1\.1 200 /) + assert.deepEqual(socks.requests.at(-1), { atyp: 3, host: 'example.test', port: 8443 }) + socket.write('ping') + assert.equal((await onceData(socket)).toString(), 'ping') + socket.destroy() +}) + +test('CONNECT to an IPv6 literal strips brackets and dials ATYP 4', async () => { + const { socket, response } = await rawConnect(bridgeUrl, 'CONNECT [::1]:443 HTTP/1.1\r\n\r\n') + assert.match(response, /^HTTP\/1\.1 200 /) + assert.deepEqual(socks.requests.at(-1), { atyp: 4, host: '0'.repeat(31) + '1', port: 443 }) + socket.destroy() +}) + +test('a SOCKS5 failure answers 502 and closes', async () => { + const { socket, response } = await rawConnect(bridgeFailUrl, 'CONNECT example.test:443 HTTP/1.1\r\n\r\n') + assert.match(response, /^HTTP\/1\.1 502 /) + await onceClose(socket) +}) + +test('non-CONNECT requests answer 405', async () => { + const { socket, response } = await rawConnect(bridgeUrl, 'GET / HTTP/1.1\r\nHost: example.test\r\n\r\n') + assert.match(response, /^HTTP\/1\.1 405 /) + await onceClose(socket) +}) + +test('oversized headers answer 431', async () => { + const { socket, response } = await rawConnect(bridgeUrl, 'A'.repeat(9 * 1024)) + assert.match(response, /^HTTP\/1\.1 431 /) + await onceClose(socket) +}) + +test('getProxyAgent routes socks5 through one bridge per URL, one agent per account', async () => { + const [agentA, agentAConcurrent] = await Promise.all([ + getProxyAgent({ email: 'a@test', proxy: socksUrl }), + getProxyAgent({ email: 'a@test', proxy: socksUrl }) + ]) + assert.ok(agentA instanceof HttpsProxyAgent) + assert.equal(agentA.proxy.href, `${bridgeUrl}/`) + assert.equal(agentAConcurrent, agentA) + const agentB = await getProxyAgent({ email: 'b@test', proxy: socksUrl }) + assert.notEqual(agentB, agentA) + assert.equal(agentB.proxy.href, agentA.proxy.href) + const agentOther = await getProxyAgent({ email: 'a@test', proxy: socksFailUrl }) + assert.equal(agentOther.proxy.href, `${bridgeFailUrl}/`) + assert.notEqual(agentOther.proxy.port, agentA.proxy.port) +}) + +test('http proxies are used as-is and no proxy resolves to undefined', async () => { + const agent = await getProxyAgent({ email: 'c@test', proxy: 'http://127.0.0.1:9' }) + assert.ok(agent instanceof HttpsProxyAgent) + assert.equal(agent.proxy.href, 'http://127.0.0.1:9/') + assert.equal(await getProxyAgent({ email: 'd@test' }), undefined) + assert.equal(await getProxyAgent(undefined), undefined) +})