chore: remove Node.js TLS proxy, use Go native utls
Removing Node.js TLS proxy to align with upstream: - antigravity/node-tls-proxy/proxy.js The upstream version uses Go native utls fingerprinting, which is more efficient and maintainable. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a3f2d4577e
commit
c2db74a24c
@ -1,898 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const http2 = require('http2');
|
||||
const net = require('net');
|
||||
const crypto = require('crypto');
|
||||
// os 模块不引用 — 避免暴露真实主机信息
|
||||
|
||||
// ─── 配置 ───────────────────────────────────────────────
|
||||
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 TELEMETRY_ENABLED = process.env.TELEMETRY_ENABLED !== 'false'; // 默认开启
|
||||
const DD_API_KEY = process.env.DD_API_KEY || 'pubbbf48e6d78dae54bceaa4acf463299bf';
|
||||
const CLI_VERSION = process.env.CLI_VERSION || '2.1.88';
|
||||
const BUILD_TIME = process.env.BUILD_TIME || '2026-03-31T01:39:46Z';
|
||||
// 伪装的 Node 版本(CLI 2.1.88 打包的 Bun 报告的 Node 兼容版本)
|
||||
const FAKE_NODE_VERSION = process.env.FAKE_NODE_VERSION || 'v24.3.0';
|
||||
|
||||
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';
|
||||
const h2Hosts = new Set();
|
||||
|
||||
// Strip userinfo (user:pass) from proxy URL for safe logging
|
||||
function redactProxyURL(raw) {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
u.username = '';
|
||||
u.password = '';
|
||||
return u.toString();
|
||||
} catch { return '<redacted-proxy-url>'; }
|
||||
}
|
||||
const h2Sessions = new Map();
|
||||
|
||||
// ─── 虚拟主机身份生成 ─────────────────────────────────────
|
||||
// 每个账号基于 seed 生成全局唯一的主机身份,看起来像一台真实的个人开发机
|
||||
// 匹配 CLI 的 OTEL detectResources: hostDetector + processDetector + serviceInstanceIdDetector
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. 同一账号(seed)永远产出同一台"机器"的特征
|
||||
// 2. 不同账号的特征互不相同(无共享池、无碰撞)
|
||||
// 3. 每个字段都像人手动设置的,不是程序生成的
|
||||
|
||||
// ─── macOS 主机身份词表 ──────────────────────────────────────────
|
||||
// macOS 用户 hostname 习惯: "alex-MBP", "sam-MacBook-Pro" 等
|
||||
const MBP_NAMES = ['alex','sam','chris','max','lee','kai','jamie','taylor','morgan','casey',
|
||||
'drew','avery','riley','blake','jordan','ryan','parker','quinn','reese','cameron'];
|
||||
const MBP_SUFFIX = ['-MBP','-MacBook','-MacBook-Pro','-MacBook-Air',"s-MBP","s-MacBook","s-MacBook-Pro"];
|
||||
|
||||
function generateHostIdentity(seed) {
|
||||
const h = (s) => crypto.createHash('sha256').update(seed + ':' + s).digest();
|
||||
|
||||
// ── hostname: macOS 风格 ──
|
||||
const hb = h('hostname');
|
||||
const name = MBP_NAMES[hb.readUInt8(0) % MBP_NAMES.length];
|
||||
const sfx = MBP_SUFFIX[hb.readUInt8(1) % MBP_SUFFIX.length];
|
||||
const hostname = `${name}${sfx}`;
|
||||
|
||||
// ── username: 取自 hostname 名字(真实 Mac 行为) ──
|
||||
const username = name;
|
||||
|
||||
// ── terminal: macOS 常见终端分布 ──
|
||||
const termRoll = h('terminal').readUInt8(0) % 100;
|
||||
const terminal = termRoll < 75 ? 'xterm-256color' :
|
||||
termRoll < 88 ? 'screen-256color' :
|
||||
termRoll < 96 ? 'alacritty' : 'kitty';
|
||||
|
||||
// ── shell: macOS 默认 zsh(Catalina+);部分用 bash/fish ──
|
||||
const shellRoll = h('shell').readUInt8(0) % 100;
|
||||
const shell = shellRoll < 65 ? '/bin/zsh' :
|
||||
shellRoll < 82 ? '/usr/local/bin/zsh' :
|
||||
shellRoll < 93 ? '/bin/bash' : '/opt/homebrew/bin/fish';
|
||||
|
||||
// ── host.id: macOS IOPlatformUUID 格式(大写 UUID) ──
|
||||
const mid = h('machine-id');
|
||||
const machineId = [
|
||||
mid.slice(0,4).toString('hex').toUpperCase(),
|
||||
mid.slice(4,6).toString('hex').toUpperCase(),
|
||||
mid.slice(6,8).toString('hex').toUpperCase(),
|
||||
mid.slice(8,10).toString('hex').toUpperCase(),
|
||||
mid.slice(10,16).toString('hex').toUpperCase(),
|
||||
].join('-');
|
||||
|
||||
// ── PID: macOS GUI 应用 PID 通常较小 ──
|
||||
const pid = 500 + Math.floor(Math.random() * 8000);
|
||||
|
||||
// ── macOS 版本: 13(Ventura)/14(Sonoma)/15(Sequoia) ──
|
||||
const kb = h('kernel');
|
||||
const macosMajor = 13 + (kb.readUInt8(0) % 3);
|
||||
const macosMinor = kb.readUInt8(1) % 8;
|
||||
const macosPatch = kb.readUInt8(2) % 5;
|
||||
// Darwin 内核: macOS 13=22.x, 14=23.x, 15=24.x
|
||||
const darwinMajor = 22 + (macosMajor - 13);
|
||||
const darwinMinor = kb.readUInt8(3) % 7;
|
||||
const darwinPatch = kb.readUInt8(4) % 5;
|
||||
const osVersion = `${macosMajor}.${macosMinor}.${macosPatch}`;
|
||||
|
||||
// ── arch: Apple Silicon arm64 占 70%,Intel x64 占 30% ──
|
||||
const arch = h('arch').readUInt8(0) % 100 < 70 ? 'arm64' : 'x64';
|
||||
|
||||
// ── 可执行文件路径: macOS 常见安装位置 ──
|
||||
const pathRoll = h('execpath').readUInt8(0) % 100;
|
||||
const executablePath = pathRoll < 50 ? `/Users/${username}/.claude/local/claude` :
|
||||
pathRoll < 80 ? '/usr/local/bin/claude' :
|
||||
pathRoll < 95 ? `/Users/${username}/.local/bin/claude` :
|
||||
'/opt/homebrew/bin/claude';
|
||||
|
||||
return {
|
||||
hostname, username, terminal, shell, machineId, pid, arch,
|
||||
osType: 'Darwin',
|
||||
osVersion,
|
||||
kernelRelease: `${darwinMajor}.${darwinMinor}.${darwinPatch}`,
|
||||
serviceInstanceId: crypto.randomUUID(),
|
||||
executablePath,
|
||||
executableName: 'claude',
|
||||
command: 'claude',
|
||||
commandArgs: [],
|
||||
runtimeName: 'nodejs',
|
||||
runtimeVersion: FAKE_NODE_VERSION.replace('v', ''),
|
||||
ripgrepVersion: (() => {
|
||||
const rv = h('ripgrep');
|
||||
return ['14.1.1','14.1.0','14.0.2','13.0.0','13.0.1','14.0.1','14.0.0'][rv.readUInt8(0) % 7];
|
||||
})(),
|
||||
ripgrepPath: (() => {
|
||||
const rp = h('rgpath');
|
||||
return [
|
||||
'/opt/homebrew/bin/rg',
|
||||
'/usr/local/bin/rg',
|
||||
`/Users/${username}/.cargo/bin/rg`,
|
||||
'/usr/local/opt/ripgrep/bin/rg',
|
||||
][rp.readUInt8(0) % 4];
|
||||
})(),
|
||||
mcpServerCount: 1 + (h('mcp').readUInt8(0) % 5),
|
||||
mcpFailCount: h('mcp').readUInt8(1) % 3,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 遥测模拟 ────────────────────────────────────────────
|
||||
|
||||
// 每个 device_id 的会话状态
|
||||
const sessionStates = new Map();
|
||||
|
||||
function getOrCreateSession(deviceId) {
|
||||
if (sessionStates.has(deviceId)) return sessionStates.get(deviceId);
|
||||
const hostId = generateHostIdentity(deviceId);
|
||||
const state = {
|
||||
sessionId: crypto.randomUUID(),
|
||||
deviceId,
|
||||
hostId,
|
||||
startTime: Date.now(),
|
||||
requestCount: 0,
|
||||
// 追踪 ripgrep 是否已上报
|
||||
ripgrepReported: false,
|
||||
};
|
||||
sessionStates.set(deviceId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function generateDeviceId(accountSeed) {
|
||||
return crypto.createHash('sha256').update(`device:${accountSeed}`).digest('hex');
|
||||
}
|
||||
|
||||
// ─── OTEL Resource Attributes (匹配 CLI 的 detectResources) ───
|
||||
|
||||
function buildEnvBlock(hostId) {
|
||||
const platformStr = 'darwin';
|
||||
return {
|
||||
platform: platformStr,
|
||||
node_version: FAKE_NODE_VERSION,
|
||||
terminal: hostId.terminal,
|
||||
package_managers: 'npm,pnpm',
|
||||
runtimes: 'deno,node',
|
||||
is_running_with_bun: true,
|
||||
is_ci: false,
|
||||
is_claubbit: false,
|
||||
is_github_action: false,
|
||||
is_claude_code_action: false,
|
||||
is_claude_ai_auth: false,
|
||||
version: CLI_VERSION,
|
||||
arch: hostId.arch,
|
||||
is_claude_code_remote: false,
|
||||
deployment_environment: `unknown-${platformStr}`,
|
||||
is_conductor: false,
|
||||
version_base: CLI_VERSION,
|
||||
build_time: BUILD_TIME,
|
||||
is_local_agent_mode: false,
|
||||
vcs: 'git',
|
||||
platform_raw: platformStr,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProcessMetrics(uptime) {
|
||||
// 模拟真实 CLI 的内存曲线:RSS 随 uptime 缓慢增长
|
||||
const baseRss = 180_000_000 + Math.min(uptime * 50_000, 200_000_000);
|
||||
const rss = Math.floor(baseRss + Math.random() * 80_000_000);
|
||||
const heapTotal = Math.floor(rss * 0.6 + Math.random() * 10_000_000);
|
||||
const heapUsed = Math.floor(heapTotal * 0.5 + Math.random() * heapTotal * 0.3);
|
||||
return Buffer.from(JSON.stringify({
|
||||
uptime,
|
||||
rss,
|
||||
heapTotal,
|
||||
heapUsed,
|
||||
external: 14_000_000 + Math.floor(Math.random() * 2_000_000),
|
||||
arrayBuffers: Math.floor(Math.random() * 200_000),
|
||||
constrainedMemory: 51539607552,
|
||||
cpuUsage: {
|
||||
user: Math.floor(uptime * 10_000 + Math.random() * 300_000),
|
||||
system: Math.floor(uptime * 2_000 + Math.random() * 80_000),
|
||||
},
|
||||
cpuPercent: Math.random() * 200,
|
||||
})).toString('base64');
|
||||
}
|
||||
|
||||
function buildEvent(eventName, session, model, betas, extraData, timestampOverride) {
|
||||
const uptime = (Date.now() - session.startTime) / 1000;
|
||||
const processMetrics = buildProcessMetrics(uptime);
|
||||
// 缓存最近一次的 process metrics,供 DataDog 日志复用(保持两边一致)
|
||||
session._lastProcessMetrics = { uptime, raw: processMetrics };
|
||||
const eventData = {
|
||||
event_name: eventName,
|
||||
client_timestamp: timestampOverride || new Date().toISOString(),
|
||||
model: model || 'claude-sonnet-4-6',
|
||||
session_id: session.sessionId,
|
||||
user_type: 'external',
|
||||
betas: betas || 'claude-code-20250219,interleaved-thinking-2025-05-14',
|
||||
env: buildEnvBlock(session.hostId),
|
||||
entrypoint: 'cli',
|
||||
is_interactive: true,
|
||||
client_type: 'cli',
|
||||
process: processMetrics,
|
||||
event_id: crypto.randomUUID(),
|
||||
device_id: session.deviceId,
|
||||
// 注意:不加 resource 字段 — event_logging/batch 是自定义端点,
|
||||
// OTEL resource attributes 由 CLI 通过单独的 OTLP exporter 发送,不在这里
|
||||
};
|
||||
// 合并额外字段(用于特定事件的附加数据)
|
||||
if (extraData) Object.assign(eventData, extraData);
|
||||
return {
|
||||
event_type: 'ClaudeCodeInternalEvent',
|
||||
event_data: eventData,
|
||||
};
|
||||
}
|
||||
|
||||
// 发送遥测到 api.anthropic.com/api/event_logging/batch
|
||||
function sendTelemetryEvents(events, session) {
|
||||
if (!TELEMETRY_ENABLED || events.length === 0) return;
|
||||
|
||||
const body = JSON.stringify({ events });
|
||||
const headers = {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': `claude-code/${CLI_VERSION}`,
|
||||
'x-service-name': 'claude-code',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
};
|
||||
// 注意:真实 CLI 2.1.84 的 event_logging/batch 不发 traceparent
|
||||
// traceparent 仅在 OTLP exporter(单独通道)中使用,不在这个端点
|
||||
|
||||
const opts = {
|
||||
hostname: 'api.anthropic.com',
|
||||
port: 443,
|
||||
path: '/api/event_logging/batch',
|
||||
method: 'POST',
|
||||
headers,
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
const req = https.request(opts, (res) => {
|
||||
res.resume(); // drain
|
||||
log('debug', 'telemetry_sent', { status: res.statusCode, events: events.length });
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
log('debug', 'telemetry_error', { error: err.message });
|
||||
});
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.end(body);
|
||||
}
|
||||
|
||||
// 发送 DataDog 日志
|
||||
function sendDatadogLog(eventName, session, model) {
|
||||
if (!TELEMETRY_ENABLED) return;
|
||||
|
||||
const hostId = session.hostId;
|
||||
const uptime = (Date.now() - session.startTime) / 1000;
|
||||
|
||||
// 复用 Anthropic 事件侧缓存的 process metrics(保持两边数值一致)
|
||||
// 如果没有缓存(首次调用),现场生成
|
||||
let pm;
|
||||
if (session._lastProcessMetrics && Math.abs(session._lastProcessMetrics.uptime - uptime) < 2) {
|
||||
pm = JSON.parse(Buffer.from(session._lastProcessMetrics.raw, 'base64').toString());
|
||||
} else {
|
||||
const baseRss = 180_000_000 + Math.min(uptime * 50_000, 200_000_000);
|
||||
const rss = Math.floor(baseRss + Math.random() * 80_000_000);
|
||||
const heapTotal = Math.floor(rss * 0.6 + Math.random() * 10_000_000);
|
||||
const heapUsed = Math.floor(heapTotal * 0.5 + Math.random() * heapTotal * 0.3);
|
||||
pm = {
|
||||
uptime,
|
||||
rss,
|
||||
heapTotal,
|
||||
heapUsed,
|
||||
external: 14_000_000 + Math.floor(Math.random() * 2_000_000),
|
||||
arrayBuffers: Math.floor(Math.random() * 10_000),
|
||||
constrainedMemory: 0,
|
||||
cpuUsage: {
|
||||
user: Math.floor(uptime * 10_000 + Math.random() * 300_000),
|
||||
system: Math.floor(uptime * 2_000 + Math.random() * 80_000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const entry = {
|
||||
ddsource: 'nodejs',
|
||||
ddtags: `event:${eventName},arch:${hostId.arch},client_type:cli,model:${model || 'claude-sonnet-4-6'},platform:darwin,user_type:external,version:${CLI_VERSION},version_base:${CLI_VERSION}`,
|
||||
message: eventName,
|
||||
service: 'claude-code',
|
||||
hostname: hostId.hostname,
|
||||
env: 'external',
|
||||
model: model || 'claude-sonnet-4-6',
|
||||
session_id: session.sessionId,
|
||||
user_type: 'external',
|
||||
entrypoint: 'cli',
|
||||
is_interactive: 'true',
|
||||
client_type: 'cli',
|
||||
process_metrics: pm,
|
||||
platform: 'darwin',
|
||||
platform_raw: 'darwin',
|
||||
arch: hostId.arch,
|
||||
node_version: FAKE_NODE_VERSION,
|
||||
version: CLI_VERSION,
|
||||
version_base: CLI_VERSION,
|
||||
build_time: BUILD_TIME,
|
||||
deployment_environment: 'unknown-darwin',
|
||||
vcs: 'git',
|
||||
};
|
||||
|
||||
const body = JSON.stringify([entry]);
|
||||
const opts = {
|
||||
hostname: 'http-intake.logs.us5.datadoghq.com',
|
||||
port: 443,
|
||||
path: '/api/v2/logs',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'axios/1.13.6',
|
||||
'dd-api-key': DD_API_KEY,
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
const req = https.request(opts, (res) => { res.resume(); });
|
||||
req.on('error', () => {});
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.end(body);
|
||||
}
|
||||
|
||||
// 请求前发遥测(模拟 CLI 启动 + 初始化事件)
|
||||
function emitPreRequestTelemetry(reqHeaders, body) {
|
||||
const accountSeed = reqHeaders['x-forwarded-host'] || 'default';
|
||||
const deviceId = generateDeviceId(accountSeed + ':' + (reqHeaders['authorization'] || '').slice(-16));
|
||||
const session = getOrCreateSession(deviceId);
|
||||
session.requestCount++;
|
||||
|
||||
// 从请求体解析真实 model
|
||||
let model = 'claude-sonnet-4-6';
|
||||
try {
|
||||
const parsed = JSON.parse(body.toString());
|
||||
if (parsed.model) model = parsed.model;
|
||||
} catch (_) {}
|
||||
|
||||
const betas = reqHeaders['anthropic-beta'] || 'claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01';
|
||||
|
||||
// 首次请求:发完整启动事件序列(匹配真实 CLI 的时序)
|
||||
if (session.requestCount === 1) {
|
||||
const hostId = session.hostId;
|
||||
// 生成递增的时间戳,模拟真实 CLI 启动流程的时间差
|
||||
const baseTime = Date.now();
|
||||
const ts = (offsetMs) => new Date(baseTime + offsetMs).toISOString();
|
||||
|
||||
// 第一批:启动 + 工具检测 + MCP 连接事件
|
||||
const batch1 = [
|
||||
buildEvent('tengu_started', session, model, betas, null, ts(0)),
|
||||
buildEvent('tengu_init', session, model, betas, null, ts(80 + Math.floor(Math.random() * 120))),
|
||||
// tengu_ripgrep_availability — CLI 必发的工具检测事件,版本/路径按账号不同
|
||||
buildEvent('tengu_ripgrep_availability', session, model, betas, {
|
||||
ripgrep_available: true,
|
||||
ripgrep_version: hostId.ripgrepVersion,
|
||||
ripgrep_path: hostId.ripgrepPath,
|
||||
}, ts(200 + Math.floor(Math.random() * 150))),
|
||||
];
|
||||
// MCP 连接事件:数量按账号不同(真实用户配置的 MCP server 数量差异很大)
|
||||
let mcpOffset = 400;
|
||||
const mcpSuccessCount = hostId.mcpServerCount - hostId.mcpFailCount;
|
||||
for (let i = 0; i < hostId.mcpFailCount; i++) {
|
||||
mcpOffset += 100 + Math.floor(Math.random() * 300);
|
||||
batch1.push(buildEvent('tengu_mcp_server_connection_failed', session, model, betas, null, ts(mcpOffset)));
|
||||
}
|
||||
for (let i = 0; i < mcpSuccessCount; i++) {
|
||||
mcpOffset += 200 + Math.floor(Math.random() * 500);
|
||||
batch1.push(buildEvent('tengu_mcp_server_connection_succeeded', session, model, betas, null, ts(mcpOffset)));
|
||||
}
|
||||
|
||||
session.ripgrepReported = true;
|
||||
sendTelemetryEvents(batch1, session);
|
||||
sendDatadogLog('tengu_started', session, model);
|
||||
sendDatadogLog('tengu_init', session, model);
|
||||
|
||||
// 第二批延迟发送(真实 CLI 间隔约 30 秒)
|
||||
setTimeout(() => {
|
||||
const batch2 = [
|
||||
buildEvent('tengu_session_init', session, model, betas),
|
||||
buildEvent('tengu_context_loaded', session, model, betas),
|
||||
];
|
||||
sendTelemetryEvents(batch2, session);
|
||||
}, 25000 + Math.floor(Math.random() * 10000));
|
||||
}
|
||||
|
||||
// 每次请求:发 request_started
|
||||
const events = [
|
||||
buildEvent('tengu_api_request_started', session, model, betas),
|
||||
];
|
||||
sendTelemetryEvents(events, session);
|
||||
}
|
||||
|
||||
// 请求后发遥测
|
||||
function emitPostRequestTelemetry(reqHeaders, statusCode, body) {
|
||||
const accountSeed = reqHeaders['x-forwarded-host'] || 'default';
|
||||
const deviceId = generateDeviceId(accountSeed + ':' + (reqHeaders['authorization'] || '').slice(-16));
|
||||
const session = getOrCreateSession(deviceId);
|
||||
|
||||
let model = 'claude-sonnet-4-6';
|
||||
try {
|
||||
const parsed = JSON.parse(body.toString());
|
||||
if (parsed.model) model = parsed.model;
|
||||
} catch (_) {}
|
||||
|
||||
const betas = reqHeaders['anthropic-beta'] || 'claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01';
|
||||
|
||||
// 请求完成事件
|
||||
const events = [
|
||||
buildEvent('tengu_api_request_completed', session, model, betas),
|
||||
buildEvent('tengu_conversation_turn_completed', session, model, betas),
|
||||
];
|
||||
sendTelemetryEvents(events, session);
|
||||
sendDatadogLog('tengu_api_request_completed', session, model);
|
||||
|
||||
// 模拟错误遥测(低概率,匹配 TelemetrySafeError)
|
||||
if (statusCode >= 400 && Math.random() < 0.5) {
|
||||
const errorEvent = buildEvent('tengu_api_request_error', session, model, betas, {
|
||||
error_type: 'TelemetrySafeError',
|
||||
error_code: statusCode,
|
||||
error_message: statusCode === 429 ? 'rate_limit_exceeded' :
|
||||
statusCode === 529 ? 'overloaded' :
|
||||
statusCode >= 500 ? 'server_error' : 'client_error',
|
||||
});
|
||||
sendTelemetryEvents([errorEvent], session);
|
||||
}
|
||||
|
||||
// 随机发额外事件(仅使用已知的真实 CLI 事件名)
|
||||
if (Math.random() < 0.3) {
|
||||
setTimeout(() => {
|
||||
const extra = [
|
||||
buildEvent('tengu_tool_use_completed', session, model, betas),
|
||||
];
|
||||
sendTelemetryEvents(extra, session);
|
||||
}, 2000 + Math.floor(Math.random() * 5000));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── H2 session 管理 ────────────────────────────────────
|
||||
// h2Sessions key 改为 host+proxy 组合,避免不同代理的 session 混用
|
||||
function h2SessionKey(host, proxyUrl) {
|
||||
return proxyUrl ? `${host}|${proxyUrl}` : host;
|
||||
}
|
||||
|
||||
async function getOrCreateH2Session(host, proxyUrl) {
|
||||
const key = h2SessionKey(host, proxyUrl);
|
||||
const existing = h2Sessions.get(key);
|
||||
// 检查 session 是否仍然可用:connected 且未关闭
|
||||
// GOAWAY 后 session.connected 变为 false,必须重建
|
||||
if (existing && !existing.closed && !existing.destroyed && existing.connected) return existing;
|
||||
if (existing) {
|
||||
h2Sessions.delete(key);
|
||||
try { existing.close(); } catch (_) {}
|
||||
}
|
||||
|
||||
let session;
|
||||
if (proxyUrl) {
|
||||
// 通过 CONNECT 隧道建立 h2 session(支持 HTTP CONNECT / SOCKS5)
|
||||
const socket = await connectViaProxy(proxyUrl, host, 443);
|
||||
session = http2.connect(`https://${host}`, {
|
||||
createConnection: () => socket,
|
||||
});
|
||||
log('info', 'h2_session_via_proxy', { host, proxy: redactProxyURL(proxyUrl) });
|
||||
} else {
|
||||
session = http2.connect(`https://${host}`);
|
||||
}
|
||||
|
||||
session.on('error', (err) => {
|
||||
log('warn', 'h2_session_error', { host, error: err.message });
|
||||
h2Sessions.delete(key);
|
||||
try { session.close(); } catch (_) {}
|
||||
});
|
||||
session.on('close', () => h2Sessions.delete(key));
|
||||
session.on('goaway', (errorCode) => {
|
||||
log('info', 'h2_goaway', { host, errorCode });
|
||||
h2Sessions.delete(key);
|
||||
try { session.close(); } catch (_) {}
|
||||
});
|
||||
session.setTimeout(IDLE_TIMEOUT, () => { session.close(); h2Sessions.delete(key); });
|
||||
h2Sessions.set(key, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
function waitForConnect(session) {
|
||||
if (session.connected) return Promise.resolve();
|
||||
// session 已断开(GOAWAY / 半关闭),不要等不会来的 connect 事件
|
||||
if (session.closed || session.destroyed) {
|
||||
return Promise.reject(new Error('h2 session already closed'));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const onConnect = () => { clearTimeout(t); cleanup(); resolve(); };
|
||||
const onError = (err) => { clearTimeout(t); cleanup(); reject(err); };
|
||||
const onClose = () => { clearTimeout(t); cleanup(); reject(new Error('h2 session closed before connect')); };
|
||||
const cleanup = () => {
|
||||
session.removeListener('connect', onConnect);
|
||||
session.removeListener('error', onError);
|
||||
session.removeListener('close', onClose);
|
||||
};
|
||||
session.once('connect', onConnect);
|
||||
session.once('error', onError);
|
||||
session.once('close', onClose);
|
||||
const t = setTimeout(() => { cleanup(); reject(new Error('h2 connect timeout')); }, CONNECT_TIMEOUT);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── CONNECT 隧道(HTTP CONNECT + SOCKS5)─────────────────
|
||||
function connectViaProxy(proxyUrl, targetHost, targetPort) {
|
||||
const proxy = new URL(proxyUrl);
|
||||
const scheme = proxy.protocol.replace(':', '').toLowerCase();
|
||||
|
||||
if (scheme === 'socks5' || scheme === 'socks5h') {
|
||||
return connectViaSocks5(proxy, targetHost, parseInt(targetPort, 10));
|
||||
}
|
||||
return connectViaHttpConnect(proxy, targetHost, targetPort);
|
||||
}
|
||||
|
||||
// HTTP CONNECT 隧道
|
||||
function connectViaHttpConnect(proxy, targetHost, targetPort) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = net.connect(parseInt(proxy.port || '80', 10), proxy.hostname, () => {
|
||||
const auth = proxy.username
|
||||
? `Proxy-Authorization: Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password || '')}`).toString('base64')}\r\n`
|
||||
: '';
|
||||
conn.write(`CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${auth}\r\n`);
|
||||
});
|
||||
conn.once('error', reject);
|
||||
conn.setTimeout(CONNECT_TIMEOUT, () => conn.destroy(new Error('CONNECT timeout')));
|
||||
let buf = '';
|
||||
conn.on('data', function onData(chunk) {
|
||||
buf += chunk.toString();
|
||||
const idx = buf.indexOf('\r\n\r\n');
|
||||
if (idx === -1) return;
|
||||
conn.removeListener('data', onData);
|
||||
const code = parseInt(buf.split(' ')[1], 10);
|
||||
if (code === 200) { conn.setTimeout(0); resolve(conn); }
|
||||
else { conn.destroy(); reject(new Error(`CONNECT ${code}`)); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// SOCKS5 隧道 (RFC 1928 + RFC 1929 username/password auth)
|
||||
function connectViaSocks5(proxy, targetHost, targetPort) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = net.connect(parseInt(proxy.port || '1080', 10), proxy.hostname);
|
||||
conn.once('error', reject);
|
||||
conn.setTimeout(CONNECT_TIMEOUT, () => conn.destroy(new Error('SOCKS5 timeout')));
|
||||
|
||||
const username = proxy.username ? decodeURIComponent(proxy.username) : '';
|
||||
const password = proxy.password ? decodeURIComponent(proxy.password) : '';
|
||||
const useAuth = !!(username || password);
|
||||
|
||||
let step = 'greeting';
|
||||
|
||||
conn.once('connect', () => {
|
||||
// Step 1: 发送 greeting — 支持的认证方式
|
||||
// 0x00 = 无认证, 0x02 = 用户名/密码
|
||||
if (useAuth) {
|
||||
conn.write(Buffer.from([0x05, 0x02, 0x00, 0x02]));
|
||||
} else {
|
||||
conn.write(Buffer.from([0x05, 0x01, 0x00]));
|
||||
}
|
||||
});
|
||||
|
||||
let pending = Buffer.alloc(0);
|
||||
conn.on('data', function onData(chunk) {
|
||||
pending = Buffer.concat([pending, chunk]);
|
||||
|
||||
if (step === 'greeting') {
|
||||
if (pending.length < 2) return;
|
||||
const ver = pending[0], method = pending[1];
|
||||
if (ver !== 0x05) { conn.destroy(); return reject(new Error(`SOCKS5 bad version: ${ver}`)); }
|
||||
|
||||
if (method === 0x02 && useAuth) {
|
||||
// Step 2: 用户名/密码认证 (RFC 1929)
|
||||
step = 'auth';
|
||||
pending = pending.slice(2);
|
||||
const uBuf = Buffer.from(username, 'utf8');
|
||||
const pBuf = Buffer.from(password, 'utf8');
|
||||
const authBuf = Buffer.alloc(3 + uBuf.length + pBuf.length);
|
||||
authBuf[0] = 0x01; // auth version
|
||||
authBuf[1] = uBuf.length;
|
||||
uBuf.copy(authBuf, 2);
|
||||
authBuf[2 + uBuf.length] = pBuf.length;
|
||||
pBuf.copy(authBuf, 3 + uBuf.length);
|
||||
conn.write(authBuf);
|
||||
} else if (method === 0x00) {
|
||||
// 无需认证,直接发 CONNECT
|
||||
step = 'connect';
|
||||
pending = pending.slice(2);
|
||||
sendSocks5Connect(conn, targetHost, targetPort);
|
||||
} else {
|
||||
conn.destroy();
|
||||
reject(new Error(`SOCKS5 unsupported auth method: ${method}`));
|
||||
}
|
||||
} else if (step === 'auth') {
|
||||
if (pending.length < 2) return;
|
||||
const status = pending[1];
|
||||
if (status !== 0x00) { conn.destroy(); return reject(new Error(`SOCKS5 auth failed: ${status}`)); }
|
||||
step = 'connect';
|
||||
pending = pending.slice(2);
|
||||
sendSocks5Connect(conn, targetHost, targetPort);
|
||||
} else if (step === 'connect') {
|
||||
// 最小响应: VER(1) + REP(1) + RSV(1) + ATYP(1) + ADDR(variable) + PORT(2)
|
||||
if (pending.length < 4) return;
|
||||
const rep = pending[1];
|
||||
const atyp = pending[3];
|
||||
let minLen = 4 + 2; // base + port
|
||||
if (atyp === 0x01) minLen += 4; // IPv4
|
||||
else if (atyp === 0x04) minLen += 16; // IPv6
|
||||
else if (atyp === 0x03 && pending.length > 4) minLen += 1 + pending[4]; // domain
|
||||
else if (atyp === 0x03) return; // 等更多数据
|
||||
if (pending.length < minLen) return;
|
||||
|
||||
conn.removeListener('data', onData);
|
||||
if (rep !== 0x00) { conn.destroy(); return reject(new Error(`SOCKS5 connect failed: rep=${rep}`)); }
|
||||
conn.setTimeout(0);
|
||||
resolve(conn);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendSocks5Connect(conn, host, port) {
|
||||
// SOCKS5 CONNECT: VER(05) CMD(01=CONNECT) RSV(00) ATYP ADDR PORT
|
||||
const hostBuf = Buffer.from(host, 'utf8');
|
||||
const buf = Buffer.alloc(4 + 1 + hostBuf.length + 2);
|
||||
buf[0] = 0x05; // version
|
||||
buf[1] = 0x01; // CONNECT
|
||||
buf[2] = 0x00; // reserved
|
||||
buf[3] = 0x03; // domain name
|
||||
buf[4] = hostBuf.length;
|
||||
hostBuf.copy(buf, 5);
|
||||
buf.writeUInt16BE(port, 5 + hostBuf.length);
|
||||
conn.write(buf);
|
||||
}
|
||||
|
||||
// ─── 收集请求体 ──────────────────────────────────────────
|
||||
function collectBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── H1 代理 ─────────────────────────────────────────────
|
||||
function sendViaH1(targetHost, method, path, reqHeaders, body, res, savedHeaders, explicitProxy) {
|
||||
return new Promise((resolve) => {
|
||||
const headers = { ...reqHeaders, host: targetHost };
|
||||
['x-forwarded-host', 'connection', 'keep-alive', 'proxy-connection', 'transfer-encoding'].forEach(h => delete headers[h]);
|
||||
delete headers['x-upstream-proxy'];
|
||||
if (body.length > 0) headers['content-length'] = String(body.length);
|
||||
|
||||
const opts = { hostname: targetHost, port: 443, path, method, headers, servername: targetHost, timeout: CONNECT_TIMEOUT };
|
||||
const startTime = Date.now();
|
||||
|
||||
const finish = (requestOpts) => {
|
||||
const proxyReq = https.request(requestOpts);
|
||||
proxyReq.on('response', (proxyRes) => {
|
||||
log('info', 'proxy_response', { host: targetHost, status: proxyRes.statusCode, path, proto: 'h1' });
|
||||
const rh = { ...proxyRes.headers };
|
||||
delete rh['connection']; delete rh['keep-alive'];
|
||||
res.writeHead(proxyRes.statusCode, rh);
|
||||
proxyRes.pipe(res, { end: true });
|
||||
// 请求完成后发遥测
|
||||
if (path.includes('/v1/messages') && savedHeaders) {
|
||||
emitPostRequestTelemetry(savedHeaders, proxyRes.statusCode, body);
|
||||
}
|
||||
resolve('ok');
|
||||
});
|
||||
proxyReq.on('error', (err) => {
|
||||
if (err.message === 'socket hang up' && (Date.now() - startTime) < 2000) {
|
||||
log('info', 'h1_rejected_switching_to_h2', { host: targetHost });
|
||||
h2Hosts.add(targetHost);
|
||||
sendViaH2(targetHost, method, path, reqHeaders, body, res, savedHeaders, false, explicitProxy).then(() => resolve('h2'));
|
||||
return;
|
||||
}
|
||||
log('error', 'h1_error', { error: err.message, host: targetHost, path });
|
||||
if (!res.headersSent) { res.writeHead(502); res.end(JSON.stringify({ error: 'upstream_connection_error' })); }
|
||||
resolve('error');
|
||||
});
|
||||
proxyReq.on('timeout', () => proxyReq.destroy(new Error('timeout')));
|
||||
proxyReq.end(body);
|
||||
};
|
||||
|
||||
// 动态上游代理:使用显式传入的代理地址
|
||||
const upstreamProxy = explicitProxy || '';
|
||||
|
||||
if (upstreamProxy) {
|
||||
connectViaProxy(upstreamProxy, targetHost, 443)
|
||||
.then((socket) => { opts.socket = socket; opts.agent = false; finish(opts); })
|
||||
.catch((err) => { log('error', 'tunnel_failed', { error: err.message, proxy: redactProxyURL(upstreamProxy) }); if (!res.headersSent) { res.writeHead(502); res.end(JSON.stringify({ error: 'upstream_connection_error' })); } resolve('error'); });
|
||||
} else {
|
||||
finish(opts);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── H2 代理 ─────────────────────────────────────────────
|
||||
async function sendViaH2(targetHost, method, path, reqHeaders, body, res, savedHeaders, _retried, proxyUrl) {
|
||||
try {
|
||||
const session = await getOrCreateH2Session(targetHost, proxyUrl);
|
||||
await waitForConnect(session);
|
||||
|
||||
const headers = {};
|
||||
const skip = new Set(['host','connection','keep-alive','proxy-connection','transfer-encoding','upgrade','x-forwarded-host','http2-settings']);
|
||||
for (const [k, v] of Object.entries(reqHeaders)) {
|
||||
if (!skip.has(k.toLowerCase())) headers[k] = v;
|
||||
}
|
||||
headers[':method'] = method;
|
||||
headers[':path'] = path;
|
||||
headers[':authority'] = targetHost;
|
||||
headers[':scheme'] = 'https';
|
||||
if (body.length > 0) headers['content-length'] = String(body.length);
|
||||
|
||||
const stream = session.request(headers);
|
||||
let responded = false;
|
||||
|
||||
stream.on('response', (h2h) => {
|
||||
responded = true;
|
||||
const status = h2h[':status'] || 502;
|
||||
const rh = {};
|
||||
for (const [k, v] of Object.entries(h2h)) { if (!k.startsWith(':')) rh[k] = v; }
|
||||
log('info', 'proxy_response', { host: targetHost, status, path, proto: 'h2' });
|
||||
res.writeHead(status, rh);
|
||||
stream.on('data', (c) => res.write(c));
|
||||
stream.on('end', () => res.end());
|
||||
if (path.includes('/v1/messages') && savedHeaders) {
|
||||
emitPostRequestTelemetry(savedHeaders, status);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
if (err.message && err.message.includes('NGHTTP2')) {
|
||||
h2Sessions.delete(targetHost);
|
||||
try { session.close(); } catch (_) {}
|
||||
}
|
||||
if (responded) { if (!res.writableEnded) res.end(); return; }
|
||||
log('error', 'h2_error', { error: err.message, host: targetHost, path });
|
||||
if (!res.headersSent) { res.writeHead(502); res.end(JSON.stringify({ error: 'upstream_connection_error' })); }
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
if (!responded && !res.headersSent) {
|
||||
log('warn', 'h2_no_response', { host: targetHost, path });
|
||||
res.writeHead(502); res.end(JSON.stringify({ error: 'upstream_connection_error' }));
|
||||
} else if (!res.writableEnded) { res.end(); }
|
||||
});
|
||||
|
||||
stream.setTimeout(CONNECT_TIMEOUT, () => stream.close());
|
||||
stream.end(body);
|
||||
} catch (err) {
|
||||
log('error', 'h2_exception', { error: err.message, host: targetHost, retried: !!_retried });
|
||||
h2Sessions.delete(targetHost);
|
||||
// 首次失败时重试一次(用全新 session)
|
||||
if (!_retried && !res.headersSent) {
|
||||
log('info', 'h2_retry_with_fresh_session', { host: targetHost, path });
|
||||
return sendViaH2(targetHost, method, path, reqHeaders, body, res, savedHeaders, true, proxyUrl);
|
||||
}
|
||||
if (!res.headersSent) { res.writeHead(502); res.end(JSON.stringify({ error: err.message })); }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 请求入口 ─────────────────────────────────────────────
|
||||
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 });
|
||||
|
||||
// 保存原始 headers 用于遥测
|
||||
const savedHeaders = { ...req.headers };
|
||||
|
||||
const body = await collectBody(req);
|
||||
|
||||
// 请求前发遥测(仅 /v1/messages 请求)
|
||||
if (req.url.includes('/v1/messages') && TELEMETRY_ENABLED) {
|
||||
emitPreRequestTelemetry(savedHeaders, body);
|
||||
}
|
||||
|
||||
// ── Jitter 注入 ──────────────────────────────────────────────────
|
||||
// 模拟人类编码间歇:80% 快速响应(80-300ms),20% 慢速思考(400-1200ms)
|
||||
// 使用 -log(rand) 指数衰减使延迟尾部更接近真实键盘输入节奏
|
||||
const jitterMs = (() => {
|
||||
if (Math.random() < 0.80) {
|
||||
return Math.floor(80 + (-Math.log(Math.random()) * 90)); // 快:~80-300ms
|
||||
}
|
||||
return Math.floor(400 + Math.random() * 800); // 慢:400-1200ms
|
||||
})();
|
||||
await new Promise(r => setTimeout(r, jitterMs));
|
||||
|
||||
// ── H2 / H1 路由策略 ──────────────────────────────────────────────
|
||||
// H2 现在支持通过 CONNECT 隧道代理,优先为 H2_PREFER_HOSTS 使用 h2。
|
||||
// 有代理时通过 connectViaProxy 建立隧道后再 h2 连接。
|
||||
const upstreamProxy = req.headers['x-upstream-proxy'] || UPSTREAM_PROXY;
|
||||
// 清除内部 header,不传给上游(h2 路径也需要清理)
|
||||
delete req.headers['x-upstream-proxy'];
|
||||
const H2_PREFER_HOSTS = new Set([
|
||||
'api.anthropic.com',
|
||||
'cloudaicompanion.googleapis.com',
|
||||
'generativelanguage.googleapis.com',
|
||||
'cloudcode-pa.googleapis.com',
|
||||
'daily-cloudcode-pa.googleapis.com',
|
||||
]);
|
||||
if (H2_PREFER_HOSTS.has(targetHost) || h2Hosts.has(targetHost)) {
|
||||
await sendViaH2(targetHost, req.method, req.url, req.headers, body, res, savedHeaders, false, upstreamProxy || undefined);
|
||||
} else {
|
||||
await sendViaH1(targetHost, req.method, req.url, req.headers, body, res, savedHeaders, upstreamProxy || undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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', node: process.version, openssl: process.versions.openssl,
|
||||
uptime: process.uptime(), h2Hosts: [...h2Hosts],
|
||||
telemetry: TELEMETRY_ENABLED, sessions: sessionStates.size,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
proxyRequest(req, res).catch((err) => {
|
||||
log('error', 'unhandled', { error: err.message });
|
||||
if (!res.headersSent) { res.writeHead(500); res.end('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}`, node: process.version, openssl: process.versions.openssl,
|
||||
telemetry: TELEMETRY_ENABLED,
|
||||
});
|
||||
});
|
||||
|
||||
// 定期清理过期 session(1 小时无活动)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, state] of sessionStates) {
|
||||
if (now - state.startTime > 3600_000) sessionStates.delete(id);
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
let stopping = false;
|
||||
function shutdown(sig) {
|
||||
if (stopping) return; stopping = true;
|
||||
for (const s of h2Sessions.values()) try { s.close(); } catch (_) {}
|
||||
h2Sessions.clear();
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(1), 5000);
|
||||
}
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('uncaughtException', (e) => log('error', 'uncaught', { error: e.message }));
|
||||
process.on('unhandledRejection', (r) => log('error', 'rejection', { error: String(r) }));
|
||||
Loading…
x
Reference in New Issue
Block a user