From 37a1b404e93ae8d3bc1644e33d398ab1929fdf22 Mon Sep 17 00:00:00 2001 From: win Date: Sun, 22 Mar 2026 02:06:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=99=BA=E8=83=BD=20H1/H2=20=E8=87=AA?= =?UTF-8?q?=E9=80=82=E5=BA=94=20=E2=80=94=20=E9=A6=96=E6=AC=A1=20H1=20?= =?UTF-8?q?=E7=A7=92=E6=8C=82=E8=87=AA=E5=8A=A8=E5=88=87=20H2=20=E5=B9=B6?= =?UTF-8?q?=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 首次请求走 HTTP/1.1,如果 socket hang up < 2s 自动切 HTTP/2 - H2 主机缓存在内存中,后续请求直接走 H2(如 googleapis.com) - H2 session 池复用 + 空闲超时自动清理 - 详细日志:proxy_request → proxy_response/error,含协议标识 - 解决 googleapis.com 强制 H2 导致请求失败的问题 --- tools/node-tls-proxy/proxy.js | 313 +++++++++++++++++++++------------- 1 file changed, 197 insertions(+), 116 deletions(-) diff --git a/tools/node-tls-proxy/proxy.js b/tools/node-tls-proxy/proxy.js index 79d29740..2ea4e266 100644 --- a/tools/node-tls-proxy/proxy.js +++ b/tools/node-tls-proxy/proxy.js @@ -2,6 +2,7 @@ const http = require('http'); const https = require('https'); +const http2 = require('http2'); const net = require('net'); // ─── 配置 ─────────────────────────────────────────────── @@ -12,7 +13,6 @@ 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'); @@ -20,149 +20,237 @@ const log = (level, msg, extra = {}) => { const HEALTH_PATH = '/__health'; -// ─── 通过 HTTP 代理建立 CONNECT 隧道 ────────────────────── +// ─── 协议缓存:记录哪些主机需要 H2 ────────────────────── +// 首次请求用 H1,如果秒挂(socket hang up < 2s)自动切 H2 并缓存 +const h2Hosts = new Set(); + +// ─── H2 会话池 ────────────────────────────────────────── +const h2Sessions = new Map(); + +function getH2Session(host) { + const existing = h2Sessions.get(host); + if (existing && !existing.closed && !existing.destroyed) return existing; + + const session = http2.connect(`https://${host}`); + session.on('error', (err) => { + log('warn', 'h2_session_error', { host, error: err.message }); + h2Sessions.delete(host); + }); + session.on('close', () => h2Sessions.delete(host)); + session.setTimeout(IDLE_TIMEOUT, () => { + session.close(); + h2Sessions.delete(host); + }); + h2Sessions.set(host, session); + return session; +} + +// ─── CONNECT 隧道 ──────────────────────────────────────── function connectViaProxy(proxyUrl, targetHost, targetPort) { return new Promise((resolve, reject) => { const proxy = new URL(proxyUrl); - const proxyPort = parseInt(proxy.port || '80', 10); - - const conn = net.connect(proxyPort, proxy.hostname, () => { - const connectReq = - `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n` + - `Host: ${targetHost}:${targetPort}\r\n`; - + 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(connectReq + auth + '\r\n'); + conn.write( + `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n` + + `Host: ${targetHost}:${targetPort}\r\n` + auth + '\r\n' + ); }); - conn.once('error', reject); - conn.setTimeout(CONNECT_TIMEOUT, () => { - conn.destroy(new Error('proxy CONNECT timeout')); - }); + conn.setTimeout(CONNECT_TIMEOUT, () => conn.destroy(new Error('proxy CONNECT timeout'))); let buf = ''; const onData = (chunk) => { buf += chunk.toString(); const idx = buf.indexOf('\r\n\r\n'); if (idx === -1) return; - conn.removeListener('data', onData); - const statusLine = buf.slice(0, buf.indexOf('\r\n')); - const statusCode = parseInt(statusLine.split(' ')[1], 10); - - if (statusCode === 200) { + const code = parseInt(buf.split(' ')[1], 10); + if (code === 200) { conn.setTimeout(0); - const remainder = buf.slice(idx + 4); - if (remainder.length > 0) { - conn.unshift(Buffer.from(remainder)); - } + const rest = buf.slice(idx + 4); + if (rest.length > 0) conn.unshift(Buffer.from(rest)); resolve(conn); } else { conn.destroy(); - reject(new Error(`proxy CONNECT failed: ${statusLine}`)); + reject(new Error(`CONNECT failed: ${code}`)); } }; conn.on('data', onData); }); } -// ─── 代理请求 ─────────────────────────────────────────── -async function proxyRequest(req, res) { - // 动态确定上游主机 - const targetHost = req.headers['x-forwarded-host'] || UPSTREAM_HOST; +// ─── H1 代理 ───────────────────────────────────────────── +function proxyViaH1(targetHost, req, res) { + return new Promise((resolve) => { + const headers = { ...req.headers }; + headers.host = targetHost; + delete headers['x-forwarded-host']; + delete headers['connection']; + delete headers['keep-alive']; + delete headers['proxy-connection']; + delete headers['transfer-encoding']; - log('info', 'proxy_request', { host: targetHost, method: req.method, path: req.url }); + const opts = { + hostname: targetHost, port: 443, path: req.url, + method: req.method, headers, servername: targetHost, + timeout: CONNECT_TIMEOUT, + }; - // 构建上游请求头 - const headers = { ...req.headers }; - headers.host = targetHost; - delete headers['x-forwarded-host']; - delete headers['connection']; - delete headers['keep-alive']; - delete headers['proxy-connection']; - delete headers['transfer-encoding']; + const startTime = Date.now(); + let proxyReq; - const opts = { - hostname: targetHost, - port: 443, - path: req.url, - method: req.method, - headers, - servername: targetHost, - timeout: CONNECT_TIMEOUT, - // 不设置任何自定义 TLS 选项 → Node.js 默认 TLS stack → JA3/JA4 天然匹配 - }; + const doRequest = (requestOpts) => { + proxyReq = https.request(requestOpts); - let proxyReq; + proxyReq.on('response', (proxyRes) => { + log('info', 'proxy_response', { host: targetHost, status: proxyRes.statusCode, path: req.url, proto: 'h1' }); + const rh = { ...proxyRes.headers }; + delete rh['connection']; + delete rh['keep-alive']; + res.writeHead(proxyRes.statusCode, rh); + proxyRes.pipe(res, { end: true }); + proxyRes.on('error', (e) => { log('error', 'h1_response_error', { error: e.message }); res.end(); }); + resolve('ok'); + }); - if (UPSTREAM_PROXY) { - try { - const socket = await connectViaProxy(UPSTREAM_PROXY, targetHost, 443); - opts.socket = socket; - opts.agent = false; - proxyReq = https.request(opts); - } catch (err) { - log('error', 'proxy tunnel failed', { error: err.message, host: targetHost }); - if (!res.headersSent) { - res.writeHead(502, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ error: 'proxy_tunnel_error', message: err.message })); - } - return; + proxyReq.on('error', (err) => { + const elapsed = Date.now() - startTime; + // socket hang up < 2 秒 = 服务器拒绝 H1,切换到 H2 + if (err.message === 'socket hang up' && elapsed < 2000) { + log('info', 'h1_rejected_switching_to_h2', { host: targetHost, elapsed }); + h2Hosts.add(targetHost); + proxyViaH2(targetHost, req, res); + resolve('h2_fallback'); + return; + } + log('error', 'h1_upstream_error', { error: err.message, host: targetHost, path: req.url }); + if (!res.headersSent) { + res.writeHead(502, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'upstream_error', message: err.message })); + } + resolve('error'); + }); + + proxyReq.on('timeout', () => proxyReq.destroy(new Error('upstream timeout'))); + req.on('close', () => { if (!proxyReq.destroyed) proxyReq.destroy(); }); + req.pipe(proxyReq, { end: true }); + }; + + if (UPSTREAM_PROXY) { + connectViaProxy(UPSTREAM_PROXY, targetHost, 443) + .then((socket) => { opts.socket = socket; opts.agent = false; doRequest(opts); }) + .catch((err) => { + log('error', 'proxy_tunnel_failed', { error: err.message, host: targetHost }); + if (!res.headersSent) { + res.writeHead(502, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'proxy_tunnel_error' })); + } + resolve('error'); + }); + } else { + doRequest(opts); } - } else { - proxyReq = https.request(opts); - } - - // 上游响应 - proxyReq.on('response', (proxyRes) => { - const responseHeaders = { ...proxyRes.headers }; - delete responseHeaders['connection']; - delete responseHeaders['keep-alive']; - - log('info', 'proxy_response', { - host: targetHost, - status: proxyRes.statusCode, - path: req.url, - }); - - res.writeHead(proxyRes.statusCode, responseHeaders); - proxyRes.pipe(res, { end: true }); - - proxyRes.on('error', (err) => { - log('error', 'upstream response error', { error: err.message, host: targetHost }); - res.end(); - }); }); +} - // 上游连接错误 - proxyReq.on('error', (err) => { - log('error', 'upstream request error', { - error: err.message, - host: targetHost, - path: req.url, - method: req.method, +// ─── H2 代理 ───────────────────────────────────────────── +function proxyViaH2(targetHost, req, res) { + try { + const session = getH2Session(targetHost); + + const headers = {}; + // 只拷贝合法的 H2 头(跳过 H1 专用头和连接头) + const skipHeaders = new Set([ + 'host', 'connection', 'keep-alive', 'proxy-connection', + 'transfer-encoding', 'upgrade', 'x-forwarded-host', + 'http2-settings', + ]); + for (const [k, v] of Object.entries(req.headers)) { + if (!skipHeaders.has(k.toLowerCase())) { + headers[k] = v; + } + } + + // H2 伪头 + headers[':method'] = req.method; + headers[':path'] = req.url; + headers[':authority'] = targetHost; + headers[':scheme'] = 'https'; + + const h2Stream = session.request(headers); + + let responded = false; + + h2Stream.on('response', (h2Headers) => { + responded = true; + const status = h2Headers[':status'] || 502; + const respHeaders = {}; + for (const [k, v] of Object.entries(h2Headers)) { + if (!k.startsWith(':')) respHeaders[k] = v; + } + log('info', 'proxy_response', { host: targetHost, status, path: req.url, proto: 'h2' }); + res.writeHead(status, respHeaders); + h2Stream.pipe(res, { end: true }); }); + + h2Stream.on('error', (err) => { + log('error', 'h2_stream_error', { error: err.message, host: targetHost, path: req.url }); + h2Sessions.delete(targetHost); // 清理坏 session + if (!responded && !res.headersSent) { + res.writeHead(502, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'h2_error', message: err.message })); + } + }); + + h2Stream.on('close', () => { + if (!responded && !res.headersSent) { + log('warn', 'h2_stream_closed_no_response', { host: targetHost, path: req.url }); + res.writeHead(502, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'h2_no_response' })); + } + }); + + // 超时 + h2Stream.setTimeout(CONNECT_TIMEOUT, () => { + log('warn', 'h2_timeout', { host: targetHost, path: req.url }); + h2Stream.close(); + }); + + req.on('close', () => { + if (!h2Stream.destroyed) h2Stream.close(); + }); + + // pipe 请求体 + req.pipe(h2Stream, { end: true }); + + } catch (err) { + log('error', 'h2_proxy_exception', { error: err.message, host: targetHost }); + h2Sessions.delete(targetHost); if (!res.headersSent) { res.writeHead(502, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ error: 'upstream_error', message: err.message })); + res.end(JSON.stringify({ error: 'h2_exception', message: err.message })); } - }); + } +} - proxyReq.on('timeout', () => { - log('warn', 'upstream request timeout', { host: targetHost, path: req.url }); - proxyReq.destroy(new Error('upstream timeout')); - }); +// ─── 请求入口 ───────────────────────────────────────────── +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 }); - req.on('close', () => { - if (!proxyReq.destroyed) proxyReq.destroy(); - }); + // 已知需要 H2 的主机直接走 H2 + if (h2Hosts.has(targetHost)) { + proxyViaH2(targetHost, req, res); + return; + } - req.pipe(proxyReq, { end: true }); + // 首次请求走 H1,如果秒挂自动切 H2 + await proxyViaH1(targetHost, req, res); } // ─── HTTP 服务器 ───────────────────────────────────────── @@ -175,12 +263,12 @@ const server = http.createServer((req, res) => { node: process.version, openssl: process.versions.openssl, uptime: process.uptime(), + h2Hosts: [...h2Hosts], })); return; } - proxyRequest(req, res).catch((err) => { - log('error', 'unhandled proxy error', { error: err.message }); + log('error', 'unhandled_error', { error: err.message }); if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal_error' })); @@ -202,24 +290,17 @@ server.listen(LISTEN_PORT, LISTEN_HOST, () => { }); }); -// ─── 优雅关闭 ───────────────────────────────────────────── let shuttingDown = false; function shutdown(signal) { if (shuttingDown) return; shuttingDown = true; log('info', `received ${signal}, shutting down`); - server.close(() => { - log('info', 'server closed'); - process.exit(0); - }); + for (const s of h2Sessions.values()) s.close(); + 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', (err) => { - log('error', 'uncaught exception', { error: err.message, stack: err.stack }); -}); -process.on('unhandledRejection', (reason) => { - log('error', 'unhandled rejection', { error: String(reason) }); -}); +process.on('uncaughtException', (err) => log('error', 'uncaught', { error: err.message, stack: err.stack })); +process.on('unhandledRejection', (r) => log('error', 'unhandled_rejection', { error: String(r) }));