feat: Node.js TLS 代理支持 HTTP/2 + 动态主机路由
- proxy.js: 自动探测上游 ALPN (h2/http1.1),按需选择协议 - proxy.js: X-Forwarded-Host 动态路由,支持任意上游主机 - proxy.js: HTTP/2 session 缓存 + 空闲超时自动清理 - Go: 所有 HTTPS 上游请求统一走 Node.js 代理,无域名白名单 - 解决 googleapis.com 要求 HTTP/2 导致 socket hang up
This commit is contained in:
parent
0086cfdfe8
commit
4f82ce23a9
@ -2,33 +2,54 @@
|
|||||||
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
|
const http2 = require('http2');
|
||||||
|
const tls = require('tls');
|
||||||
const net = require('net');
|
const net = require('net');
|
||||||
|
|
||||||
// ─── 配置 ───────────────────────────────────────────────
|
// ─── 配置 ───────────────────────────────────────────────
|
||||||
const UPSTREAM_HOST = process.env.UPSTREAM_HOST || 'api.anthropic.com';
|
const UPSTREAM_HOST = process.env.UPSTREAM_HOST || 'api.anthropic.com';
|
||||||
const LISTEN_PORT = parseInt(process.env.PROXY_PORT || '3456', 10);
|
const LISTEN_PORT = parseInt(process.env.PROXY_PORT || '3456', 10);
|
||||||
const LISTEN_HOST = process.env.PROXY_HOST || '127.0.0.1';
|
const LISTEN_HOST = process.env.PROXY_HOST || '127.0.0.1';
|
||||||
// 可选:上游 HTTPS 代理(仅支持 HTTP CONNECT 隧道)
|
|
||||||
const UPSTREAM_PROXY = process.env.UPSTREAM_PROXY || '';
|
const UPSTREAM_PROXY = process.env.UPSTREAM_PROXY || '';
|
||||||
// 连接超时(ms)
|
|
||||||
const CONNECT_TIMEOUT = parseInt(process.env.CONNECT_TIMEOUT || '30000', 10);
|
const CONNECT_TIMEOUT = parseInt(process.env.CONNECT_TIMEOUT || '30000', 10);
|
||||||
// 空闲超时(ms)— 对 SSE 长连接要足够大
|
|
||||||
const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '600000', 10);
|
const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '600000', 10);
|
||||||
|
|
||||||
// ─── 日志 ───────────────────────────────────────────────
|
// ─── 日志 ───────────────────────────────────────────────
|
||||||
const log = (level, msg, extra = {}) => {
|
const log = (level, msg, extra = {}) => {
|
||||||
const entry = {
|
const entry = { time: new Date().toISOString(), level, msg, ...extra };
|
||||||
time: new Date().toISOString(),
|
|
||||||
level,
|
|
||||||
msg,
|
|
||||||
...extra,
|
|
||||||
};
|
|
||||||
process.stderr.write(JSON.stringify(entry) + '\n');
|
process.stderr.write(JSON.stringify(entry) + '\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── 健康检查路径 ─────────────────────────────────────────
|
|
||||||
const HEALTH_PATH = '/__health';
|
const HEALTH_PATH = '/__health';
|
||||||
|
|
||||||
|
// ─── HTTP/2 会话缓存 ─────────────────────────────────────
|
||||||
|
// 按 host 缓存 h2 session,避免每个请求都建新连接
|
||||||
|
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}`, {
|
||||||
|
// 不设置自定义 TLS 选项 → 用 Node.js 默认 TLS stack
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 通过 HTTP 代理建立 CONNECT 隧道 ──────────────────────
|
// ─── 通过 HTTP 代理建立 CONNECT 隧道 ──────────────────────
|
||||||
function connectViaProxy(proxyUrl, targetHost, targetPort) {
|
function connectViaProxy(proxyUrl, targetHost, targetPort) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@ -40,7 +61,6 @@ function connectViaProxy(proxyUrl, targetHost, targetPort) {
|
|||||||
`CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n` +
|
`CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n` +
|
||||||
`Host: ${targetHost}:${targetPort}\r\n`;
|
`Host: ${targetHost}:${targetPort}\r\n`;
|
||||||
|
|
||||||
// 代理认证
|
|
||||||
const auth = proxy.username
|
const auth = proxy.username
|
||||||
? `Proxy-Authorization: Basic ${Buffer.from(
|
? `Proxy-Authorization: Basic ${Buffer.from(
|
||||||
`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password || '')}`
|
`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password || '')}`
|
||||||
@ -55,20 +75,18 @@ function connectViaProxy(proxyUrl, targetHost, targetPort) {
|
|||||||
conn.destroy(new Error('proxy CONNECT timeout'));
|
conn.destroy(new Error('proxy CONNECT timeout'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 读取代理响应
|
|
||||||
let buf = '';
|
let buf = '';
|
||||||
const onData = (chunk) => {
|
const onData = (chunk) => {
|
||||||
buf += chunk.toString();
|
buf += chunk.toString();
|
||||||
const idx = buf.indexOf('\r\n\r\n');
|
const idx = buf.indexOf('\r\n\r\n');
|
||||||
if (idx === -1) return; // 头还没完整
|
if (idx === -1) return;
|
||||||
|
|
||||||
conn.removeListener('data', onData);
|
conn.removeListener('data', onData);
|
||||||
const statusLine = buf.slice(0, buf.indexOf('\r\n'));
|
const statusLine = buf.slice(0, buf.indexOf('\r\n'));
|
||||||
const statusCode = parseInt(statusLine.split(' ')[1], 10);
|
const statusCode = parseInt(statusLine.split(' ')[1], 10);
|
||||||
|
|
||||||
if (statusCode === 200) {
|
if (statusCode === 200) {
|
||||||
conn.setTimeout(0); // 清除超时
|
conn.setTimeout(0);
|
||||||
// 如果头之后有残余数据,先 unshift 回去
|
|
||||||
const remainder = buf.slice(idx + 4);
|
const remainder = buf.slice(idx + 4);
|
||||||
if (remainder.length > 0) {
|
if (remainder.length > 0) {
|
||||||
conn.unshift(Buffer.from(remainder));
|
conn.unshift(Buffer.from(remainder));
|
||||||
@ -83,49 +101,122 @@ function connectViaProxy(proxyUrl, targetHost, targetPort) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 构建上游请求选项 ──────────────────────────────────────
|
// ─── TLS + ALPN 探测:判断上游支持 h2 还是 http/1.1 ─────────
|
||||||
function buildUpstreamOptions(req) {
|
const alpnCache = new Map();
|
||||||
// 动态确定上游主机:优先使用 X-Forwarded-Host,回退到 UPSTREAM_HOST 配置
|
|
||||||
const targetHost = req.headers['x-forwarded-host'] || UPSTREAM_HOST;
|
|
||||||
|
|
||||||
// 复制头,重写 host 为实际目标
|
function probeALPN(host) {
|
||||||
|
const cached = alpnCache.get(host);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = tls.connect(443, host, {
|
||||||
|
ALPNProtocols: ['h2', 'http/1.1'],
|
||||||
|
servername: host,
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
socket.once('secureConnect', () => {
|
||||||
|
const proto = socket.alpnProtocol || 'http/1.1';
|
||||||
|
alpnCache.set(host, proto);
|
||||||
|
socket.destroy();
|
||||||
|
resolve(proto);
|
||||||
|
});
|
||||||
|
socket.once('error', () => {
|
||||||
|
alpnCache.set(host, 'http/1.1');
|
||||||
|
socket.destroy();
|
||||||
|
resolve('http/1.1');
|
||||||
|
});
|
||||||
|
socket.once('timeout', () => {
|
||||||
|
alpnCache.set(host, 'http/1.1');
|
||||||
|
socket.destroy();
|
||||||
|
resolve('http/1.1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTTP/2 代理请求 ─────────────────────────────────────
|
||||||
|
function proxyViaH2(targetHost, req, res) {
|
||||||
|
const session = getH2Session(targetHost);
|
||||||
|
|
||||||
|
// 构建 h2 请求头
|
||||||
|
const headers = { ...req.headers };
|
||||||
|
headers[':method'] = req.method;
|
||||||
|
headers[':path'] = req.url;
|
||||||
|
headers[':authority'] = targetHost;
|
||||||
|
headers[':scheme'] = 'https';
|
||||||
|
// 移除 HTTP/1.1 专用头
|
||||||
|
delete headers['host'];
|
||||||
|
delete headers['connection'];
|
||||||
|
delete headers['keep-alive'];
|
||||||
|
delete headers['proxy-connection'];
|
||||||
|
delete headers['transfer-encoding'];
|
||||||
|
delete headers['x-forwarded-host'];
|
||||||
|
|
||||||
|
const h2Req = session.request(headers);
|
||||||
|
|
||||||
|
h2Req.on('response', (h2Headers) => {
|
||||||
|
const status = h2Headers[':status'] || 502;
|
||||||
|
// 过滤 h2 伪头
|
||||||
|
const respHeaders = {};
|
||||||
|
for (const [k, v] of Object.entries(h2Headers)) {
|
||||||
|
if (!k.startsWith(':')) {
|
||||||
|
respHeaders[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.writeHead(status, respHeaders);
|
||||||
|
h2Req.pipe(res, { end: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
h2Req.on('error', (err) => {
|
||||||
|
log('error', 'h2 upstream error', { error: err.message, host: targetHost, path: req.url });
|
||||||
|
// h2 session 可能坏了,清理缓存
|
||||||
|
h2Sessions.delete(targetHost);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(502, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'h2_upstream_error', message: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
h2Req.setTimeout(CONNECT_TIMEOUT, () => {
|
||||||
|
h2Req.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
if (!h2Req.destroyed) h2Req.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.pipe(h2Req, { end: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTTP/1.1 代理请求 ────────────────────────────────────
|
||||||
|
async function proxyViaH1(targetHost, req, res) {
|
||||||
const headers = { ...req.headers };
|
const headers = { ...req.headers };
|
||||||
headers.host = targetHost;
|
headers.host = targetHost;
|
||||||
// 移除内部头和 hop-by-hop 头
|
|
||||||
delete headers['x-forwarded-host'];
|
delete headers['x-forwarded-host'];
|
||||||
delete headers['connection'];
|
delete headers['connection'];
|
||||||
delete headers['keep-alive'];
|
delete headers['keep-alive'];
|
||||||
delete headers['proxy-connection'];
|
delete headers['proxy-connection'];
|
||||||
delete headers['transfer-encoding'];
|
delete headers['transfer-encoding'];
|
||||||
|
|
||||||
return {
|
const opts = {
|
||||||
hostname: targetHost,
|
hostname: targetHost,
|
||||||
port: 443,
|
port: 443,
|
||||||
path: req.url,
|
path: req.url,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
headers,
|
headers,
|
||||||
// 关键:不设置任何自定义 TLS 选项
|
servername: targetHost,
|
||||||
// 让 Node.js 使用默认 TLS stack → JA3/JA4 天然匹配
|
|
||||||
servername: targetHost, // SNI
|
|
||||||
timeout: CONNECT_TIMEOUT,
|
timeout: CONNECT_TIMEOUT,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 代理请求 ───────────────────────────────────────────
|
|
||||||
async function proxyRequest(req, res) {
|
|
||||||
const opts = buildUpstreamOptions(req);
|
|
||||||
|
|
||||||
let proxyReq;
|
let proxyReq;
|
||||||
|
|
||||||
if (UPSTREAM_PROXY) {
|
if (UPSTREAM_PROXY) {
|
||||||
// 通过代理建立隧道,然后 TLS 握手
|
|
||||||
try {
|
try {
|
||||||
const socket = await connectViaProxy(UPSTREAM_PROXY, UPSTREAM_HOST, 443);
|
const socket = await connectViaProxy(UPSTREAM_PROXY, targetHost, 443);
|
||||||
opts.socket = socket;
|
opts.socket = socket;
|
||||||
opts.agent = false; // 使用自定义 socket,不走连接池
|
opts.agent = false;
|
||||||
proxyReq = https.request(opts);
|
proxyReq = https.request(opts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log('error', 'proxy tunnel failed', { error: err.message });
|
log('error', 'proxy tunnel failed', { error: err.message, host: targetHost });
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.writeHead(502, { 'content-type': 'application/json' });
|
res.writeHead(502, { 'content-type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'proxy_tunnel_error', message: err.message }));
|
res.end(JSON.stringify({ error: 'proxy_tunnel_error', message: err.message }));
|
||||||
@ -136,58 +227,59 @@ async function proxyRequest(req, res) {
|
|||||||
proxyReq = https.request(opts);
|
proxyReq = https.request(opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上游响应
|
|
||||||
proxyReq.on('response', (proxyRes) => {
|
proxyReq.on('response', (proxyRes) => {
|
||||||
// 过滤 hop-by-hop 头
|
|
||||||
const responseHeaders = { ...proxyRes.headers };
|
const responseHeaders = { ...proxyRes.headers };
|
||||||
delete responseHeaders['connection'];
|
delete responseHeaders['connection'];
|
||||||
delete responseHeaders['keep-alive'];
|
delete responseHeaders['keep-alive'];
|
||||||
delete responseHeaders['transfer-encoding']; // Node.js 会自动处理
|
delete responseHeaders['transfer-encoding'];
|
||||||
|
|
||||||
res.writeHead(proxyRes.statusCode, responseHeaders);
|
res.writeHead(proxyRes.statusCode, responseHeaders);
|
||||||
|
|
||||||
// SSE / 流式透传:逐块 pipe
|
|
||||||
proxyRes.pipe(res, { end: true });
|
proxyRes.pipe(res, { end: true });
|
||||||
|
|
||||||
proxyRes.on('error', (err) => {
|
proxyRes.on('error', (err) => {
|
||||||
log('error', 'upstream response error', { error: err.message });
|
log('error', 'upstream response error', { error: err.message, host: targetHost });
|
||||||
res.end();
|
res.end();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 上游连接错误
|
|
||||||
proxyReq.on('error', (err) => {
|
proxyReq.on('error', (err) => {
|
||||||
log('error', 'upstream request error', {
|
log('error', 'h1 upstream error', { error: err.message, host: targetHost, path: req.url, method: req.method });
|
||||||
error: err.message,
|
|
||||||
path: req.url,
|
|
||||||
method: req.method,
|
|
||||||
});
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.writeHead(502, { 'content-type': 'application/json' });
|
res.writeHead(502, { 'content-type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'upstream_error', message: err.message }));
|
res.end(JSON.stringify({ error: 'upstream_error', message: err.message }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 超时
|
|
||||||
proxyReq.on('timeout', () => {
|
proxyReq.on('timeout', () => {
|
||||||
log('warn', 'upstream request timeout', { path: req.url });
|
log('warn', 'upstream request timeout', { host: targetHost, path: req.url });
|
||||||
proxyReq.destroy(new Error('upstream timeout'));
|
proxyReq.destroy(new Error('upstream timeout'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 客户端断开时中止上游请求
|
|
||||||
req.on('close', () => {
|
req.on('close', () => {
|
||||||
if (!proxyReq.destroyed) {
|
if (!proxyReq.destroyed) proxyReq.destroy();
|
||||||
proxyReq.destroy();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 将客户端请求体 pipe 到上游
|
|
||||||
req.pipe(proxyReq, { end: true });
|
req.pipe(proxyReq, { end: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 代理请求入口 ─────────────────────────────────────────
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// 探测上游支持的协议,选择 h2 或 h1
|
||||||
|
const proto = await probeALPN(targetHost);
|
||||||
|
|
||||||
|
if (proto === 'h2') {
|
||||||
|
proxyViaH2(targetHost, req, res);
|
||||||
|
} else {
|
||||||
|
await proxyViaH1(targetHost, req, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── HTTP 服务器 ─────────────────────────────────────────
|
// ─── HTTP 服务器 ─────────────────────────────────────────
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
// 健康检查
|
|
||||||
if (req.url === HEALTH_PATH) {
|
if (req.url === HEALTH_PATH) {
|
||||||
res.writeHead(200, { 'content-type': 'application/json' });
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({
|
||||||
@ -196,6 +288,8 @@ const server = http.createServer((req, res) => {
|
|||||||
node: process.version,
|
node: process.version,
|
||||||
openssl: process.versions.openssl,
|
openssl: process.versions.openssl,
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
|
h2Sessions: h2Sessions.size,
|
||||||
|
alpnCache: Object.fromEntries(alpnCache),
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -209,7 +303,6 @@ const server = http.createServer((req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// SSE 长连接:禁用 server 级超时(由上游控制)
|
|
||||||
server.timeout = 0;
|
server.timeout = 0;
|
||||||
server.keepAliveTimeout = IDLE_TIMEOUT;
|
server.keepAliveTimeout = IDLE_TIMEOUT;
|
||||||
server.headersTimeout = 60000;
|
server.headersTimeout = 60000;
|
||||||
@ -221,6 +314,7 @@ server.listen(LISTEN_PORT, LISTEN_HOST, () => {
|
|||||||
proxy: UPSTREAM_PROXY || '(direct)',
|
proxy: UPSTREAM_PROXY || '(direct)',
|
||||||
node: process.version,
|
node: process.version,
|
||||||
openssl: process.versions.openssl,
|
openssl: process.versions.openssl,
|
||||||
|
features: ['dynamic-host', 'h2-auto', 'alpn-probe'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -230,17 +324,20 @@ function shutdown(signal) {
|
|||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
log('info', `received ${signal}, shutting down`);
|
log('info', `received ${signal}, shutting down`);
|
||||||
|
// 关闭所有 h2 session
|
||||||
|
for (const [host, session] of h2Sessions) {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
h2Sessions.clear();
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
log('info', 'server closed');
|
log('info', 'server closed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
// 强制退出兜底
|
|
||||||
setTimeout(() => process.exit(1), 5000);
|
setTimeout(() => process.exit(1), 5000);
|
||||||
}
|
}
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|
||||||
// 未捕获异常不要崩进程
|
|
||||||
process.on('uncaughtException', (err) => {
|
process.on('uncaughtException', (err) => {
|
||||||
log('error', 'uncaught exception', { error: err.message, stack: err.stack });
|
log('error', 'uncaught exception', { error: err.message, stack: err.stack });
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user