From e5d78f8e56a3f3a3b117fbef6b0bb4f2934479f9 Mon Sep 17 00:00:00 2001 From: win Date: Wed, 25 Mar 2026 11:34:45 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=B0=86=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E4=BB=A3=E7=A0=81=E9=9B=86=E4=B8=AD=E5=88=B0=20antigr?= =?UTF-8?q?avity/=20=E7=9B=AE=E5=BD=95=E5=92=8C=20*=5Fantigravity.go=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 时快速识别和保留自定义改动 --- antigravity/firewall/setup-firewall.sh | 104 +++ antigravity/maintenance/save-patches.sh | 30 + antigravity/maintenance/sync-upstream.sh | 82 ++ antigravity/node-tls-proxy/Dockerfile | 24 + antigravity/node-tls-proxy/package.json | 14 + antigravity/node-tls-proxy/proxy.js | 727 ++++++++++++++++++ backend/internal/repository/http_upstream.go | 73 -- .../repository/http_upstream_antigravity.go | 94 +++ backend/internal/service/account.go | 28 +- .../internal/service/account_antigravity.go | 30 + backend/internal/service/identity_service.go | 24 +- .../service/identity_service_antigravity.go | 40 + 12 files changed, 1162 insertions(+), 108 deletions(-) create mode 100755 antigravity/firewall/setup-firewall.sh create mode 100755 antigravity/maintenance/save-patches.sh create mode 100755 antigravity/maintenance/sync-upstream.sh create mode 100644 antigravity/node-tls-proxy/Dockerfile create mode 100644 antigravity/node-tls-proxy/package.json create mode 100644 antigravity/node-tls-proxy/proxy.js create mode 100644 backend/internal/repository/http_upstream_antigravity.go create mode 100644 backend/internal/service/account_antigravity.go create mode 100644 backend/internal/service/identity_service_antigravity.go diff --git a/antigravity/firewall/setup-firewall.sh b/antigravity/firewall/setup-firewall.sh new file mode 100755 index 00000000..14041d88 --- /dev/null +++ b/antigravity/firewall/setup-firewall.sh @@ -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 diff --git a/antigravity/maintenance/save-patches.sh b/antigravity/maintenance/save-patches.sh new file mode 100755 index 00000000..ba861d44 --- /dev/null +++ b/antigravity/maintenance/save-patches.sh @@ -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" diff --git a/antigravity/maintenance/sync-upstream.sh b/antigravity/maintenance/sync-upstream.sh new file mode 100755 index 00000000..dfc58133 --- /dev/null +++ b/antigravity/maintenance/sync-upstream.sh @@ -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" diff --git a/antigravity/node-tls-proxy/Dockerfile b/antigravity/node-tls-proxy/Dockerfile new file mode 100644 index 00000000..cbcb4f93 --- /dev/null +++ b/antigravity/node-tls-proxy/Dockerfile @@ -0,0 +1,24 @@ +FROM node:24.13.0-slim + +LABEL maintainer="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"] diff --git a/antigravity/node-tls-proxy/package.json b/antigravity/node-tls-proxy/package.json new file mode 100644 index 00000000..a68007cb --- /dev/null +++ b/antigravity/node-tls-proxy/package.json @@ -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" + } +} diff --git a/antigravity/node-tls-proxy/proxy.js b/antigravity/node-tls-proxy/proxy.js new file mode 100644 index 00000000..cb5c6c3f --- /dev/null +++ b/antigravity/node-tls-proxy/proxy.js @@ -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) })); diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index d90a1755..a0ad1d97 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -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) { diff --git a/backend/internal/repository/http_upstream_antigravity.go b/backend/internal/repository/http_upstream_antigravity.go new file mode 100644 index 00000000..b12dead9 --- /dev/null +++ b/backend/internal/repository/http_upstream_antigravity.go @@ -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) +} diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index d42c6a11..f76723ed 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -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 { diff --git a/backend/internal/service/account_antigravity.go b/backend/internal/service/account_antigravity.go new file mode 100644 index 00000000..758e315b --- /dev/null +++ b/backend/internal/service/account_antigravity.go @@ -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 +} diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index db4935f9..33c60c4b 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -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版本,新版本则更新 diff --git a/backend/internal/service/identity_service_antigravity.go b/backend/internal/service/identity_service_antigravity.go new file mode 100644 index 00000000..994ab08e --- /dev/null +++ b/backend/internal/service/identity_service_antigravity.go @@ -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} +}