refactor: 将自定义代码集中到 antigravity/ 目录和 *_antigravity.go 文件
- antigravity/node-tls-proxy/ ← 原 tools/node-tls-proxy - antigravity/firewall/ ← 原 tools/firewall - antigravity/maintenance/ ← 原 tools/maintenance - repository/http_upstream_antigravity.go ← Node.js 代理 3 个方法(原在 http_upstream.go) - service/identity_service_antigravity.go ← ApplyDefaultFingerprintOverrides + NewIdentityServiceWithSalt - service/account_antigravity.go ← Gemini TLS 指纹扩展函数 对上游文件 http_upstream.go 的钩子调用精简为 2 处 if 块(共 14 行) 对上游文件 account.go Gemini 分支精简为 1 行函数调用 便于 upstream rebase 时快速识别和保留自定义改动
This commit is contained in:
parent
8b71fa1bf3
commit
e5d78f8e56
104
antigravity/firewall/setup-firewall.sh
Executable file
104
antigravity/firewall/setup-firewall.sh
Executable file
@ -0,0 +1,104 @@
|
||||
#!/bin/bash
|
||||
# sub2api 指纹防泄露 iptables 规则
|
||||
# 确保只有 Node.js TLS Proxy 能直连上游 HTTPS,
|
||||
# sub2api Go 进程即使有 bug 也无法绕过。
|
||||
#
|
||||
# 用法:
|
||||
# sudo bash setup-firewall.sh [apply|remove|status]
|
||||
#
|
||||
# 前置条件:
|
||||
# - Node.js proxy 以专用用户 "nodeproxy" 运行
|
||||
# - 创建用户: sudo useradd -r -s /usr/sbin/nologin nodeproxy
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NODE_PROXY_USER="${MG_NODE_PROXY_USER:-nodeproxy}"
|
||||
CHAIN_NAME="MG_FINGERPRINT"
|
||||
|
||||
log() { echo "[$(date '+%H:%M:%S')] $*"; }
|
||||
|
||||
apply_rules() {
|
||||
log "Applying fingerprint firewall rules..."
|
||||
|
||||
# 验证用户存在
|
||||
if ! id "$NODE_PROXY_USER" &>/dev/null; then
|
||||
log "ERROR: User '$NODE_PROXY_USER' does not exist."
|
||||
log "Create it: sudo useradd -r -s /usr/sbin/nologin $NODE_PROXY_USER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建自定义链(幂等)
|
||||
iptables -N "$CHAIN_NAME" 2>/dev/null || iptables -F "$CHAIN_NAME"
|
||||
|
||||
# === Rule 1: QUIC 阻断 — 丢弃所有出站 UDP 443/4433 ===
|
||||
iptables -A "$CHAIN_NAME" -p udp --dport 443 -j DROP \
|
||||
-m comment --comment "MG: block QUIC/HTTP3 UDP 443"
|
||||
iptables -A "$CHAIN_NAME" -p udp --dport 4433 -j DROP \
|
||||
-m comment --comment "MG: block QUIC alt UDP 4433"
|
||||
|
||||
# === Rule 2: 允许 Node.js proxy 出站 TCP 443 ===
|
||||
iptables -A "$CHAIN_NAME" -p tcp --dport 443 \
|
||||
-m owner --uid-owner "$NODE_PROXY_USER" -j ACCEPT \
|
||||
-m comment --comment "MG: allow nodeproxy TCP 443"
|
||||
|
||||
# === Rule 3: 阻止其他进程直连 TCP 443 ===
|
||||
iptables -A "$CHAIN_NAME" -p tcp --dport 443 -j REJECT --reject-with tcp-reset \
|
||||
-m comment --comment "MG: block non-proxy TCP 443"
|
||||
|
||||
# 将自定义链挂载到 OUTPUT(幂等)
|
||||
if ! iptables -C OUTPUT -j "$CHAIN_NAME" 2>/dev/null; then
|
||||
iptables -A OUTPUT -j "$CHAIN_NAME"
|
||||
fi
|
||||
|
||||
# === Rule 4: IPv6 全面阻断 ===
|
||||
ip6tables -N "${CHAIN_NAME}_V6" 2>/dev/null || ip6tables -F "${CHAIN_NAME}_V6"
|
||||
# 允许回环
|
||||
ip6tables -A "${CHAIN_NAME}_V6" -o lo -j ACCEPT \
|
||||
-m comment --comment "MG: allow IPv6 loopback"
|
||||
# 阻断其他 IPv6 出站
|
||||
ip6tables -A "${CHAIN_NAME}_V6" -j DROP \
|
||||
-m comment --comment "MG: block all IPv6 outbound"
|
||||
|
||||
if ! ip6tables -C OUTPUT -j "${CHAIN_NAME}_V6" 2>/dev/null; then
|
||||
ip6tables -A OUTPUT -j "${CHAIN_NAME}_V6"
|
||||
fi
|
||||
|
||||
log "Firewall rules applied successfully."
|
||||
log " - UDP 443/4433: BLOCKED (QUIC)"
|
||||
log " - TCP 443: ONLY '$NODE_PROXY_USER' allowed"
|
||||
log " - IPv6 outbound: BLOCKED"
|
||||
}
|
||||
|
||||
remove_rules() {
|
||||
log "Removing fingerprint firewall rules..."
|
||||
|
||||
# 从 OUTPUT 移除引用
|
||||
iptables -D OUTPUT -j "$CHAIN_NAME" 2>/dev/null || true
|
||||
ip6tables -D OUTPUT -j "${CHAIN_NAME}_V6" 2>/dev/null || true
|
||||
|
||||
# 清空并删除自定义链
|
||||
iptables -F "$CHAIN_NAME" 2>/dev/null || true
|
||||
iptables -X "$CHAIN_NAME" 2>/dev/null || true
|
||||
ip6tables -F "${CHAIN_NAME}_V6" 2>/dev/null || true
|
||||
ip6tables -X "${CHAIN_NAME}_V6" 2>/dev/null || true
|
||||
|
||||
log "Firewall rules removed."
|
||||
}
|
||||
|
||||
show_status() {
|
||||
log "=== IPv4 MG_FINGERPRINT chain ==="
|
||||
iptables -L "$CHAIN_NAME" -n -v 2>/dev/null || echo "(not found)"
|
||||
echo
|
||||
log "=== IPv6 MG_FINGERPRINT_V6 chain ==="
|
||||
ip6tables -L "${CHAIN_NAME}_V6" -n -v 2>/dev/null || echo "(not found)"
|
||||
}
|
||||
|
||||
case "${1:-apply}" in
|
||||
apply) apply_rules ;;
|
||||
remove) remove_rules ;;
|
||||
status) show_status ;;
|
||||
*)
|
||||
echo "Usage: $0 [apply|remove|status]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
30
antigravity/maintenance/save-patches.sh
Executable file
30
antigravity/maintenance/save-patches.sh
Executable file
@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# save-patches.sh — 将 Antigravity 自定义改动导出为 patch 文件
|
||||
# 用法: ./tools/scripts/save-patches.sh [输出目录]
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
OUTPUT_DIR="${1:-$REPO_ROOT/tools/patches}"
|
||||
UPSTREAM="origin/main"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# 检查是否有新的 upstream commits
|
||||
DIVERGED=$(git log --oneline "$UPSTREAM"..HEAD 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$DIVERGED" -eq 0 ]; then
|
||||
echo "[save-patches] 没有领先 upstream 的 commits,无需保存。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# 导出 patches
|
||||
git format-patch "$UPSTREAM"..HEAD --output-directory "$OUTPUT_DIR" --no-stat
|
||||
|
||||
COUNT=$(ls "$OUTPUT_DIR"/*.patch 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo "[save-patches] ✅ 已导出 $COUNT 个 patch 到 $OUTPUT_DIR/"
|
||||
echo ""
|
||||
echo "恢复方法(在全新 upstream checkout 上):"
|
||||
echo " git am $OUTPUT_DIR/*.patch"
|
||||
echo " # 或逐一应用:"
|
||||
echo " for p in $OUTPUT_DIR/*.patch; do git am \"\$p\" || git am --skip; done"
|
||||
82
antigravity/maintenance/sync-upstream.sh
Executable file
82
antigravity/maintenance/sync-upstream.sh
Executable file
@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# sync-upstream.sh — 从 upstream (origin/main) 同步更新,保留自定义改动
|
||||
# 用法: ./tools/scripts/sync-upstream.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
UPSTREAM="origin/main"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo "========================================"
|
||||
echo " Antigravity Fork — Upstream Sync Tool"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Step 1: 检查工作区
|
||||
if ! git diff --quiet || ! git diff --staged --quiet; then
|
||||
echo "❌ 工作区有未提交的改动,请先 git stash 或 git commit"
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Fetch
|
||||
echo "[1/4] Fetching upstream..."
|
||||
git fetch origin
|
||||
|
||||
# Step 3: 检查是否有新 commits
|
||||
NEW=$(git log --oneline HEAD.."$UPSTREAM" 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$NEW" -eq 0 ]; then
|
||||
echo "✅ 已是最新,无需同步。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "上游有 $NEW 个新 commits:"
|
||||
git log --oneline HEAD.."$UPSTREAM"
|
||||
echo ""
|
||||
|
||||
# Step 4: 备份当前 patches
|
||||
PATCH_DIR="/tmp/antigravity-patches-$(date +%Y%m%d-%H%M%S)"
|
||||
echo "[2/4] 备份自定义 patches 到 $PATCH_DIR ..."
|
||||
mkdir -p "$PATCH_DIR"
|
||||
git format-patch "$UPSTREAM"..HEAD -o "$PATCH_DIR/" --no-stat
|
||||
BACKED=$(ls "$PATCH_DIR"/*.patch 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo " 已备份 $BACKED 个 patch"
|
||||
|
||||
# Step 5: Rebase
|
||||
echo ""
|
||||
echo "[3/4] 执行 rebase (git rebase $UPSTREAM)..."
|
||||
echo " 如果出现冲突,请参考 .agents/workflows/sync-upstream.md 中的冲突解决指南"
|
||||
echo ""
|
||||
if ! git rebase "$UPSTREAM"; then
|
||||
echo ""
|
||||
echo "❌ Rebase 出现冲突!"
|
||||
echo ""
|
||||
echo "请按以下步骤处理:"
|
||||
echo " 1. 查看冲突文件: git diff --name-only --diff-filter=U"
|
||||
echo " 2. 解决冲突(参考 .agents/workflows/sync-upstream.md)"
|
||||
echo " 3. git add <解决的文件>"
|
||||
echo " 4. git rebase --continue"
|
||||
echo ""
|
||||
echo "备份的 patches 在: $PATCH_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 6: 编译验证
|
||||
echo "[4/4] 编译验证..."
|
||||
if ! (cd "$REPO_ROOT/backend" && go build ./... 2>&1); then
|
||||
echo ""
|
||||
echo "❌ 编译失败!rebase 后有破坏性改动需要修复。"
|
||||
echo "备份的 patches 在: $PATCH_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ 同步完成!"
|
||||
echo ""
|
||||
echo "自定义改动(我方 commits)已成功移植到最新 upstream 上。"
|
||||
echo "请运行以下命令推送:"
|
||||
echo " git push origin main --force-with-lease"
|
||||
echo ""
|
||||
echo "备份路径(可删除): $PATCH_DIR"
|
||||
24
antigravity/node-tls-proxy/Dockerfile
Normal file
24
antigravity/node-tls-proxy/Dockerfile
Normal file
@ -0,0 +1,24 @@
|
||||
FROM node:24.13.0-slim
|
||||
|
||||
LABEL maintainer="Wei-Shaw <github.com/Wei-Shaw>"
|
||||
LABEL description="Node.js TLS Forward Proxy - native JA3/JA4 fingerprint matching"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY proxy.js package.json ./
|
||||
|
||||
# 零依赖,不需要 npm install
|
||||
|
||||
ENV PROXY_PORT=3456
|
||||
ENV PROXY_HOST=0.0.0.0
|
||||
ENV UPSTREAM_HOST=api.anthropic.com
|
||||
|
||||
EXPOSE 3456
|
||||
|
||||
# 健康检查:用 Node.js 内置 http 模块,不依赖 curl
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=5s \
|
||||
CMD node -e "const http=require('http');const r=http.get('http://127.0.0.1:'+(process.env.PROXY_PORT||3456)+'/__health',s=>{process.exit(s.statusCode===200?0:1)});r.on('error',()=>process.exit(1));r.setTimeout(3000,()=>{r.destroy();process.exit(1)})"
|
||||
|
||||
USER node
|
||||
CMD ["node", "proxy.js"]
|
||||
14
antigravity/node-tls-proxy/package.json
Normal file
14
antigravity/node-tls-proxy/package.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "node-tls-proxy",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Node.js TLS forward proxy for native JA3/JA4 fingerprint matching",
|
||||
"main": "proxy.js",
|
||||
"scripts": {
|
||||
"start": "node proxy.js",
|
||||
"health": "curl -s http://127.0.0.1:${PROXY_PORT:-3456}/__health | jq ."
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
727
antigravity/node-tls-proxy/proxy.js
Normal file
727
antigravity/node-tls-proxy/proxy.js
Normal file
@ -0,0 +1,727 @@
|
||||
'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.81';
|
||||
const BUILD_TIME = process.env.BUILD_TIME || '2026-03-20T21:26:18Z';
|
||||
// 伪装的 Node 版本(CLI 2.1.81 打包的 Node 版本)
|
||||
const FAKE_NODE_VERSION = process.env.FAKE_NODE_VERSION || 'v22.14.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();
|
||||
const h2Sessions = new Map();
|
||||
|
||||
// ─── 虚拟主机身份生成 ─────────────────────────────────────
|
||||
// 每个账号基于 seed 生成全局唯一的主机身份,看起来像一台真实的个人开发机
|
||||
// 匹配 CLI 的 OTEL detectResources: hostDetector + processDetector + serviceInstanceIdDetector
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. 同一账号(seed)永远产出同一台"机器"的特征
|
||||
// 2. 不同账号的特征互不相同(无共享池、无碰撞)
|
||||
// 3. 每个字段都像人手动设置的,不是程序生成的
|
||||
|
||||
// hostname 构造词表 — 组合后空间 > 100万,基本不碰撞
|
||||
const HN_PREFIX = ['dev','code','work','build','my','home','lab','eng','hack','prog','desk','box','main','personal','linux'];
|
||||
const HN_MIDDLE = ['','station','machine','server','node','pc','setup','rig','env','hub'];
|
||||
const HN_STYLE = ['dash','dot','bare']; // 连接风格
|
||||
|
||||
// 用户名词表 — 真实开发者常用,组合后也是高基数
|
||||
const UN_FIRST = ['alex','sam','chris','jordan','max','lee','kai','pat','jamie','taylor','morgan','casey','drew','avery','riley','blake','quinn','reese','cameron','skyler','dev','coder','user','admin','ubuntu','runner'];
|
||||
const UN_SUFFIX = ['','dev','eng','42','_dev','01','x','z','_','99','007'];
|
||||
|
||||
function generateHostIdentity(seed) {
|
||||
// 确定性哈希工具:同一 seed+suffix 永远返回同一结果
|
||||
const h = (suffix) => crypto.createHash('sha256').update(seed + ':' + suffix).digest();
|
||||
|
||||
// ── hostname: 组合生成,如 "alex-devstation", "work-box-7f3a" ──
|
||||
const hb = h('hostname');
|
||||
const prefix = HN_PREFIX[hb.readUInt8(0) % HN_PREFIX.length];
|
||||
const middle = HN_MIDDLE[hb.readUInt8(1) % HN_MIDDLE.length];
|
||||
const style = HN_STYLE[hb.readUInt8(2) % HN_STYLE.length];
|
||||
const tail = hb.slice(3, 5).toString('hex'); // 4 hex chars 保证唯一
|
||||
let hostname;
|
||||
if (middle) {
|
||||
const sep = style === 'dot' ? '.' : style === 'dash' ? '-' : '';
|
||||
hostname = `${prefix}${sep}${middle}`;
|
||||
} else {
|
||||
// 无中间词时必须加 hex 尾缀,避免 hostname 太短(如裸 "my"、"dev")
|
||||
hostname = `${prefix}-${tail}`;
|
||||
}
|
||||
// 有中间词时 50% 概率加 hex 尾缀(真实场景很多人用 hostname 如 "dev-box-a3f2")
|
||||
if (middle && hb.readUInt8(5) % 2 === 0) hostname += `-${tail}`;
|
||||
|
||||
// ── username: 组合生成,如 "alexdev", "sam42", "chris_dev" ──
|
||||
const ub = h('username');
|
||||
const first = UN_FIRST[ub.readUInt8(0) % UN_FIRST.length];
|
||||
const suffix = UN_SUFFIX[ub.readUInt8(1) % UN_SUFFIX.length];
|
||||
const username = `${first}${suffix}`;
|
||||
|
||||
// ── terminal & shell: 按权重分布(xterm-256color 占大多数) ──
|
||||
const termRoll = h('terminal').readUInt8(0) % 100;
|
||||
const terminal = termRoll < 60 ? 'xterm-256color' :
|
||||
termRoll < 75 ? 'screen-256color' :
|
||||
termRoll < 88 ? 'tmux-256color' :
|
||||
termRoll < 95 ? 'alacritty' : 'rxvt-unicode-256color';
|
||||
|
||||
const shellRoll = h('shell').readUInt8(0) % 100;
|
||||
const shell = shellRoll < 55 ? '/bin/bash' :
|
||||
shellRoll < 85 ? '/bin/zsh' :
|
||||
shellRoll < 95 ? '/usr/bin/bash' : '/usr/bin/zsh';
|
||||
|
||||
// ── host.id: /etc/machine-id (32 hex chars, Linux 标准) ──
|
||||
const machineId = h('machine-id').slice(0, 16).toString('hex');
|
||||
|
||||
// ── PID: 每个 session 随机生成,模拟每次启动新进程 ──
|
||||
// 不用 seed 确定性生成,因为真实 CLI 每次启动都是新 PID
|
||||
const pid = 1000 + Math.floor(Math.random() * 64000);
|
||||
|
||||
// ── kernel version: 模拟真实 Linux 发行版 ──
|
||||
const kb = h('kernel');
|
||||
const kernelMajor = 5 + (kb.readUInt8(0) % 2); // 5 or 6
|
||||
const kernelMinor = kb.readUInt8(1) % 20;
|
||||
const kernelPatch = kb.readUInt8(2) % 200;
|
||||
const ubuntuBuild = 50 + (kb.readUInt8(3) % 150);
|
||||
const osVersion = `#${ubuntuBuild}-Ubuntu SMP`;
|
||||
|
||||
// ── 可执行文件路径: 按安装方式分布 ──
|
||||
const pathRoll = h('execpath').readUInt8(0) % 100;
|
||||
const executablePath = pathRoll < 40 ? `/home/${username}/.claude/local/claude` :
|
||||
pathRoll < 70 ? '/usr/local/bin/claude' :
|
||||
pathRoll < 90 ? `/home/${username}/.local/bin/claude` :
|
||||
'/usr/bin/claude';
|
||||
|
||||
return {
|
||||
hostname,
|
||||
username,
|
||||
terminal,
|
||||
shell,
|
||||
machineId,
|
||||
pid,
|
||||
arch: 'x64',
|
||||
osType: 'Linux',
|
||||
osVersion,
|
||||
kernelRelease: `${kernelMajor}.${kernelMinor}.${kernelPatch}-generic`,
|
||||
// service.instance.id: 每个 session 唯一(CLI 用 randomUUID)
|
||||
serviceInstanceId: crypto.randomUUID(),
|
||||
executablePath,
|
||||
executableName: 'claude',
|
||||
command: 'claude',
|
||||
commandArgs: [],
|
||||
runtimeName: 'nodejs',
|
||||
runtimeVersion: FAKE_NODE_VERSION.replace('v', ''),
|
||||
// ripgrep 信息也按 seed 生成,不同账号不一样
|
||||
ripgrepVersion: (() => {
|
||||
const rv = h('ripgrep');
|
||||
const versions = ['14.1.1','14.1.0','14.0.2','13.0.0','13.0.1','14.0.1','14.0.0'];
|
||||
return versions[rv.readUInt8(0) % versions.length];
|
||||
})(),
|
||||
ripgrepPath: (() => {
|
||||
const rp = h('rgpath');
|
||||
const paths = ['/usr/bin/rg','/usr/local/bin/rg','/home/'+username+'/.cargo/bin/rg','/snap/bin/rg','/usr/bin/rg','/usr/bin/rg'];
|
||||
return paths[rp.readUInt8(0) % paths.length];
|
||||
})(),
|
||||
// MCP server 数量(真实用户 0~6 个,影响启动事件序列)
|
||||
mcpServerCount: 1 + (h('mcp').readUInt8(0) % 5), // 1~5
|
||||
mcpFailCount: h('mcp').readUInt8(1) % 3, // 0~2 个失败
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 遥测模拟 ────────────────────────────────────────────
|
||||
|
||||
// 每个 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) {
|
||||
return {
|
||||
platform: 'linux',
|
||||
node_version: FAKE_NODE_VERSION,
|
||||
terminal: hostId.terminal,
|
||||
package_managers: 'npm',
|
||||
runtimes: 'node',
|
||||
is_running_with_bun: false,
|
||||
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-linux',
|
||||
is_conductor: false,
|
||||
version_base: CLI_VERSION,
|
||||
build_time: BUILD_TIME,
|
||||
is_local_agent_mode: false,
|
||||
vcs: 'git',
|
||||
platform_raw: 'linux',
|
||||
};
|
||||
}
|
||||
|
||||
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() * 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),
|
||||
},
|
||||
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),
|
||||
};
|
||||
// 如果有 session,注入 OTEL trace headers(匹配 CLI 的 W3C Trace Context)
|
||||
if (session) {
|
||||
const traceId = crypto.randomBytes(16).toString('hex');
|
||||
const spanId = crypto.randomBytes(8).toString('hex');
|
||||
headers['traceparent'] = `00-${traceId}-${spanId}-01`;
|
||||
}
|
||||
|
||||
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:linux,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: 'linux',
|
||||
platform_raw: 'linux',
|
||||
arch: hostId.arch,
|
||||
node_version: FAKE_NODE_VERSION,
|
||||
version: CLI_VERSION,
|
||||
version_base: CLI_VERSION,
|
||||
build_time: BUILD_TIME,
|
||||
deployment_environment: 'unknown-linux',
|
||||
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';
|
||||
|
||||
// 首次请求:发完整启动事件序列(匹配真实 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';
|
||||
|
||||
// 请求完成事件
|
||||
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 管理 ────────────────────────────────────
|
||||
function getOrCreateH2Session(host) {
|
||||
const existing = h2Sessions.get(host);
|
||||
if (existing && !existing.closed && !existing.destroyed) return existing;
|
||||
if (existing) { try { existing.close(); } catch (_) {} }
|
||||
|
||||
const session = http2.connect(`https://${host}`);
|
||||
session.on('error', (err) => {
|
||||
log('warn', 'h2_session_error', { host, error: err.message });
|
||||
h2Sessions.delete(host);
|
||||
try { session.close(); } catch (_) {}
|
||||
});
|
||||
session.on('close', () => h2Sessions.delete(host));
|
||||
session.on('goaway', () => { h2Sessions.delete(host); try { session.close(); } catch (_) {} });
|
||||
session.setTimeout(IDLE_TIMEOUT, () => { session.close(); h2Sessions.delete(host); });
|
||||
h2Sessions.set(host, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
function waitForConnect(session) {
|
||||
if (session.connected) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
session.once('connect', resolve);
|
||||
session.once('error', reject);
|
||||
const t = setTimeout(() => reject(new Error('h2 connect timeout')), CONNECT_TIMEOUT);
|
||||
session.once('connect', () => clearTimeout(t));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── CONNECT 隧道 ────────────────────────────────────────
|
||||
function connectViaProxy(proxyUrl, targetHost, targetPort) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proxy = new URL(proxyUrl);
|
||||
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}`)); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 收集请求体 ──────────────────────────────────────────
|
||||
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) {
|
||||
return new Promise((resolve) => {
|
||||
const headers = { ...reqHeaders, host: targetHost };
|
||||
['x-forwarded-host', 'connection', 'keep-alive', 'proxy-connection', 'transfer-encoding'].forEach(h => delete headers[h]);
|
||||
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).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: err.message })); }
|
||||
resolve('error');
|
||||
});
|
||||
proxyReq.on('timeout', () => proxyReq.destroy(new Error('timeout')));
|
||||
proxyReq.end(body);
|
||||
};
|
||||
|
||||
if (UPSTREAM_PROXY) {
|
||||
connectViaProxy(UPSTREAM_PROXY, targetHost, 443)
|
||||
.then((socket) => { opts.socket = socket; opts.agent = false; finish(opts); })
|
||||
.catch((err) => { log('error', 'tunnel_failed', { error: err.message }); if (!res.headersSent) { res.writeHead(502); res.end('tunnel error'); } resolve('error'); });
|
||||
} else {
|
||||
finish(opts);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── H2 代理 ─────────────────────────────────────────────
|
||||
async function sendViaH2(targetHost, method, path, reqHeaders, body, res, savedHeaders) {
|
||||
try {
|
||||
const session = getOrCreateH2Session(targetHost);
|
||||
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: err.message })); }
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
if (!responded && !res.headersSent) {
|
||||
log('warn', 'h2_no_response', { host: targetHost, path });
|
||||
res.writeHead(502); res.end('{"error":"h2_no_response"}');
|
||||
} 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 });
|
||||
h2Sessions.delete(targetHost);
|
||||
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);
|
||||
// 随机延迟 50-200ms 模拟真实 CLI 行为
|
||||
await new Promise(r => setTimeout(r, 50 + Math.floor(Math.random() * 150)));
|
||||
}
|
||||
|
||||
if (h2Hosts.has(targetHost)) {
|
||||
await sendViaH2(targetHost, req.method, req.url, req.headers, body, res, savedHeaders);
|
||||
} else {
|
||||
await sendViaH1(targetHost, req.method, req.url, req.headers, body, res, savedHeaders);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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) }));
|
||||
@ -246,81 +246,8 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// isNodeTLSProxyEnabled 检查 Node.js TLS 代理是否启用
|
||||
func (s *httpUpstreamService) isNodeTLSProxyEnabled() bool {
|
||||
if s.cfg == nil {
|
||||
return false
|
||||
}
|
||||
return s.cfg.Gateway.NodeTLSProxy.Enabled
|
||||
}
|
||||
|
||||
// shouldRouteViaNodeProxy 判断请求是否应该走 Node.js TLS 代理
|
||||
// 仅拦截目标主机在 proxy_hosts 白名单中的 HTTPS 请求,
|
||||
// 白名单为空时默认只代理 api.anthropic.com。
|
||||
func (s *httpUpstreamService) shouldRouteViaNodeProxy(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 {
|
||||
// 默认只代理 Anthropic
|
||||
return reqHost == "api.anthropic.com"
|
||||
}
|
||||
for _, h := range hosts {
|
||||
if reqHost == h {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// doViaNodeTLSProxy 通过 Node.js TLS 代理发送请求
|
||||
// 将 HTTPS 请求改为 HTTP 明文发送到本地 Node.js 代理,
|
||||
// 由 Node.js 进程使用原生 TLS 栈完成到上游的 HTTPS 连接。
|
||||
// 原始目标主机通过 X-Forwarded-Host 传递给 Node.js 代理,
|
||||
// 代理据此动态连接到正确的上游主机。
|
||||
func (s *httpUpstreamService) doViaNodeTLSProxy(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
||||
proxyCfg := s.cfg.Gateway.NodeTLSProxy
|
||||
listenHost := proxyCfg.ListenHost
|
||||
if listenHost == "" {
|
||||
listenHost = "127.0.0.1"
|
||||
}
|
||||
listenPort := proxyCfg.ListenPort
|
||||
if listenPort == 0 {
|
||||
listenPort = 3456
|
||||
}
|
||||
|
||||
// 克隆请求,避免修改原始 req(重试时需要原始 URL)
|
||||
proxyReq := req.Clone(req.Context())
|
||||
// 安全复制 Body:优先用 GetBody 工厂方法
|
||||
if req.GetBody != nil {
|
||||
proxyReq.Body, _ = req.GetBody()
|
||||
} else {
|
||||
proxyReq.Body = req.Body
|
||||
}
|
||||
|
||||
// 保存原始目标主机,通过自定义头传给 Node.js 代理
|
||||
originalHost := req.URL.Host
|
||||
proxyReq.Header.Set("X-Forwarded-Host", originalHost)
|
||||
|
||||
// 重写请求 URL:https://api.anthropic.com/v1/... → http://127.0.0.1:3456/v1/...
|
||||
proxyReq.URL.Scheme = "http"
|
||||
proxyReq.URL.Host = fmt.Sprintf("%s:%d", listenHost, listenPort)
|
||||
|
||||
slog.Debug("node_tls_proxy_rewrite",
|
||||
"account_id", accountID,
|
||||
"original_host", originalHost,
|
||||
"rewritten_to", proxyReq.URL.Host,
|
||||
)
|
||||
|
||||
// 通过标准 HTTP 客户端发送(不需要 TLS,代理是本地 HTTP)
|
||||
return s.Do(proxyReq, "", accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
// acquireClientWithTLS 获取或创建带 TLS 指纹的客户端
|
||||
func (s *httpUpstreamService) acquireClientWithTLS(proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*upstreamClientEntry, error) {
|
||||
|
||||
94
backend/internal/repository/http_upstream_antigravity.go
Normal file
94
backend/internal/repository/http_upstream_antigravity.go
Normal file
@ -0,0 +1,94 @@
|
||||
package repository
|
||||
|
||||
// ==============================================================
|
||||
// antigravity — Node.js TLS 代理扩展
|
||||
//
|
||||
// 此文件包含 Antigravity fork 新增的 Node.js TLS 代理功能,
|
||||
// 与 upstream 代码完全隔离,便于 upstream 更新时的合并维护。
|
||||
//
|
||||
// 上游文件 http_upstream.go 中的钩子调用点:
|
||||
// Do() L128-137 — 直接路由到 doViaNodeTLSProxy
|
||||
// DoWithTLS() L188-193 — 优先走 Node.js 代理
|
||||
// ==============================================================
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// isNodeTLSProxyEnabled 检查 Node.js TLS 代理是否启用
|
||||
func (s *httpUpstreamService) isNodeTLSProxyEnabled() bool {
|
||||
if s.cfg == nil {
|
||||
return false
|
||||
}
|
||||
return s.cfg.Gateway.NodeTLSProxy.Enabled
|
||||
}
|
||||
|
||||
// shouldRouteViaNodeProxy 判断请求是否应该走 Node.js TLS 代理
|
||||
// 仅拦截目标主机在 proxy_hosts 白名单中的 HTTPS 请求,
|
||||
// 白名单为空时默认只代理 api.anthropic.com。
|
||||
func (s *httpUpstreamService) shouldRouteViaNodeProxy(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 {
|
||||
// 默认只代理 Anthropic
|
||||
return reqHost == "api.anthropic.com"
|
||||
}
|
||||
for _, h := range hosts {
|
||||
if reqHost == h {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// doViaNodeTLSProxy 通过 Node.js TLS 代理发送请求
|
||||
// 将 HTTPS 请求改为 HTTP 明文发送到本地 Node.js 代理,
|
||||
// 由 Node.js 进程使用原生 TLS 栈完成到上游的 HTTPS 连接。
|
||||
// 原始目标主机通过 X-Forwarded-Host 传递给 Node.js 代理,
|
||||
// 代理据此动态连接到正确的上游主机。
|
||||
func (s *httpUpstreamService) doViaNodeTLSProxy(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
||||
proxyCfg := s.cfg.Gateway.NodeTLSProxy
|
||||
listenHost := proxyCfg.ListenHost
|
||||
if listenHost == "" {
|
||||
listenHost = "127.0.0.1"
|
||||
}
|
||||
listenPort := proxyCfg.ListenPort
|
||||
if listenPort == 0 {
|
||||
listenPort = 3456
|
||||
}
|
||||
|
||||
// 克隆请求,避免修改原始 req(重试时需要原始 URL)
|
||||
proxyReq := req.Clone(req.Context())
|
||||
// 安全复制 Body:优先用 GetBody 工厂方法
|
||||
if req.GetBody != nil {
|
||||
proxyReq.Body, _ = req.GetBody()
|
||||
} else {
|
||||
proxyReq.Body = req.Body
|
||||
}
|
||||
|
||||
// 保存原始目标主机,通过自定义头传给 Node.js 代理
|
||||
originalHost := req.URL.Host
|
||||
proxyReq.Header.Set("X-Forwarded-Host", originalHost)
|
||||
|
||||
// 重写请求 URL:https://api.anthropic.com/v1/... → http://127.0.0.1:3456/v1/...
|
||||
proxyReq.URL.Scheme = "http"
|
||||
proxyReq.URL.Host = fmt.Sprintf("%s:%d", listenHost, listenPort)
|
||||
|
||||
slog.Debug("node_tls_proxy_rewrite",
|
||||
"account_id", accountID,
|
||||
"original_host", originalHost,
|
||||
"rewritten_to", proxyReq.URL.Host,
|
||||
)
|
||||
|
||||
// 通过标准 HTTP 客户端发送(不需要 TLS,代理是本地 HTTP)
|
||||
return s.Do(proxyReq, "", accountID, accountConcurrency)
|
||||
}
|
||||
@ -1147,24 +1147,26 @@ func (a *Account) IsAnthropicOAuthOrSetupToken() bool {
|
||||
}
|
||||
|
||||
// IsTLSFingerprintEnabled 检查是否启用 TLS 指纹伪装
|
||||
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
|
||||
// 启用后将模拟 Claude Code (Node.js) 客户端的 TLS 握手特征
|
||||
// 支持 Anthropic OAuth/SetupToken 和 Gemini OAuth 账号(扩展见 account_antigravity.go)
|
||||
// 启用后将通过 node-tls-proxy 路由流量,获得真实 Node.js TLS 握手特征
|
||||
func (a *Account) IsTLSFingerprintEnabled() bool {
|
||||
// 仅支持 Anthropic OAuth/SetupToken 账号
|
||||
if !a.IsAnthropicOAuthOrSetupToken() {
|
||||
return false
|
||||
}
|
||||
if a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := a.Extra["enable_tls_fingerprint"]; ok {
|
||||
if enabled, ok := v.(bool); ok {
|
||||
return enabled
|
||||
// Anthropic OAuth/SetupToken — 原有逻辑
|
||||
if a.IsAnthropicOAuthOrSetupToken() {
|
||||
if a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := a.Extra["enable_tls_fingerprint"]; ok {
|
||||
if enabled, ok := v.(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
// Gemini OAuth — 扩展(实现在 account_antigravity.go)
|
||||
return geminiTLSFingerprintEnabled(a)
|
||||
}
|
||||
|
||||
|
||||
// GetUserMsgQueueMode 获取用户消息队列模式
|
||||
// "serialize" = 串行队列, "throttle" = 软性限速, "" = 未设置(使用全局配置)
|
||||
func (a *Account) GetUserMsgQueueMode() string {
|
||||
|
||||
30
backend/internal/service/account_antigravity.go
Normal file
30
backend/internal/service/account_antigravity.go
Normal file
@ -0,0 +1,30 @@
|
||||
package service
|
||||
|
||||
// ==============================================================
|
||||
// antigravity — account 扩展
|
||||
//
|
||||
// 此文件包含 Antigravity fork 对 Account 的扩展,
|
||||
// 新增了 Gemini OAuth 账号的 TLS 指纹伪装支持。
|
||||
//
|
||||
// 对上游文件 account.go 的改动:
|
||||
// - IsTLSFingerprintEnabled() 方法改为调用本文件的 geminiTLSFingerprintEnabled()
|
||||
// (仅需在与上游合并时确保钩子调用点存在)
|
||||
// ==============================================================
|
||||
|
||||
// geminiTLSFingerprintEnabled 检查 Gemini OAuth 账号是否启用 TLS 指纹伪装
|
||||
// Gemini CLI 也是 Node.js 应用,通过 node-tls-proxy 代理后
|
||||
// TLS 指纹天然匹配 Gemini CLI,无需单独模拟
|
||||
func geminiTLSFingerprintEnabled(a *Account) bool {
|
||||
if a.Platform != PlatformGemini || a.Type != AccountTypeOAuth {
|
||||
return false
|
||||
}
|
||||
if a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := a.Extra["enable_tls_fingerprint"]; ok {
|
||||
if enabled, ok := v.(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -35,24 +35,7 @@ var defaultFingerprint = Fingerprint{
|
||||
StainlessRuntimeVersion: "v24.13.0",
|
||||
}
|
||||
|
||||
// ApplyDefaultFingerprintOverrides 用配置覆盖 identity_service 的默认指纹
|
||||
func ApplyDefaultFingerprintOverrides(cliVersion, pkgVersion, runtimeVersion, os_, arch string) {
|
||||
if cliVersion != "" {
|
||||
defaultFingerprint.UserAgent = "claude-cli/" + cliVersion + " (external, cli)"
|
||||
}
|
||||
if pkgVersion != "" {
|
||||
defaultFingerprint.StainlessPackageVersion = pkgVersion
|
||||
}
|
||||
if runtimeVersion != "" {
|
||||
defaultFingerprint.StainlessRuntimeVersion = runtimeVersion
|
||||
}
|
||||
if os_ != "" {
|
||||
defaultFingerprint.StainlessOS = os_
|
||||
}
|
||||
if arch != "" {
|
||||
defaultFingerprint.StainlessArch = arch
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Fingerprint represents account fingerprint data
|
||||
type Fingerprint struct {
|
||||
@ -91,10 +74,7 @@ func NewIdentityService(cache IdentityCache) *IdentityService {
|
||||
return &IdentityService{cache: cache}
|
||||
}
|
||||
|
||||
// NewIdentityServiceWithSalt 创建带实例盐值的 IdentityService
|
||||
func NewIdentityServiceWithSalt(cache IdentityCache, salt string) *IdentityService {
|
||||
return &IdentityService{cache: cache, instanceSalt: salt}
|
||||
}
|
||||
|
||||
|
||||
// GetOrCreateFingerprint 获取或创建账号的指纹
|
||||
// 如果缓存存在,检测user-agent版本,新版本则更新
|
||||
|
||||
40
backend/internal/service/identity_service_antigravity.go
Normal file
40
backend/internal/service/identity_service_antigravity.go
Normal file
@ -0,0 +1,40 @@
|
||||
package service
|
||||
|
||||
// ==============================================================
|
||||
// antigravity — identity_service 扩展
|
||||
//
|
||||
// 此文件包含 Antigravity fork 对 IdentityService 的扩展,
|
||||
// 新增了实例级隔离盐值和指纹默认值覆盖功能。
|
||||
//
|
||||
// 对上游文件 identity_service.go 的最小化改动:
|
||||
// - defaultFingerprint 版本号更新(L29/L31):claude-cli 2.1.81 / sdk 0.80.0
|
||||
// - IdentityService struct 新增 instanceSalt 字段(L86)
|
||||
// [以上两处改动仍在原文件中,因为是对已有定义的修改,无法完全抽离]
|
||||
// ==============================================================
|
||||
|
||||
// ApplyDefaultFingerprintOverrides 用配置覆盖 identity_service 的默认指纹
|
||||
// 允许不同部署实例设置不同的 CLI/SDK 版本号,避免所有实例指纹相同
|
||||
func ApplyDefaultFingerprintOverrides(cliVersion, pkgVersion, runtimeVersion, os_, arch string) {
|
||||
if cliVersion != "" {
|
||||
defaultFingerprint.UserAgent = "claude-cli/" + cliVersion + " (external, cli)"
|
||||
}
|
||||
if pkgVersion != "" {
|
||||
defaultFingerprint.StainlessPackageVersion = pkgVersion
|
||||
}
|
||||
if runtimeVersion != "" {
|
||||
defaultFingerprint.StainlessRuntimeVersion = runtimeVersion
|
||||
}
|
||||
if os_ != "" {
|
||||
defaultFingerprint.StainlessOS = os_
|
||||
}
|
||||
if arch != "" {
|
||||
defaultFingerprint.StainlessArch = arch
|
||||
}
|
||||
}
|
||||
|
||||
// NewIdentityServiceWithSalt 创建带实例盐值的 IdentityService
|
||||
// 实例盐值用于 user_id 重写时的 session hash 混淆,
|
||||
// 使不同 sub2api 实例对相同输入产生不同的 hash 输出,增加隔离性
|
||||
func NewIdentityServiceWithSalt(cache IdentityCache, salt string) *IdentityService {
|
||||
return &IdentityService{cache: cache, instanceSalt: salt}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user