bookworm-boot/patches/proxy-v2.js
bookworm 5e0ff18aa1 feat: Bookworm Portable v1.5 — 8 fixes (P0 NDA + P1 banners + P2 perf)
- P1: Banner v1.3→v1.5, Hooks 29→34
- P1: 卸载脚本补删 更新Bookworm.lnk
- P1: git stash pop 安全检查
- P2: Playwright 检测改用 npm list
- P2: 代理端口扫描 500ms async 超时

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:34:27 +08:00

350 lines
10 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';
/**
* BYOK 代理 v2 — 稳定性增强版
*
* 改进点:
* 1. keepAlive 连接池 (复用 TCP 连接, 减少 TLS 握手)
* 2. 可重试的瞬态错误 (ECONNRESET, ETIMEDOUT, 429, 502, 503)
* 3. SSE 心跳 (每 15s 发送 :ping, 防止 Nginx/CDN 超时断连)
* 4. backpressure 处理 (res.write 返回 false 时暂停上游)
* 5. 分离连接超时(15s)和读取超时(180s)
* 6. 详细错误分类与日志
*
* @module src/proxy
*/
const https = require('https');
const http = require('http');
const { URL } = require('url');
// ─── ❶ SSRF 防护base_url 白名单 ───
const ALLOWED_API_HOSTS = new Set([
'api.anthropic.com',
]);
if (process.env.ALLOWED_API_HOSTS) {
for (const h of process.env.ALLOWED_API_HOSTS.split(',')) {
if (h.trim()) ALLOWED_API_HOSTS.add(h.trim());
}
}
function isPrivateHost(hostname) {
// IPv6 mapped IPv4
if (hostname.startsWith('[::ffff:')) {
return isPrivateHost(hostname.slice(8, -1));
}
// IPv6 私有地址段: ULA (fc00::/7), Link-local (fe80::/10), loopback (::1), unspecified (::)
const lower = hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true; // fc00::/7 (ULA)
if (/^fe[89ab][0-9a-f]:/.test(lower)) return true; // fe80::/10 (link-local)
if (lower === '::1' || lower === '::') return true;
// IPv4 私有地址
const parts = hostname.split('.');
if (parts.length === 4 && parts.every(p => /^\d+$/.test(p))) {
const [a, b] = parts.map(Number);
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 127) return true;
if (a === 169 && b === 254) return true;
if (a === 0) return true;
}
return hostname === 'localhost' || hostname === '[::1]';
}
function validateBaseUrl(baseUrl) {
if (!baseUrl) return;
let url;
try {
url = new URL(baseUrl);
} catch {
throw { status: 400, message: 'base_url 格式无效' };
}
if (ALLOWED_API_HOSTS.has(url.hostname)) return;
if (isPrivateHost(url.hostname)) {
throw { status: 403, message: '不允许访问内网地址' };
}
// W10 修复: 非白名单公网地址也拒绝 (防止变成开放代理)
throw { status: 403, message: '不允许的 API 地址,请联系管理员将域名加入白名单' };
}
// ─── ❷ keepAlive 连接池 ───
const httpsAgent = new https.Agent({
keepAlive: true,
maxSockets: 10,
maxFreeSockets: 5,
keepAliveMsecs: 30000,
timeout: 15000,
});
const httpAgent = new http.Agent({
keepAlive: true,
maxSockets: 10,
maxFreeSockets: 5,
keepAliveMsecs: 30000,
timeout: 15000,
});
// ─── ❸ 重试配置 ───
const RETRYABLE_CODES = new Set([429, 502, 503, 504]);
const RETRYABLE_ERRORS = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_SOCKET']);
const MAX_RETRIES = 2;
const RETRY_BASE_DELAY = 1000; // 1s 指数退避
function isRetryable(err, statusCode) {
if (statusCode && RETRYABLE_CODES.has(statusCode)) return true;
if (err && err.code && RETRYABLE_ERRORS.has(err.code)) return true;
if (err && err.message && /socket hang up|ECONNRESET|ETIMEDOUT/i.test(err.message)) return true;
return false;
}
function retryDelay(attempt) {
// 指数退避 + 抖动: 1s, 2s + random(0-500ms)
return RETRY_BASE_DELAY * Math.pow(2, attempt) + Math.random() * 500;
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
// ─── ❹ SSE 心跳 ───
const SSE_HEARTBEAT_INTERVAL = 15000; // 每 15 秒
function startSSEHeartbeat(res) {
const timer = setInterval(() => {
if (res.writableEnded || res.destroyed) {
clearInterval(timer);
return;
}
try {
res.write(':ping\n\n');
} catch {
clearInterval(timer);
}
}, SSE_HEARTBEAT_INTERVAL);
// 不阻止进程退出
if (timer.unref) timer.unref();
return timer;
}
// ─── BYOK 代理核心 ───
async function proxyChat(opts, res) {
const {
apiKey,
model = 'claude-sonnet-4-5-20250514',
messages,
maxTokens = 8192,
stream = false,
baseUrl,
systemPrompt,
} = opts;
validateBaseUrl(baseUrl);
const base = baseUrl || process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com';
const url = new URL('/v1/messages', base);
const isHttps = url.protocol === 'https:';
const body = {
model,
messages,
max_tokens: maxTokens,
stream,
};
if (systemPrompt) body.system = systemPrompt;
const payload = JSON.stringify(body);
const requestOpts = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': process.env.ANTHROPIC_API_VERSION || '2023-06-01',
'Content-Length': Buffer.byteLength(payload),
},
agent: isHttps ? httpsAgent : httpAgent,
timeout: 15000, // 连接超时 15s
};
// ─── 流式请求 (不重试, 因为 headers 一旦发送不可回退) ───
if (stream) {
return _proxyChatStream(requestOpts, payload, res, isHttps);
}
// ─── 非流式请求 (支持重试) ───
let lastError = null;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const result = await _proxyChatOnce(requestOpts, payload, isHttps);
// 上游返回可重试状态码
if (result.status && RETRYABLE_CODES.has(result.status) && attempt < MAX_RETRIES) {
const delay = result.status === 429
? _parseRetryAfter(result.headers) || retryDelay(attempt)
: retryDelay(attempt);
await sleep(delay);
continue;
}
return result;
} catch (err) {
lastError = err;
if (isRetryable(err) && attempt < MAX_RETRIES) {
await sleep(retryDelay(attempt));
continue;
}
throw err;
}
}
throw lastError;
}
// 解析 Retry-After 头 (秒或日期)
function _parseRetryAfter(headers) {
const val = headers && headers['retry-after'];
if (!val) return null;
const secs = parseInt(val, 10);
if (!isNaN(secs) && secs > 0 && secs < 120) return secs * 1000;
return null;
}
// 单次非流式请求
function _proxyChatOnce(requestOpts, payload, isHttps) {
return new Promise((resolve, reject) => {
const transport = isHttps ? https : http;
const proxyReq = transport.request(requestOpts, (proxyRes) => {
// 连接成功 → 切换为读取超时 180s
proxyReq.setTimeout(180_000);
const chunks = [];
proxyRes.on('data', (chunk) => chunks.push(chunk));
proxyRes.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
try {
resolve({ status: proxyRes.statusCode, data: JSON.parse(raw), headers: proxyRes.headers });
} catch {
resolve({ status: proxyRes.statusCode, data: raw, headers: proxyRes.headers });
}
});
proxyRes.on('error', reject);
});
proxyReq.on('error', reject);
proxyReq.on('timeout', () => {
proxyReq.destroy(new Error('Claude API 连接超时 (15s)'));
});
proxyReq.write(payload);
proxyReq.end();
});
}
// 流式请求 (不重试)
function _proxyChatStream(requestOpts, payload, res, isHttps) {
return new Promise((resolve, reject) => {
const transport = isHttps ? https : http;
const proxyReq = transport.request(requestOpts, (proxyRes) => {
// 连接成功 → 切换为读取超时 300s (流式更长)
proxyReq.setTimeout(300_000);
// 上游返回错误: 不走 SSE, 收集后 JSON 返回
if (proxyRes.statusCode !== 200) {
const chunks = [];
proxyRes.on('data', (chunk) => chunks.push(chunk));
proxyRes.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
try {
resolve({ status: proxyRes.statusCode, data: JSON.parse(raw), streamed: false });
} catch {
resolve({ status: proxyRes.statusCode, data: { error: raw }, streamed: false });
}
});
proxyRes.on('error', reject);
return;
}
// 正常 SSE 透传
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
// 启动心跳
const heartbeatTimer = startSSEHeartbeat(res);
let tokensIn = 0, tokensOut = 0;
let fullText = '';
proxyRes.on('data', (chunk) => {
// backpressure: 如果客户端消费慢, 暂停上游
const canWrite = res.write(chunk);
if (!canWrite) {
proxyRes.pause();
res.once('drain', () => proxyRes.resume());
}
// 提取用量 + 全文
try {
const text = chunk.toString();
for (const line of text.split('\n')) {
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
const obj = JSON.parse(line.slice(6));
if (obj.type === 'message_start' && obj.message?.usage) {
tokensIn = obj.message.usage.input_tokens || 0;
} else if (obj.type === 'content_block_delta' && obj.delta?.text) {
fullText += obj.delta.text;
} else if (obj.type === 'message_delta' && obj.usage) {
tokensOut = obj.usage.output_tokens || 0;
}
}
} catch { /* 解析失败不影响透传 */ }
});
proxyRes.on('end', () => {
clearInterval(heartbeatTimer);
res.end();
resolve({ streamed: true, status: 200, usage: { tokensIn, tokensOut }, fullText: fullText || undefined });
});
proxyRes.on('error', (err) => {
clearInterval(heartbeatTimer);
// 尝试发送 SSE 错误事件后关闭
try {
res.write(`data: ${JSON.stringify({ type: 'error', error: { type: 'stream_error', message: err.message } })}\n\n`);
} catch { /* ignore */ }
res.end();
reject(err);
});
// 客户端断开时清理
res.on('close', () => {
clearInterval(heartbeatTimer);
proxyReq.destroy();
});
});
proxyReq.on('error', (err) => {
reject(err);
});
proxyReq.on('timeout', () => {
proxyReq.destroy(new Error('Claude API 连接超时 (15s)'));
});
proxyReq.write(payload);
proxyReq.end();
});
}
module.exports = { proxyChat, validateBaseUrl };