Compare commits
10 Commits
e301fbc46f
...
b0ed2eefb6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0ed2eefb6 | ||
|
|
78f91da858 | ||
|
|
1a6a077743 | ||
|
|
1182647a59 | ||
|
|
b285fb7b2f | ||
|
|
2f817dd248 | ||
|
|
0df29af0ab | ||
|
|
71bafae881 | ||
|
|
35b0d85d0d | ||
|
|
95210a1023 |
@ -127,9 +127,9 @@ COPY --chown=sub2api:sub2api deploy/ls-bin/language_server_linux_* /tmp/ls-bin/
|
||||
COPY --chown=sub2api:sub2api deploy/ls-bin/cert.pem /app/ls/extensions/antigravity/dist/languageServer/
|
||||
RUN mkdir -p /app/ls/extensions/antigravity/bin && \
|
||||
if [ "$TARGETARCH" = "arm64" ]; then \
|
||||
cp /tmp/ls-bin/language_server_linux_arm /app/ls/extensions/antigravity/bin/language_server_linux_arm; \
|
||||
cp /tmp/ls-bin/language_server_linux_arm /app/ls/extensions/antigravity/bin/language_server_linux_arm; \
|
||||
else \
|
||||
cp /tmp/ls-bin/language_server_linux_x64 /app/ls/extensions/antigravity/bin/language_server_linux_x64; \
|
||||
cp /tmp/ls-bin/language_server_linux_x64 /app/ls/extensions/antigravity/bin/language_server_linux_x64; \
|
||||
fi && \
|
||||
chmod +x /app/ls/extensions/antigravity/bin/language_server_linux_* && \
|
||||
rm -rf /tmp/ls-bin
|
||||
|
||||
898
antigravity/node-tls-proxy/proxy.js
Normal file
898
antigravity/node-tls-proxy/proxy.js
Normal file
@ -0,0 +1,898 @@
|
||||
'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) }));
|
||||
@ -79,6 +79,7 @@ func provideCleanup(
|
||||
soraMediaCleanup *service.SoraMediaCleanupService,
|
||||
schedulerSnapshot *service.SchedulerSnapshotService,
|
||||
tokenRefresh *service.TokenRefreshService,
|
||||
lsPoolBootstrap *service.LSPoolBootstrapService,
|
||||
accountExpiry *service.AccountExpiryService,
|
||||
subscriptionExpiry *service.SubscriptionExpiryService,
|
||||
usageCleanup *service.UsageCleanupService,
|
||||
@ -171,6 +172,12 @@ func provideCleanup(
|
||||
tokenRefresh.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"LSPoolBootstrapService", func() error {
|
||||
if lsPoolBootstrap != nil {
|
||||
lsPoolBootstrap.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"AccountExpiryService", func() error {
|
||||
accountExpiry.Stop()
|
||||
return nil
|
||||
|
||||
@ -246,10 +246,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
|
||||
soraMediaCleanupService := service.ProvideSoraMediaCleanupService(soraMediaStorage, configConfig)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, soraAccountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oauthRefreshAPI)
|
||||
lsPoolBootstrapService := service.ProvideLSPoolBootstrapService(accountRepository, configConfig)
|
||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
|
||||
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, soraMediaCleanupService, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, soraMediaCleanupService, schedulerSnapshotService, tokenRefreshService, lsPoolBootstrapService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
Cleanup: v,
|
||||
@ -287,6 +288,7 @@ func provideCleanup(
|
||||
soraMediaCleanup *service.SoraMediaCleanupService,
|
||||
schedulerSnapshot *service.SchedulerSnapshotService,
|
||||
tokenRefresh *service.TokenRefreshService,
|
||||
lsPoolBootstrap *service.LSPoolBootstrapService,
|
||||
accountExpiry *service.AccountExpiryService,
|
||||
subscriptionExpiry *service.SubscriptionExpiryService,
|
||||
usageCleanup *service.UsageCleanupService,
|
||||
@ -378,6 +380,12 @@ func provideCleanup(
|
||||
tokenRefresh.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"LSPoolBootstrapService", func() error {
|
||||
if lsPoolBootstrap != nil {
|
||||
lsPoolBootstrap.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"AccountExpiryService", func() error {
|
||||
accountExpiry.Stop()
|
||||
return nil
|
||||
|
||||
@ -47,6 +47,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
idempotencyCleanupSvc := service.NewIdempotencyCleanupService(nil, cfg)
|
||||
schedulerSnapshotSvc := service.NewSchedulerSnapshotService(nil, nil, nil, nil, cfg)
|
||||
opsSystemLogSinkSvc := service.NewOpsSystemLogSink(nil)
|
||||
lsPoolBootstrapSvc := service.NewLSPoolBootstrapService(nil, nil, cfg)
|
||||
|
||||
cleanup := provideCleanup(
|
||||
nil, // entClient
|
||||
@ -60,6 +61,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
&service.SoraMediaCleanupService{},
|
||||
schedulerSnapshotSvc,
|
||||
tokenRefreshSvc,
|
||||
lsPoolBootstrapSvc,
|
||||
accountExpirySvc,
|
||||
subscriptionExpirySvc,
|
||||
&service.UsageCleanupService{},
|
||||
|
||||
@ -381,6 +381,8 @@ type GatewayConfig struct {
|
||||
OpenAIWS GatewayOpenAIWSConfig `mapstructure:"openai_ws"`
|
||||
// AntigravityLSWorker: LS worker 容器控制平面配置
|
||||
AntigravityLSWorker GatewayAntigravityLSWorkerConfig `mapstructure:"antigravity_ls_worker"`
|
||||
// NodeTLSProxy: Node.js TLS 代理配置
|
||||
NodeTLSProxy NodeTLSProxyConfig `mapstructure:"node_tls_proxy"`
|
||||
|
||||
// HTTP 上游连接池配置(性能优化:支持高并发场景调优)
|
||||
// MaxIdleConns: 所有主机的最大空闲连接总数
|
||||
@ -669,6 +671,23 @@ type SoraModelFiltersConfig struct {
|
||||
HidePromptEnhance bool `mapstructure:"hide_prompt_enhance"`
|
||||
}
|
||||
|
||||
// NodeTLSProxyConfig Node.js TLS 代理配置
|
||||
// 通过本地 Node.js 进程转发 HTTPS 请求,利用原生 TLS 栈产生真实 JA3 指纹
|
||||
type NodeTLSProxyConfig struct {
|
||||
// Enabled: 全局开关
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
// ListenPort: Node.js 代理监听端口
|
||||
ListenPort int `mapstructure:"listen_port"`
|
||||
// ListenHost: Node.js 代理监听地址(Docker 内用服务名,裸机用 127.0.0.1)
|
||||
ListenHost string `mapstructure:"listen_host"`
|
||||
// HealthPath: 健康检查路径
|
||||
HealthPath string `mapstructure:"health_path"`
|
||||
// UpstreamHost: 默认上游主机
|
||||
UpstreamHost string `mapstructure:"upstream_host"`
|
||||
// ProxyHosts: 允许走代理的主机白名单,为空时仅代理 api.anthropic.com
|
||||
ProxyHosts []string `mapstructure:"proxy_hosts"`
|
||||
}
|
||||
|
||||
// TLSFingerprintConfig TLS指纹伪装配置
|
||||
// 用于模拟 Claude CLI (Node.js) 的 TLS 握手特征,避免被识别为非官方客户端
|
||||
type TLSFingerprintConfig struct {
|
||||
|
||||
@ -515,7 +515,7 @@ func validateDataProxy(item DataProxy) error {
|
||||
return errors.New("proxy port is invalid")
|
||||
}
|
||||
switch item.Protocol {
|
||||
case "http", "https", "socks5", "socks5h":
|
||||
case "http", "socks5", "socks5h":
|
||||
default:
|
||||
return fmt.Errorf("proxy protocol is invalid: %s", item.Protocol)
|
||||
}
|
||||
|
||||
@ -1453,6 +1453,12 @@ func (h *OAuthHandler) GenerateSetupTokenURL(c *gin.Context) {
|
||||
req = GenerateAuthURLRequest{}
|
||||
}
|
||||
|
||||
if req.ProxyID != nil {
|
||||
slog.Info("generate_setup_token_url", "proxy_id", *req.ProxyID)
|
||||
} else {
|
||||
slog.Info("generate_setup_token_url", "proxy_id", nil)
|
||||
}
|
||||
|
||||
result, err := h.oauthService.GenerateSetupTokenURL(c.Request.Context(), req.ProxyID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@ -1500,6 +1506,12 @@ func (h *OAuthHandler) ExchangeSetupTokenCode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.ProxyID != nil {
|
||||
slog.Info("exchange_setup_token_code", "session_id", req.SessionID, "proxy_id", *req.ProxyID)
|
||||
} else {
|
||||
slog.Info("exchange_setup_token_code", "session_id", req.SessionID, "proxy_id", nil)
|
||||
}
|
||||
|
||||
tokenInfo, err := h.oauthService.ExchangeCode(c.Request.Context(), &service.ExchangeCodeInput{
|
||||
SessionID: req.SessionID,
|
||||
Code: req.Code,
|
||||
|
||||
@ -27,7 +27,7 @@ func NewProxyHandler(adminService service.AdminService) *ProxyHandler {
|
||||
// CreateProxyRequest represents create proxy request
|
||||
type CreateProxyRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Protocol string `json:"protocol" binding:"required,oneof=http https socks5 socks5h"`
|
||||
Protocol string `json:"protocol" binding:"required,oneof=http socks5 socks5h"`
|
||||
Host string `json:"host" binding:"required"`
|
||||
Port int `json:"port" binding:"required,min=1,max=65535"`
|
||||
Username string `json:"username"`
|
||||
@ -37,7 +37,7 @@ type CreateProxyRequest struct {
|
||||
// UpdateProxyRequest represents update proxy request
|
||||
type UpdateProxyRequest struct {
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"`
|
||||
Protocol string `json:"protocol" binding:"omitempty,oneof=http socks5 socks5h"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
|
||||
Username string `json:"username"`
|
||||
@ -299,7 +299,7 @@ func (h *ProxyHandler) GetProxyAccounts(c *gin.Context) {
|
||||
|
||||
// BatchCreateProxyItem represents a single proxy in batch create request
|
||||
type BatchCreateProxyItem struct {
|
||||
Protocol string `json:"protocol" binding:"required,oneof=http https socks5 socks5h"`
|
||||
Protocol string `json:"protocol" binding:"required,oneof=http socks5 socks5h"`
|
||||
Host string `json:"host" binding:"required"`
|
||||
Port int `json:"port" binding:"required,min=1,max=65535"`
|
||||
Username string `json:"username"`
|
||||
|
||||
@ -53,8 +53,8 @@ const (
|
||||
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.107.0
|
||||
var defaultUserAgentVersion = "1.107.0"
|
||||
|
||||
// defaultClientSecret 可通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 配置
|
||||
var defaultClientSecret = "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"
|
||||
// defaultClientSecret 必须通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 配置
|
||||
var defaultClientSecret string
|
||||
|
||||
func init() {
|
||||
// 从环境变量读取版本号,未设置则使用默认值
|
||||
@ -73,6 +73,10 @@ func GetUserAgent() string {
|
||||
}
|
||||
|
||||
func getClientSecret() (string, error) {
|
||||
if secret := strings.TrimSpace(os.Getenv(AntigravityOAuthClientSecretEnv)); secret != "" {
|
||||
defaultClientSecret = secret
|
||||
return secret, nil
|
||||
}
|
||||
if v := strings.TrimSpace(defaultClientSecret); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
19
backend/internal/pkg/antigravity/oauth_runtime_env_test.go
Normal file
19
backend/internal/pkg/antigravity/oauth_runtime_env_test.go
Normal file
@ -0,0 +1,19 @@
|
||||
package antigravity
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetClientSecret_ReadsRuntimeEnvironment(t *testing.T) {
|
||||
old := defaultClientSecret
|
||||
defaultClientSecret = ""
|
||||
t.Cleanup(func() { defaultClientSecret = old })
|
||||
|
||||
t.Setenv(AntigravityOAuthClientSecretEnv, "runtime-secret")
|
||||
|
||||
secret, err := getClientSecret()
|
||||
if err != nil {
|
||||
t.Fatalf("getClientSecret returned error: %v", err)
|
||||
}
|
||||
if secret != "runtime-secret" {
|
||||
t.Fatalf("unexpected secret: got %q want %q", secret, "runtime-secret")
|
||||
}
|
||||
}
|
||||
@ -3,12 +3,15 @@ package claude
|
||||
|
||||
// Claude Code 客户端相关常量
|
||||
|
||||
// DefaultCLIVersion 是当前模拟的 Claude CLI 版本
|
||||
const DefaultCLIVersion = "2.1.88"
|
||||
|
||||
// Beta header 常量
|
||||
const (
|
||||
BetaOAuth = "oauth-2025-04-20"
|
||||
BetaClaudeCode = "claude-code-20250219"
|
||||
BetaInterleavedThinking = "interleaved-thinking-2025-05-14"
|
||||
BetaFineGrainedToolStreaming = "fine-grained-tool-streaming-2025-05-14"
|
||||
BetaFineGrainedToolStreaming = "fine-grained-tool-streaming-2025-05-14"
|
||||
BetaTokenCounting = "token-counting-2024-11-01"
|
||||
BetaContext1M = "context-1m-2025-08-07"
|
||||
BetaFastMode = "fast-mode-2026-02-01"
|
||||
@ -16,6 +19,11 @@ const (
|
||||
BetaContextManagement = "context-management-2025-06-27"
|
||||
BetaPromptCachingScope = "prompt-caching-scope-2026-01-05"
|
||||
BetaEffort = "effort-2025-11-24"
|
||||
BetaTaskBudgets = "task-budgets-2026-03-13"
|
||||
BetaTokenEfficientTools = "token-efficient-tools-2026-03-28"
|
||||
BetaStructuredOutputs = "structured-outputs-2025-12-15"
|
||||
BetaAdvisor = "advisor-tool-2026-03-01"
|
||||
BetaWebSearch = "web-search-2025-03-05"
|
||||
)
|
||||
|
||||
// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。
|
||||
@ -23,7 +31,7 @@ const (
|
||||
var DroppedBetas = []string{}
|
||||
|
||||
// DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header
|
||||
const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming
|
||||
const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming + "," + BetaContext1M + "," + BetaRedactThinking + "," + BetaContextManagement + "," + BetaPromptCachingScope + "," + BetaEffort
|
||||
|
||||
// MessageBetaHeaderNoTools /v1/messages 在无工具时的 beta header
|
||||
//
|
||||
@ -31,28 +39,28 @@ const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleav
|
||||
// Claude Code for non-Claude-Code clients, we must include the claude-code beta
|
||||
// even if the request doesn't use tools, otherwise upstream may reject the
|
||||
// request as a non-Claude-Code API request.
|
||||
const MessageBetaHeaderNoTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking
|
||||
const MessageBetaHeaderNoTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaContext1M + "," + BetaRedactThinking + "," + BetaContextManagement + "," + BetaPromptCachingScope + "," + BetaEffort
|
||||
|
||||
// MessageBetaHeaderWithTools /v1/messages 在有工具时的 beta header
|
||||
const MessageBetaHeaderWithTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking
|
||||
const MessageBetaHeaderWithTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaContext1M + "," + BetaRedactThinking + "," + BetaContextManagement + "," + BetaPromptCachingScope + "," + BetaEffort
|
||||
|
||||
// CountTokensBetaHeader count_tokens 请求使用的 anthropic-beta header
|
||||
const CountTokensBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaTokenCounting
|
||||
const CountTokensBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaTokenCounting + "," + BetaContextManagement
|
||||
|
||||
// HaikuBetaHeader Haiku 模型使用的 anthropic-beta header(不需要 claude-code beta)
|
||||
const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking
|
||||
const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking + "," + BetaEffort
|
||||
|
||||
// APIKeyBetaHeader API-key 账号建议使用的 anthropic-beta header(不包含 oauth)
|
||||
const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming
|
||||
const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming + "," + BetaContext1M + "," + BetaEffort + "," + BetaPromptCachingScope
|
||||
|
||||
// APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不包含 oauth / claude-code)
|
||||
const APIKeyHaikuBetaHeader = BetaInterleavedThinking
|
||||
const APIKeyHaikuBetaHeader = BetaInterleavedThinking + "," + BetaEffort
|
||||
|
||||
// DefaultHeaders 是 Claude Code 客户端默认请求头。
|
||||
var DefaultHeaders = map[string]string{
|
||||
// Keep these in sync with recent Claude CLI traffic to reduce the chance
|
||||
// that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
|
||||
"User-Agent": "claude-cli/2.1.87 (external, cli)",
|
||||
"User-Agent": "claude-cli/" + DefaultCLIVersion + " (external, cli)",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Package-Version": "0.74.0",
|
||||
"X-Stainless-OS": "MacOS",
|
||||
|
||||
@ -35,13 +35,11 @@ const (
|
||||
// GeminiCLIRedirectURI is the redirect URI used by Gemini CLI for Code Assist OAuth.
|
||||
GeminiCLIRedirectURI = "https://codeassist.google.com/authcode"
|
||||
|
||||
// GeminiCLIOAuthClientID/Secret are the public OAuth client credentials used by Google Gemini CLI.
|
||||
// They enable the "login without creating your own OAuth client" experience, but Google may
|
||||
// restrict which scopes are allowed for this client.
|
||||
GeminiCLIOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
||||
GeminiCLIOAuthClientSecret = "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"
|
||||
// GeminiCLIOAuthClientID is the public OAuth client ID used by Google Gemini CLI.
|
||||
GeminiCLIOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
||||
|
||||
// GeminiCLIOAuthClientSecretEnv is the environment variable name for the built-in client secret.
|
||||
// The secret MUST be provided via this env var — no hardcoded fallback.
|
||||
GeminiCLIOAuthClientSecretEnv = "GEMINI_CLI_OAUTH_CLIENT_SECRET"
|
||||
|
||||
SessionTTL = 30 * time.Minute
|
||||
|
||||
@ -170,11 +170,9 @@ func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error
|
||||
// Fall back to built-in Gemini CLI OAuth client when not configured.
|
||||
// SECURITY: This repo does not embed the built-in client secret; it must be provided via env.
|
||||
if effective.ClientID == "" && effective.ClientSecret == "" {
|
||||
secret := strings.TrimSpace(GeminiCLIOAuthClientSecret)
|
||||
if secret == "" {
|
||||
if v, ok := os.LookupEnv(GeminiCLIOAuthClientSecretEnv); ok {
|
||||
secret = strings.TrimSpace(v)
|
||||
}
|
||||
var secret string
|
||||
if v, ok := os.LookupEnv(GeminiCLIOAuthClientSecretEnv); ok {
|
||||
secret = strings.TrimSpace(v)
|
||||
}
|
||||
if secret == "" {
|
||||
return OAuthConfig{}, infraerrors.Newf(http.StatusBadRequest, "GEMINI_CLI_OAUTH_CLIENT_SECRET_MISSING", "built-in Gemini CLI OAuth client_secret is not configured; set %s or provide a custom OAuth client", GeminiCLIOAuthClientSecretEnv)
|
||||
|
||||
@ -408,10 +408,10 @@ func TestBuildAuthorizationURL_WithProjectID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURL_UsesBuiltinSecretFallback(t *testing.T) {
|
||||
func TestBuildAuthorizationURL_RequiresBuiltinSecretEnv(t *testing.T) {
|
||||
t.Setenv(GeminiCLIOAuthClientSecretEnv, "")
|
||||
|
||||
authURL, err := BuildAuthorizationURL(
|
||||
_, err := BuildAuthorizationURL(
|
||||
OAuthConfig{},
|
||||
"test-state",
|
||||
"test-challenge",
|
||||
@ -419,11 +419,11 @@ func TestBuildAuthorizationURL_UsesBuiltinSecretFallback(t *testing.T) {
|
||||
"",
|
||||
"code_assist",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAuthorizationURL() 不应报错: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("BuildAuthorizationURL() 应在未配置内置 secret 环境变量时报错")
|
||||
}
|
||||
if !strings.Contains(authURL, "client_id="+GeminiCLIOAuthClientID) {
|
||||
t.Errorf("应使用内置 Gemini CLI client_id,实际 URL: %s", authURL)
|
||||
if !strings.Contains(err.Error(), GeminiCLIOAuthClientSecretEnv) {
|
||||
t.Fatalf("错误消息应提示缺少 %s: %v", GeminiCLIOAuthClientSecretEnv, err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -686,18 +686,15 @@ func TestEffectiveOAuthConfig_WhitespaceTriming(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveOAuthConfig_NoEnvSecret(t *testing.T) {
|
||||
func TestEffectiveOAuthConfig_RequiresEnvSecret(t *testing.T) {
|
||||
t.Setenv(GeminiCLIOAuthClientSecretEnv, "")
|
||||
|
||||
cfg, err := EffectiveOAuthConfig(OAuthConfig{}, "code_assist")
|
||||
if err != nil {
|
||||
t.Fatalf("不设置环境变量时应回退到内置 secret,实际报错: %v", err)
|
||||
_, err := EffectiveOAuthConfig(OAuthConfig{}, "code_assist")
|
||||
if err == nil {
|
||||
t.Fatal("未配置环境变量时应报错,而不是回退到仓库内置 secret")
|
||||
}
|
||||
if strings.TrimSpace(cfg.ClientSecret) == "" {
|
||||
t.Error("ClientSecret 不应为空")
|
||||
}
|
||||
if cfg.ClientID != GeminiCLIOAuthClientID {
|
||||
t.Errorf("ClientID 应回退为内置客户端 ID,实际: %q", cfg.ClientID)
|
||||
if !strings.Contains(err.Error(), GeminiCLIOAuthClientSecretEnv) {
|
||||
t.Fatalf("错误消息应提示缺少 %s: %v", GeminiCLIOAuthClientSecretEnv, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -45,6 +45,8 @@ import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
@ -137,9 +139,11 @@ type Instance struct {
|
||||
startedAt time.Time
|
||||
inflight int64 // atomic: current number of concurrent cascade calls
|
||||
modelMapReady int32
|
||||
modelMapHard int32
|
||||
remote bool
|
||||
workerToken string
|
||||
routingKey string
|
||||
modelMapError string
|
||||
}
|
||||
|
||||
// AcquireConcurrency atomically increments the inflight counter.
|
||||
@ -176,6 +180,38 @@ func (i *Instance) SetModelMappingReady(ready bool) {
|
||||
atomic.StoreInt32(&i.modelMapReady, 0)
|
||||
}
|
||||
|
||||
// SetModelMappingUnavailable marks the instance as unable to load model config
|
||||
// with the current token/client combination.
|
||||
func (i *Instance) SetModelMappingUnavailable(reason string) {
|
||||
atomic.StoreInt32(&i.modelMapHard, 1)
|
||||
i.mu.Lock()
|
||||
i.modelMapError = strings.TrimSpace(reason)
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
// ClearModelMappingUnavailable resets any previously recorded permanent model
|
||||
// mapping failure state.
|
||||
func (i *Instance) ClearModelMappingUnavailable() {
|
||||
atomic.StoreInt32(&i.modelMapHard, 0)
|
||||
i.mu.Lock()
|
||||
i.modelMapError = ""
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
// HasModelMappingUnavailable reports whether model config loading is currently
|
||||
// known to be incompatible with the account/token.
|
||||
func (i *Instance) HasModelMappingUnavailable() bool {
|
||||
return atomic.LoadInt32(&i.modelMapHard) == 1
|
||||
}
|
||||
|
||||
// ModelMappingUnavailableReason returns the last recorded permanent failure
|
||||
// reason, if any.
|
||||
func (i *Instance) ModelMappingUnavailableReason() string {
|
||||
i.mu.RLock()
|
||||
defer i.mu.RUnlock()
|
||||
return strings.TrimSpace(i.modelMapError)
|
||||
}
|
||||
|
||||
// HasModelMappingReady reports whether this LS instance has completed model
|
||||
// config loading successfully.
|
||||
func (i *Instance) HasModelMappingReady() bool {
|
||||
@ -630,6 +666,16 @@ func (p *Pool) SetAccountToken(accountID, accessToken, refreshToken string, expi
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
}
|
||||
p.mu.RLock()
|
||||
slots := append([]*Instance(nil), p.instances[accountID]...)
|
||||
p.mu.RUnlock()
|
||||
for _, inst := range slots {
|
||||
if inst == nil {
|
||||
continue
|
||||
}
|
||||
inst.SetModelMappingReady(false)
|
||||
inst.ClearModelMappingUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
// SetAccountModelCredits updates the JS-parity uss-modelCredits state for an account.
|
||||
@ -735,9 +781,9 @@ func (p *Pool) startInstance(accountID string, proxyURL string, replica int) (*I
|
||||
p.logger.Info("LS starting",
|
||||
"account", shortAccountID(accountID),
|
||||
"replica", replica,
|
||||
"proxy_source", rawProxyURL,
|
||||
"proxy_source", logredact.RedactProxyURL(rawProxyURL),
|
||||
"proxy_mode", launchPlan.proxyMode,
|
||||
"effective_proxy", launchPlan.effectiveProxyURL)
|
||||
"effective_proxy", logredact.RedactProxyURL(launchPlan.effectiveProxyURL))
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
@ -849,6 +895,14 @@ func (p *Pool) startInstance(accountID string, proxyURL string, replica int) (*I
|
||||
p.logger.Info("model mapping loaded", "account", shortAccountID(accountID), "replica", replica, "attempt", attempt)
|
||||
return
|
||||
}
|
||||
if inst.HasModelMappingUnavailable() {
|
||||
p.logger.Warn("model mapping unavailable",
|
||||
"account", shortAccountID(accountID),
|
||||
"replica", replica,
|
||||
"attempt", attempt,
|
||||
"reason", truncate(inst.ModelMappingUnavailableReason(), 200))
|
||||
return
|
||||
}
|
||||
p.logger.Warn("model mapping not loaded, retrying", "account", shortAccountID(accountID), "replica", replica, "attempt", attempt)
|
||||
time.Sleep(time.Duration(attempt*10) * time.Second)
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@ -197,6 +198,35 @@ func TestCurrentLSStrategy(t *testing.T) {
|
||||
require.Equal(t, LSStrategyDirect, CurrentLSStrategy())
|
||||
}
|
||||
|
||||
func TestIsPermanentModelMappingError(t *testing.T) {
|
||||
require.True(t, isPermanentModelMappingError(errors.New(`oauth2: "unauthorized_client" "Unauthorized"`)))
|
||||
require.False(t, isPermanentModelMappingError(errors.New("context deadline exceeded")))
|
||||
}
|
||||
|
||||
func TestPoolSetAccountTokenClearsModelMappingUnavailable(t *testing.T) {
|
||||
pool := &Pool{
|
||||
instances: map[string][]*Instance{
|
||||
"9": {
|
||||
{AccountID: "9", Replica: 0},
|
||||
},
|
||||
},
|
||||
}
|
||||
inst := pool.instances["9"][0]
|
||||
inst.SetModelMappingReady(true)
|
||||
inst.SetModelMappingUnavailable(`oauth2: "unauthorized_client" "Unauthorized"`)
|
||||
|
||||
pool.SetAccountToken("9", "ya29.new", "refresh", time.Now().Add(time.Hour))
|
||||
|
||||
require.False(t, inst.HasModelMappingReady())
|
||||
require.False(t, inst.HasModelMappingUnavailable())
|
||||
require.Empty(t, inst.ModelMappingUnavailableReason())
|
||||
}
|
||||
|
||||
func TestShouldFallbackDirectForModelMappingUnavailable(t *testing.T) {
|
||||
require.True(t, shouldFallbackDirect(fmt.Errorf("%w: oauth2 unauthorized_client", errLSModelMapDenied)))
|
||||
require.False(t, shouldFallbackDirect(errLSModelMapPending))
|
||||
}
|
||||
|
||||
func TestParseLSReplicaCountDefaultAndEnv(t *testing.T) {
|
||||
t.Setenv("ANTIGRAVITY_LS_REPLICAS_PER_ACCOUNT", "")
|
||||
require.Equal(t, 5, parseLSReplicaCount())
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestBuildProxychainsConfigIncludesAuthAndLocalBypass(t *testing.T) {
|
||||
proxyURL, err := url.Parse("socks5h://gostuser:fastapipwd@216.167.85.31:1080")
|
||||
proxyURL, err := url.Parse("socks5h://testuser:testpass@192.0.2.1:1080")
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := buildProxychainsConfig(proxyURL)
|
||||
@ -18,7 +18,7 @@ func TestBuildProxychainsConfigIncludesAuthAndLocalBypass(t *testing.T) {
|
||||
require.Contains(t, cfg, "localnet 127.0.0.0/255.0.0.0\n")
|
||||
require.Contains(t, cfg, "localnet ::1/128\n")
|
||||
require.Contains(t, cfg, "[ProxyList]\n")
|
||||
require.Contains(t, cfg, "socks5 216.167.85.31 1080 gostuser fastapipwd\n")
|
||||
require.Contains(t, cfg, "socks5 192.0.2.1 1080 testuser testpass\n")
|
||||
}
|
||||
|
||||
func TestBuildProxychainsConfigRejectsWhitespaceCredentials(t *testing.T) {
|
||||
|
||||
@ -71,6 +71,7 @@ var (
|
||||
errLSTranscriptDrift = errors.New("request transcript diverged from cached cascade session")
|
||||
errLSQuotaExhausted = errors.New("ls cascade returned quota exhausted")
|
||||
errLSModelMapPending = errors.New("model mapping not ready")
|
||||
errLSModelMapDenied = errors.New("model mapping unavailable")
|
||||
)
|
||||
|
||||
// IsLSQuotaExhaustedError reports whether err originated from an LS cascade
|
||||
@ -98,6 +99,20 @@ func LSQuotaExhaustedMessage(err error) string {
|
||||
return msg
|
||||
}
|
||||
|
||||
func isPermanentModelMappingError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "unauthorized_client")
|
||||
}
|
||||
|
||||
func modelMappingDeniedReason(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return truncate(strings.TrimSpace(err.Error()), 200)
|
||||
}
|
||||
|
||||
type cascadeSessionState struct {
|
||||
CascadeID string
|
||||
SystemText string
|
||||
@ -269,7 +284,9 @@ func (u *LSPoolUpstream) doViaLS(req *http.Request, body []byte, accountID int64
|
||||
}
|
||||
|
||||
func shouldFallbackDirect(err error) bool {
|
||||
return errors.Is(err, errLSRouteDirect) || errors.Is(err, errLSTranscriptDrift)
|
||||
return errors.Is(err, errLSRouteDirect) ||
|
||||
errors.Is(err, errLSTranscriptDrift) ||
|
||||
errors.Is(err, errLSModelMapDenied)
|
||||
}
|
||||
|
||||
func (u *LSPoolUpstream) forwardDirectWithKeepalive(req *http.Request, body []byte, accountKey string, accountID int64, proxyURL string) (*http.Response, error) {
|
||||
@ -413,6 +430,11 @@ func (u *LSPoolUpstream) forwardChatViaLS(req *http.Request, body []byte, parsed
|
||||
}
|
||||
trace.GetOrCreateDuration = time.Since(getOrCreateStartedAt)
|
||||
trace.Replica = inst.Replica
|
||||
if inst.HasModelMappingUnavailable() {
|
||||
reason := inst.ModelMappingUnavailableReason()
|
||||
u.logTraceSummary(slog.LevelInfo, "[LS-POOL] model mapping unavailable, routing direct", trace, "reason", reason)
|
||||
return nil, fmt.Errorf("%w: %s", errLSModelMapDenied, reason)
|
||||
}
|
||||
if !inst.HasModelMappingReady() {
|
||||
u.logTraceSummary(slog.LevelInfo, "[LS-POOL] model mapping pending, routing direct", trace)
|
||||
return nil, errLSModelMapPending
|
||||
@ -1391,6 +1413,18 @@ func RefreshModelMapping(inst *Instance) bool {
|
||||
resp, err := inst.CallUnaryJSON(ctx, LSService, "GetCascadeModelConfigData", map[string]any{})
|
||||
if err != nil {
|
||||
inst.SetModelMappingReady(false)
|
||||
if isPermanentModelMappingError(err) {
|
||||
reason := modelMappingDeniedReason(err)
|
||||
inst.SetModelMappingUnavailable(reason)
|
||||
slog.Warn("[LS-POOL] Model mapping unavailable",
|
||||
"account", inst.AccountID,
|
||||
"replica", inst.Replica,
|
||||
"address", inst.Address,
|
||||
"elapsed", time.Since(startedAt).Truncate(time.Millisecond),
|
||||
"reason", reason)
|
||||
return false
|
||||
}
|
||||
inst.ClearModelMappingUnavailable()
|
||||
slog.Warn("[LS-POOL] Failed to get model config",
|
||||
"account", inst.AccountID,
|
||||
"replica", inst.Replica,
|
||||
@ -1408,6 +1442,7 @@ func RefreshModelMapping(inst *Instance) bool {
|
||||
}
|
||||
if err := json.Unmarshal(resp, &data); err != nil {
|
||||
inst.SetModelMappingReady(false)
|
||||
inst.ClearModelMappingUnavailable()
|
||||
return false
|
||||
}
|
||||
|
||||
@ -1440,6 +1475,7 @@ func RefreshModelMapping(inst *Instance) bool {
|
||||
dynamicModelMap = newMap
|
||||
dynamicModelMapMu.Unlock()
|
||||
inst.SetModelMappingReady(true)
|
||||
inst.ClearModelMappingUnavailable()
|
||||
slog.Info("[LS-POOL] Model mapping refreshed",
|
||||
"account", inst.AccountID,
|
||||
"replica", inst.Replica,
|
||||
@ -1449,6 +1485,7 @@ func RefreshModelMapping(inst *Instance) bool {
|
||||
return true
|
||||
}
|
||||
inst.SetModelMappingReady(false)
|
||||
inst.ClearModelMappingUnavailable()
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@ -501,6 +501,11 @@ func (m *workerManager) waitForWorkerReady(handle *workerHandle, routingKey stri
|
||||
values.Set("routing_key", routingKey)
|
||||
}
|
||||
|
||||
var (
|
||||
lastStatus int
|
||||
lastBody string
|
||||
)
|
||||
|
||||
for {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, workerURL(handle, "/readyz", values), nil)
|
||||
if err != nil {
|
||||
@ -511,22 +516,48 @@ func (m *workerManager) waitForWorkerReady(handle *workerHandle, routingKey stri
|
||||
if err == nil {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
lastStatus = resp.StatusCode
|
||||
lastBody = truncate(string(body), 200)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
if len(body) > 0 {
|
||||
if isWorkerModelMappingUnavailable(resp.StatusCode, lastBody) {
|
||||
return fmt.Errorf("%w: worker %s %s", errLSModelMapDenied, handle.Container, strings.TrimSpace(lastBody))
|
||||
}
|
||||
if len(body) > 0 && shouldWarnWorkerNotReady(resp.StatusCode, lastBody) {
|
||||
m.logger.Warn("ls worker not ready yet", "container", handle.Container, "status", resp.StatusCode, "body", truncate(string(body), 200))
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if lastStatus > 0 || lastBody != "" {
|
||||
return fmt.Errorf("worker %s not ready for routing key %q (last_status=%d last_body=%q): %w", handle.Container, routingKey, lastStatus, lastBody, ctx.Err())
|
||||
}
|
||||
return fmt.Errorf("worker %s not ready for routing key %q: %w", handle.Container, routingKey, ctx.Err())
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldWarnWorkerNotReady(status int, body string) bool {
|
||||
if status == http.StatusServiceUnavailable {
|
||||
normalized := strings.ToLower(strings.TrimSpace(body))
|
||||
if strings.Contains(normalized, "model mapping not ready") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isWorkerModelMappingUnavailable(status int, body string) bool {
|
||||
if status != http.StatusServiceUnavailable {
|
||||
return false
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(body))
|
||||
return strings.Contains(normalized, errLSModelMapDenied.Error())
|
||||
}
|
||||
|
||||
func (m *workerManager) syncWorkerState(handle *workerHandle, state *workerAccountState) error {
|
||||
if state == nil {
|
||||
return fmt.Errorf("ls worker state is nil")
|
||||
|
||||
@ -264,3 +264,72 @@ func TestFakeDockerClientImplementsFilterAwareList(t *testing.T) {
|
||||
_, err := fakeDocker.ContainerList(context.Background(), container.ListOptions{Filters: filters.NewArgs()})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestShouldWarnWorkerNotReadySuppressesModelMappingPending(t *testing.T) {
|
||||
require.False(t, shouldWarnWorkerNotReady(http.StatusServiceUnavailable, "worker model mapping not ready for replica 0"))
|
||||
require.True(t, shouldWarnWorkerNotReady(http.StatusServiceUnavailable, "worker access token not configured"))
|
||||
require.True(t, shouldWarnWorkerNotReady(http.StatusBadGateway, "upstream failed"))
|
||||
}
|
||||
|
||||
func TestWorkerManagerWaitForWorkerReadyStopsOnModelMappingUnavailable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/readyz", r.URL.Path)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`model mapping unavailable for replica 0: oauth2: "unauthorized_client" "Unauthorized"`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
manager, err := newWorkerManager(workerManagerConfig{
|
||||
Image: "worker:latest",
|
||||
Network: "sub2api-network",
|
||||
DockerSocket: "unix:///var/run/docker.sock",
|
||||
IdleTTL: time.Minute,
|
||||
MaxActive: 1,
|
||||
StartupTimeout: time.Second,
|
||||
RequestTimeout: time.Second,
|
||||
}, &fakeDockerClient{})
|
||||
require.NoError(t, err)
|
||||
defer manager.Close()
|
||||
|
||||
handle := &workerHandle{
|
||||
Container: "sub2api-ls-test",
|
||||
Address: strings.TrimPrefix(server.URL, "http://"),
|
||||
AuthToken: "worker-token",
|
||||
}
|
||||
|
||||
err = manager.waitForWorkerReady(handle, "")
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errLSModelMapDenied)
|
||||
}
|
||||
|
||||
func TestWorkerManagerWaitForWorkerReadyIncludesLastBodyOnTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/readyz", r.URL.Path)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte("worker model mapping not ready for replica 0\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
manager, err := newWorkerManager(workerManagerConfig{
|
||||
Image: "worker:latest",
|
||||
Network: "sub2api-network",
|
||||
DockerSocket: "unix:///var/run/docker.sock",
|
||||
IdleTTL: time.Minute,
|
||||
MaxActive: 1,
|
||||
StartupTimeout: 100 * time.Millisecond,
|
||||
RequestTimeout: time.Second,
|
||||
}, &fakeDockerClient{})
|
||||
require.NoError(t, err)
|
||||
defer manager.Close()
|
||||
|
||||
handle := &workerHandle{
|
||||
Container: "sub2api-ls-test",
|
||||
Address: strings.TrimPrefix(server.URL, "http://"),
|
||||
AuthToken: "worker-token",
|
||||
}
|
||||
|
||||
err = manager.waitForWorkerReady(handle, "")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), `last_status=503`)
|
||||
require.Contains(t, err.Error(), `last_body="worker model mapping not ready for replica 0`)
|
||||
}
|
||||
|
||||
@ -308,6 +308,9 @@ func (s *WorkerServer) ensureReady(ctx context.Context, routingKey string) (*Ins
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inst.HasModelMappingUnavailable() {
|
||||
return nil, fmt.Errorf("%w for replica %d: %s", errLSModelMapDenied, inst.Replica, inst.ModelMappingUnavailableReason())
|
||||
}
|
||||
if inst.HasModelMappingReady() {
|
||||
return inst, nil
|
||||
}
|
||||
@ -316,6 +319,9 @@ func (s *WorkerServer) ensureReady(ctx context.Context, routingKey string) (*Ins
|
||||
defer cancel()
|
||||
_ = modelCtx
|
||||
if !RefreshModelMapping(inst) {
|
||||
if inst.HasModelMappingUnavailable() {
|
||||
return nil, fmt.Errorf("%w for replica %d: %s", errLSModelMapDenied, inst.Replica, inst.ModelMappingUnavailableReason())
|
||||
}
|
||||
return nil, fmt.Errorf("worker model mapping not ready for replica %d", inst.Replica)
|
||||
}
|
||||
return inst, nil
|
||||
|
||||
@ -13,9 +13,12 @@ import (
|
||||
)
|
||||
|
||||
// allowedSchemes 代理协议白名单
|
||||
// 注意: https 代理已被移除。当前实现(Go dialer.go 和 Node proxy.js)
|
||||
// 对 https:// 代理仅做 TCP 连接后发明文 CONNECT,不建立外层 TLS,
|
||||
// 导致 Proxy-Authorization 凭据在首跳明文传输。
|
||||
// 若需 https 代理支持,须先在 dialer.go 和 proxy.js 中实现 TLS-to-proxy。
|
||||
var allowedSchemes = map[string]bool{
|
||||
"http": true,
|
||||
"https": true,
|
||||
"socks5": true,
|
||||
"socks5h": true,
|
||||
}
|
||||
@ -31,7 +34,7 @@ var allowedSchemes = map[string]bool{
|
||||
// - TrimSpace 后为空视为直连
|
||||
// - url.Parse 失败返回 error(不含原始 URL,防凭据泄露)
|
||||
// - Host 为空返回 error(用 Redacted() 脱敏)
|
||||
// - Scheme 必须为 http/https/socks5/socks5h
|
||||
// - Scheme 必须为 http/socks5/socks5h(https 不支持,因 CONNECT 明文传输)
|
||||
// - socks5:// 自动升级为 socks5h://(确保 DNS 由代理端解析,防止 DNS 泄漏)
|
||||
func Parse(raw string) (trimmed string, parsed *url.URL, err error) {
|
||||
trimmed = strings.TrimSpace(raw)
|
||||
@ -51,7 +54,10 @@ func Parse(raw string) (trimmed string, parsed *url.URL, err error) {
|
||||
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
if !allowedSchemes[scheme] {
|
||||
return "", nil, fmt.Errorf("unsupported proxy scheme %q (allowed: http, https, socks5, socks5h)", scheme)
|
||||
if scheme == "https" {
|
||||
return "", nil, fmt.Errorf("https proxy scheme is not supported: current implementation sends CONNECT in plaintext (use http:// or socks5:// instead)")
|
||||
}
|
||||
return "", nil, fmt.Errorf("unsupported proxy scheme %q (allowed: http, socks5, socks5h)", scheme)
|
||||
}
|
||||
|
||||
// 自动升级 socks5 → socks5h,确保 DNS 由代理端解析,防止 DNS 泄漏。
|
||||
|
||||
@ -47,13 +47,13 @@ func TestParse_有效HTTP代理(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_有效HTTPS代理(t *testing.T) {
|
||||
_, parsed, err := Parse("https://proxy.example.com:443")
|
||||
if err != nil {
|
||||
t.Fatalf("有效 HTTPS 代理应成功: %v", err)
|
||||
func TestParse_HTTPS代理被拒绝(t *testing.T) {
|
||||
_, _, err := Parse("https://proxy.example.com:443")
|
||||
if err == nil {
|
||||
t.Fatal("https 代理应返回错误(当前实现不支持 TLS-to-proxy)")
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
t.Errorf("Scheme 不匹配: got %q", parsed.Scheme)
|
||||
if !strings.Contains(err.Error(), "https proxy scheme is not supported") {
|
||||
t.Errorf("错误信息应包含 'https proxy scheme is not supported': got %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
571
backend/internal/pkg/telemetry/telemetry.go
Normal file
571
backend/internal/pkg/telemetry/telemetry.go
Normal file
@ -0,0 +1,571 @@
|
||||
// Package telemetry simulates the real Claude Code CLI's OTEL telemetry events.
|
||||
//
|
||||
// Real CLI emits events to two channels:
|
||||
// 1. Anthropic event_logging/batch (first-party events)
|
||||
// 2. Datadog log intake (third-party observability)
|
||||
//
|
||||
// Ported from antigravity/node-tls-proxy/proxy.js — see that file for JS original.
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
claude "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
)
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────
|
||||
|
||||
const (
|
||||
ddAPIKey = "pubbbf48e6d78dae54bceaa4acf463299bf"
|
||||
fakeNodeVersion = "v24.3.0"
|
||||
buildTime = "2026-03-31T01:39:46Z"
|
||||
sessionMaxAge = time.Hour
|
||||
sessionCleanup = 5 * time.Minute
|
||||
telemetryTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// ─── Virtual Host Identity ───────────────────────────────
|
||||
|
||||
var (
|
||||
mbpNames = []string{"alex", "sam", "chris", "max", "lee", "kai", "jamie", "taylor", "morgan", "casey", "drew", "avery", "riley", "blake", "jordan", "ryan", "parker", "quinn", "reese", "cameron"}
|
||||
mbpSuffix = []string{"-MBP", "-MacBook", "-MacBook-Pro", "-MacBook-Air", "s-MBP", "s-MacBook", "s-MacBook-Pro"}
|
||||
)
|
||||
|
||||
type hostIdentity struct {
|
||||
Hostname string
|
||||
Username string
|
||||
Terminal string
|
||||
Shell string
|
||||
MachineID string
|
||||
Arch string
|
||||
OSVersion string
|
||||
KernelRelease string
|
||||
ExecPath string
|
||||
RipgrepVersion string
|
||||
RipgrepPath string
|
||||
McpServerCount int
|
||||
McpFailCount int
|
||||
}
|
||||
|
||||
func hashField(seed, field string) []byte {
|
||||
h := sha256.Sum256([]byte(seed + ":" + field))
|
||||
return h[:]
|
||||
}
|
||||
|
||||
func generateHostIdentity(seed string) hostIdentity {
|
||||
hb := hashField(seed, "hostname")
|
||||
name := mbpNames[int(hb[0])%len(mbpNames)]
|
||||
sfx := mbpSuffix[int(hb[1])%len(mbpSuffix)]
|
||||
|
||||
termRoll := int(hashField(seed, "terminal")[0]) % 100
|
||||
var terminal string
|
||||
switch {
|
||||
case termRoll < 75:
|
||||
terminal = "xterm-256color"
|
||||
case termRoll < 88:
|
||||
terminal = "screen-256color"
|
||||
case termRoll < 96:
|
||||
terminal = "alacritty"
|
||||
default:
|
||||
terminal = "kitty"
|
||||
}
|
||||
|
||||
shellRoll := int(hashField(seed, "shell")[0]) % 100
|
||||
var shell string
|
||||
switch {
|
||||
case shellRoll < 65:
|
||||
shell = "/bin/zsh"
|
||||
case shellRoll < 82:
|
||||
shell = "/usr/local/bin/zsh"
|
||||
case shellRoll < 93:
|
||||
shell = "/bin/bash"
|
||||
default:
|
||||
shell = "/opt/homebrew/bin/fish"
|
||||
}
|
||||
|
||||
mid := hashField(seed, "machine-id")
|
||||
machineID := fmt.Sprintf("%s-%s-%s-%s-%s",
|
||||
strings.ToUpper(hex.EncodeToString(mid[0:4])),
|
||||
strings.ToUpper(hex.EncodeToString(mid[4:6])),
|
||||
strings.ToUpper(hex.EncodeToString(mid[6:8])),
|
||||
strings.ToUpper(hex.EncodeToString(mid[8:10])),
|
||||
strings.ToUpper(hex.EncodeToString(mid[10:16])),
|
||||
)
|
||||
|
||||
osb := hashField(seed, "os")
|
||||
major := 13 + int(osb[0])%3
|
||||
minor := int(osb[1]) % 8
|
||||
patch := int(osb[2]) % 5
|
||||
darwinMajor := major + 9 // macOS 13 = Darwin 22
|
||||
darwinMinor := int(osb[3]) % 7
|
||||
darwinPatch := int(osb[4]) % 3
|
||||
|
||||
archRoll := int(hashField(seed, "arch")[0]) % 100
|
||||
arch := "arm64"
|
||||
if archRoll >= 70 {
|
||||
arch = "x64"
|
||||
}
|
||||
|
||||
execRoll := int(hashField(seed, "exec")[0]) % 100
|
||||
var execPath string
|
||||
switch {
|
||||
case execRoll < 40:
|
||||
execPath = "/usr/local/bin/claude"
|
||||
case execRoll < 70:
|
||||
execPath = "/opt/homebrew/bin/claude"
|
||||
case execRoll < 90:
|
||||
execPath = fmt.Sprintf("/Users/%s/.npm-global/bin/claude", name)
|
||||
default:
|
||||
execPath = fmt.Sprintf("/Users/%s/.local/bin/claude", name)
|
||||
}
|
||||
|
||||
rgVersions := []string{"14.1.1", "14.1.0", "14.0.3", "14.0.2", "13.0.0", "14.1.2", "14.0.1"}
|
||||
rgPaths := []string{"/opt/homebrew/bin/rg", "/usr/local/bin/rg", "/Users/" + name + "/.cargo/bin/rg", "/usr/bin/rg"}
|
||||
rb := hashField(seed, "ripgrep")
|
||||
|
||||
return hostIdentity{
|
||||
Hostname: name + sfx,
|
||||
Username: name,
|
||||
Terminal: terminal,
|
||||
Shell: shell,
|
||||
MachineID: machineID,
|
||||
Arch: arch,
|
||||
OSVersion: fmt.Sprintf("%d.%d.%d", major, minor, patch),
|
||||
KernelRelease: fmt.Sprintf("%d.%d.%d", darwinMajor, darwinMinor, darwinPatch),
|
||||
ExecPath: execPath,
|
||||
RipgrepVersion: rgVersions[int(rb[0])%len(rgVersions)],
|
||||
RipgrepPath: rgPaths[int(rb[1])%len(rgPaths)],
|
||||
McpServerCount: int(rb[2])%5 + 1,
|
||||
McpFailCount: int(rb[3]) % 3,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Session State ───────────────────────────────────────
|
||||
|
||||
type sessionState struct {
|
||||
SessionID string
|
||||
DeviceID string
|
||||
HostID hostIdentity
|
||||
StartTime time.Time
|
||||
RequestCount int64
|
||||
RipgrepReported bool
|
||||
}
|
||||
|
||||
var (
|
||||
sessions = make(map[string]*sessionState)
|
||||
sessionsMu sync.Mutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(sessionCleanup)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
sessionsMu.Lock()
|
||||
for k, s := range sessions {
|
||||
if now.Sub(s.StartTime) > sessionMaxAge {
|
||||
delete(sessions, k)
|
||||
}
|
||||
}
|
||||
sessionsMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func generateDeviceID(accountSeed string) string {
|
||||
h := sha256.Sum256([]byte("device:" + accountSeed))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func getOrCreateSession(deviceID string) *sessionState {
|
||||
sessionsMu.Lock()
|
||||
defer sessionsMu.Unlock()
|
||||
|
||||
if s, ok := sessions[deviceID]; ok {
|
||||
return s
|
||||
}
|
||||
s := &sessionState{
|
||||
SessionID: generateUUID(),
|
||||
DeviceID: deviceID,
|
||||
HostID: generateHostIdentity(deviceID),
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
sessions[deviceID] = s
|
||||
return s
|
||||
}
|
||||
|
||||
func generateUUID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // variant
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
// ─── Process Metrics Simulation ──────────────────────────
|
||||
|
||||
func buildProcessMetrics(uptime float64) string {
|
||||
baseRss := 180_000_000.0 + math.Min(uptime*50_000, 200_000_000)
|
||||
rss := int64(baseRss + rand.Float64()*80_000_000)
|
||||
heapTotal := int64(float64(rss)*0.6 + rand.Float64()*10_000_000)
|
||||
heapUsed := int64(float64(heapTotal)*0.5 + rand.Float64()*float64(heapTotal)*0.3)
|
||||
|
||||
metrics := map[string]any{
|
||||
"uptime": uptime,
|
||||
"rss": rss,
|
||||
"heapTotal": heapTotal,
|
||||
"heapUsed": heapUsed,
|
||||
"external": 14_000_000 + rand.Intn(2_000_000),
|
||||
"arrayBuffers": rand.Intn(200_000),
|
||||
"constrainedMemory": 51539607552,
|
||||
"cpuUsage": map[string]int64{
|
||||
"user": int64(uptime*10_000 + rand.Float64()*300_000),
|
||||
"system": int64(uptime*2_000 + rand.Float64()*80_000),
|
||||
},
|
||||
"cpuPercent": rand.Float64() * 200,
|
||||
}
|
||||
data, _ := json.Marshal(metrics)
|
||||
return base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// ─── Env Block ───────────────────────────────────────────
|
||||
|
||||
func buildEnvBlock(hostID hostIdentity) map[string]any {
|
||||
return map[string]any{
|
||||
"platform": "darwin",
|
||||
"node_version": fakeNodeVersion,
|
||||
"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": claude.DefaultCLIVersion,
|
||||
"arch": hostID.Arch,
|
||||
"is_claude_code_remote": false,
|
||||
"deployment_environment": "unknown-darwin",
|
||||
"is_conductor": false,
|
||||
"version_base": claude.DefaultCLIVersion,
|
||||
"build_time": buildTime,
|
||||
"is_local_agent_mode": false,
|
||||
"vcs": "git",
|
||||
"platform_raw": "darwin",
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Event Building ──────────────────────────────────────
|
||||
|
||||
type eventWrapper struct {
|
||||
EventType string `json:"event_type"`
|
||||
EventData map[string]any `json:"event_data"`
|
||||
}
|
||||
|
||||
func buildEvent(eventName string, session *sessionState, model, betas string, extraData map[string]any, tsOverride string) eventWrapper {
|
||||
uptime := time.Since(session.StartTime).Seconds()
|
||||
pm := buildProcessMetrics(uptime)
|
||||
|
||||
ts := tsOverride
|
||||
if ts == "" {
|
||||
ts = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
if model == "" {
|
||||
model = "claude-sonnet-4-6"
|
||||
}
|
||||
if betas == "" {
|
||||
betas = "claude-code-20250219,interleaved-thinking-2025-05-14"
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"event_name": eventName,
|
||||
"client_timestamp": ts,
|
||||
"model": model,
|
||||
"session_id": session.SessionID,
|
||||
"user_type": "external",
|
||||
"betas": betas,
|
||||
"env": buildEnvBlock(session.HostID),
|
||||
"entrypoint": "cli",
|
||||
"is_interactive": true,
|
||||
"client_type": "cli",
|
||||
"process": pm,
|
||||
"event_id": generateUUID(),
|
||||
"device_id": session.DeviceID,
|
||||
}
|
||||
|
||||
for k, v := range extraData {
|
||||
data[k] = v
|
||||
}
|
||||
|
||||
return eventWrapper{
|
||||
EventType: "ClaudeCodeInternalEvent",
|
||||
EventData: data,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Send Functions ──────────────────────────────────────
|
||||
|
||||
var httpClient = &http.Client{Timeout: telemetryTimeout}
|
||||
|
||||
func sendTelemetryEvents(events []eventWrapper, session *sessionState, authToken string) {
|
||||
if len(events) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]any{"events": events}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://api.anthropic.com/api/event_logging/batch", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "claude-code/"+claude.DefaultCLIVersion)
|
||||
req.Header.Set("x-service-name", "claude-code")
|
||||
if authToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Debug("telemetry_error", "error", err.Error())
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
slog.Debug("telemetry_sent", "status", resp.StatusCode, "events", len(events))
|
||||
}
|
||||
|
||||
func sendDatadogLog(eventName string, session *sessionState, model string) {
|
||||
hostID := session.HostID
|
||||
uptime := time.Since(session.StartTime).Seconds()
|
||||
|
||||
if model == "" {
|
||||
model = "claude-sonnet-4-6"
|
||||
}
|
||||
|
||||
baseRss := 180_000_000.0 + math.Min(uptime*50_000, 200_000_000)
|
||||
rss := int64(baseRss + rand.Float64()*80_000_000)
|
||||
heapTotal := int64(float64(rss)*0.6 + rand.Float64()*10_000_000)
|
||||
heapUsed := int64(float64(heapTotal)*0.5 + rand.Float64()*float64(heapTotal)*0.3)
|
||||
|
||||
pm := map[string]any{
|
||||
"uptime": uptime,
|
||||
"rss": rss,
|
||||
"heapTotal": heapTotal,
|
||||
"heapUsed": heapUsed,
|
||||
"external": 14_000_000 + rand.Intn(2_000_000),
|
||||
"arrayBuffers": rand.Intn(10_000),
|
||||
"constrainedMemory": 0,
|
||||
"cpuUsage": map[string]int64{
|
||||
"user": int64(uptime*10_000 + rand.Float64()*300_000),
|
||||
"system": int64(uptime*2_000 + rand.Float64()*80_000),
|
||||
},
|
||||
}
|
||||
|
||||
entry := map[string]any{
|
||||
"ddsource": "nodejs",
|
||||
"ddtags": fmt.Sprintf("event:%s,arch:%s,client_type:cli,model:%s,platform:darwin,user_type:external,version:%s,version_base:%s", eventName, hostID.Arch, model, claude.DefaultCLIVersion, claude.DefaultCLIVersion),
|
||||
"message": eventName,
|
||||
"service": "claude-code",
|
||||
"hostname": "claude-code",
|
||||
"env": "external",
|
||||
"model": model,
|
||||
"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": fakeNodeVersion,
|
||||
"version": claude.DefaultCLIVersion,
|
||||
"version_base": claude.DefaultCLIVersion,
|
||||
"build_time": buildTime,
|
||||
"deployment_environment": "unknown-darwin",
|
||||
"vcs": "git",
|
||||
}
|
||||
|
||||
body, err := json.Marshal([]any{entry})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://http-intake.logs.us5.datadoghq.com/api/v2/logs", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "axios/1.13.6")
|
||||
req.Header.Set("DD-API-KEY", ddAPIKey)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────
|
||||
|
||||
// EmitPreRequest fires pre-request telemetry events for a /v1/messages request.
|
||||
// accountSeed should be a stable identifier for the account (e.g. account ID or OAuth token suffix).
|
||||
// authHeader is the Authorization header value (used for device ID derivation).
|
||||
// authToken is the raw OAuth token (without "Bearer " prefix) for 1P auth.
|
||||
// model is the model name from the request body (e.g. "claude-sonnet-4-6").
|
||||
// betaHeader is the anthropic-beta header value.
|
||||
func EmitPreRequest(accountSeed, authHeader, authToken, model, betaHeader string) {
|
||||
authSuffix := authHeader
|
||||
if len(authSuffix) > 16 {
|
||||
authSuffix = authSuffix[len(authSuffix)-16:]
|
||||
}
|
||||
deviceID := generateDeviceID(accountSeed + ":" + authSuffix)
|
||||
session := getOrCreateSession(deviceID)
|
||||
session.RequestCount++
|
||||
|
||||
if model == "" {
|
||||
model = "claude-sonnet-4-6"
|
||||
}
|
||||
betas := betaHeader
|
||||
if betas == "" {
|
||||
betas = claude.DefaultBetaHeader
|
||||
}
|
||||
|
||||
// First request: full startup sequence
|
||||
if session.RequestCount == 1 {
|
||||
hostID := session.HostID
|
||||
baseTime := time.Now()
|
||||
ts := func(offsetMs int) string {
|
||||
return baseTime.Add(time.Duration(offsetMs) * time.Millisecond).UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
batch1 := []eventWrapper{
|
||||
buildEvent("tengu_started", session, model, betas, nil, ts(0)),
|
||||
buildEvent("tengu_init", session, model, betas, nil, ts(80+rand.Intn(120))),
|
||||
buildEvent("tengu_ripgrep_availability", session, model, betas, map[string]any{
|
||||
"ripgrep_available": true,
|
||||
"ripgrep_version": hostID.RipgrepVersion,
|
||||
"ripgrep_path": hostID.RipgrepPath,
|
||||
}, ts(200+rand.Intn(150))),
|
||||
}
|
||||
|
||||
// MCP connection events
|
||||
mcpOffset := 400
|
||||
mcpSuccessCount := hostID.McpServerCount - hostID.McpFailCount
|
||||
for i := 0; i < hostID.McpFailCount; i++ {
|
||||
mcpOffset += 100 + rand.Intn(300)
|
||||
batch1 = append(batch1, buildEvent("tengu_mcp_server_connection_failed", session, model, betas, nil, ts(mcpOffset)))
|
||||
}
|
||||
for i := 0; i < mcpSuccessCount; i++ {
|
||||
mcpOffset += 200 + rand.Intn(500)
|
||||
batch1 = append(batch1, buildEvent("tengu_mcp_server_connection_succeeded", session, model, betas, nil, ts(mcpOffset)))
|
||||
}
|
||||
|
||||
session.RipgrepReported = true
|
||||
go sendTelemetryEvents(batch1, session, authToken)
|
||||
go sendDatadogLog("tengu_started", session, model)
|
||||
go sendDatadogLog("tengu_init", session, model)
|
||||
|
||||
// Delayed batch (~25-35s later, matches real CLI timing)
|
||||
go func() {
|
||||
time.Sleep(time.Duration(25000+rand.Intn(10000)) * time.Millisecond)
|
||||
sendTelemetryEvents([]eventWrapper{
|
||||
buildEvent("tengu_session_init", session, model, betas, nil, ""),
|
||||
buildEvent("tengu_context_loaded", session, model, betas, nil, ""),
|
||||
}, session, authToken)
|
||||
}()
|
||||
}
|
||||
|
||||
// Every request: tengu_api_query (real CLI event name)
|
||||
go sendTelemetryEvents([]eventWrapper{
|
||||
buildEvent("tengu_api_query", session, model, betas, nil, ""),
|
||||
}, session, authToken)
|
||||
}
|
||||
|
||||
// EmitPostRequest fires post-request telemetry events after upstream response.
|
||||
func EmitPostRequest(accountSeed, authHeader, authToken, model, betaHeader string, statusCode int) {
|
||||
authSuffix := authHeader
|
||||
if len(authSuffix) > 16 {
|
||||
authSuffix = authSuffix[len(authSuffix)-16:]
|
||||
}
|
||||
deviceID := generateDeviceID(accountSeed + ":" + authSuffix)
|
||||
session := getOrCreateSession(deviceID)
|
||||
|
||||
if model == "" {
|
||||
model = "claude-sonnet-4-6"
|
||||
}
|
||||
betas := betaHeader
|
||||
if betas == "" {
|
||||
betas = claude.DefaultBetaHeader
|
||||
}
|
||||
|
||||
// Real CLI uses tengu_api_success on success, tengu_api_error on failure
|
||||
if statusCode < 400 {
|
||||
events := []eventWrapper{
|
||||
buildEvent("tengu_api_success", session, model, betas, nil, ""),
|
||||
}
|
||||
go sendTelemetryEvents(events, session, authToken)
|
||||
go sendDatadogLog("tengu_api_success", session, model)
|
||||
} else {
|
||||
var errMsg string
|
||||
switch {
|
||||
case statusCode == 429:
|
||||
errMsg = "rate_limit_exceeded"
|
||||
case statusCode == 529:
|
||||
errMsg = "overloaded"
|
||||
case statusCode >= 500:
|
||||
errMsg = "server_error"
|
||||
default:
|
||||
errMsg = "client_error"
|
||||
}
|
||||
errEvent := buildEvent("tengu_api_error", session, model, betas, map[string]any{
|
||||
"error_type": "TelemetrySafeError",
|
||||
"error_code": statusCode,
|
||||
"error_message": errMsg,
|
||||
}, "")
|
||||
go sendTelemetryEvents([]eventWrapper{errEvent}, session, authToken)
|
||||
go sendDatadogLog("tengu_api_error", session, model)
|
||||
}
|
||||
|
||||
// Random tool_use event (30% probability, 2-7s delay)
|
||||
if rand.Float64() < 0.3 {
|
||||
go func() {
|
||||
time.Sleep(time.Duration(2000+rand.Intn(5000)) * time.Millisecond)
|
||||
sendTelemetryEvents([]eventWrapper{
|
||||
buildEvent("tengu_tool_use_success", session, model, betas, nil, ""),
|
||||
}, session, authToken)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Jitter returns a random delay to inject before forwarding a request.
|
||||
// 80% fast (80-300ms exponential), 20% slow (400-1200ms uniform).
|
||||
func Jitter() time.Duration {
|
||||
if rand.Float64() < 0.80 {
|
||||
ms := 80.0 + (-math.Log(rand.Float64()) * 90.0)
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
ms := 400.0 + rand.Float64()*800.0
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
@ -141,7 +141,7 @@ func NewSOCKS5ProxyDialer(profile *Profile, proxyURL *url.URL) *SOCKS5ProxyDiale
|
||||
// DialTLSContext establishes a TLS connection through SOCKS5 proxy with the configured fingerprint.
|
||||
// Flow: SOCKS5 CONNECT to target -> TLS handshake with utls on the tunnel
|
||||
func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
slog.Debug("tls_fingerprint_socks5_connecting", "proxy", d.proxyURL.Host, "target", addr)
|
||||
slog.Info("tls_fingerprint_socks5_connecting", "proxy", d.proxyURL.Host, "target", addr)
|
||||
|
||||
// Step 1: Create SOCKS5 dialer
|
||||
var auth *proxy.Auth
|
||||
@ -160,20 +160,24 @@ func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr st
|
||||
proxyAddr = net.JoinHostPort(d.proxyURL.Hostname(), "1080") // Default SOCKS5 port
|
||||
}
|
||||
|
||||
socksDialer, err := proxy.SOCKS5("tcp", proxyAddr, auth, proxy.Direct)
|
||||
// Use a TCP-only forward dialer (no DNS resolution) so the SOCKS5 protocol
|
||||
// sends the target hostname to the proxy for remote DNS resolution (socks5h semantics).
|
||||
// proxy.Direct would attempt local DNS first, which fails inside Docker.
|
||||
tcpDialer := &net.Dialer{}
|
||||
socksDialer, err := proxy.SOCKS5("tcp", proxyAddr, auth, tcpDialer)
|
||||
if err != nil {
|
||||
slog.Debug("tls_fingerprint_socks5_dialer_failed", "error", err)
|
||||
slog.Info("tls_fingerprint_socks5_dialer_failed", "error", err)
|
||||
return nil, fmt.Errorf("create SOCKS5 dialer: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Establish SOCKS5 tunnel to target
|
||||
slog.Debug("tls_fingerprint_socks5_establishing_tunnel", "target", addr)
|
||||
conn, err := socksDialer.Dial("tcp", addr)
|
||||
slog.Info("tls_fingerprint_socks5_establishing_tunnel", "target", addr)
|
||||
conn, err := socksDialer.(proxy.ContextDialer).DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
slog.Debug("tls_fingerprint_socks5_connect_failed", "error", err)
|
||||
slog.Info("tls_fingerprint_socks5_connect_failed", "error", err)
|
||||
return nil, fmt.Errorf("SOCKS5 connect: %w", err)
|
||||
}
|
||||
slog.Debug("tls_fingerprint_socks5_tunnel_established")
|
||||
slog.Info("tls_fingerprint_socks5_tunnel_established", "target", addr)
|
||||
|
||||
// Step 3: Perform TLS handshake on the tunnel with utls fingerprint
|
||||
return performTLSHandshake(ctx, conn, d.profile, addr)
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@ -278,20 +279,18 @@ func createReqClient(proxyURL string) (*req.Client, error) {
|
||||
}
|
||||
|
||||
client := req.C().
|
||||
SetTimeout(15 * time.Second).
|
||||
SetCookieJar(nil) // 禁用 CookieJar,确保每次授权都是干净的会话
|
||||
SetTimeout(60 * time.Second).
|
||||
SetCookieJar(nil). // 禁用 CookieJar,确保每次授权都是干净的会话
|
||||
EnableForceHTTP1() // 强制 HTTP/1.1,避免 H2 升级与自定义 TLS dialer 冲突
|
||||
|
||||
trimmed, _, err := proxyurl.Parse(proxyURL)
|
||||
trimmed, parsedProxy, err := proxyurl.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if trimmed != "" {
|
||||
parsedProxy, parseErr := url.Parse(trimmed)
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("parse proxy URL: %w", parseErr)
|
||||
}
|
||||
if trimmed != "" && parsedProxy != nil {
|
||||
scheme := strings.ToLower(parsedProxy.Scheme)
|
||||
slog.Info("oauth_create_client", "proxy_scheme", scheme, "proxy_host", parsedProxy.Hostname())
|
||||
switch scheme {
|
||||
case "socks5", "socks5h":
|
||||
socks5Dialer := tlsfingerprint.NewSOCKS5ProxyDialer(profile, parsedProxy)
|
||||
@ -303,6 +302,7 @@ func createReqClient(proxyURL string) (*req.Client, error) {
|
||||
client.SetProxyURL(trimmed)
|
||||
}
|
||||
} else {
|
||||
slog.Info("oauth_create_client", "proxy_scheme", "none", "raw_proxy_url", proxyURL)
|
||||
dialer := tlsfingerprint.NewDialer(profile, nil)
|
||||
client.SetDialTLS(dialer.DialTLSContext)
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
)
|
||||
|
||||
@ -45,7 +46,7 @@ const (
|
||||
defaultIdleConnTimeout = 90 * time.Second
|
||||
// defaultResponseHeaderTimeout: 默认等待响应头超时时间(5分钟)
|
||||
// LLM 请求可能排队较久,需要较长超时
|
||||
defaultResponseHeaderTimeout = 300 * time.Second
|
||||
defaultResponseHeaderTimeout = 600 * time.Second
|
||||
// defaultMaxUpstreamClients: 默认最大客户端缓存数量
|
||||
// 超出后会淘汰最久未使用的客户端
|
||||
defaultMaxUpstreamClients = 5000
|
||||
@ -148,6 +149,14 @@ func NewHTTPUpstream(cfg *config.Config) service.HTTPUpstream {
|
||||
// - 调用方必须关闭 resp.Body,否则会导致 inFlight 计数泄漏
|
||||
// - inFlight > 0 的客户端不会被淘汰,确保活跃请求不被中断
|
||||
func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
||||
// TLS 指纹路由:对匹配主机使用 Go 原生 utls 指纹
|
||||
// 使用 utls 模拟 Claude CLI 的 JA3/JA4 指纹,支持直连和代理
|
||||
if s.isTLSFingerprintRoutingEnabled() && req != nil && req.URL != nil && req.URL.Scheme == "https" {
|
||||
if s.shouldRouteWithTLSFingerprint(req) {
|
||||
return s.doWithTLSFingerprint(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.validateRequestHost(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -195,7 +204,7 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
|
||||
}
|
||||
proxyInfo := "direct"
|
||||
if proxyURL != "" {
|
||||
proxyInfo = proxyURL
|
||||
proxyInfo = logredact.RedactProxyURL(proxyURL)
|
||||
}
|
||||
slog.Debug("tls_fingerprint_enabled", "account_id", accountID, "target", targetHost, "proxy", proxyInfo, "profile", profile.Name)
|
||||
|
||||
@ -292,7 +301,7 @@ func (s *httpUpstreamService) getClientEntryWithTLS(proxyURL string, accountID i
|
||||
}
|
||||
|
||||
// 创建带 TLS 指纹的 Transport
|
||||
slog.Debug("tls_fingerprint_creating_new_client", "account_id", accountID, "cache_key", cacheKey, "proxy", proxyKey)
|
||||
slog.Debug("tls_fingerprint_creating_new_client", "account_id", accountID, "cache_key", cacheKey, "proxy", logredact.RedactProxyURL(proxyKey))
|
||||
settings := s.resolvePoolSettings(isolation, accountConcurrency)
|
||||
transport, err := buildUpstreamTransportWithTLSFingerprint(settings, parsedProxy, profile)
|
||||
if err != nil {
|
||||
|
||||
85
backend/internal/repository/http_upstream_antigravity.go
Normal file
85
backend/internal/repository/http_upstream_antigravity.go
Normal file
@ -0,0 +1,85 @@
|
||||
package repository
|
||||
|
||||
// ==============================================================
|
||||
// antigravity — Go 原生 TLS 指纹扩展
|
||||
//
|
||||
// 此文件包含 Antigravity fork 新增的 TLS 指纹代理功能,
|
||||
// 与 upstream 代码完全隔离,便于 upstream 更新时的合并维护。
|
||||
//
|
||||
// 上游文件 http_upstream.go 中的钩子调用点:
|
||||
// Do() — 匹配主机时路由到 doWithTLSFingerprint
|
||||
// DoWithTLS() — profile==nil 时回退到 Do(),触发同样的路由
|
||||
//
|
||||
// 替代原先的 Node.js TLS 代理(node-tls-proxy),
|
||||
// 直接使用 Go utls 库模拟 Claude CLI 的 TLS 指纹。
|
||||
// ==============================================================
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
|
||||
)
|
||||
|
||||
// isTLSFingerprintRoutingEnabled 检查 TLS 指纹路由是否启用
|
||||
// 复用 NodeTLSProxy.Enabled 配置项,保持配置兼容
|
||||
func (s *httpUpstreamService) isTLSFingerprintRoutingEnabled() bool {
|
||||
if s.cfg == nil {
|
||||
return false
|
||||
}
|
||||
return s.cfg.Gateway.NodeTLSProxy.Enabled
|
||||
}
|
||||
|
||||
// shouldRouteWithTLSFingerprint 判断请求是否应该使用 TLS 指纹
|
||||
// 仅拦截目标主机在 proxy_hosts 白名单中的 HTTPS 请求,
|
||||
// 白名单为空时默认只代理 api.anthropic.com。
|
||||
func (s *httpUpstreamService) shouldRouteWithTLSFingerprint(req *http.Request) bool {
|
||||
if req == nil || req.URL == nil || req.URL.Scheme != "https" {
|
||||
return false
|
||||
}
|
||||
reqHost := req.URL.Hostname()
|
||||
if reqHost == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
hosts := s.cfg.Gateway.NodeTLSProxy.ProxyHosts
|
||||
if len(hosts) == 0 {
|
||||
return reqHost == "api.anthropic.com"
|
||||
}
|
||||
for _, h := range hosts {
|
||||
if reqHost == h {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// defaultTLSProfile 返回模拟 Claude CLI (Node.js 24.x) 的默认 TLS 指纹配置
|
||||
// 所有 slice 字段留空 → dialer.go 自动使用内置的 Node.js 24.x 默认值
|
||||
// ALPN 仅声明 http/1.1,与真实 CLI 行为一致(undici allowH2=false)
|
||||
func defaultTLSProfile() *tlsfingerprint.Profile {
|
||||
return &tlsfingerprint.Profile{
|
||||
Name: "claude_cli_builtin",
|
||||
EnableGREASE: true,
|
||||
}
|
||||
}
|
||||
|
||||
// doWithTLSFingerprint 使用 Go 原生 utls TLS 指纹发送请求
|
||||
// 直接通过 DoWithTLS 路径,利用已有的 utls dialer 基础设施:
|
||||
// - 直连:Dialer (TCP → utls handshake)
|
||||
// - HTTP 代理:HTTPProxyDialer (CONNECT 隧道 → utls handshake)
|
||||
// - SOCKS5 代理:SOCKS5ProxyDialer (SOCKS5 隧道 → utls handshake)
|
||||
func (s *httpUpstreamService) doWithTLSFingerprint(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
||||
proxyInfo := "direct"
|
||||
if proxyURL != "" {
|
||||
proxyInfo = logredact.RedactProxyURL(proxyURL)
|
||||
}
|
||||
slog.Debug("tls_fingerprint_routing",
|
||||
"account_id", accountID,
|
||||
"target", req.URL.Host,
|
||||
"proxy", proxyInfo,
|
||||
)
|
||||
|
||||
return s.DoWithTLS(req, proxyURL, accountID, accountConcurrency, defaultTLSProfile())
|
||||
}
|
||||
@ -53,8 +53,9 @@ const migrationsLockRetryInterval = 500 * time.Millisecond
|
||||
const nonTransactionalMigrationSuffix = "_notx.sql"
|
||||
|
||||
type migrationChecksumCompatibilityRule struct {
|
||||
fileChecksum string
|
||||
acceptedDBChecksum map[string]struct{}
|
||||
fileChecksum string
|
||||
acceptedFileChecksums map[string]struct{}
|
||||
acceptedDBChecksum map[string]struct{}
|
||||
}
|
||||
|
||||
// migrationChecksumCompatibilityRules 仅用于兼容历史上误修改过的迁移文件 checksum。
|
||||
@ -73,6 +74,15 @@ var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibil
|
||||
"222b4a09c797c22e5922b6b172327c824f5463aaa8760e4f621bc5c22e2be0f3": {},
|
||||
},
|
||||
},
|
||||
"082_create_gateway_debug_logs.sql": {
|
||||
fileChecksum: "b740d7274afbd37d4448e3a3a9aa1fb562181ded5d0319e47a6444187d22f6b1",
|
||||
acceptedFileChecksums: map[string]struct{}{
|
||||
"bf5348a22cf1f27c852096beb3583b67ec43819af82b2f9664397a5638e5b386": {},
|
||||
},
|
||||
acceptedDBChecksum: map[string]struct{}{
|
||||
"d00c2e69711cc0c006b0234566101d8639ba08db77283558f07e2ba412ec177d": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ApplyMigrations 将嵌入的 SQL 迁移文件应用到指定的数据库。
|
||||
@ -328,7 +338,9 @@ func isMigrationChecksumCompatible(name, dbChecksum, fileChecksum string) bool {
|
||||
return false
|
||||
}
|
||||
if rule.fileChecksum != fileChecksum {
|
||||
return false
|
||||
if _, ok := rule.acceptedFileChecksums[fileChecksum]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
_, ok = rule.acceptedDBChecksum[dbChecksum]
|
||||
return ok
|
||||
|
||||
@ -92,6 +92,11 @@ func TestIsMigrationChecksumCompatible_AdditionalCases(t *testing.T) {
|
||||
}
|
||||
require.NotEmpty(t, accepted)
|
||||
require.True(t, isMigrationChecksumCompatible(name, accepted, rule.fileChecksum))
|
||||
|
||||
for alternateFileChecksum := range rule.acceptedFileChecksums {
|
||||
require.True(t, isMigrationChecksumCompatible(name, accepted, alternateFileChecksum))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAtlasBaselineAligned(t *testing.T) {
|
||||
|
||||
@ -103,8 +103,15 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
|
||||
defer cancel()
|
||||
result, err := p.refreshAPI.RefreshIfNeeded(refreshCtx, account, p.executor, antigravityTokenRefreshSkew)
|
||||
if err != nil {
|
||||
// 标记账号临时不可调度,避免后续请求继续命中
|
||||
p.markTempUnschedulable(account, err)
|
||||
// 全局 OAuth 配置缺失不应污染账号状态;账号级失败才标记 temp unschedulable。
|
||||
if shouldMarkTempUnschedulableForRefreshError(err) {
|
||||
p.markTempUnschedulable(account, err)
|
||||
} else {
|
||||
slog.Warn("antigravity_token_provider.temp_unschedulable_skipped",
|
||||
"account_id", account.ID,
|
||||
"reason", err.Error(),
|
||||
)
|
||||
}
|
||||
if p.refreshPolicy.OnRefreshError == ProviderRefreshErrorReturn {
|
||||
return "", err
|
||||
}
|
||||
@ -226,6 +233,23 @@ func (p *AntigravityTokenProvider) markTempUnschedulable(account *Account, refre
|
||||
}
|
||||
}
|
||||
|
||||
func shouldMarkTempUnschedulableForRefreshError(refreshErr error) bool {
|
||||
if refreshErr == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(refreshErr.Error()))
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(msg, "antigravity_oauth_client_secret_missing") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(msg, "missing antigravity oauth client_secret") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *AntigravityTokenProvider) markBackfillAttempted(accountID int64) {
|
||||
p.backfillCooldown.Store(accountID, time.Now())
|
||||
}
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestShouldMarkTempUnschedulableForRefreshError(t *testing.T) {
|
||||
t.Run("skip global oauth client secret missing", func(t *testing.T) {
|
||||
err := errors.New(`token 刷新失败 (重试后): error: code=400 reason="ANTIGRAVITY_OAUTH_CLIENT_SECRET_MISSING" message="missing antigravity oauth client_secret; set ANTIGRAVITY_OAUTH_CLIENT_SECRET" metadata=map[]`)
|
||||
require.False(t, shouldMarkTempUnschedulableForRefreshError(err))
|
||||
})
|
||||
|
||||
t.Run("allow account specific refresh error", func(t *testing.T) {
|
||||
err := errors.New("token 刷新失败 (重试后): invalid_grant")
|
||||
require.True(t, shouldMarkTempUnschedulableForRefreshError(err))
|
||||
})
|
||||
}
|
||||
258
backend/internal/service/bootstrap_preflight.go
Normal file
258
backend/internal/service/bootstrap_preflight.go
Normal file
@ -0,0 +1,258 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
claude "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
// backgroundSimulator simulates the real Claude Code CLI's background network behavior.
|
||||
// Real CLI performs bootstrap, GrowthBook feature-flag polling, and policy_limits polling.
|
||||
// Missing these creates a behavioral correlation gap detectable by Anthropic.
|
||||
type backgroundSimulator struct {
|
||||
mu sync.Mutex
|
||||
called map[int64]*accountBackgroundState
|
||||
client *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
type accountBackgroundState struct {
|
||||
bootstrapAt time.Time
|
||||
growthbookAt time.Time
|
||||
policyLimitsAt time.Time
|
||||
// Timers for periodic polling — stopped when account goes idle
|
||||
growthbookTimer *time.Timer
|
||||
policyLimitsTimer *time.Timer
|
||||
exitTimer *time.Timer // fires tengu_exit after idle timeout
|
||||
accessToken string
|
||||
accountID int64
|
||||
}
|
||||
|
||||
const (
|
||||
bootstrapCooldown = 1 * time.Hour
|
||||
growthbookInterval = 20 * time.Minute
|
||||
policyLimitsInterval = 1 * time.Hour
|
||||
sessionIdleTimeout = 10 * time.Minute // fire tengu_exit after no requests for 10min
|
||||
)
|
||||
|
||||
var globalBgSim = &backgroundSimulator{
|
||||
called: make(map[int64]*accountBackgroundState),
|
||||
client: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
|
||||
// SetBootstrapBaseURL configures the API base URL for background simulation calls.
|
||||
func SetBootstrapBaseURL(baseURL string) {
|
||||
globalBgSim.baseURL = baseURL
|
||||
}
|
||||
|
||||
// TriggerBootstrapIfNeeded fires background simulation calls for the given OAuth account.
|
||||
// On first call per account: bootstrap + GrowthBook + policy_limits + start periodic timers.
|
||||
// On subsequent calls: refresh idle timer (delays tengu_exit).
|
||||
func TriggerBootstrapIfNeeded(accountID int64, accessToken string) {
|
||||
bg := globalBgSim
|
||||
|
||||
bg.mu.Lock()
|
||||
state, exists := bg.called[accountID]
|
||||
|
||||
if !exists {
|
||||
// First time: create state, fire all startup calls
|
||||
state = &accountBackgroundState{
|
||||
accessToken: accessToken,
|
||||
accountID: accountID,
|
||||
}
|
||||
bg.called[accountID] = state
|
||||
bg.mu.Unlock()
|
||||
|
||||
// Fire-and-forget startup sequence (matches real CLI order)
|
||||
go bg.doBootstrap(state)
|
||||
go bg.doGrowthBookFetch(state)
|
||||
go bg.doPolicyLimitsFetch(state)
|
||||
bg.startPeriodicPolling(state)
|
||||
bg.resetExitTimer(state)
|
||||
return
|
||||
}
|
||||
|
||||
// Update token (may have been refreshed)
|
||||
state.accessToken = accessToken
|
||||
|
||||
// Bootstrap: 1 hour cooldown
|
||||
if time.Since(state.bootstrapAt) >= bootstrapCooldown {
|
||||
state.bootstrapAt = time.Now()
|
||||
bg.mu.Unlock()
|
||||
go bg.doBootstrap(state)
|
||||
} else {
|
||||
bg.mu.Unlock()
|
||||
}
|
||||
|
||||
// Reset idle timer (user is active)
|
||||
bg.resetExitTimer(state)
|
||||
}
|
||||
|
||||
func (bg *backgroundSimulator) getBaseURL() string {
|
||||
if bg.baseURL != "" {
|
||||
return bg.baseURL
|
||||
}
|
||||
return "https://api.anthropic.com"
|
||||
}
|
||||
|
||||
// ─── Bootstrap ───────────────────────────────────────────
|
||||
|
||||
func (bg *backgroundSimulator) doBootstrap(state *accountBackgroundState) {
|
||||
state.bootstrapAt = time.Now()
|
||||
endpoint := bg.getBaseURL() + "/api/claude_cli/bootstrap"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Source: extracted/src/services/api/bootstrap.ts:85-91
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("claude-code/%s", claude.DefaultCLIVersion))
|
||||
req.Header.Set("Authorization", "Bearer "+state.accessToken)
|
||||
req.Header.Set("anthropic-beta", claude.BetaOAuth)
|
||||
|
||||
resp, err := bg.client.Do(req)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.bootstrap", "Bootstrap preflight failed: %v", err)
|
||||
return
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
logger.LegacyPrintf("service.bootstrap", "Bootstrap completed: account=%d status=%d", state.accountID, resp.StatusCode)
|
||||
}
|
||||
|
||||
// ─── GrowthBook Feature Flags ────────────────────────────
|
||||
|
||||
func (bg *backgroundSimulator) doGrowthBookFetch(state *accountBackgroundState) {
|
||||
state.growthbookAt = time.Now()
|
||||
|
||||
// Real CLI uses GrowthBook SDK with remoteEval: true
|
||||
// SDK key for external users: sdk-zAZezfDKGoZuXXKe
|
||||
// Endpoint: GET {apiHost}/sub/features/{clientKey}
|
||||
// Source: extracted/src/services/analytics/growthbook.ts:503-555
|
||||
endpoint := bg.getBaseURL() + "/sub/features/sdk-zAZezfDKGoZuXXKe"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+state.accessToken)
|
||||
req.Header.Set("anthropic-beta", claude.BetaOAuth)
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("claude-code/%s", claude.DefaultCLIVersion))
|
||||
|
||||
resp, err := bg.client.Do(req)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.bootstrap", "GrowthBook fetch failed: %v", err)
|
||||
return
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
logger.LegacyPrintf("service.bootstrap", "GrowthBook fetch completed: account=%d status=%d", state.accountID, resp.StatusCode)
|
||||
}
|
||||
|
||||
// ─── Policy Limits ───────────────────────────────────────
|
||||
|
||||
func (bg *backgroundSimulator) doPolicyLimitsFetch(state *accountBackgroundState) {
|
||||
state.policyLimitsAt = time.Now()
|
||||
|
||||
// Source: extracted/src/services/policyLimits/index.ts:127
|
||||
endpoint := bg.getBaseURL() + "/api/claude_code/policy_limits"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("claude-code/%s", claude.DefaultCLIVersion))
|
||||
req.Header.Set("Authorization", "Bearer "+state.accessToken)
|
||||
req.Header.Set("anthropic-beta", claude.BetaOAuth)
|
||||
|
||||
resp, err := bg.client.Do(req)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.bootstrap", "Policy limits fetch failed: %v", err)
|
||||
return
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
logger.LegacyPrintf("service.bootstrap", "Policy limits fetch completed: account=%d status=%d", state.accountID, resp.StatusCode)
|
||||
}
|
||||
|
||||
// ─── Periodic Polling ────────────────────────────────────
|
||||
|
||||
func (bg *backgroundSimulator) startPeriodicPolling(state *accountBackgroundState) {
|
||||
// GrowthBook: every 20 minutes
|
||||
// Source: growthbook.ts setupPeriodicGrowthBookRefresh()
|
||||
go func() {
|
||||
// Add jitter to avoid all accounts polling at the same time
|
||||
jitter := time.Duration(state.accountID%300) * time.Second
|
||||
time.Sleep(growthbookInterval + jitter)
|
||||
|
||||
for {
|
||||
bg.doGrowthBookFetch(state)
|
||||
time.Sleep(growthbookInterval + time.Duration(state.accountID%60)*time.Second)
|
||||
}
|
||||
}()
|
||||
|
||||
// Policy limits: every hour
|
||||
// Source: policyLimits/index.ts refreshPolicyLimits()
|
||||
go func() {
|
||||
jitter := time.Duration(state.accountID%600) * time.Second
|
||||
time.Sleep(policyLimitsInterval + jitter)
|
||||
|
||||
for {
|
||||
bg.doPolicyLimitsFetch(state)
|
||||
time.Sleep(policyLimitsInterval + time.Duration(state.accountID%120)*time.Second)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ─── tengu_exit Event ────────────────────────────────────
|
||||
|
||||
func (bg *backgroundSimulator) resetExitTimer(state *accountBackgroundState) {
|
||||
bg.mu.Lock()
|
||||
defer bg.mu.Unlock()
|
||||
|
||||
// Cancel existing timer
|
||||
if state.exitTimer != nil {
|
||||
state.exitTimer.Stop()
|
||||
}
|
||||
|
||||
// Set new timer: fire tengu_exit after idle timeout
|
||||
state.exitTimer = time.AfterFunc(sessionIdleTimeout, func() {
|
||||
bg.fireExitEvent(state)
|
||||
})
|
||||
}
|
||||
|
||||
func (bg *backgroundSimulator) fireExitEvent(state *accountBackgroundState) {
|
||||
// tengu_exit is sent via the 1P event_logging/batch endpoint
|
||||
// Source: extracted/src/services/analytics/firstPartyEventLogger.ts
|
||||
// We use proxy.js's sendTelemetryEvents path (same endpoint), but since
|
||||
// proxy.js runs per-request and this is idle-based, we fire directly here.
|
||||
|
||||
// The event is a lightweight signal — just needs to exist in Anthropic's logs.
|
||||
// Real CLI sends it on process exit; we simulate on idle timeout.
|
||||
logger.LegacyPrintf("service.bootstrap", "Session idle timeout, would fire tengu_exit: account=%d", state.accountID)
|
||||
|
||||
// Clean up the state to allow fresh bootstrap on next request
|
||||
bg.mu.Lock()
|
||||
delete(bg.called, state.accountID)
|
||||
bg.mu.Unlock()
|
||||
}
|
||||
182
backend/internal/service/gateway_attribution.go
Normal file
182
backend/internal/service/gateway_attribution.go
Normal file
@ -0,0 +1,182 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
// Attribution block constants matching real Claude Code 2.1.88.
|
||||
// Source: src/constants/system.ts + src/utils/fingerprint.ts
|
||||
const (
|
||||
// fingerprintSalt must match the hardcoded salt in the real CLI.
|
||||
// Source: extracted/src/utils/fingerprint.ts:8
|
||||
fingerprintSalt = "59cf53e54c78"
|
||||
)
|
||||
|
||||
// computeAttributionFingerprint computes a 3-character hex fingerprint
|
||||
// matching the algorithm in the real Claude Code CLI.
|
||||
//
|
||||
// Algorithm: SHA256(SALT + msg[4] + msg[7] + msg[20] + version)[:3]
|
||||
// Source: extracted/src/utils/fingerprint.ts:50-63
|
||||
func computeAttributionFingerprint(firstUserMessageText, cliVersion string) string {
|
||||
indices := [3]int{4, 7, 20}
|
||||
chars := make([]byte, 0, 3)
|
||||
for _, i := range indices {
|
||||
if i < len(firstUserMessageText) {
|
||||
chars = append(chars, firstUserMessageText[i])
|
||||
} else {
|
||||
chars = append(chars, '0')
|
||||
}
|
||||
}
|
||||
|
||||
input := fmt.Sprintf("%s%s%s", fingerprintSalt, string(chars), cliVersion)
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
return hex.EncodeToString(hash[:])[:3]
|
||||
}
|
||||
|
||||
// extractFirstUserMessageText extracts text from the first user message in the body.
|
||||
// Handles both string content and array content (text blocks).
|
||||
func extractFirstUserMessageText(body []byte) string {
|
||||
messages := gjson.GetBytes(body, "messages")
|
||||
if !messages.Exists() || !messages.IsArray() {
|
||||
return ""
|
||||
}
|
||||
|
||||
var firstText string
|
||||
messages.ForEach(func(_, msg gjson.Result) bool {
|
||||
if msg.Get("role").String() != "user" {
|
||||
return true // continue
|
||||
}
|
||||
content := msg.Get("content")
|
||||
if content.Type == gjson.String {
|
||||
firstText = content.String()
|
||||
return false // break
|
||||
}
|
||||
if content.IsArray() {
|
||||
content.ForEach(func(_, block gjson.Result) bool {
|
||||
if block.Get("type").String() == "text" {
|
||||
firstText = block.Get("text").String()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return firstText
|
||||
}
|
||||
|
||||
// buildAttributionBlock builds the x-anthropic-billing-header attribution string
|
||||
// that real Claude Code injects as the first system text block.
|
||||
//
|
||||
// Format: x-anthropic-billing-header: cc_version=<VERSION>.<fingerprint>; cc_entrypoint=cli; cch=00000;
|
||||
// Source: extracted/src/constants/system.ts:73-95
|
||||
func buildAttributionBlock(cliVersion, fingerprint string) string {
|
||||
version := cliVersion + "." + fingerprint
|
||||
// 注意:cch 字段由 Bun 的 NATIVE_CLIENT_ATTESTATION 编译时 feature 控制。
|
||||
// npm 安装版本(非原生二进制)此 feature 为 false,所以不包含 cch 字段。
|
||||
// 只有原生二进制安装(Bun 打包)才会有 cch,且其值会被 Bun 的 Zig 层替换为真实 hash。
|
||||
// 我们模拟 npm 安装版本的行为:不包含 cch。
|
||||
return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s; cc_entrypoint=cli;", version)
|
||||
}
|
||||
|
||||
// injectAttributionBlock prepends the x-anthropic-billing-header attribution block
|
||||
// as the very first system text block in the request body.
|
||||
// This must come BEFORE the "You are Claude Code" block.
|
||||
//
|
||||
// The real CLI injects this as system[0] with cache_control: {type: "ephemeral"}.
|
||||
func injectAttributionBlock(body []byte, cliVersion string) []byte {
|
||||
// Compute fingerprint from the first user message
|
||||
firstMsgText := extractFirstUserMessageText(body)
|
||||
fingerprint := computeAttributionFingerprint(firstMsgText, cliVersion)
|
||||
attribution := buildAttributionBlock(cliVersion, fingerprint)
|
||||
|
||||
// Build the attribution text block as JSON
|
||||
attrBlock, err := marshalAnthropicSystemTextBlock(attribution, true)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.gateway", "Warning: failed to build attribution block: %v", err)
|
||||
return body
|
||||
}
|
||||
|
||||
systemResult := gjson.GetBytes(body, "system")
|
||||
|
||||
// Handle the different system formats
|
||||
switch {
|
||||
case !systemResult.Exists() || systemResult.Type == gjson.Null:
|
||||
// No system field — inject just the attribution block
|
||||
newBody, err := sjson.SetRawBytes(body, "system", buildJSONArrayRaw([][]byte{attrBlock}))
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
return newBody
|
||||
|
||||
case systemResult.Type == gjson.String:
|
||||
// String system — convert to array: [attribution, original]
|
||||
origBlock, err := marshalAnthropicSystemTextBlock(systemResult.String(), false)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
newBody, setErr := sjson.SetRawBytes(body, "system", buildJSONArrayRaw([][]byte{attrBlock, origBlock}))
|
||||
if setErr != nil {
|
||||
return body
|
||||
}
|
||||
return newBody
|
||||
|
||||
case systemResult.IsArray():
|
||||
// Array system — check if attribution already exists, prepend if not
|
||||
var items [][]byte
|
||||
alreadyHasAttribution := false
|
||||
systemResult.ForEach(func(_, item gjson.Result) bool {
|
||||
if item.Get("type").String() == "text" {
|
||||
text := item.Get("text").String()
|
||||
if len(text) > 30 && text[:30] == "x-anthropic-billing-header: cc" {
|
||||
alreadyHasAttribution = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if alreadyHasAttribution {
|
||||
return body
|
||||
}
|
||||
|
||||
items = append(items, attrBlock)
|
||||
systemResult.ForEach(func(_, item gjson.Result) bool {
|
||||
items = append(items, []byte(item.Raw))
|
||||
return true
|
||||
})
|
||||
newBody, setErr := sjson.SetRawBytes(body, "system", buildJSONArrayRaw(items))
|
||||
if setErr != nil {
|
||||
return body
|
||||
}
|
||||
return newBody
|
||||
|
||||
default:
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
// generateSessionIDForAccount generates a deterministic per-account session UUID
|
||||
// that remains stable within a process-like timeframe.
|
||||
// Uses instanceSalt + accountID to ensure uniqueness across sub2api instances.
|
||||
func generateSessionIDForAccount(instanceSalt string, accountID int64) string {
|
||||
// Use a per-account stable UUID (like real CLI's per-process UUID).
|
||||
// We use accountID as the base — each account gets a different "session".
|
||||
seed := fmt.Sprintf("session:%s:%d", instanceSalt, accountID)
|
||||
hash := sha256.Sum256([]byte(seed))
|
||||
sessionUUID, err := uuid.FromBytes(hash[:16])
|
||||
if err != nil {
|
||||
return uuid.New().String()
|
||||
}
|
||||
// Set UUID v4 variant
|
||||
sessionUUID[6] = (sessionUUID[6] & 0x0f) | 0x40
|
||||
sessionUUID[8] = (sessionUUID[8] & 0x3f) | 0x80
|
||||
return sessionUUID.String()
|
||||
}
|
||||
@ -41,9 +41,10 @@ func TestNormalizeClaudeOAuthRequestBody_PreservesTopLevelFieldOrder(t *testing.
|
||||
resultStr := string(result)
|
||||
|
||||
require.Equal(t, claude.NormalizeModelID("claude-3-5-sonnet-latest"), modelID)
|
||||
assertJSONTokenOrder(t, resultStr, `"alpha"`, `"model"`, `"system"`, `"messages"`, `"omega"`, `"tools"`, `"metadata"`)
|
||||
require.NotContains(t, resultStr, `"temperature"`)
|
||||
require.NotContains(t, resultStr, `"tool_choice"`)
|
||||
assertJSONTokenOrder(t, resultStr, `"alpha"`, `"model"`, `"temperature"`, `"system"`, `"messages"`, `"tool_choice"`, `"omega"`, `"tools"`, `"metadata"`)
|
||||
// temperature 和 tool_choice 不再剥离,透传客户端原始值(与真实 CLI 行为一致)
|
||||
require.Contains(t, resultStr, `"temperature"`)
|
||||
require.Contains(t, resultStr, `"tool_choice"`)
|
||||
require.Contains(t, resultStr, `"system":"`+claudeCodeSystemPrompt+`"`)
|
||||
require.Contains(t, resultStr, `"tools":[]`)
|
||||
require.Contains(t, resultStr, `"metadata":{"user_id":"user-1"}`)
|
||||
|
||||
@ -26,6 +26,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/telemetry"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
@ -1085,18 +1086,11 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu
|
||||
}
|
||||
}
|
||||
|
||||
if gjson.GetBytes(out, "temperature").Exists() {
|
||||
if next, ok := deleteJSONPathBytes(out, "temperature"); ok {
|
||||
out = next
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
if gjson.GetBytes(out, "tool_choice").Exists() {
|
||||
if next, ok := deleteJSONPathBytes(out, "tool_choice"); ok {
|
||||
out = next
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
// 注意:不再剥离 temperature 和 tool_choice。
|
||||
// 真实 CLI 在 thinking 关闭时发 temperature:1,透传 tool_choice。
|
||||
// 之前无条件剥离会导致:
|
||||
// 1. temperature=0 的确定性请求被静默忽略
|
||||
// 2. tool_choice 强制工具调用被静默变成 auto 模式
|
||||
|
||||
if !modified {
|
||||
return body, modelID
|
||||
@ -4182,6 +4176,20 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
body, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
|
||||
}
|
||||
|
||||
// 注入 x-anthropic-billing-header attribution block(所有 OAuth 账号)
|
||||
// 真实 CLI 在 system prompt 的第一个 text block 注入此 billing header。
|
||||
// 用于 Anthropic 后端路由和验证。
|
||||
if account.IsOAuth() && !strings.Contains(strings.ToLower(reqModel), "haiku") {
|
||||
// 获取 CLI 版本:优先用指纹中的版本,回退到默认
|
||||
attrCLIVersion := claude.DefaultCLIVersion
|
||||
if fp := getHeaderRaw(c.Request.Header, "User-Agent"); fp != "" {
|
||||
if v := ExtractCLIVersion(fp); v != "" {
|
||||
attrCLIVersion = v
|
||||
}
|
||||
}
|
||||
body = injectAttributionBlock(body, attrCLIVersion)
|
||||
}
|
||||
|
||||
// 强制执行 cache_control 块数量限制(最多 4 个)
|
||||
body = enforceCacheControlLimit(body)
|
||||
|
||||
@ -4216,6 +4224,20 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Bootstrap 预热:模拟真实 CLI 启动时的 GET /api/claude_cli/bootstrap 调用
|
||||
// 真实 CLI 在首次 messages 请求前 fire-and-forget 调用此端点。
|
||||
if tokenType == "oauth" && token != "" {
|
||||
TriggerBootstrapIfNeeded(account.ID, token)
|
||||
// OTEL telemetry: emit pre-request events (tengu_started, tengu_api_query etc.)
|
||||
go telemetry.EmitPreRequest(
|
||||
fmt.Sprintf("%d", account.ID),
|
||||
token,
|
||||
token,
|
||||
reqModel,
|
||||
getHeaderRaw(c.Request.Header, "anthropic-beta"),
|
||||
)
|
||||
}
|
||||
|
||||
// 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递)
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
@ -4631,6 +4653,18 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
|
||||
// 处理正常响应
|
||||
|
||||
// OTEL telemetry: emit post-request events (fire-and-forget)
|
||||
if tokenType == "oauth" && token != "" {
|
||||
go telemetry.EmitPostRequest(
|
||||
fmt.Sprintf("%d", account.ID),
|
||||
token,
|
||||
token,
|
||||
reqModel,
|
||||
getHeaderRaw(c.Request.Header, "anthropic-beta"),
|
||||
resp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
// 触发上游接受回调(提前释放串行锁,不等流完成)
|
||||
if parsed.OnUpstreamAccepted != nil {
|
||||
parsed.OnUpstreamAccepted()
|
||||
@ -5821,13 +5855,37 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
||||
}
|
||||
}
|
||||
|
||||
// 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
|
||||
// X-Claude-Code-Session-Id 头处理:
|
||||
// 1. 客户端已提供 → 同步为 body 中 metadata.user_id 的 session_id
|
||||
// 2. 客户端未提供(mimic 模式)→ 生成确定性 per-account session UUID
|
||||
// 真实 CLI 每个请求都携带此 header(per-process UUID)。
|
||||
if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
|
||||
if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
|
||||
if parsed := ParseMetadataUserID(uid); parsed != nil {
|
||||
setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
|
||||
}
|
||||
}
|
||||
} else if tokenType == "oauth" {
|
||||
// mimic 模式:生成 session-id
|
||||
var sessionID string
|
||||
if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
|
||||
if parsed := ParseMetadataUserID(uid); parsed != nil {
|
||||
sessionID = parsed.SessionID
|
||||
}
|
||||
}
|
||||
if sessionID == "" {
|
||||
salt := ""
|
||||
if s.cfg != nil {
|
||||
salt = s.cfg.Gateway.InstanceSalt
|
||||
}
|
||||
sessionID = generateSessionIDForAccount(salt, account.ID)
|
||||
}
|
||||
setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", sessionID)
|
||||
}
|
||||
|
||||
// x-client-request-id: 真实 CLI 每个请求生成新 UUID(仅 1P)。
|
||||
if getHeaderRaw(req.Header, "x-client-request-id") == "" && tokenType == "oauth" {
|
||||
setHeaderRaw(req.Header, "x-client-request-id", uuid.New().String())
|
||||
}
|
||||
|
||||
// === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 ===
|
||||
@ -8549,13 +8607,33 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
||||
}
|
||||
}
|
||||
|
||||
// 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
|
||||
// X-Claude-Code-Session-Id 头处理(count_tokens 路径)
|
||||
if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
|
||||
if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
|
||||
if parsed := ParseMetadataUserID(uid); parsed != nil {
|
||||
setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
|
||||
}
|
||||
}
|
||||
} else if tokenType == "oauth" {
|
||||
var sessionID string
|
||||
if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
|
||||
if parsed := ParseMetadataUserID(uid); parsed != nil {
|
||||
sessionID = parsed.SessionID
|
||||
}
|
||||
}
|
||||
if sessionID == "" {
|
||||
salt := ""
|
||||
if s.cfg != nil {
|
||||
salt = s.cfg.Gateway.InstanceSalt
|
||||
}
|
||||
sessionID = generateSessionIDForAccount(salt, account.ID)
|
||||
}
|
||||
setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", sessionID)
|
||||
}
|
||||
|
||||
// x-client-request-id(count_tokens 路径)
|
||||
if getHeaderRaw(req.Header, "x-client-request-id") == "" && tokenType == "oauth" {
|
||||
setHeaderRaw(req.Header, "x-client-request-id", uuid.New().String())
|
||||
}
|
||||
|
||||
if c != nil && tokenType == "oauth" {
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -463,7 +464,7 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] ProxyURL: %s", proxyURL)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] ProxyURL: %s", logredact.RedactProxyURL(proxyURL))
|
||||
|
||||
redirectURI := session.RedirectURI
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ var (
|
||||
|
||||
// 默认指纹值(当客户端未提供时使用)
|
||||
var defaultFingerprint = Fingerprint{
|
||||
UserAgent: "claude-cli/2.1.87 (external, cli)",
|
||||
UserAgent: "claude-cli/2.1.88 (external, cli)",
|
||||
StainlessLang: "js",
|
||||
StainlessPackageVersion: "0.74.0",
|
||||
StainlessOS: "MacOS",
|
||||
|
||||
225
backend/internal/service/lspool_bootstrap_service.go
Normal file
225
backend/internal/service/lspool_bootstrap_service.go
Normal file
@ -0,0 +1,225 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/lspool"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLSPoolBootstrapConcurrency = 4
|
||||
)
|
||||
|
||||
type lsBootstrapAccountReader interface {
|
||||
ListByPlatform(ctx context.Context, platform string) ([]Account, error)
|
||||
}
|
||||
|
||||
// LSPoolBootstrapService pre-creates LS workers for eligible Antigravity accounts on startup.
|
||||
type LSPoolBootstrapService struct {
|
||||
accountReader lsBootstrapAccountReader
|
||||
backend lspool.Backend
|
||||
cfg *config.Config
|
||||
logger *slog.Logger
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
once sync.Once
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewLSPoolBootstrapService(accountReader lsBootstrapAccountReader, backend lspool.Backend, cfg *config.Config) *LSPoolBootstrapService {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &LSPoolBootstrapService{
|
||||
accountReader: accountReader,
|
||||
backend: backend,
|
||||
cfg: cfg,
|
||||
logger: slog.Default().With("component", "service.lspool_bootstrap"),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// ProvideLSPoolBootstrapService creates and starts the LS pool bootstrap worker.
|
||||
func ProvideLSPoolBootstrapService(accountRepo AccountRepository, cfg *config.Config) *LSPoolBootstrapService {
|
||||
svc := NewLSPoolBootstrapService(accountRepo, lspool.GlobalPool(cfg), cfg)
|
||||
svc.Start()
|
||||
return svc
|
||||
}
|
||||
|
||||
func (s *LSPoolBootstrapService) Start() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.once.Do(func() {
|
||||
if s.backend == nil {
|
||||
if lspool.IsLSModeEnabled() {
|
||||
s.logger.Warn("startup bootstrap skipped: ls backend unavailable")
|
||||
}
|
||||
return
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.bootstrap(s.ctx)
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LSPoolBootstrapService) Stop() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *LSPoolBootstrapService) bootstrap(ctx context.Context) {
|
||||
if s.backend == nil || s.accountReader == nil {
|
||||
return
|
||||
}
|
||||
|
||||
accounts, err := s.accountReader.ListByPlatform(ctx, PlatformAntigravity)
|
||||
if err != nil {
|
||||
s.logger.Warn("load antigravity accounts for ls bootstrap failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
candidates := make([]Account, 0, len(accounts))
|
||||
for i := range accounts {
|
||||
if shouldBootstrapLSPoolAccount(&accounts[i], now) {
|
||||
candidates = append(candidates, accounts[i])
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
s.logger.Info("startup bootstrap skipped: no eligible antigravity accounts")
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Info("starting ls worker bootstrap",
|
||||
"accounts_total", len(accounts),
|
||||
"accounts_eligible", len(candidates),
|
||||
"concurrency", s.bootstrapConcurrency())
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
started int
|
||||
failed int
|
||||
)
|
||||
sem := make(chan struct{}, s.bootstrapConcurrency())
|
||||
var wg sync.WaitGroup
|
||||
|
||||
loop:
|
||||
for i := range candidates {
|
||||
account := candidates[i]
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break loop
|
||||
case sem <- struct{}{}:
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(account Account) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
if err := s.bootstrapAccount(&account); err != nil {
|
||||
mu.Lock()
|
||||
failed++
|
||||
mu.Unlock()
|
||||
s.logger.Warn("bootstrap ls worker failed", "account_id", account.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
started++
|
||||
mu.Unlock()
|
||||
s.logger.Info("bootstrap ls worker ready", "account_id", account.ID)
|
||||
}(account)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
s.logger.Info("ls worker bootstrap completed",
|
||||
"accounts_total", len(accounts),
|
||||
"accounts_eligible", len(candidates),
|
||||
"workers_ready", started,
|
||||
"workers_failed", failed,
|
||||
"canceled", ctx.Err() != nil)
|
||||
}
|
||||
|
||||
func (s *LSPoolBootstrapService) bootstrapAccount(account *Account) error {
|
||||
if s.backend == nil {
|
||||
return fmt.Errorf("ls backend unavailable")
|
||||
}
|
||||
if account == nil {
|
||||
return fmt.Errorf("account is nil")
|
||||
}
|
||||
|
||||
accountKey := strconv.FormatInt(account.ID, 10)
|
||||
accessToken := strings.TrimSpace(account.GetCredential("access_token"))
|
||||
if accessToken == "" {
|
||||
return fmt.Errorf("missing access token")
|
||||
}
|
||||
refreshToken := strings.TrimSpace(account.GetCredential("refresh_token"))
|
||||
|
||||
expiresAt := time.Time{}
|
||||
if ts := account.GetCredentialAsTime("expires_at"); ts != nil {
|
||||
expiresAt = ts.UTC()
|
||||
}
|
||||
|
||||
s.backend.SetAccountToken(accountKey, accessToken, refreshToken, expiresAt)
|
||||
availableCredits, minimumCreditAmount := resolveLSPoolModelCreditsState(account)
|
||||
s.backend.SetAccountModelCredits(accountKey, account.IsOveragesEnabled(), availableCredits, minimumCreditAmount)
|
||||
|
||||
proxyURL := ""
|
||||
if account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
if _, err := s.backend.GetOrCreate(accountKey, "", proxyURL); err != nil {
|
||||
return fmt.Errorf("get or create ls worker: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LSPoolBootstrapService) bootstrapConcurrency() int {
|
||||
parallelism := defaultLSPoolBootstrapConcurrency
|
||||
if s.cfg != nil && s.cfg.Gateway.AntigravityLSWorker.MaxActive > 0 && s.cfg.Gateway.AntigravityLSWorker.MaxActive < parallelism {
|
||||
parallelism = s.cfg.Gateway.AntigravityLSWorker.MaxActive
|
||||
}
|
||||
if parallelism < 1 {
|
||||
return 1
|
||||
}
|
||||
return parallelism
|
||||
}
|
||||
|
||||
func shouldBootstrapLSPoolAccount(account *Account, now time.Time) bool {
|
||||
if account == nil {
|
||||
return false
|
||||
}
|
||||
if account.Platform != PlatformAntigravity {
|
||||
return false
|
||||
}
|
||||
if account.Type != AccountTypeOAuth {
|
||||
return false
|
||||
}
|
||||
if account.Status != StatusActive || !account.Schedulable {
|
||||
return false
|
||||
}
|
||||
if account.AutoPauseOnExpired && account.ExpiresAt != nil && !now.Before(*account.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(account.GetCredential("access_token")) == "" {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(account.GetCredential("project_id")) != ""
|
||||
}
|
||||
262
backend/internal/service/lspool_bootstrap_service_test.go
Normal file
262
backend/internal/service/lspool_bootstrap_service_test.go
Normal file
@ -0,0 +1,262 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/lspool"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeLSBootstrapAccountReader struct {
|
||||
mu sync.Mutex
|
||||
accounts []Account
|
||||
err error
|
||||
platforms []string
|
||||
}
|
||||
|
||||
func (f *fakeLSBootstrapAccountReader) ListByPlatform(_ context.Context, platform string) ([]Account, error) {
|
||||
f.mu.Lock()
|
||||
f.platforms = append(f.platforms, platform)
|
||||
accounts := append([]Account(nil), f.accounts...)
|
||||
err := f.err
|
||||
f.mu.Unlock()
|
||||
return accounts, err
|
||||
}
|
||||
|
||||
type fakeLSPoolBackend struct {
|
||||
mu sync.Mutex
|
||||
tokenCalls map[string]fakeLSPoolTokenCall
|
||||
creditCalls map[string]fakeLSPoolCreditCall
|
||||
getCalls []fakeLSPoolGetCall
|
||||
getErrs map[string]error
|
||||
}
|
||||
|
||||
type fakeLSPoolTokenCall struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type fakeLSPoolCreditCall struct {
|
||||
UseAICredits bool
|
||||
AvailableCredits *int32
|
||||
MinimumCreditAmount *int32
|
||||
}
|
||||
|
||||
type fakeLSPoolGetCall struct {
|
||||
AccountID string
|
||||
RoutingKey string
|
||||
ProxyURL string
|
||||
}
|
||||
|
||||
func newFakeLSPoolBackend() *fakeLSPoolBackend {
|
||||
return &fakeLSPoolBackend{
|
||||
tokenCalls: make(map[string]fakeLSPoolTokenCall),
|
||||
creditCalls: make(map[string]fakeLSPoolCreditCall),
|
||||
getErrs: make(map[string]error),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeLSPoolBackend) GetOrCreate(accountID, routingKey string, proxyURL ...string) (*lspool.Instance, error) {
|
||||
rawProxy := ""
|
||||
if len(proxyURL) > 0 {
|
||||
rawProxy = proxyURL[0]
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.getCalls = append(f.getCalls, fakeLSPoolGetCall{
|
||||
AccountID: accountID,
|
||||
RoutingKey: routingKey,
|
||||
ProxyURL: rawProxy,
|
||||
})
|
||||
if err := f.getErrs[accountID]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lspool.Instance{AccountID: accountID}, nil
|
||||
}
|
||||
|
||||
func (f *fakeLSPoolBackend) SetAccountToken(accountID, accessToken, refreshToken string, expiresAt time.Time) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.tokenCalls[accountID] = fakeLSPoolTokenCall{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeLSPoolBackend) SetAccountModelCredits(accountID string, useAICredits bool, availableCredits, minimumCreditAmountForUsage *int32) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.creditCalls[accountID] = fakeLSPoolCreditCall{
|
||||
UseAICredits: useAICredits,
|
||||
AvailableCredits: copyInt32Ptr(availableCredits),
|
||||
MinimumCreditAmount: copyInt32Ptr(minimumCreditAmountForUsage),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeLSPoolBackend) Stats() map[string]any { return nil }
|
||||
|
||||
func (f *fakeLSPoolBackend) Close() {}
|
||||
|
||||
func copyInt32Ptr(v *int32) *int32 {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *v
|
||||
return &cp
|
||||
}
|
||||
|
||||
func TestLSPoolBootstrapServiceBootstrapEligibleAccounts(t *testing.T) {
|
||||
expiresAt := time.Now().Add(2 * time.Hour).UTC().Truncate(time.Second)
|
||||
expiredAt := time.Now().Add(-2 * time.Hour)
|
||||
reader := &fakeLSBootstrapAccountReader{
|
||||
accounts: []Account{
|
||||
{
|
||||
ID: 101,
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token-101",
|
||||
"refresh_token": "refresh-101",
|
||||
"expires_at": expiresAt.Format(time.RFC3339),
|
||||
"project_id": "proj-101",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"allow_overages": true,
|
||||
"ai_credits": []any{
|
||||
map[string]any{
|
||||
"credit_type": "GOOGLE_ONE_AI",
|
||||
"amount": 120,
|
||||
"minimum_balance": 55,
|
||||
},
|
||||
},
|
||||
},
|
||||
Proxy: &Proxy{
|
||||
Protocol: "socks5h",
|
||||
Host: "127.0.0.1",
|
||||
Port: 1080,
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 102,
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Schedulable: false,
|
||||
Credentials: map[string]any{"access_token": "token-102", "project_id": "proj-102"},
|
||||
},
|
||||
{
|
||||
ID: 103,
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{"access_token": "token-103"},
|
||||
},
|
||||
{
|
||||
ID: 104,
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
AutoPauseOnExpired: true,
|
||||
ExpiresAt: &expiredAt,
|
||||
Credentials: map[string]any{"access_token": "token-104", "project_id": "proj-104"},
|
||||
},
|
||||
{
|
||||
ID: 106,
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeUpstream,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{"access_token": "token-106", "project_id": "proj-106"},
|
||||
},
|
||||
{
|
||||
ID: 105,
|
||||
Platform: PlatformOpenAI,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{"access_token": "token-105"},
|
||||
},
|
||||
},
|
||||
}
|
||||
backend := newFakeLSPoolBackend()
|
||||
svc := NewLSPoolBootstrapService(reader, backend, &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
AntigravityLSWorker: config.GatewayAntigravityLSWorkerConfig{MaxActive: 3},
|
||||
},
|
||||
})
|
||||
|
||||
svc.bootstrap(context.Background())
|
||||
|
||||
require.Equal(t, []string{PlatformAntigravity}, reader.platforms)
|
||||
|
||||
require.Len(t, backend.getCalls, 1)
|
||||
require.Equal(t, fakeLSPoolGetCall{
|
||||
AccountID: "101",
|
||||
RoutingKey: "",
|
||||
ProxyURL: "socks5h://alice:secret@127.0.0.1:1080",
|
||||
}, backend.getCalls[0])
|
||||
|
||||
tokenCall, ok := backend.tokenCalls["101"]
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "token-101", tokenCall.AccessToken)
|
||||
require.Equal(t, "refresh-101", tokenCall.RefreshToken)
|
||||
require.Equal(t, expiresAt, tokenCall.ExpiresAt)
|
||||
|
||||
creditCall, ok := backend.creditCalls["101"]
|
||||
require.True(t, ok)
|
||||
require.True(t, creditCall.UseAICredits)
|
||||
require.NotNil(t, creditCall.AvailableCredits)
|
||||
require.Equal(t, int32(120), *creditCall.AvailableCredits)
|
||||
require.NotNil(t, creditCall.MinimumCreditAmount)
|
||||
require.Equal(t, int32(55), *creditCall.MinimumCreditAmount)
|
||||
|
||||
require.NotContains(t, backend.tokenCalls, "102")
|
||||
require.NotContains(t, backend.tokenCalls, "103")
|
||||
require.NotContains(t, backend.tokenCalls, "104")
|
||||
require.NotContains(t, backend.tokenCalls, "106")
|
||||
}
|
||||
|
||||
func TestLSPoolBootstrapServiceBootstrapContinuesOnWorkerFailure(t *testing.T) {
|
||||
reader := &fakeLSBootstrapAccountReader{
|
||||
accounts: []Account{
|
||||
{
|
||||
ID: 201,
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{"access_token": "token-201", "project_id": "proj-201"},
|
||||
},
|
||||
{
|
||||
ID: 202,
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{"access_token": "token-202", "project_id": "proj-202"},
|
||||
},
|
||||
},
|
||||
}
|
||||
backend := newFakeLSPoolBackend()
|
||||
backend.getErrs["201"] = errors.New("create failed")
|
||||
|
||||
svc := NewLSPoolBootstrapService(reader, backend, &config.Config{})
|
||||
svc.bootstrap(context.Background())
|
||||
|
||||
require.Len(t, backend.getCalls, 2)
|
||||
require.Contains(t, backend.tokenCalls, "201")
|
||||
require.Contains(t, backend.tokenCalls, "202")
|
||||
}
|
||||
@ -471,6 +471,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewCRSSyncService,
|
||||
ProvideUpdateService,
|
||||
ProvideTokenRefreshService,
|
||||
ProvideLSPoolBootstrapService,
|
||||
ProvideAccountExpiryService,
|
||||
ProvideSubscriptionExpiryService,
|
||||
ProvideTimingWheelService,
|
||||
|
||||
@ -2,6 +2,7 @@ package logredact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@ -230,3 +231,19 @@ func isSensitiveKey(key string, keys map[string]struct{}) bool {
|
||||
func normalizeKey(key string) string {
|
||||
return strings.ToLower(strings.TrimSpace(key))
|
||||
}
|
||||
|
||||
// RedactProxyURL strips userinfo (username:password) from a proxy URL string
|
||||
// for safe logging. Returns the input unchanged if it's not a valid URL.
|
||||
func RedactProxyURL(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "<redacted-proxy-url>"
|
||||
}
|
||||
if parsed.User != nil {
|
||||
parsed.User = nil
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
@ -38,6 +38,34 @@ func TestRedactText_GOCSPX(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactProxyURL_StripsUserinfo(t *testing.T) {
|
||||
in := "http://user:pass@proxy.example.com:8080"
|
||||
out := RedactProxyURL(in)
|
||||
if out != "http://proxy.example.com:8080" {
|
||||
t.Fatalf("expected userinfo stripped, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactProxyURL_EmptyString(t *testing.T) {
|
||||
if got := RedactProxyURL(""); got != "" {
|
||||
t.Fatalf("expected empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactProxyURL_NoUserinfo(t *testing.T) {
|
||||
in := "socks5h://proxy.example.com:1080"
|
||||
out := RedactProxyURL(in)
|
||||
if out != in {
|
||||
t.Fatalf("expected unchanged URL, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactProxyURL_InvalidURL(t *testing.T) {
|
||||
if got := RedactProxyURL("://invalid"); got != "<redacted-proxy-url>" {
|
||||
t.Fatalf("unexpected invalid URL redaction result: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactText_ExtraKeyCacheUsesNormalizedSortedKey(t *testing.T) {
|
||||
clearExtraTextPatternCache()
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user