'use strict'; const http = require('http'); const https = require('https'); const http2 = require('http2'); const net = require('net'); // ─── 配置 ─────────────────────────────────────────────── const UPSTREAM_HOST = process.env.UPSTREAM_HOST || 'api.anthropic.com'; const LISTEN_PORT = parseInt(process.env.PROXY_PORT || '3456', 10); const LISTEN_HOST = process.env.PROXY_HOST || '127.0.0.1'; const UPSTREAM_PROXY = process.env.UPSTREAM_PROXY || ''; const CONNECT_TIMEOUT = parseInt(process.env.CONNECT_TIMEOUT || '30000', 10); const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '600000', 10); const log = (level, msg, extra = {}) => { const entry = { time: new Date().toISOString(), level, msg, ...extra }; process.stderr.write(JSON.stringify(entry) + '\n'); }; const HEALTH_PATH = '/__health'; const h2Hosts = new Set(); // 已知需要 H2 的主机 const h2Sessions = new Map(); // H2 session 缓存 // ─── H2 session 管理 ──────────────────────────────────── function getOrCreateH2Session(host) { const existing = h2Sessions.get(host); if (existing && !existing.closed && !existing.destroyed) return existing; if (existing) { try { existing.close(); } catch (_) {} } const session = http2.connect(`https://${host}`); session.on('error', (err) => { log('warn', 'h2_session_error', { host, error: err.message }); h2Sessions.delete(host); try { session.close(); } catch (_) {} }); session.on('close', () => h2Sessions.delete(host)); session.on('goaway', () => { h2Sessions.delete(host); try { session.close(); } catch (_) {} }); session.setTimeout(IDLE_TIMEOUT, () => { session.close(); h2Sessions.delete(host); }); h2Sessions.set(host, session); return session; } function waitForConnect(session) { if (session.connected) return Promise.resolve(); return new Promise((resolve, reject) => { session.once('connect', resolve); session.once('error', reject); const t = setTimeout(() => reject(new Error('h2 connect timeout')), CONNECT_TIMEOUT); session.once('connect', () => clearTimeout(t)); }); } // ─── CONNECT 隧道 ──────────────────────────────────────── function connectViaProxy(proxyUrl, targetHost, targetPort) { return new Promise((resolve, reject) => { const proxy = new URL(proxyUrl); const conn = net.connect(parseInt(proxy.port || '80', 10), proxy.hostname, () => { const auth = proxy.username ? `Proxy-Authorization: Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password || '')}`).toString('base64')}\r\n` : ''; conn.write(`CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${auth}\r\n`); }); conn.once('error', reject); conn.setTimeout(CONNECT_TIMEOUT, () => conn.destroy(new Error('CONNECT timeout'))); let buf = ''; conn.on('data', function onData(chunk) { buf += chunk.toString(); const idx = buf.indexOf('\r\n\r\n'); if (idx === -1) return; conn.removeListener('data', onData); const code = parseInt(buf.split(' ')[1], 10); if (code === 200) { conn.setTimeout(0); resolve(conn); } else { conn.destroy(); reject(new Error(`CONNECT ${code}`)); } }); }); } // ─── 收集请求体 ────────────────────────────────────────── function collectBody(req) { return new Promise((resolve) => { const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', () => resolve(Buffer.concat(chunks))); }); } // ─── H1 代理 ───────────────────────────────────────────── function sendViaH1(targetHost, method, path, reqHeaders, body, res) { return new Promise((resolve) => { const headers = { ...reqHeaders, host: targetHost }; ['x-forwarded-host', 'connection', 'keep-alive', 'proxy-connection', 'transfer-encoding'].forEach(h => delete headers[h]); if (body.length > 0) headers['content-length'] = String(body.length); const opts = { hostname: targetHost, port: 443, path, method, headers, servername: targetHost, timeout: CONNECT_TIMEOUT }; const startTime = Date.now(); const finish = (requestOpts) => { const proxyReq = https.request(requestOpts); proxyReq.on('response', (proxyRes) => { log('info', 'proxy_response', { host: targetHost, status: proxyRes.statusCode, path, proto: 'h1' }); const rh = { ...proxyRes.headers }; delete rh['connection']; delete rh['keep-alive']; res.writeHead(proxyRes.statusCode, rh); proxyRes.pipe(res, { end: true }); resolve('ok'); }); proxyReq.on('error', (err) => { if (err.message === 'socket hang up' && (Date.now() - startTime) < 2000) { log('info', 'h1_rejected_switching_to_h2', { host: targetHost }); h2Hosts.add(targetHost); sendViaH2(targetHost, method, path, reqHeaders, body, res).then(() => resolve('h2')); return; } log('error', 'h1_error', { error: err.message, host: targetHost, path }); if (!res.headersSent) { res.writeHead(502); res.end(JSON.stringify({ error: err.message })); } resolve('error'); }); proxyReq.on('timeout', () => proxyReq.destroy(new Error('timeout'))); proxyReq.end(body); }; if (UPSTREAM_PROXY) { connectViaProxy(UPSTREAM_PROXY, targetHost, 443) .then((socket) => { opts.socket = socket; opts.agent = false; finish(opts); }) .catch((err) => { log('error', 'tunnel_failed', { error: err.message }); if (!res.headersSent) { res.writeHead(502); res.end('tunnel error'); } resolve('error'); }); } else { finish(opts); } }); } // ─── H2 代理 ───────────────────────────────────────────── async function sendViaH2(targetHost, method, path, reqHeaders, body, res) { try { const session = getOrCreateH2Session(targetHost); await waitForConnect(session); const headers = {}; const skip = new Set(['host','connection','keep-alive','proxy-connection','transfer-encoding','upgrade','x-forwarded-host','http2-settings']); for (const [k, v] of Object.entries(reqHeaders)) { if (!skip.has(k.toLowerCase())) headers[k] = v; } headers[':method'] = method; headers[':path'] = path; headers[':authority'] = targetHost; headers[':scheme'] = 'https'; if (body.length > 0) headers['content-length'] = String(body.length); const stream = session.request(headers); let responded = false; stream.on('response', (h2h) => { responded = true; const status = h2h[':status'] || 502; const rh = {}; for (const [k, v] of Object.entries(h2h)) { if (!k.startsWith(':')) rh[k] = v; } log('info', 'proxy_response', { host: targetHost, status, path, proto: 'h2' }); res.writeHead(status, rh); stream.on('data', (c) => res.write(c)); stream.on('end', () => res.end()); }); stream.on('error', (err) => { if (err.message && err.message.includes('NGHTTP2')) { h2Sessions.delete(targetHost); try { session.close(); } catch (_) {} } if (responded) { if (!res.writableEnded) res.end(); return; } log('error', 'h2_error', { error: err.message, host: targetHost, path }); if (!res.headersSent) { res.writeHead(502); res.end(JSON.stringify({ error: err.message })); } }); stream.on('close', () => { if (!responded && !res.headersSent) { log('warn', 'h2_no_response', { host: targetHost, path }); res.writeHead(502); res.end('{"error":"h2_no_response"}'); } else if (!res.writableEnded) { res.end(); } }); stream.setTimeout(CONNECT_TIMEOUT, () => stream.close()); stream.end(body); } catch (err) { log('error', 'h2_exception', { error: err.message, host: targetHost }); h2Sessions.delete(targetHost); if (!res.headersSent) { res.writeHead(502); res.end(JSON.stringify({ error: err.message })); } } } // ─── 请求入口 ───────────────────────────────────────────── async function proxyRequest(req, res) { const targetHost = req.headers['x-forwarded-host'] || UPSTREAM_HOST; log('info', 'proxy_request', { host: targetHost, method: req.method, path: req.url }); const body = await collectBody(req); if (h2Hosts.has(targetHost)) { await sendViaH2(targetHost, req.method, req.url, req.headers, body, res); } else { await sendViaH1(targetHost, req.method, req.url, req.headers, body, res); } } // ─── HTTP 服务器 ───────────────────────────────────────── const server = http.createServer((req, res) => { if (req.url === HEALTH_PATH) { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', node: process.version, openssl: process.versions.openssl, uptime: process.uptime(), h2Hosts: [...h2Hosts] })); return; } proxyRequest(req, res).catch((err) => { log('error', 'unhandled', { error: err.message }); if (!res.headersSent) { res.writeHead(500); res.end('internal error'); } }); }); server.timeout = 0; server.keepAliveTimeout = IDLE_TIMEOUT; server.headersTimeout = 60000; server.listen(LISTEN_PORT, LISTEN_HOST, () => { log('info', 'node-tls-proxy started', { listen: `${LISTEN_HOST}:${LISTEN_PORT}`, node: process.version, openssl: process.versions.openssl }); }); let stopping = false; function shutdown(sig) { if (stopping) return; stopping = true; for (const s of h2Sessions.values()) try { s.close(); } catch (_) {} h2Sessions.clear(); server.close(() => process.exit(0)); setTimeout(() => process.exit(1), 5000); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); process.on('uncaughtException', (e) => log('error', 'uncaught', { error: e.message })); process.on('unhandledRejection', (r) => log('error', 'rejection', { error: String(r) }));