win 47066d4111
Some checks failed
CI / test (push) Failing after 1m32s
CI / golangci-lint (push) Failing after 31s
Security Scan / backend-security (push) Failing after 1m32s
Security Scan / frontend-security (push) Failing after 32s
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
2026-03-22 01:49:30 +08:00

347 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const http = require('http');
const https = require('https');
const http2 = require('http2');
const tls = require('tls');
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';
// ─── 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 隧道 ──────────────────────
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 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.once('error', reject);
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) {
conn.setTimeout(0);
const remainder = buf.slice(idx + 4);
if (remainder.length > 0) {
conn.unshift(Buffer.from(remainder));
}
resolve(conn);
} else {
conn.destroy();
reject(new Error(`proxy CONNECT failed: ${statusLine}`));
}
};
conn.on('data', onData);
});
}
// ─── TLS + ALPN 探测:判断上游支持 h2 还是 http/1.1 ─────────
const alpnCache = new Map();
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 };
headers.host = targetHost;
delete headers['x-forwarded-host'];
delete headers['connection'];
delete headers['keep-alive'];
delete headers['proxy-connection'];
delete headers['transfer-encoding'];
const opts = {
hostname: targetHost,
port: 443,
path: req.url,
method: req.method,
headers,
servername: targetHost,
timeout: CONNECT_TIMEOUT,
};
let proxyReq;
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;
}
} else {
proxyReq = https.request(opts);
}
proxyReq.on('response', (proxyRes) => {
const responseHeaders = { ...proxyRes.headers };
delete responseHeaders['connection'];
delete responseHeaders['keep-alive'];
delete responseHeaders['transfer-encoding'];
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', 'h1 upstream error', { error: err.message, host: targetHost, path: req.url, method: req.method });
if (!res.headersSent) {
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'upstream_error', message: err.message }));
}
});
proxyReq.on('timeout', () => {
log('warn', 'upstream request timeout', { host: targetHost, path: req.url });
proxyReq.destroy(new Error('upstream timeout'));
});
req.on('close', () => {
if (!proxyReq.destroyed) proxyReq.destroy();
});
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 服务器 ─────────────────────────────────────────
const server = http.createServer((req, res) => {
if (req.url === HEALTH_PATH) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
status: 'ok',
upstream: UPSTREAM_HOST,
node: process.version,
openssl: process.versions.openssl,
uptime: process.uptime(),
h2Sessions: h2Sessions.size,
alpnCache: Object.fromEntries(alpnCache),
}));
return;
}
proxyRequest(req, res).catch((err) => {
log('error', 'unhandled proxy error', { error: err.message });
if (!res.headersSent) {
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: '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}`,
upstream: `${UPSTREAM_HOST}:443`,
proxy: UPSTREAM_PROXY || '(direct)',
node: process.version,
openssl: process.versions.openssl,
features: ['dynamic-host', 'h2-auto', 'alpn-probe'],
});
});
// ─── 优雅关闭 ─────────────────────────────────────────────
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
log('info', `received ${signal}, shutting down`);
// 关闭所有 h2 session
for (const [host, session] of h2Sessions) {
session.close();
}
h2Sessions.clear();
server.close(() => {
log('info', 'server closed');
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) });
});