- 新增 Node.js TLS Forward Proxy (tools/node-tls-proxy/) 原生 Node.js TLS 栈发起上游 HTTPS,JA3/JA4 天然匹配 Claude CLI SSE 流式透传,支持上游 HTTP CONNECT 代理 零依赖,Node.js 24.13.0 锁定版本 - Go 集成 (config.go + http_upstream.go) 新增 NodeTLSProxyConfig 配置 DoWithTLS 优先走 Node.js 代理模式,URL 重写 https→http://localhost:3456 - Docker 网络隔离 (docker-compose.tls-proxy.yml) sub2api 容器仅 internal 网络,物理隔离外网 node-tls-proxy 唯一出站通道,IPv6 内核级禁用 - iptables 防泄露脚本 (tools/firewall/) QUIC/UDP 443 全局 DROP,仅 nodeproxy 用户可出站 TCP 443 - 镜像切换为 zfc931912343/ 仓库
246 lines
8.1 KiB
JavaScript
246 lines
8.1 KiB
JavaScript
'use strict';
|
||
|
||
const http = require('http');
|
||
const https = require('https');
|
||
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';
|
||
// 可选:上游 HTTPS 代理(仅支持 HTTP CONNECT 隧道)
|
||
const UPSTREAM_PROXY = process.env.UPSTREAM_PROXY || '';
|
||
// 连接超时(ms)
|
||
const CONNECT_TIMEOUT = parseInt(process.env.CONNECT_TIMEOUT || '30000', 10);
|
||
// 空闲超时(ms)— 对 SSE 长连接要足够大
|
||
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 代理建立 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); // 清除超时
|
||
// 如果头之后有残余数据,先 unshift 回去
|
||
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);
|
||
});
|
||
}
|
||
|
||
// ─── 构建上游请求选项 ──────────────────────────────────────
|
||
function buildUpstreamOptions(req) {
|
||
// 复制头,重写 host
|
||
const headers = { ...req.headers };
|
||
headers.host = UPSTREAM_HOST;
|
||
// 移除 hop-by-hop 头
|
||
delete headers['connection'];
|
||
delete headers['keep-alive'];
|
||
delete headers['proxy-connection'];
|
||
delete headers['transfer-encoding'];
|
||
|
||
return {
|
||
hostname: UPSTREAM_HOST,
|
||
port: 443,
|
||
path: req.url,
|
||
method: req.method,
|
||
headers,
|
||
// 关键:不设置任何自定义 TLS 选项
|
||
// 让 Node.js 使用默认 TLS stack → JA3/JA4 天然匹配
|
||
servername: UPSTREAM_HOST, // SNI
|
||
timeout: CONNECT_TIMEOUT,
|
||
};
|
||
}
|
||
|
||
// ─── 代理请求 ───────────────────────────────────────────
|
||
async function proxyRequest(req, res) {
|
||
const opts = buildUpstreamOptions(req);
|
||
|
||
let proxyReq;
|
||
|
||
if (UPSTREAM_PROXY) {
|
||
// 通过代理建立隧道,然后 TLS 握手
|
||
try {
|
||
const socket = await connectViaProxy(UPSTREAM_PROXY, UPSTREAM_HOST, 443);
|
||
opts.socket = socket;
|
||
opts.agent = false; // 使用自定义 socket,不走连接池
|
||
proxyReq = https.request(opts);
|
||
} catch (err) {
|
||
log('error', 'proxy tunnel failed', { error: err.message });
|
||
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) => {
|
||
// 过滤 hop-by-hop 头
|
||
const responseHeaders = { ...proxyRes.headers };
|
||
delete responseHeaders['connection'];
|
||
delete responseHeaders['keep-alive'];
|
||
delete responseHeaders['transfer-encoding']; // Node.js 会自动处理
|
||
|
||
res.writeHead(proxyRes.statusCode, responseHeaders);
|
||
|
||
// SSE / 流式透传:逐块 pipe
|
||
proxyRes.pipe(res, { end: true });
|
||
|
||
proxyRes.on('error', (err) => {
|
||
log('error', 'upstream response error', { error: err.message });
|
||
res.end();
|
||
});
|
||
});
|
||
|
||
// 上游连接错误
|
||
proxyReq.on('error', (err) => {
|
||
log('error', 'upstream request error', {
|
||
error: err.message,
|
||
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', { path: req.url });
|
||
proxyReq.destroy(new Error('upstream timeout'));
|
||
});
|
||
|
||
// 客户端断开时中止上游请求
|
||
req.on('close', () => {
|
||
if (!proxyReq.destroyed) {
|
||
proxyReq.destroy();
|
||
}
|
||
});
|
||
|
||
// 将客户端请求体 pipe 到上游
|
||
req.pipe(proxyReq, { end: true });
|
||
}
|
||
|
||
// ─── 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(),
|
||
}));
|
||
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' }));
|
||
}
|
||
});
|
||
});
|
||
|
||
// SSE 长连接:禁用 server 级超时(由上游控制)
|
||
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,
|
||
});
|
||
});
|
||
|
||
// ─── 优雅关闭 ─────────────────────────────────────────────
|
||
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);
|
||
});
|
||
// 强制退出兜底
|
||
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) });
|
||
});
|