226 lines
7.3 KiB
JavaScript
226 lines
7.3 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';
|
|
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 代理建立 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);
|
|
});
|
|
}
|
|
|
|
// ─── 代理请求 ───────────────────────────────────────────
|
|
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 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,
|
|
// 不设置任何自定义 TLS 选项 → Node.js 默认 TLS stack → JA3/JA4 天然匹配
|
|
};
|
|
|
|
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'];
|
|
|
|
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,
|
|
});
|
|
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 });
|
|
}
|
|
|
|
// ─── 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' }));
|
|
}
|
|
});
|
|
});
|
|
|
|
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) });
|
|
});
|