Compare commits

..

60 Commits

Author SHA1 Message Date
win
8eb2bbcb20 feat: 从 main 分支迁移 Claude 指纹常量和实例级隔离配置
Some checks failed
CI / test (push) Failing after 5s
CI / golangci-lint (push) Failing after 13s
Security Scan / backend-security (push) Failing after 7s
Security Scan / frontend-security (push) Failing after 7s
将 main 分支的 Claude/Anthropic 相关逆向工作迁移到 codex 分支:
- claude/constants.go: 添加 4 个新 Beta 常量 + 版本升级至 2.1.84/0.74.0
- config.go: 添加 InstanceSalt 和 FingerprintDefaultsConfig 配置
- identity_service: 版本升级 + instanceSalt 支持 + ApplyDefaultFingerprintOverrides
- wire_gen.go: 初始化指纹覆盖 + 使用 NewIdentityServiceWithSalt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:04:52 +08:00
shaw
1dfd974432 chore: update readme
Some checks failed
CI / test (push) Failing after 4s
CI / golangci-lint (push) Failing after 6s
Security Scan / backend-security (push) Failing after 5s
Security Scan / frontend-security (push) Failing after 4s
2026-03-30 16:28:31 +08:00
shaw
cc396f59cf chore: update readme 2026-03-30 16:24:29 +08:00
github-actions[bot]
aa8b9cc508 chore: sync VERSION to 0.1.106 [skip ci] 2026-03-30 08:13:49 +00:00
Wesley Liddick
6a2cf09ee0
Merge pull request #1349 from touwaeriol/feat/antigravity-internal500-penalty
feat(antigravity): progressive penalty for consecutive INTERNAL 500 errors
2026-03-30 15:54:04 +08:00
Wesley Liddick
c6fd88116b
Merge pull request #1354 from wucm667/fix/billing-use-requested-model
fix(billing): 计费始终使用用户请求的原始模型,而非映射后的上游模型
2026-03-30 15:52:31 +08:00
Wesley Liddick
8f0dbdeaba
Merge pull request #1343 from yilinyo/fix/api-key-unique-conflict-after-soft-delete
fix(api-key):软删除apikey后key没有被释放后续无法再自定义相同的key
2026-03-30 15:47:28 +08:00
Wesley Liddick
007c09b84e
Merge pull request #1338 from LvyuanW/fix/safari-ops-log-select
fix(admin): fix Safari system log select height
2026-03-30 15:45:35 +08:00
Wesley Liddick
73f3c068ef
Merge pull request #1344 from 7836246/fix/i18n-sora-storage-missing-keys
fix(i18n): 修复 Sora 存储配置页面表格列头「存储桶」翻译缺失
2026-03-30 15:45:03 +08:00
Wesley Liddick
9a92fa4a60
Merge pull request #1370 from YanzheL/fix/1320-openai-messages-gpt54-xhigh
fix(gateway): normalize gpt-5.4-xhigh for /v1/messages
2026-03-30 15:44:34 +08:00
Wesley Liddick
576af710be
Merge pull request #1352 from StarryKira/feat/add-file-upload-oauth-scope
Feat/add file upload oauth scope
2026-03-30 15:41:18 +08:00
Wesley Liddick
b5642bd068
Merge pull request #1377 from DaydreamCoding/fix/lifecycle-stop-duplicate-close
fix(lifecycle): TokenRefreshService Stop() 防重复 close
2026-03-30 15:38:39 +08:00
Wesley Liddick
128f322252
Merge pull request #1376 from weak-fox/fix/privacy-without-refresh-token
修复缺少 refresh_token 时被临时停调度
2026-03-30 15:38:27 +08:00
Wesley Liddick
17d7e57a2e
Merge pull request #1375 from weak-fox/fix/batch-reset-temp-unsched
修复重置状态时未清理临时停调度
2026-03-30 15:37:58 +08:00
shaw
50288e6b01 fix: 修复模型定价文件更新url 2026-03-30 15:36:53 +08:00
shaw
ab3e44e4bd fix: 适配X-Claude-Code-Session-Id头 2026-03-30 11:43:07 +08:00
QTom
61607990c8 fix(lifecycle): TokenRefreshService Stop() 防重复 close
使用 sync.Once 包裹 close(stopCh),避免多次调用 Stop() 时
触发 panic: close of closed channel。
2026-03-30 10:33:06 +08:00
shaw
b65275235f feat: Anthropic oauth/setup-token账号支持自定义转发URL 2026-03-30 09:10:57 +08:00
weak-fox
e298a71834 fix: clear temp unsched when resetting account status 2026-03-30 00:22:02 +08:00
weak-fox
3f6fa1e3db fix: avoid temp unsched when refresh token is missing 2026-03-30 00:21:51 +08:00
YanzheL
f2c2abe628 fix(openai): keep xhigh normalization scoped to messages 2026-03-29 21:09:19 +08:00
YanzheL
ff5b467fbe fix(handler): normalize compat model for message routing 2026-03-29 20:53:14 +08:00
YanzheL
8c10941142 fix(openai): normalize gpt-5.4-xhigh compat mapping 2026-03-29 20:52:29 +08:00
wucm667
f5764d8dc6 fix(billing): 计费始终使用用户请求的原始模型,而非映射后的上游模型
当账号配置了模型映射(如 claude-sonnet-4-6 → glm-5.0)时,系统错误地
使用映射后的上游模型名计算费用。由于上游模型(如 glm-5.0)在定价系统中
没有价格配置,导致计费失败后被静默置为 0,用户不被扣费。

修改 forwardResultBillingModel 优先返回请求模型名,并移除 OpenAI 路径
中 BillingModel 字段对计费模型的覆盖逻辑。
2026-03-28 16:22:06 +08:00
Elysia
81ca4f12dd 修复误删的url 2026-03-28 00:55:55 +08:00
Elysia
941c469ab9 fix: use standard PKCE code verifier generation
Replace charset→base64url double-encoding with standard random
bytes→base64url approach to match official client behavior and avoid
risk control detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 00:47:31 +08:00
Elysia
8fcd819e6f feat: add user:file_upload OAuth scope
Align OAuth scopes with upstream Claude Code client which now includes
the user:file_upload scope for file upload support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 00:40:36 +08:00
erio
9abdaed20c style: gofmt antigravity_internal500_penalty.go 2026-03-27 20:18:07 +08:00
erio
eb94342f78 chore: adjust internal500 penalty durations to 30m / 2h 2026-03-27 20:11:24 +08:00
erio
d563eb2336 test: add unit tests for INTERNAL 500 progressive penalty
Cover isAntigravityInternalServerError body matching,
applyInternal500Penalty tier escalation, handleInternal500RetryExhausted
nil-safety and error handling, and resetInternal500Counter paths.
2026-03-27 20:11:24 +08:00
erio
3ee6f085db refactor: extract internal500 penalty logic to dedicated file
Move constants, detection, and penalty functions from
antigravity_gateway_service.go to antigravity_internal500_penalty.go.
Fix gofmt alignment and replace hardcoded duration strings with
constant references.
2026-03-27 20:11:24 +08:00
erio
7cca69a136 fix: move internal500 counter reset to cover all success paths
Move the reset logic after urlFallbackLoop so it covers both direct
success and smart retry (429/503) success paths.
2026-03-27 20:11:24 +08:00
erio
093a5a260e feat(antigravity): progressive penalty for consecutive INTERNAL 500 errors
When an antigravity account returns 500 "Internal error encountered."
on all 3 retry attempts, increment a Redis counter and apply escalating
penalties:
- 1st round: temp unschedulable 10 minutes
- 2nd round: temp unschedulable 10 hours
- 3rd round: permanently mark as error

Counter resets on any successful response (< 400).
2026-03-27 20:11:24 +08:00
小海
2c072c0ed6 fix(i18n): add missing bucket column translation key for Sora S3 storage settings
The `admin.settings.soraS3.columns.bucket` key was used in
DataManagementView.vue but missing from both en.ts and zh.ts locale
files, causing the raw translation key to be displayed as a column
header instead of the localized text.
2026-03-27 16:44:14 +08:00
YilinMacAir
1f39bf8a78 fix:修复由于数据库唯一键导致软删除apikey后key没有被释放后续无法再自定义相同的key 2026-03-27 16:37:10 +08:00
github-actions[bot]
fdd8499ffc chore: sync VERSION to 0.1.105 [skip ci] 2026-03-27 08:04:27 +00:00
Wesley Liddick
9398ea7af5
Merge pull request #1340 from DaydreamCoding/fix/privacy-and-system-prompt
fix(openai): OpenAI 隐私模式全场景覆盖 & 修复转发路径 system prompt 丢失
2026-03-27 15:03:57 +08:00
Wesley Liddick
29dce1a59c
Merge pull request #1266 from eltociear/add-ja-doc
docs: add Japanese README
2026-03-27 14:51:37 +08:00
QTom
c729ee425f fix(gateway): 修复 OpenAI→Anthropic 转换路径 system prompt 被静默丢弃的 bug
injectClaudeCodePrompt 和 systemIncludesClaudeCodePrompt 的 type switch
无法匹配 json.RawMessage 类型(Go typed nil 陷阱),导致 ForwardAsResponses
和 ForwardAsChatCompletions 路径中用户 system prompt 被替换为仅 Claude Code
banner。新增 normalizeSystemParam 将 json.RawMessage 转为标准 Go 类型。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:51:36 +08:00
QTom
c489f23810 feat(privacy): 创建/批量创建 OpenAI OAuth 账号时异步设置隐私模式
参照 Antigravity 的模式,单个创建时同步调用 ForceOpenAIPrivacy,
批量创建时收集 OpenAI OAuth 账号后异步 goroutine 设置,避免阻塞请求。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:51:36 +08:00
QTom
47a544230a fix(privacy): 刷新令牌失败时也尝试设置 OpenAI 隐私模式
刷新失败不代表 access_token 无效,在后台定时刷新(不可重试错误 +
重试耗尽)和前端批量/单次刷新的失败路径中,均利用可能仍有效的
access_token 调用隐私设置。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:51:36 +08:00
QTom
c13c81f09d feat(privacy): 为 OpenAI OAuth 账号添加前端手动设置隐私按钮
复用已有的 set-privacy API 端点,Handler 通过 platform 分发到
ForceOpenAIPrivacy / ForceAntigravityPrivacy,前端 AccountActionMenu
扩展隐私按钮支持 OpenAI OAuth 账号。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:51:36 +08:00
Wesley Liddick
20544a4447
Merge pull request #1300 from xilu0/fix/forward-failed-log-missing-account-proxy-info
fix: add account and proxy details to forward_failed log
2026-03-27 14:47:51 +08:00
Wesley Liddick
b688ebeefa
Merge pull request #1215 from weak-fox/fix/privacy-retry-failed-mode
fix: 允许 OpenAI privacy_mode修改失败后能在 token 刷新时重试
2026-03-27 14:46:38 +08:00
shaw
1854050df3 feat(tls-fingerprint): 新增 TLS 指纹 Profile 数据库管理及代码质量优化
新增功能:
- 新增 TLS 指纹 Profile CRUD 管理(Ent schema + 迁移 + Admin API + 前端管理界面)
- 支持账号绑定数据库中的自定义 TLS Profile,或随机选择(profile_id=-1)
- HTTPUpstream.DoWithTLS 接口从 bool 改为 *tlsfingerprint.Profile,支持按账号指定 Profile
- AccountUsageService 注入 TLSFingerprintProfileService,统一 usage 场景与网关的 Profile 解析逻辑

代码优化:
- 删除已被 TLSFingerprintProfileService 完全取代的 registry.go 死代码(418 行)
- 提取 3 个 dialer 的重复 TLS 握手逻辑为 performTLSHandshake() 共用函数
- 修复 GetTLSFingerprintProfileID 缺少 json.Number 处理的 bug
- gateway_service.Forward 中 ResolveTLSProfile 从重试循环内重复调用改为预解析局部变量
- 删除冗余的 buildClientHelloSpec() 单行 wrapper 和 int64(e.ID) 无效转换
- tls_fingerprint_profile_cache.go 日志从 log.Printf 改为 slog 结构化日志
- dialer_capture_test.go 添加 //go:build integration 标签,防止 CI 失败
- 去重 TestProfileExpectation 类型至共享 test_types_test.go
- 修复 9 个测试文件缺少 tlsfingerprint import 的编译错误
- 修复 error_policy_integration_test.go 中 handleError 回调签名被错误替换的问题
2026-03-27 14:33:05 +08:00
Wang Lvyuan
c7f4a649df fix(admin): use custom select for ops log filters 2026-03-27 14:07:12 +08:00
Wesley Liddick
ef5c8e6839
Merge pull request #1231 from LvyuanW/bulk-openai-passthrough-worktree
Support bulk editing for OpenAI passthrough
2026-03-26 16:47:49 +08:00
shaw
d571f300e5 feat(rectifier): 请求整流器增加 API Key 账号签名整流支持
新增独立开关控制 API Key 账号的签名整流功能,支持配置自定义
匹配关键词以捕获不同格式的上游错误响应。

- 新增 apikey_signature_enabled 开关(默认关闭)
- 新增 apikey_signature_patterns 自定义关键词配置
- 内置签名检测规则对 API Key 账号同样生效
- 自定义关键词对完整响应体做不区分大小写匹配
- 重试二阶段检测仅做模式匹配,不重复校验开关
- Handler 层校验关键词数量(≤50)和长度(≤500)
- API 响应 nil patterns 统一序列化为空数组
- OAuth/SetupToken/Upstream/Bedrock 账号行为不变
2026-03-26 16:43:38 +08:00
Wesley Liddick
ce96527dd9
Merge pull request #1302 from DaydreamCoding/fix/openai-error-handling
fix(ratelimit): OpenAI 401 token_invalidated/token_revoked 及 402 deac…
2026-03-26 11:30:52 +08:00
Wesley Liddick
f8b8b53985
Merge pull request #1299 from DaydreamCoding/feat/antigravity-privacy-and-subscription
feat(antigravity): 自动隐私设置 + 订阅状态检测
2026-03-26 11:30:24 +08:00
shaw
b20e142249 feat: 网关请求头 wire casing 保持、转发行为开关、调试日志增强及 accept-encoding 恢复
- 新增 header_util.go,通过 setHeaderRaw/getHeaderRaw/addHeaderRaw 绕过
  Go 的 canonical-case 规范化,保持真实 Claude CLI 抓包的请求头大小写
  (如 "x-app" 而非 "X-App","X-Stainless-OS" 而非 "X-Stainless-Os")
- 新增管理后台开关:指纹统一化(默认开启)和 metadata 透传(默认关闭),
  使用 atomic.Value + singleflight 缓存模式,60s TTL
- 调试日志从控制台 body 打印升级为文件级完整快照
  (按真实 wire 顺序输出 headers + 格式化 JSON body + 上下文元数据)
- 恢复 accept-encoding 到白名单,在 http_upstream.go 新增 decompressResponseBody
  处理 gzip/brotli/deflate 解压(Go 显式设置 Accept-Encoding 时不会自动解压)
- OAuth 服务 axios UA 从 1.8.4 更新至 1.13.6
- 测试断言改用 getHeaderRaw 适配 raw header 存储方式
2026-03-26 11:17:25 +08:00
Dave King
7c6dc9dda8 fix: add account and proxy details to gateway.forward_failed log
The forward_failed error log only included account_id, making it
difficult to identify which account and proxy caused the failure
without querying the database. Add account_name, account_platform,
and proxy details (id, name, host, port) to the log fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:19:17 +00:00
QTom
5875571215 fix(ratelimit): OpenAI 401 token_invalidated/token_revoked 及 402 deactivated_workspace 标记账号异常
- 401 token_invalidated / token_revoked: OAuth token 被永久作废,跳过临时不可调度逻辑,直接 SetError
- 402 deactivated_workspace: 解析 detail.code 字段,标记工作区已停用

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:46:17 +08:00
QTom
975e6b1563 fix: 修复 golangci-lint 报告的 5 个问题
- gofmt: 修复 admin_service/antigravity_oauth_service/token_refresh_service 格式
- staticcheck S1009: 移除 SetUserSettingsResponse.IsSuccess 中冗余的 nil 检查
- unused: 将仅测试使用的 applyAntigravitySubscriptionResult 移至测试文件

Made-with: Cursor
2026-03-25 19:03:12 +08:00
QTom
f6fd7c83e3 feat(antigravity): 从 LoadCodeAssist 复用 TierInfo 提取 plan_type
复用已有 GetTier() 返回的 tier ID(free-tier / g1-pro-tier /
g1-ultra-tier),通过 TierIDToPlanType 映射为 Free / Pro / Ultra,
在 loadProjectIDWithRetry 中顺带提取并写入 credentials.plan_type;
前端增加 Abnormal 异常套餐红色标记。

Made-with: Cursor
2026-03-25 17:38:41 +08:00
QTom
c2965c0fb0 feat(antigravity): 自动设置隐私并支持后台手动重试
新增 Antigravity OAuth 隐私设置能力,在账号创建、刷新、导入和后台
Token 刷新路径自动调用 setUserSettings + fetchUserInfo 关闭遥测;
持久化后同步内存 Extra,错误处理改为日志记录。

Made-with: Cursor
2026-03-25 17:38:41 +08:00
Ikko Ashimine
fdad55956e docs: add Japanese README 2026-03-25 00:34:56 +09:00
Wang Lvyuan
bb399e56b0 merge: resolve upstream main conflicts for bulk OpenAI passthrough 2026-03-24 19:27:51 +08:00
Wang Lvyuan
73d72651b4 feat: support bulk OpenAI passthrough toggle 2026-03-23 17:17:42 +08:00
weak-fox
ccd42c1d1a Retry OpenAI privacy opt-out after failed states 2026-03-23 00:10:22 +08:00
147 changed files with 3557 additions and 7696 deletions

View File

@ -1,102 +0,0 @@
---
description: 从上游 (origin/main) 同步更新,保留 Antigravity 自定义改动
---
# 前置检查
// turbo
1. 查看当前状态和上游差异
```bash
cd /Users/win/2025/aitool/MiniGravity/sub2api
git fetch origin
git log --oneline HEAD..origin/main
```
如果上游没有新 commits停止无需同步
# 执行同步
2. 确保工作区干净
```bash
git status
```
如果有未提交的改动,先 `git stash`
// turbo
3. 备份当前自定义 patches
```bash
mkdir -p /tmp/antigravity-patches
git format-patch origin/main..HEAD -o /tmp/antigravity-patches/
echo "已备份 $(ls /tmp/antigravity-patches/*.patch 2>/dev/null | wc -l) 个 patch 到 /tmp/antigravity-patches/"
```
4. 执行 rebase把自定义 commits 移植到最新 upstream 上)
```bash
git rebase origin/main
```
如果有冲突,根据下方"冲突解决指南"处理,然后 `git rebase --continue`
// turbo
5. 编译验证
```bash
cd /Users/win/2025/aitool/MiniGravity/sub2api/backend && go build ./...
```
6. 推送
```bash
git push origin main --force-with-lease
```
---
# 冲突解决指南
## 高频冲突文件及处理策略
### `backend/internal/repository/http_upstream.go`
**我方改动**:在 `Do()``DoWithTLS()` 中新增了 Node.js TLS 代理路由逻辑。
**策略**:保留上游对函数签名/连接池的改动,确保我方在函数开头新增的 `isNodeTLSProxyEnabled()` 判断块被保留。
```bash
# 查看冲突
git diff backend/internal/repository/http_upstream.go
# 关键:确保以下两个块被保留(来自 ours
# 1. Do() 中的 Node.js proxy 路由 (~L128-137)
# 2. DoWithTLS() 中的 Node.js proxy 路由 (~L180-187)
# 3. isNodeTLSProxyEnabled() / shouldRouteViaNodeProxy() / doViaNodeTLSProxy() 函数
```
### `backend/internal/config/config.go`
**我方改动**:在 `GatewayConfig` struct 新增了 `NodeTLSProxy``InstanceSalt``FingerprintDefaults` 三个字段。
**策略**:上游通常只在 struct 末尾添加新字段,我方也是添加字段,基本不冲突。
```bash
# 确保以下字段存在于 GatewayConfig struct 中
grep -n "NodeTLSProxy\|InstanceSalt\|FingerprintDefaults" backend/internal/config/config.go
```
### `backend/internal/service/identity_service.go`
**我方改动**
- 更新了 `defaultFingerprint` 的版本号
- 新增 `ApplyDefaultFingerprintOverrides()` 函数
- 新增 `NewIdentityServiceWithSalt()` 函数
- 在 `IdentityService` struct 加 `instanceSalt` 字段
**策略**:上游通常不改 defaultFingerprint直接 Accept Ours 这部分。
### `backend/internal/pkg/claude/constants.go`
**我方改动**:更新 Claude CLI 版本常量CLI_VERSION / SDK_VERSION
**策略**:直接保留我方版本号(更新的)。
### `backend/cmd/server/wire_gen.go`
**我方改动**:可能因 Wire 依赖注入改变。
**策略**:先接受 Theirs然后重新运行 `go generate ./cmd/server/` 重新生成。
---
# 零冲突文件(永远不会冲突)
以下目录是我方全新添加upstream 没有,永远不会冲突:
- `tools/node-tls-proxy/` — Node.js TLS 代理
- `tools/firewall/` — iptables 防火墙规则
- `tools/sora-curl-cffi-sidecar/` — Sora curl_cffi sidecar
- `deploy/docker-compose.tls-proxy.yml` — TLS 代理 compose
- `deploy/build-push-tls-proxy.sh` — 构建推送脚本

View File

@ -7,7 +7,7 @@
# =============================================================================
ARG NODE_IMAGE=node:24-alpine
ARG GOLANG_IMAGE=golang:1.26.1
ARG GOLANG_IMAGE=golang:1.26.1-alpine
ARG ALPINE_IMAGE=alpine:3.21
ARG POSTGRES_IMAGE=postgres:18-alpine
ARG GOPROXY=https://goproxy.cn,direct
@ -46,8 +46,8 @@ ARG GOSUMDB
ENV GOPROXY=${GOPROXY}
ENV GOSUMDB=${GOSUMDB}
# Install build dependencies (non-alpine image uses apt)
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
# Install build dependencies
RUN apk add --no-cache git ca-certificates tzdata
WORKDIR /app/backend
@ -61,14 +61,14 @@ COPY backend/ ./
# Copy frontend dist from previous stage (must be after backend copy to avoid being overwritten)
COPY --from=frontend-builder /app/backend/internal/web/dist ./internal/web/dist
# Build the binary with BoringCrypto (matches real Antigravity TLS fingerprint)
# CGO_ENABLED=1 required for BoringCrypto; static linking via -extldflags for scratch-like deployment
# Build the binary (BuildType=release for CI builds, embed frontend)
# Version precedence: build arg VERSION > cmd/server/VERSION
RUN VERSION_VALUE="${VERSION}" && \
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(tr -d '\r\n' < ./cmd/server/VERSION)"; fi && \
DATE_VALUE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \
CGO_ENABLED=1 GOEXPERIMENT=boringcrypto GOOS=linux go build \
CGO_ENABLED=0 GOOS=linux go build \
-tags embed \
-ldflags="-s -w -linkmode external -extldflags '-static' -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \
-ldflags="-s -w -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \
-trimpath \
-o /app/sub2api \
./cmd/server

View File

@ -12,7 +12,7 @@
**AI API Gateway Platform for Subscription Quota Distribution**
English | [中文](README_CN.md)
English | [中文](README_CN.md) | [日本語](README_JA.md)
</div>
@ -49,9 +49,13 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
<table>
<tr>
<td width="180" align="center" valign="middle"><a href="https://shop.pincc.ai/"><img src="assets/partners/logos/pincc-logo.png" alt="pincc" width="120"></a></td>
<td width="180" align="center" valign="middle"><a href="https://shop.pincc.ai/"><img src="assets/partners/logos/pincc-logo.png" alt="pincc" width="150"></a></td>
<td valign="middle"><b><a href="https://shop.pincc.ai/">PinCC</a></b> is the official relay service built on Sub2API, offering stable access to Claude Code, Codex, Gemini and other popular models — ready to use, no deployment or maintenance required.</td>
</tr>
<tr>
<td width="180"><a href="https://www.packyapi.com/register?aff=sub2api"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=sub2api">this link</a> and enter the "sub2api" promo code during first recharge to get 10% off.</td>
</tr>
</table>
## Ecosystem

View File

@ -12,7 +12,7 @@
**AI API 网关平台 - 订阅配额分发管理**
[English](README.md) | 中文
[English](README.md) | 中文 | [日本語](README_JA.md)
</div>
@ -48,9 +48,13 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的
<table>
<tr>
<td width="180" align="center" valign="middle"><a href="https://shop.pincc.ai/"><img src="assets/partners/logos/pincc-logo.png" alt="pincc" width="120"></a></td>
<td width="180" align="center" valign="middle"><a href="https://shop.pincc.ai/"><img src="assets/partners/logos/pincc-logo.png" alt="pincc" width="150"></a></td>
<td valign="middle"><b><a href="https://shop.pincc.ai/">PinCC</a></b> 是基于 Sub2API 搭建的官方中转服务,提供 Claude Code、Codex、Gemini 等主流模型的稳定中转,开箱即用,免去自建部署与运维烦恼。</td>
</tr>
<tr>
<td width="180"><a href="https://www.packyapi.com/register?aff=sub2api"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>感谢 PackyCode 赞助了本项目PackyCode 是一家稳定、高效的API中转服务商提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=sub2api">此链接</a>注册并在充值时填写"sub2api"优惠码首次充值可以享受9折优惠</td>
</tr>
</table>
## 生态项目

589
README_JA.md Normal file
View File

@ -0,0 +1,589 @@
# Sub2API
<div align="center">
[![Go](https://img.shields.io/badge/Go-1.25.7-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](https://www.docker.com/)
<a href="https://trendshift.io/repositories/21823" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21823" alt="Wei-Shaw%2Fsub2api | Trendshift" width="250" height="55"/></a>
**サブスクリプションクォータ配分のための AI API ゲートウェイプラットフォーム**
[English](README.md) | [中文](README_CN.md) | 日本語
</div>
> **Sub2API が公式に使用しているドメインは `sub2api.org``pincc.ai` のみです。Sub2API の名称を使用している他のウェブサイトは、サードパーティによるデプロイやサービスであり、本プロジェクトとは一切関係がありません。ご利用の際はご自身で確認・判断をお願いします。**
---
## デモ
Sub2API をオンラインでお試しください: **[https://demo.sub2api.org/](https://demo.sub2api.org/)**
デモ用認証情報(共有デモ環境です。セルフホスト環境では**自動作成されません**:
| メールアドレス | パスワード |
|-------|----------|
| admin@sub2api.org | admin123 |
## 概要
Sub2API は、AI 製品のサブスクリプションから API クォータを配分・管理するために設計された AI API ゲートウェイプラットフォームです。ユーザーはプラットフォームが生成した API キーを通じて上流の AI サービスにアクセスでき、プラットフォームは認証、課金、負荷分散、リクエスト転送を処理します。
## 機能
- **マルチアカウント管理** - 複数の上流アカウントタイプOAuth、APIキーをサポート
- **APIキー配布** - ユーザー向けの APIキーの生成と管理
- **精密な課金** - トークンレベルの使用量追跡とコスト計算
- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
- **管理ダッシュボード** - 監視・管理のための Web インターフェース
- **外部システム連携** - 外部システム(決済、チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
## セルフホストが不要な方へ
<table>
<tr>
<td width="180" align="center" valign="middle"><a href="https://shop.pincc.ai/"><img src="assets/partners/logos/pincc-logo.png" alt="pincc" width="150"></a></td>
<td valign="middle"><b><a href="https://shop.pincc.ai/">PinCC</a></b> は Sub2API 上に構築された公式リレーサービスで、Claude Code、Codex、Gemini などの人気モデルへの安定したアクセスを提供します。デプロイやメンテナンスは不要で、すぐにご利用いただけます。</td>
</tr>
<tr>
<td width="180"><a href="https://www.packyapi.com/register?aff=sub2api"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>PackyCode のご支援に感謝しますPackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=sub2api">このリンク</a>で登録し、チャージ時に「sub2api」クーポンを入力すると 10% オフになります。</td>
</tr>
</table>
## エコシステム
Sub2API を拡張・統合するコミュニティプロジェクト:
| プロジェクト | 説明 | 機能 |
|---------|-------------|----------|
| [Sub2ApiPay](https://github.com/touwaeriol/sub2apipay) | セルフサービス決済システム | セルフサービスによるチャージおよびサブスクリプション購入。YiPay プロトコル、WeChat Pay、Alipay、Stripe 対応。iframe での埋め込み可能 |
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | モバイル管理コンソール | ユーザー管理、アカウント管理、監視ダッシュボード、マルチバックエンド切り替えが可能なクロスプラットフォームアプリiOS/Android/Web。Expo + React Native で構築 |
## 技術スタック
| コンポーネント | 技術 |
|-----------|------------|
| バックエンド | Go 1.25.7, Gin, Ent |
| フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS |
| データベース | PostgreSQL 15+ |
| キャッシュ/キュー | Redis 7+ |
---
## Nginx リバースプロキシに関する注意
Sub2APIまたは CRSを Nginx でリバースプロキシし、Codex CLI と組み合わせて使用する場合、Nginx の `http` ブロックに以下の設定を追加してください:
```nginx
underscores_in_headers on;
```
Nginx はデフォルトでアンダースコアを含むヘッダー(例: `session_id`)を破棄するため、マルチアカウント構成でのスティッキーセッションルーティングに支障をきたします。
---
## デプロイ
### 方法1: スクリプトによるインストール(推奨)
GitHub Releases からビルド済みバイナリをダウンロードするワンクリックインストールスクリプトです。
#### 前提条件
- Linux サーバーamd64 または arm64
- PostgreSQL 15+(インストール済みかつ稼働中)
- Redis 7+(インストール済みかつ稼働中)
- root 権限
#### インストール手順
```bash
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash
```
スクリプトは以下を実行します:
1. システムアーキテクチャの検出
2. 最新リリースのダウンロード
3. バイナリを `/opt/sub2api` にインストール
4. systemd サービスの作成
5. システムユーザーと権限の設定
#### インストール後の作業
```bash
# 1. サービスを起動
sudo systemctl start sub2api
# 2. 起動時の自動起動を有効化
sudo systemctl enable sub2api
# 3. ブラウザでセットアップウィザードを開く
# http://YOUR_SERVER_IP:8080
```
セットアップウィザードでは以下の設定を行います:
- データベース設定
- Redis 設定
- 管理者アカウントの作成
#### アップグレード
**管理ダッシュボード**の左上にある**アップデートを確認**ボタンをクリックすることで、ダッシュボードから直接アップグレードできます。
Web インターフェースでは以下が可能です:
- 新しいバージョンの自動確認
- ワンクリックでのアップデートのダウンロードと適用
- 必要に応じたロールバック
#### よく使うコマンド
```bash
# ステータスを確認
sudo systemctl status sub2api
# ログを表示
sudo journalctl -u sub2api -f
# サービスを再起動
sudo systemctl restart sub2api
# アンインストール
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y
```
---
### 方法2: Docker Compose推奨
PostgreSQL と Redis のコンテナを含む Docker Compose でデプロイします。
#### 前提条件
- Docker 20.10+
- Docker Compose v2+
#### クイックスタート(ワンクリックデプロイ)
自動デプロイスクリプトを使用して簡単にセットアップできます:
```bash
# デプロイ用ディレクトリを作成
mkdir -p sub2api-deploy && cd sub2api-deploy
# デプロイ準備スクリプトをダウンロードして実行
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash
# サービスを起動
docker compose up -d
# ログを表示
docker compose logs -f sub2api
```
**スクリプトの動作内容:**
- `docker-compose.local.yml``docker-compose.yml` として保存)と `.env.example` をダウンロード
- セキュアな認証情報JWT_SECRET、TOTP_ENCRYPTION_KEY、POSTGRES_PASSWORDを自動生成
- 自動生成されたシークレットで `.env` ファイルを作成
- データディレクトリを作成(バックアップ・移行が容易なローカルディレクトリを使用)
- 生成された認証情報を参照用に表示
#### 手動デプロイ
手動でセットアップする場合:
```bash
# 1. リポジトリをクローン
git clone https://github.com/Wei-Shaw/sub2api.git
cd sub2api/deploy
# 2. 環境設定ファイルをコピー
cp .env.example .env
# 3. 設定を編集(セキュアなパスワードを生成)
nano .env
```
**`.env` の必須設定:**
```bash
# PostgreSQL パスワード(必須)
POSTGRES_PASSWORD=your_secure_password_here
# JWT シークレット(推奨 - 再起動後もユーザーのログイン状態を保持)
JWT_SECRET=your_jwt_secret_here
# TOTP 暗号化キー(推奨 - 再起動後も二要素認証を維持)
TOTP_ENCRYPTION_KEY=your_totp_key_here
# オプション: 管理者アカウント
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your_admin_password
# オプション: カスタムポート
SERVER_PORT=8080
```
**セキュアなシークレットの生成方法:**
```bash
# JWT_SECRET を生成
openssl rand -hex 32
# TOTP_ENCRYPTION_KEY を生成
openssl rand -hex 32
# POSTGRES_PASSWORD を生成
openssl rand -hex 32
```
```bash
# 4. データディレクトリを作成(ローカルバージョンの場合)
mkdir -p data postgres_data redis_data
# 5. すべてのサービスを起動
# オプション A: ローカルディレクトリバージョン(推奨 - 移行が容易)
docker compose -f docker-compose.local.yml up -d
# オプション B: 名前付きボリュームバージョン(シンプルなセットアップ)
docker compose up -d
# 6. ステータスを確認
docker compose -f docker-compose.local.yml ps
# 7. ログを表示
docker compose -f docker-compose.local.yml logs -f sub2api
```
#### デプロイバージョン
| バージョン | データストレージ | 移行 | 推奨用途 |
|---------|-------------|-----------|----------|
| **docker-compose.local.yml** | ローカルディレクトリ | ✅ 容易(ディレクトリ全体を tar | 本番環境、頻繁なバックアップ |
| **docker-compose.yml** | 名前付きボリューム | ⚠️ docker コマンドが必要 | シンプルなセットアップ |
**推奨:** データ管理が容易な `docker-compose.local.yml`(スクリプトによるデプロイ)を使用してください。
#### アクセス
ブラウザで `http://YOUR_SERVER_IP:8080` を開いてください。
管理者パスワードが自動生成された場合は、ログで確認できます:
```bash
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
```
#### アップグレード
```bash
# 最新イメージをプルしてコンテナを再作成
docker compose -f docker-compose.local.yml pull
docker compose -f docker-compose.local.yml up -d
```
#### 簡単な移行(ローカルディレクトリバージョン)
`docker-compose.local.yml` を使用している場合、新しいサーバーへの移行が簡単です:
```bash
# 移行元サーバーにて
docker compose -f docker-compose.local.yml down
cd ..
tar czf sub2api-complete.tar.gz sub2api-deploy/
# 新しいサーバーに転送
scp sub2api-complete.tar.gz user@new-server:/path/
# 移行先サーバーにて
tar xzf sub2api-complete.tar.gz
cd sub2api-deploy/
docker compose -f docker-compose.local.yml up -d
```
#### よく使うコマンド
```bash
# すべてのサービスを停止
docker compose -f docker-compose.local.yml down
# 再起動
docker compose -f docker-compose.local.yml restart
# すべてのログを表示
docker compose -f docker-compose.local.yml logs -f
# すべてのデータを削除(注意!)
docker compose -f docker-compose.local.yml down
rm -rf data/ postgres_data/ redis_data/
```
---
### 方法3: ソースからビルド
開発やカスタマイズのためにソースコードからビルドして実行します。
#### 前提条件
- Go 1.21+
- Node.js 18+
- PostgreSQL 15+
- Redis 7+
#### ビルド手順
```bash
# 1. リポジトリをクローン
git clone https://github.com/Wei-Shaw/sub2api.git
cd sub2api
# 2. pnpm をインストール(未インストールの場合)
npm install -g pnpm
# 3. フロントエンドをビルド
cd frontend
pnpm install
pnpm run build
# 出力先: ../backend/internal/web/dist/
# 4. フロントエンドを組み込んだバックエンドをビルド
cd ../backend
go build -tags embed -o sub2api ./cmd/server
# 5. 設定ファイルを作成
cp ../deploy/config.example.yaml ./config.yaml
# 6. 設定を編集
nano config.yaml
```
> **注意:** `-tags embed` フラグはフロントエンドをバイナリに組み込みます。このフラグがない場合、バイナリはフロントエンド UI を提供しません。
**`config.yaml` の主要設定:**
```yaml
server:
host: "0.0.0.0"
port: 8080
mode: "release"
database:
host: "localhost"
port: 5432
user: "postgres"
password: "your_password"
dbname: "sub2api"
redis:
host: "localhost"
port: 6379
password: ""
jwt:
secret: "change-this-to-a-secure-random-string"
expire_hour: 24
default:
user_concurrency: 5
user_balance: 0
api_key_prefix: "sk-"
rate_multiplier: 1.0
```
### Sora ステータス(一時的に利用不可)
> ⚠️ Sora 関連の機能は、上流統合およびメディア配信の技術的問題により一時的に利用できません。
> 現時点では本番環境で Sora に依存しないでください。
> 既存の `gateway.sora_*` 設定キーは予約されていますが、これらの問題が解決されるまで有効にならない場合があります。
`config.yaml` では追加のセキュリティ関連オプションも利用できます:
- `cors.allowed_origins` - CORS 許可リスト
- `security.url_allowlist` - 上流/価格/CRS ホストの許可リスト
- `security.url_allowlist.enabled` - URL バリデーションの無効化(注意して使用)
- `security.url_allowlist.allow_insecure_http` - バリデーション無効時に HTTP URL を許可
- `security.url_allowlist.allow_private_hosts` - プライベート/ローカル IP アドレスを許可
- `security.response_headers.enabled` - 設定可能なレスポンスヘッダーフィルタリングを有効化(無効時はデフォルトの許可リストを使用)
- `security.csp` - Content-Security-Policy ヘッダーの制御
- `billing.circuit_breaker` - 課金エラー時にフェイルクローズ
- `server.trusted_proxies` - X-Forwarded-For パースの有効化
- `turnstile.required` - リリースモードでの Turnstile 必須化
**⚠️ セキュリティ警告: HTTP URL 設定**
`security.url_allowlist.enabled=false` の場合、システムはデフォルトで最小限の URL バリデーションを行い、**HTTP URL を拒否**して HTTPS のみを許可します。HTTP URL を許可するには(開発環境や内部テスト用など)、以下を明示的に設定する必要があります:
```yaml
security:
url_allowlist:
enabled: false # 許可リストチェックを無効化
allow_insecure_http: true # HTTP URL を許可(⚠️ セキュリティリスクあり)
```
**または環境変数で設定:**
```bash
SECURITY_URL_ALLOWLIST_ENABLED=false
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
```
**HTTP を許可するリスク:**
- API キーとデータが**平文**で送信される(傍受の危険性)
- **中間者攻撃MITM**を受けやすい
- **本番環境には不適切**
**HTTP を使用すべき場面:**
- ✅ ローカルサーバーでの開発・テストhttp://localhost
- ✅ 信頼できるエンドポイントを持つ内部ネットワーク
- ✅ HTTPS 取得前のアカウント接続テスト
- ❌ 本番環境HTTPS のみを使用)
**この設定なしで表示されるエラー例:**
```
Invalid base URL: invalid url scheme: http
```
URL バリデーションまたはレスポンスヘッダーフィルタリングを無効にする場合は、ネットワーク層を強化してください:
- 上流ドメイン/IP のエグレス許可リストを適用
- プライベート/ループバック/リンクローカル範囲をブロック
- TLS のみのアウトバウンドトラフィックを強制
- プロキシで機密性の高い上流レスポンスヘッダーを除去
```bash
# 6. アプリケーションを実行
./sub2api
```
#### 開発モード
```bash
# バックエンド(ホットリロード付き)
cd backend
go run ./cmd/server
# フロントエンド(ホットリロード付き)
cd frontend
pnpm run dev
```
#### コード生成
`backend/ent/schema` を編集した場合、Ent + Wire を再生成してください:
```bash
cd backend
go generate ./ent
go generate ./cmd/server
```
---
## シンプルモード
シンプルモードは、フル SaaS 機能を必要とせず、素早くアクセスしたい個人開発者や社内チーム向けに設計されています。
- 有効化: 環境変数 `RUN_MODE=simple` を設定
- 違い: SaaS 関連機能を非表示にし、課金プロセスをスキップ
- セキュリティに関する注意: 本番環境では `SIMPLE_MODE_CONFIRM=true` も設定する必要があります
---
## Antigravity サポート
Sub2API は [Antigravity](https://antigravity.so/) アカウントをサポートしています。認証後、Claude および Gemini モデル用の専用エンドポイントが利用可能になります。
### 専用エンドポイント
| エンドポイント | モデル |
|----------|-------|
| `/antigravity/v1/messages` | Claude モデル |
| `/antigravity/v1beta/` | Gemini モデル |
### Claude Code の設定
```bash
export ANTHROPIC_BASE_URL="http://localhost:8080/antigravity"
export ANTHROPIC_AUTH_TOKEN="sk-xxx"
```
### ハイブリッドスケジューリングモード
Antigravity アカウントはオプションの**ハイブリッドスケジューリング**をサポートしています。有効にすると、汎用エンドポイント `/v1/messages` および `/v1beta/` も Antigravity アカウントにリクエストをルーティングします。
> **⚠️ 警告**: Anthropic Claude と Antigravity Claude は**同じ会話コンテキスト内で混在させることはできません**。グループを使用して適切に分離してください。
### 既知の問題
Claude Code では、Plan Mode を自動的に終了できません。(通常、ネイティブの Claude API を使用する場合、計画が完了すると Claude Code はユーザーに計画を承認または拒否するオプションをポップアップ表示します。)
**回避策**: `Shift + Tab` を押して手動で Plan Mode を終了し、計画を承認または拒否するためのレスポンスを入力してください。
---
## プロジェクト構成
```
sub2api/
├── backend/ # Go バックエンドサービス
│ ├── cmd/server/ # アプリケーションエントリ
│ ├── internal/ # 内部モジュール
│ │ ├── config/ # 設定
│ │ ├── model/ # データモデル
│ │ ├── service/ # ビジネスロジック
│ │ ├── handler/ # HTTP ハンドラー
│ │ └── gateway/ # API ゲートウェイコア
│ └── resources/ # 静的リソース
├── frontend/ # Vue 3 フロントエンド
│ └── src/
│ ├── api/ # API 呼び出し
│ ├── stores/ # 状態管理
│ ├── views/ # ページコンポーネント
│ └── components/ # 再利用可能なコンポーネント
└── deploy/ # デプロイファイル
├── docker-compose.yml # Docker Compose 設定
├── .env.example # Docker Compose 用環境変数
├── config.example.yaml # バイナリデプロイ用フル設定ファイル
└── install.sh # ワンクリックインストールスクリプト
```
## 免責事項
> **本プロジェクトをご利用の前に、以下をよくお読みください:**
>
> :rotating_light: **利用規約違反のリスク**: 本プロジェクトの使用は Anthropic の利用規約に違反する可能性があります。使用前に Anthropic のユーザー契約をよくお読みください。本プロジェクトの使用に起因するすべてのリスクは、ユーザー自身が負うものとします。
>
> :book: **免責事項**: 本プロジェクトは技術的な学習および研究目的のみで提供されています。作者は、本プロジェクトの使用によるアカウント停止、サービス中断、その他の損失について一切の責任を負いません。
---
## スター履歴
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
</picture>
</a>
---
## ライセンス
MIT License
---
<div align="center">
**このプロジェクトが役に立ったら、ぜひスターをお願いします!**
</div>

View File

@ -1,218 +0,0 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────
# capture_tls.sh - Capture TLS ClientHello fingerprints (JA3)
#
# mitmproxy terminates TLS so it can't see the real JA3 that
# Claude CLI / Antigravity sends to Anthropic. This script
# captures the REAL TLS fingerprint using tshark.
#
# Usage:
# # Run BEFORE starting claude login / claude "hello"
# # (don't use HTTPS_PROXY for this - direct connection)
#
# sudo ./capture_tls.sh # capture on default interface
# sudo ./capture_tls.sh en0 # specify interface
# sudo ./capture_tls.sh en0 30 # capture for 30 seconds
#
# Output:
# ./captures/tls_capture_<timestamp>.txt
# ./captures/tls_capture_<timestamp>.pcap
# ─────────────────────────────────────────────────────────────
set -euo pipefail
IFACE="${1:-en0}"
DURATION="${2:-60}"
OUTDIR="./captures"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
PCAP_FILE="${OUTDIR}/tls_capture_${TIMESTAMP}.pcap"
TXT_FILE="${OUTDIR}/tls_capture_${TIMESTAMP}.txt"
mkdir -p "$OUTDIR"
# Resolve target IPs
echo "Resolving target domains..."
DOMAINS=(
"api.anthropic.com"
"platform.claude.com"
"claude.ai"
"cloudaicompanion.googleapis.com"
"generativelanguage.googleapis.com"
"oauth2.googleapis.com"
"accounts.google.com"
)
HOST_FILTER=""
for domain in "${DOMAINS[@]}"; do
ips=$(dig +short "$domain" 2>/dev/null | grep -E '^[0-9]+\.' | head -5)
for ip in $ips; do
if [ -n "$HOST_FILTER" ]; then
HOST_FILTER="$HOST_FILTER or host $ip"
else
HOST_FILTER="host $ip"
fi
done
echo " $domain$ips"
done
if [ -z "$HOST_FILTER" ]; then
echo "ERROR: Could not resolve any target domains"
exit 1
fi
CAPTURE_FILTER="tcp port 443 and ($HOST_FILTER)"
echo ""
echo "═══════════════════════════════════════════════════════"
echo " TLS Fingerprint Capture"
echo " Interface: $IFACE"
echo " Duration: ${DURATION}s"
echo " Filter: $CAPTURE_FILTER"
echo " PCAP: $PCAP_FILE"
echo " Report: $TXT_FILE"
echo "═══════════════════════════════════════════════════════"
echo ""
echo ">>> Now run 'claude login' or 'claude \"hello\"' in another terminal <<<"
echo ">>> Press Ctrl+C to stop early <<<"
echo ""
# Capture pcap in background
tshark -i "$IFACE" -f "$CAPTURE_FILTER" -w "$PCAP_FILE" -a "duration:$DURATION" 2>/dev/null &
TSHARK_PID=$!
# Wait for capture to complete or Ctrl+C
trap "kill $TSHARK_PID 2>/dev/null; wait $TSHARK_PID 2>/dev/null" INT TERM
wait $TSHARK_PID 2>/dev/null || true
echo ""
echo "Capture complete. Analyzing..."
echo ""
# ─── Analysis ───
{
echo "═══════════════════════════════════════════════════════"
echo " TLS ClientHello Fingerprint Report"
echo " Captured: $(date)"
echo " PCAP: $PCAP_FILE"
echo "═══════════════════════════════════════════════════════"
echo ""
# Extract JA3 fingerprints
echo "─── JA3 Fingerprints (ClientHello) ───"
echo ""
tshark -r "$PCAP_FILE" \
-Y "tls.handshake.type == 1" \
-T fields \
-e frame.time \
-e ip.dst \
-e tls.handshake.extensions_server_name \
-e tls.handshake.ja3 \
-e tls.handshake.ja3_full \
2>/dev/null | while IFS=$'\t' read -r ts dst sni ja3 ja3_full; do
echo " Time: $ts"
echo " Dest IP: $dst"
echo " SNI: $sni"
echo " JA3 Hash: $ja3"
if [ -n "$ja3_full" ]; then
echo " JA3 Full: $ja3_full"
fi
echo ""
done
echo ""
echo "─── TLS Versions ───"
echo ""
tshark -r "$PCAP_FILE" \
-Y "tls.handshake.type == 1" \
-T fields \
-e tls.handshake.extensions_server_name \
-e tls.handshake.version \
-e tls.handshake.extensions.supported_version \
2>/dev/null | sort -u | while IFS=$'\t' read -r sni ver supported; do
echo " SNI: $sni"
echo " Record Version: $ver"
echo " Supported Versions: $supported"
echo ""
done
echo ""
echo "─── ALPN Protocols ───"
echo ""
tshark -r "$PCAP_FILE" \
-Y "tls.handshake.type == 1" \
-T fields \
-e tls.handshake.extensions_server_name \
-e tls.handshake.extensions_alpn_str \
2>/dev/null | sort -u | while IFS=$'\t' read -r sni alpn; do
echo " SNI: $sni → ALPN: $alpn"
done
echo ""
echo ""
echo "─── Cipher Suites (per ClientHello) ───"
echo ""
tshark -r "$PCAP_FILE" \
-Y "tls.handshake.type == 1" \
-T fields \
-e tls.handshake.extensions_server_name \
-e tls.handshake.ciphersuite \
2>/dev/null | head -5 | while IFS=$'\t' read -r sni ciphers; do
echo " SNI: $sni"
echo " Cipher Suites:"
echo " $ciphers" | tr ',' '\n' | while read -r c; do
echo " $c"
done
echo ""
done
echo ""
echo "─── Extensions (per ClientHello) ───"
echo ""
tshark -r "$PCAP_FILE" \
-Y "tls.handshake.type == 1" \
-T fields \
-e tls.handshake.extensions_server_name \
-e tls.handshake.extension.type \
2>/dev/null | head -5 | while IFS=$'\t' read -r sni exts; do
echo " SNI: $sni"
echo " Extensions: $exts"
echo ""
done
echo ""
echo "─── Unique JA3 Summary ───"
echo ""
tshark -r "$PCAP_FILE" \
-Y "tls.handshake.type == 1" \
-T fields \
-e tls.handshake.extensions_server_name \
-e tls.handshake.ja3 \
2>/dev/null | sort | uniq -c | sort -rn | while read -r count sni ja3; do
echo " ${count}x SNI: $sni JA3: $ja3"
done
echo ""
echo "─── TCP Fingerprint (Initial Window Size, TTL) ───"
echo ""
tshark -r "$PCAP_FILE" \
-Y "tcp.flags.syn == 1 && tcp.flags.ack == 0" \
-T fields \
-e ip.dst \
-e ip.ttl \
-e tcp.window_size_value \
-e tcp.options.mss_val \
-e tcp.options.wscale.shift \
2>/dev/null | sort -u | while IFS=$'\t' read -r dst ttl win mss wscale; do
echo " Dest: $dst TTL: $ttl Window: $win MSS: $mss WScale: $wscale"
done
} 2>/dev/null | tee "$TXT_FILE"
echo ""
echo "═══════════════════════════════════════════════════════"
echo " Report saved to: $TXT_FILE"
echo " PCAP saved to: $PCAP_FILE"
echo ""
echo " To re-analyze: tshark -r $PCAP_FILE -Y 'tls.handshake.type==1' ..."
echo "═══════════════════════════════════════════════════════"

View File

@ -1,506 +0,0 @@
"""
MiniGravity Traffic Capture - mitmproxy addon
Captures and categorizes traffic from Claude Code and Antigravity IDE.
Records: headers (with ordering), body, TLS info, timing.
Usage:
# Claude Code (terminal)
HTTPS_PROXY=http://127.0.0.1:8080 claude login
HTTPS_PROXY=http://127.0.0.1:8080 claude "hello"
# Antigravity (VS Code) - set proxy in VS Code settings or env
HTTPS_PROXY=http://127.0.0.1:8080 code .
# Start mitmproxy with this addon
mitmproxy -s capture_traffic.py --set stream_large_bodies=10m
# or headless:
mitmdump -s capture_traffic.py --set stream_large_bodies=10m
Output:
./captures/ - JSON files per request
./captures/_summary.jsonl - One-line-per-request summary
./captures/_report.txt - Human-readable report (generated on exit)
"""
import json
import os
import time
import hashlib
from datetime import datetime, timezone
from pathlib import Path
from mitmproxy import http, ctx, tls
from mitmproxy.net.http.http1.assemble import assemble_request_head
# ─── Target domains and classification ───
TARGET_DOMAINS = {
# Claude / Anthropic
"claude.ai",
"platform.claude.com",
"api.anthropic.com",
# Google / Antigravity
"accounts.google.com",
"oauth2.googleapis.com",
"cloudaicompanion.googleapis.com",
"generativelanguage.googleapis.com",
# Telemetry
"http-intake.logs.us5.datadoghq.com",
"sentry.io",
}
def classify_request(flow: http.HTTPFlow) -> dict:
"""Classify a request by source tool and purpose."""
host = flow.request.pretty_host
path = flow.request.path
method = flow.request.method
ua = flow.request.headers.get("user-agent", "")
# Determine source tool
source = "unknown"
if "claude-cli" in ua or "claude-code" in ua:
source = "claude-cli"
elif "node" in ua.lower() and ("stainless" in str(flow.request.headers)):
source = "claude-cli"
elif "axios" in ua:
source = "claude-cli-sdk"
elif "vscode" in ua.lower() or "visual studio" in ua.lower():
source = "vscode-extension"
elif "electron" in ua.lower():
source = "desktop-app"
elif "chrome" in ua.lower() or "safari" in ua.lower() or "mozilla" in ua.lower():
source = "browser"
elif "node" in ua.lower():
source = "node-generic"
elif "python" in ua.lower():
source = "python-client"
elif "go-http" in ua.lower() or "go/" in ua.lower():
source = "go-client"
# Determine request purpose
purpose = "unknown"
# OAuth flows
if "/oauth/authorize" in path:
purpose = "oauth-authorize"
elif "/oauth/token" in path or "/v1/oauth/token" in path:
# Distinguish exchange vs refresh
body = _get_request_body_str(flow)
if "refresh_token" in body:
purpose = "oauth-token-refresh"
else:
purpose = "oauth-token-exchange"
elif "/o/oauth2" in path or "/oauth2/" in path:
purpose = "google-oauth"
# API calls
elif "/v1/messages" in path:
purpose = "api-messages"
elif "/v1/complete" in path:
purpose = "api-complete"
# Organization / setup
elif "/api/organizations" in path:
purpose = "org-list"
elif "/v1/oauth/" in path and "/authorize" in path:
purpose = "oauth-authorize-api"
# Telemetry
elif "/api/event_logging" in path:
purpose = "telemetry-otel"
elif "datadoghq.com" in host:
purpose = "telemetry-datadog"
elif "sentry" in host:
purpose = "telemetry-sentry"
# Google AI
elif "cloudaicompanion" in host:
purpose = "antigravity-api"
elif "generativelanguage" in host:
purpose = "gemini-api"
return {
"source": source,
"purpose": purpose,
}
def _get_request_body_str(flow: http.HTTPFlow) -> str:
"""Safely get request body as string."""
try:
if flow.request.content:
return flow.request.content.decode("utf-8", errors="replace")
except Exception:
pass
return ""
def _get_response_body_str(flow: http.HTTPFlow, max_len: int = 4096) -> str:
"""Safely get response body as string, truncated."""
try:
if flow.response and flow.response.content:
body = flow.response.content.decode("utf-8", errors="replace")
if len(body) > max_len:
return body[:max_len] + f"\n... [truncated, total {len(body)} bytes]"
return body
except Exception:
pass
return ""
def _parse_json_body(body_str: str) -> any:
"""Try to parse body as JSON, return raw string if fails."""
if not body_str:
return None
try:
return json.loads(body_str)
except (json.JSONDecodeError, ValueError):
return body_str
def _get_tls_info(flow: http.HTTPFlow) -> dict:
"""Extract available TLS information from the flow."""
info = {}
if flow.server_conn and flow.server_conn.tls_version:
info["tls_version"] = flow.server_conn.tls_version
if flow.server_conn and hasattr(flow.server_conn, "alpn_proto_negotiated"):
info["alpn"] = (
flow.server_conn.alpn_proto_negotiated.decode()
if flow.server_conn.alpn_proto_negotiated
else None
)
# Client TLS info (what the client sent to mitmproxy)
if flow.client_conn:
if hasattr(flow.client_conn, "tls_version") and flow.client_conn.tls_version:
info["client_tls_version"] = flow.client_conn.tls_version
if (
hasattr(flow.client_conn, "alpn_proto_negotiated")
and flow.client_conn.alpn_proto_negotiated
):
info["client_alpn"] = flow.client_conn.alpn_proto_negotiated.decode()
# SNI
if hasattr(flow.client_conn, "sni") and flow.client_conn.sni:
info["client_sni"] = flow.client_conn.sni
return info
class TrafficCapture:
def __init__(self):
self.capture_dir = Path("./captures")
self.capture_dir.mkdir(exist_ok=True)
self.summary_file = self.capture_dir / "_summary.jsonl"
self.counter = 0
self.captures = []
# Write session start marker
session_start = {
"event": "session_start",
"timestamp": datetime.now(timezone.utc).isoformat(),
"note": "New capture session started",
}
with open(self.summary_file, "a") as f:
f.write(json.dumps(session_start) + "\n")
ctx.log.info(
f"[capture] Traffic capture started. Output: {self.capture_dir.absolute()}"
)
def request(self, flow: http.HTTPFlow):
"""Tag requests to target domains."""
host = flow.request.pretty_host
is_target = any(host == d or host.endswith("." + d) for d in TARGET_DOMAINS)
flow.metadata["is_target"] = is_target
if is_target:
flow.metadata["capture_time_start"] = time.time()
def response(self, flow: http.HTTPFlow):
"""Capture complete request/response for target domains."""
if not flow.metadata.get("is_target"):
return
self.counter += 1
classification = classify_request(flow)
elapsed = None
if flow.metadata.get("capture_time_start"):
elapsed = round(time.time() - flow.metadata["capture_time_start"], 3)
# Build ordered header list (order matters for fingerprinting!)
request_headers_ordered = [
[k, v] for k, v in flow.request.headers.fields
]
request_headers_ordered_decoded = []
for k, v in request_headers_ordered:
try:
request_headers_ordered_decoded.append(
[k.decode("utf-8", errors="replace"),
v.decode("utf-8", errors="replace")]
)
except AttributeError:
request_headers_ordered_decoded.append([str(k), str(v)])
response_headers_ordered = []
if flow.response:
for k, v in flow.response.headers.fields:
try:
response_headers_ordered.append(
[k.decode("utf-8", errors="replace"),
v.decode("utf-8", errors="replace")]
)
except AttributeError:
response_headers_ordered.append([str(k), str(v)])
req_body = _get_request_body_str(flow)
resp_body = _get_response_body_str(flow)
# Redact sensitive values
req_body_parsed = _parse_json_body(req_body)
if isinstance(req_body_parsed, dict):
req_body_parsed = _redact_sensitive(req_body_parsed)
resp_body_parsed = _parse_json_body(resp_body)
if isinstance(resp_body_parsed, dict):
resp_body_parsed = _redact_sensitive(resp_body_parsed)
record = {
"id": self.counter,
"timestamp": datetime.now(timezone.utc).isoformat(),
"elapsed_sec": elapsed,
# Classification
"source": classification["source"],
"purpose": classification["purpose"],
# Request
"request": {
"method": flow.request.method,
"url": flow.request.pretty_url,
"host": flow.request.pretty_host,
"path": flow.request.path,
"http_version": flow.request.http_version,
"headers_ordered": request_headers_ordered_decoded,
"body": req_body_parsed,
"content_length": len(flow.request.content) if flow.request.content else 0,
},
# Response
"response": {
"status_code": flow.response.status_code if flow.response else None,
"http_version": flow.response.http_version if flow.response else None,
"headers_ordered": response_headers_ordered,
"body": resp_body_parsed,
"content_length": (
len(flow.response.content)
if flow.response and flow.response.content
else 0
),
},
# TLS
"tls": _get_tls_info(flow),
# Connection
"connection": {
"client_address": (
f"{flow.client_conn.peername[0]}:{flow.client_conn.peername[1]}"
if flow.client_conn.peername
else None
),
"server_address": (
f"{flow.server_conn.peername[0]}:{flow.server_conn.peername[1]}"
if flow.server_conn and flow.server_conn.peername
else None
),
},
}
self.captures.append(record)
# Save individual capture file
filename = (
f"{self.counter:04d}_{classification['source']}"
f"_{classification['purpose']}"
f"_{flow.request.pretty_host}.json"
)
filepath = self.capture_dir / filename
with open(filepath, "w") as f:
json.dump(record, f, indent=2, ensure_ascii=False, default=str)
# Append to summary
summary_line = {
"id": self.counter,
"ts": datetime.now(timezone.utc).strftime("%H:%M:%S"),
"source": classification["source"],
"purpose": classification["purpose"],
"method": flow.request.method,
"url": flow.request.pretty_url[:120],
"status": flow.response.status_code if flow.response else None,
"ua": flow.request.headers.get("user-agent", "")[:80],
"elapsed": elapsed,
}
with open(self.summary_file, "a") as f:
f.write(json.dumps(summary_line) + "\n")
# Console output
status = flow.response.status_code if flow.response else "???"
ctx.log.info(
f"[capture #{self.counter}] "
f"[{classification['source']}] "
f"[{classification['purpose']}] "
f"{flow.request.method} {flow.request.pretty_url[:80]} "
f"{status} "
f"({elapsed}s)"
)
# Highlight important findings
ua = flow.request.headers.get("user-agent", "")
if classification["purpose"] in (
"oauth-token-exchange",
"oauth-token-refresh",
):
ctx.log.warn(
f"[capture] TOKEN EXCHANGE/REFRESH detected!\n"
f" UA: {ua}\n"
f" Headers: {[h[0] for h in request_headers_ordered_decoded]}"
)
def done(self):
"""Generate report on exit."""
if not self.captures:
ctx.log.info("[capture] No captures recorded.")
return
report_path = self.capture_dir / "_report.txt"
with open(report_path, "w") as f:
f.write("=" * 80 + "\n")
f.write(" MiniGravity Traffic Capture Report\n")
f.write(f" Generated: {datetime.now().isoformat()}\n")
f.write(f" Total requests captured: {len(self.captures)}\n")
f.write("=" * 80 + "\n\n")
# Group by source
by_source = {}
for cap in self.captures:
src = cap["source"]
if src not in by_source:
by_source[src] = []
by_source[src].append(cap)
for source, caps in sorted(by_source.items()):
f.write(f"\n{'' * 60}\n")
f.write(f" Source: {source} ({len(caps)} requests)\n")
f.write(f"{'' * 60}\n\n")
# Group by purpose within source
by_purpose = {}
for cap in caps:
p = cap["purpose"]
if p not in by_purpose:
by_purpose[p] = []
by_purpose[p].append(cap)
for purpose, pcaps in sorted(by_purpose.items()):
f.write(f" [{purpose}] ({len(pcaps)} requests)\n\n")
for cap in pcaps:
req = cap["request"]
f.write(f" #{cap['id']} {req['method']} {req['url'][:100]}\n")
f.write(f" HTTP Version: {req['http_version']}\n")
f.write(" Request Headers (ordered):\n")
for hdr in req["headers_ordered"]:
val = hdr[1]
# Truncate long values
if len(val) > 100:
val = val[:100] + "..."
f.write(f" {hdr[0]}: {val}\n")
if req["body"]:
body_str = json.dumps(
req["body"], indent=6, ensure_ascii=False, default=str
)
if len(body_str) > 500:
body_str = body_str[:500] + "\n ..."
f.write(f" Request Body:\n {body_str}\n")
resp = cap["response"]
f.write(f" Response: {resp['status_code']}\n")
if cap["tls"]:
f.write(f" TLS: {json.dumps(cap['tls'])}\n")
f.write("\n")
# Comparison section
f.write(f"\n{'=' * 80}\n")
f.write(" FINGERPRINT COMPARISON\n")
f.write(f"{'=' * 80}\n\n")
# Collect unique UA per source+purpose
ua_map = {}
for cap in self.captures:
key = f"{cap['source']}:{cap['purpose']}"
ua = dict(cap["request"]["headers_ordered"]).get("user-agent", "N/A")
if key not in ua_map:
ua_map[key] = set()
ua_map[key].add(ua)
f.write(" User-Agent by source:purpose\n")
for key, uas in sorted(ua_map.items()):
for ua in uas:
f.write(f" {key:40s}{ua}\n")
# Collect header sets per source+purpose
f.write("\n Header names by source:purpose\n")
header_map = {}
for cap in self.captures:
key = f"{cap['source']}:{cap['purpose']}"
hdrs = tuple(h[0].lower() for h in cap["request"]["headers_ordered"])
if key not in header_map:
header_map[key] = set()
header_map[key].add(hdrs)
for key, hdr_sets in sorted(header_map.items()):
for hdrs in hdr_sets:
f.write(f" {key}:\n")
for h in hdrs:
f.write(f" - {h}\n")
f.write("\n")
ctx.log.info(
f"[capture] Report written to {report_path.absolute()}\n"
f"[capture] {len(self.captures)} requests captured in {self.capture_dir.absolute()}"
)
def _redact_sensitive(d: dict) -> dict:
"""Redact sensitive values in a dict, preserving structure."""
sensitive_keys = {
"access_token", "refresh_token", "code", "code_verifier",
"session_key", "sessionKey", "password", "secret",
"authorization", "cookie",
}
result = {}
for k, v in d.items():
if k.lower() in {s.lower() for s in sensitive_keys}:
if isinstance(v, str) and len(v) > 8:
result[k] = v[:4] + "****" + v[-4:]
else:
result[k] = "****"
elif isinstance(v, dict):
result[k] = _redact_sensitive(v)
elif isinstance(v, list):
result[k] = [
_redact_sensitive(item) if isinstance(item, dict) else item
for item in v
]
else:
result[k] = v
return result
addons = [TrafficCapture()]

View File

@ -1,129 +0,0 @@
{
"id": 1,
"timestamp": "2026-03-26T16:28:57.647791+00:00",
"elapsed_sec": 0.322,
"source": "claude-cli-sdk",
"purpose": "unknown",
"request": {
"method": "GET",
"url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest",
"host": "downloads.claude.ai",
"path": "/claude-code-releases/plugins/claude-plugins-official/latest",
"http_version": "HTTP/1.1",
"headers_ordered": [
[
"Accept",
"application/json, text/plain, */*"
],
[
"Accept-Encoding",
"gzip, compress, deflate, br"
],
[
"User-Agent",
"axios/1.13.6"
],
[
"Host",
"downloads.claude.ai"
]
],
"body": null,
"content_length": 0
},
"response": {
"status_code": 200,
"http_version": "HTTP/1.1",
"headers_ordered": [
[
"x-guploader-uploadid",
"AMNfjG29CnIrYUAyZBJSnylKbYWnv3VH6x45qXwHunjwYiMbCueqWoZ3CouUPbV2VjfNtKXGrIpIQNI"
],
[
"x-goog-generation",
"1774486030779283"
],
[
"x-goog-metageneration",
"1"
],
[
"x-goog-stored-content-encoding",
"identity"
],
[
"x-goog-stored-content-length",
"40"
],
[
"x-goog-hash",
"crc32c=/q0yrA=="
],
[
"x-goog-hash",
"md5=tRgumXLHnEzHzEWYd8YEyg=="
],
[
"x-goog-storage-class",
"STANDARD"
],
[
"accept-ranges",
"bytes"
],
[
"Content-Length",
"40"
],
[
"server",
"UploadServer"
],
[
"via",
"1.1 google"
],
[
"Date",
"Thu, 26 Mar 2026 16:28:57 GMT"
],
[
"Age",
"0"
],
[
"Last-Modified",
"Thu, 26 Mar 2026 16:17:10 GMT"
],
[
"ETag",
"\"b5182e9972c79c4cc7cc459877c604ca\""
],
[
"Content-Type",
"text/plain"
],
[
"Cache-Control",
"public,no-cache,max-age=300"
],
[
"Alt-Svc",
"h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000"
]
],
"body": "b10b583de281385442474e836644534b938b2678",
"content_length": 40
},
"tls": {
"tls_version": "TLSv1.3",
"alpn": "http/1.1",
"client_tls_version": "TLSv1.3",
"client_alpn": "http/1.1",
"client_sni": "downloads.claude.ai"
},
"connection": {
"client_address": "127.0.0.1:55671",
"server_address": "198.18.0.44:443"
}
}

View File

@ -1,125 +0,0 @@
{
"id": 2,
"timestamp": "2026-03-26T16:28:57.668166+00:00",
"elapsed_sec": 0.481,
"source": "claude-cli-sdk",
"purpose": "unknown",
"request": {
"method": "GET",
"url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial",
"host": "api.anthropic.com",
"path": "/mcp-registry/v0/servers?version=latest&visibility=commercial",
"http_version": "HTTP/1.1",
"headers_ordered": [
[
"Accept",
"application/json, text/plain, */*"
],
[
"Accept-Encoding",
"gzip, compress, deflate, br"
],
[
"User-Agent",
"axios/1.13.6"
],
[
"Host",
"api.anthropic.com"
]
],
"body": null,
"content_length": 0
},
"response": {
"status_code": 200,
"http_version": "HTTP/1.1",
"headers_ordered": [
[
"Date",
"Thu, 26 Mar 2026 16:28:57 GMT"
],
[
"Content-Type",
"application/json"
],
[
"Transfer-Encoding",
"chunked"
],
[
"Connection",
"keep-alive"
],
[
"x-request-id",
"a26ee618-f205-4a23-87b1-6225e17b92ef"
],
[
"access-control-allow-origin",
"*"
],
[
"access-control-allow-methods",
"GET, OPTIONS"
],
[
"access-control-allow-headers",
"*"
],
[
"x-envoy-upstream-service-time",
"9"
],
[
"Content-Encoding",
"gzip"
],
[
"vary",
"Accept-Encoding"
],
[
"Server",
"cloudflare"
],
[
"server-timing",
"x-originResponse;dur=11"
],
[
"cf-cache-status",
"DYNAMIC"
],
[
"set-cookie",
"_cfuvid=XtplK6T__J5GJ7ZHZ75.1K.blAKEiURZzIRFOxbjm0U-1774542537.272683-1.0.1.1-Folg8_rQ2RrBi0Img0NdFQUYWxTawBjeo7zj11dFizU; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
],
[
"Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'"
],
[
"X-Robots-Tag",
"none"
],
[
"CF-RAY",
"9e278809ffffe371-NRT"
]
],
"body": "{\n \"servers\": [\n {\n \"server\": {\n \"name\": \"com.canva.mcp/canva\",\n \"version\": \"1.0.0\",\n \"description\": \"Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your AI workflow—an AI-powered design agent that helps you create polished visuals faster, with less friction.\",\n \"title\": \"Canva\",\n \"remotes\": [\n {\n \"type\": \"streamable-http\",\n \"url\": \"https://mcp.canva.com/mcp\"\n }\n ]\n },\n \"_meta\": {\n \"com.anthropic.api/mcp-registry\": {\n \"uuid\": \"eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"type\": \"remote\",\n \"toolNames\": [\n \"search-designs\",\n \"get-design\",\n \"get-design-pages\",\n \"get-design-content\",\n \"search\",\n \"fetch\",\n \"import-design-from-url\",\n \"get-design-import-from-url-status\",\n \"export-design\",\n \"get-export-formats\",\n \"get-design-export-status\",\n \"create-folder\",\n \"move-item-to-folder\",\n \"list-folder-items\",\n \"add-comment-thread-to-design\",\n \"generate-design\",\n \"get-design-generation-job\"\n ],\n \"promptNames\": [],\n \"isAuthless\": false,\n \"displayName\": \"Canva\",\n \"oneLiner\": \"Search, create, autofill, and export Canva designs\",\n \"iconUrl\": \"https://mcp.canva.com/mcp\",\n \"documentation\": \"https://www.canva.dev/docs/connect/canva-mcp-server-setup/\",\n \"support\": \"https://www.canva.com/en_au/help/\",\n \"privacyPolicy\": \"https://www.canva.com/policies/privacy-policy/\",\n \"url\": \"https://mcp.canva.com/mcp\",\n \"author\": {\n \"name\": \"Canva\",\n \"url\": \"https://canva.com\"\n },\n \"slug\": \"canva\",\n \"directoryUrl\": \"http://claude.ai/directory/eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"claudeCodeCopyText\": \"claude mcp add --transport http canva https://mcp.canva.com/mcp\",\n \"permissions\": \"Read and write\",\n \"useCases\": [\n \"design\"\n ],\n \"worksWith\": [\n \"claude\",\n \"claude-api\",\n \"claude-code\"\n ],\n \"publishedOn\": \"Fri Jan 16 2026 19:33:48 GMT+0000 (Coordinated Universal Time)\",\n \"createdOn\": \"Thu Aug 28 2025 14:02:54 GMT+0000 (Coordinated Universal Time)\",\n \"updatedOn\": \"Fri Jan 16 2026 19:33:28 GMT+0000 (Coordinated Universal Time)\",\n \"logo\": \"canva\",\n \"backgroundPattern\": \"Line 1\",\n \"heroVideoId\": \"wXC2u36w2Rc\",\n \"heroVideoPreviewLink\": \"https://cdn.sanity.io/files/4zrzovbb/website/4925fcd732bab964631e2678e413dfa2a549a2a9.mp4\",\n \"serverLabel\": \"mcp.canva.com\",\n \"itemId\": \"68b0618e3f55cc591b6b22a2\",\n \"collectionId\": \"68b05e2de975b4de7dd02d9d\",\n \"localeId\": \"68a44d4040f98a4adf2207b5\",\n \"htmlContent\": \"<p id=\\\"\\\">Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your workflow—helping you create polished visuals faster, with less friction.</p><p id=\\\"\\\">You can use the Canva connector to: <br><br>Browse, Search &amp; Summarize: <br>\\\"Summarize my Q2 product strategy doc\\\"</p><p id=\\\"\\\">Create New Designs from Conversation:<br>\\\"Generate a pitch deck for our AI launch with 5 slides and a bold tone\\\"</p><p id=\\\"\\\">Autofill Charts:<br>\\\"Add a chart showing monthly signups in NZ for Q1\\\"</p><p id=\\\"\\\">Autofill Brand Templates:<br>\\\"Populate our branded template with content for a product launch presentation, 8 slides, professional tone\\\"</p><p id=\\\"\\\">Import Files via Link:<br>\\\"Import this PDF [insert URL] into Canva\\\"</p><p id=\\\"\\\">Resize or Export:<br>\\\"Resize my Instagram post for LinkedIn and export as a PNG\\\"</p>\",\n \"imageUrls\": [\n {\n \"prompt\": \"Generate a sales report presentation with outline \",\n \"imageUrl\": \"https://storage.goo\n... [truncated, total 185385 bytes]",
"content_length": 185487
},
"tls": {
"tls_version": "TLSv1.3",
"alpn": "http/1.1",
"client_tls_version": "TLSv1.3",
"client_alpn": "http/1.1",
"client_sni": "api.anthropic.com"
},
"connection": {
"client_address": "127.0.0.1:55668",
"server_address": "198.18.0.32:443"
}
}

View File

@ -1,125 +0,0 @@
{
"id": 3,
"timestamp": "2026-03-26T16:30:00.064058+00:00",
"elapsed_sec": 0.731,
"source": "claude-cli-sdk",
"purpose": "unknown",
"request": {
"method": "GET",
"url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial",
"host": "api.anthropic.com",
"path": "/mcp-registry/v0/servers?version=latest&visibility=commercial",
"http_version": "HTTP/1.1",
"headers_ordered": [
[
"Accept",
"application/json, text/plain, */*"
],
[
"Accept-Encoding",
"gzip, compress, deflate, br"
],
[
"User-Agent",
"axios/1.13.6"
],
[
"Host",
"api.anthropic.com"
]
],
"body": null,
"content_length": 0
},
"response": {
"status_code": 200,
"http_version": "HTTP/1.1",
"headers_ordered": [
[
"Date",
"Thu, 26 Mar 2026 16:29:59 GMT"
],
[
"Content-Type",
"application/json"
],
[
"Transfer-Encoding",
"chunked"
],
[
"Connection",
"keep-alive"
],
[
"x-request-id",
"ddcd43e3-8799-43b9-9d49-8dcc6a0b90dd"
],
[
"access-control-allow-origin",
"*"
],
[
"access-control-allow-methods",
"GET, OPTIONS"
],
[
"access-control-allow-headers",
"*"
],
[
"x-envoy-upstream-service-time",
"8"
],
[
"Content-Encoding",
"gzip"
],
[
"vary",
"Accept-Encoding"
],
[
"Server",
"cloudflare"
],
[
"server-timing",
"x-originResponse;dur=10"
],
[
"cf-cache-status",
"DYNAMIC"
],
[
"set-cookie",
"_cfuvid=DUvPIDhglzXAjPSEJhKi0nemis9e5knKw1jmUxq8LnE-1774542599.5569572-1.0.1.1-5oD..eF758shBNx1g_VrkNhd2HcST2hu4QKN5ciERz4; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
],
[
"Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'"
],
[
"X-Robots-Tag",
"none"
],
[
"CF-RAY",
"9e27898f3c1eefbb-NRT"
]
],
"body": "{\n \"servers\": [\n {\n \"server\": {\n \"name\": \"com.canva.mcp/canva\",\n \"version\": \"1.0.0\",\n \"description\": \"Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your AI workflow—an AI-powered design agent that helps you create polished visuals faster, with less friction.\",\n \"title\": \"Canva\",\n \"remotes\": [\n {\n \"type\": \"streamable-http\",\n \"url\": \"https://mcp.canva.com/mcp\"\n }\n ]\n },\n \"_meta\": {\n \"com.anthropic.api/mcp-registry\": {\n \"uuid\": \"eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"type\": \"remote\",\n \"toolNames\": [\n \"search-designs\",\n \"get-design\",\n \"get-design-pages\",\n \"get-design-content\",\n \"search\",\n \"fetch\",\n \"import-design-from-url\",\n \"get-design-import-from-url-status\",\n \"export-design\",\n \"get-export-formats\",\n \"get-design-export-status\",\n \"create-folder\",\n \"move-item-to-folder\",\n \"list-folder-items\",\n \"add-comment-thread-to-design\",\n \"generate-design\",\n \"get-design-generation-job\"\n ],\n \"promptNames\": [],\n \"isAuthless\": false,\n \"displayName\": \"Canva\",\n \"oneLiner\": \"Search, create, autofill, and export Canva designs\",\n \"iconUrl\": \"https://mcp.canva.com/mcp\",\n \"documentation\": \"https://www.canva.dev/docs/connect/canva-mcp-server-setup/\",\n \"support\": \"https://www.canva.com/en_au/help/\",\n \"privacyPolicy\": \"https://www.canva.com/policies/privacy-policy/\",\n \"url\": \"https://mcp.canva.com/mcp\",\n \"author\": {\n \"name\": \"Canva\",\n \"url\": \"https://canva.com\"\n },\n \"slug\": \"canva\",\n \"directoryUrl\": \"http://claude.ai/directory/eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"claudeCodeCopyText\": \"claude mcp add --transport http canva https://mcp.canva.com/mcp\",\n \"permissions\": \"Read and write\",\n \"useCases\": [\n \"design\"\n ],\n \"worksWith\": [\n \"claude\",\n \"claude-api\",\n \"claude-code\"\n ],\n \"publishedOn\": \"Fri Jan 16 2026 19:33:48 GMT+0000 (Coordinated Universal Time)\",\n \"createdOn\": \"Thu Aug 28 2025 14:02:54 GMT+0000 (Coordinated Universal Time)\",\n \"updatedOn\": \"Fri Jan 16 2026 19:33:28 GMT+0000 (Coordinated Universal Time)\",\n \"logo\": \"canva\",\n \"backgroundPattern\": \"Line 1\",\n \"heroVideoId\": \"wXC2u36w2Rc\",\n \"heroVideoPreviewLink\": \"https://cdn.sanity.io/files/4zrzovbb/website/4925fcd732bab964631e2678e413dfa2a549a2a9.mp4\",\n \"serverLabel\": \"mcp.canva.com\",\n \"itemId\": \"68b0618e3f55cc591b6b22a2\",\n \"collectionId\": \"68b05e2de975b4de7dd02d9d\",\n \"localeId\": \"68a44d4040f98a4adf2207b5\",\n \"htmlContent\": \"<p id=\\\"\\\">Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your workflow—helping you create polished visuals faster, with less friction.</p><p id=\\\"\\\">You can use the Canva connector to: <br><br>Browse, Search &amp; Summarize: <br>\\\"Summarize my Q2 product strategy doc\\\"</p><p id=\\\"\\\">Create New Designs from Conversation:<br>\\\"Generate a pitch deck for our AI launch with 5 slides and a bold tone\\\"</p><p id=\\\"\\\">Autofill Charts:<br>\\\"Add a chart showing monthly signups in NZ for Q1\\\"</p><p id=\\\"\\\">Autofill Brand Templates:<br>\\\"Populate our branded template with content for a product launch presentation, 8 slides, professional tone\\\"</p><p id=\\\"\\\">Import Files via Link:<br>\\\"Import this PDF [insert URL] into Canva\\\"</p><p id=\\\"\\\">Resize or Export:<br>\\\"Resize my Instagram post for LinkedIn and export as a PNG\\\"</p>\",\n \"imageUrls\": [\n {\n \"prompt\": \"Generate a sales report presentation with outline \",\n \"imageUrl\": \"https://storage.goo\n... [truncated, total 185385 bytes]",
"content_length": 185487
},
"tls": {
"tls_version": "TLSv1.3",
"alpn": "http/1.1",
"client_tls_version": "TLSv1.3",
"client_alpn": "http/1.1",
"client_sni": "api.anthropic.com"
},
"connection": {
"client_address": "127.0.0.1:55998",
"server_address": "198.18.0.32:443"
}
}

View File

@ -1,129 +0,0 @@
{
"id": 4,
"timestamp": "2026-03-26T16:30:00.153789+00:00",
"elapsed_sec": 0.833,
"source": "claude-cli-sdk",
"purpose": "unknown",
"request": {
"method": "GET",
"url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest",
"host": "downloads.claude.ai",
"path": "/claude-code-releases/plugins/claude-plugins-official/latest",
"http_version": "HTTP/1.1",
"headers_ordered": [
[
"Accept",
"application/json, text/plain, */*"
],
[
"Accept-Encoding",
"gzip, compress, deflate, br"
],
[
"User-Agent",
"axios/1.13.6"
],
[
"Host",
"downloads.claude.ai"
]
],
"body": null,
"content_length": 0
},
"response": {
"status_code": 200,
"http_version": "HTTP/1.1",
"headers_ordered": [
[
"x-guploader-uploadid",
"AMNfjG37C4G0lUKtWOt8YyD-JuE6Y6MUhtxm8P77pzlb0lJzsdb6sG8xLwNpaolt4FWHSJVblZLmjWM"
],
[
"x-goog-generation",
"1774486030779283"
],
[
"x-goog-metageneration",
"1"
],
[
"x-goog-stored-content-encoding",
"identity"
],
[
"x-goog-stored-content-length",
"40"
],
[
"x-goog-hash",
"crc32c=/q0yrA=="
],
[
"x-goog-hash",
"md5=tRgumXLHnEzHzEWYd8YEyg=="
],
[
"x-goog-storage-class",
"STANDARD"
],
[
"accept-ranges",
"bytes"
],
[
"Content-Length",
"40"
],
[
"server",
"UploadServer"
],
[
"via",
"1.1 google"
],
[
"Date",
"Thu, 26 Mar 2026 16:29:59 GMT"
],
[
"Age",
"0"
],
[
"Last-Modified",
"Thu, 26 Mar 2026 16:17:10 GMT"
],
[
"ETag",
"\"b5182e9972c79c4cc7cc459877c604ca\""
],
[
"Content-Type",
"text/plain"
],
[
"Cache-Control",
"public,no-cache,max-age=300"
],
[
"Alt-Svc",
"h3=\":443\"; ma=2592000"
]
],
"body": "b10b583de281385442474e836644534b938b2678",
"content_length": 40
},
"tls": {
"tls_version": "TLSv1.3",
"alpn": "http/1.1",
"client_tls_version": "TLSv1.3",
"client_alpn": "http/1.1",
"client_sni": "downloads.claude.ai"
},
"connection": {
"client_address": "127.0.0.1:56003",
"server_address": "198.18.0.44:443"
}
}

View File

@ -1,68 +0,0 @@
================================================================================
MiniGravity Traffic Capture Report
Generated: 2026-03-27T00:50:12.040880
Total requests captured: 4
================================================================================
────────────────────────────────────────────────────────────
Source: claude-cli-sdk (4 requests)
────────────────────────────────────────────────────────────
[unknown] (4 requests)
#1 GET https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest
HTTP Version: HTTP/1.1
Request Headers (ordered):
Accept: application/json, text/plain, */*
Accept-Encoding: gzip, compress, deflate, br
User-Agent: axios/1.13.6
Host: downloads.claude.ai
Response: 200
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "downloads.claude.ai"}
#2 GET https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial
HTTP Version: HTTP/1.1
Request Headers (ordered):
Accept: application/json, text/plain, */*
Accept-Encoding: gzip, compress, deflate, br
User-Agent: axios/1.13.6
Host: api.anthropic.com
Response: 200
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "api.anthropic.com"}
#3 GET https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial
HTTP Version: HTTP/1.1
Request Headers (ordered):
Accept: application/json, text/plain, */*
Accept-Encoding: gzip, compress, deflate, br
User-Agent: axios/1.13.6
Host: api.anthropic.com
Response: 200
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "api.anthropic.com"}
#4 GET https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest
HTTP Version: HTTP/1.1
Request Headers (ordered):
Accept: application/json, text/plain, */*
Accept-Encoding: gzip, compress, deflate, br
User-Agent: axios/1.13.6
Host: downloads.claude.ai
Response: 200
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "downloads.claude.ai"}
================================================================================
FINGERPRINT COMPARISON
================================================================================
User-Agent by source:purpose
claude-cli-sdk:unknown → N/A
Header names by source:purpose
claude-cli-sdk:unknown:
- accept
- accept-encoding
- user-agent
- host

View File

@ -1,5 +0,0 @@
{"event": "session_start", "timestamp": "2026-03-26T16:28:39.558811+00:00", "note": "New capture session started"}
{"id": 1, "ts": "16:28:57", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.322}
{"id": 2, "ts": "16:28:57", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.481}
{"id": 3, "ts": "16:30:00", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.731}
{"id": 4, "ts": "16:30:00", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.833}

View File

@ -1,240 +0,0 @@
#!/usr/bin/env python3
"""
Extract JA3 fingerprint from pcap file (no tshark needed).
Parses TLS ClientHello directly from raw packets.
"""
import struct
import hashlib
import sys
def parse_pcap(filepath):
"""Parse pcap file and extract TLS ClientHello JA3 fingerprints."""
results = []
with open(filepath, 'rb') as f:
# Read pcap global header
magic = struct.unpack('<I', f.read(4))[0]
if magic == 0xa1b2c3d4:
endian = '<'
elif magic == 0xd4c3b2a1:
endian = '>'
else:
print(f"Not a pcap file (magic: {hex(magic)})")
return results
f.read(20) # rest of global header
packet_num = 0
while True:
# Read packet header
pkt_hdr = f.read(16)
if len(pkt_hdr) < 16:
break
ts_sec, ts_usec, incl_len, orig_len = struct.unpack(f'{endian}IIII', pkt_hdr)
pkt_data = f.read(incl_len)
if len(pkt_data) < incl_len:
break
packet_num += 1
# Parse Ethernet header (14 bytes)
if len(pkt_data) < 14:
continue
eth_type = struct.unpack('!H', pkt_data[12:14])[0]
if eth_type != 0x0800: # IPv4
continue
# Parse IP header
ip_start = 14
if len(pkt_data) < ip_start + 20:
continue
ip_ver_ihl = pkt_data[ip_start]
ip_ihl = (ip_ver_ihl & 0x0F) * 4
ip_proto = pkt_data[ip_start + 9]
dst_ip = '.'.join(str(b) for b in pkt_data[ip_start+16:ip_start+20])
if ip_proto != 6: # TCP
continue
# Parse TCP header
tcp_start = ip_start + ip_ihl
if len(pkt_data) < tcp_start + 20:
continue
dst_port = struct.unpack('!H', pkt_data[tcp_start+2:tcp_start+4])[0]
tcp_data_offset = ((pkt_data[tcp_start + 12] >> 4) & 0xF) * 4
# TLS record starts after TCP header
tls_start = tcp_start + tcp_data_offset
if len(pkt_data) < tls_start + 6:
continue
# Check for TLS Handshake (content type 22)
if pkt_data[tls_start] != 22:
continue
tls_version = struct.unpack('!H', pkt_data[tls_start+1:tls_start+3])[0]
tls_length = struct.unpack('!H', pkt_data[tls_start+3:tls_start+5])[0]
# Check for ClientHello (handshake type 1)
hs_start = tls_start + 5
if len(pkt_data) < hs_start + 4:
continue
if pkt_data[hs_start] != 1: # ClientHello
continue
# Parse ClientHello
try:
ja3 = extract_ja3(pkt_data, hs_start, dst_ip, dst_port)
if ja3:
results.append(ja3)
except Exception as e:
pass
return results
def extract_ja3(data, hs_start, dst_ip, dst_port):
"""Extract JA3 components from ClientHello."""
# Handshake header: type(1) + length(3)
pos = hs_start + 4
# ClientHello: version(2) + random(32)
if len(data) < pos + 34:
return None
ch_version = struct.unpack('!H', data[pos:pos+2])[0]
pos += 34 # skip version + random
# Session ID
if len(data) < pos + 1:
return None
session_id_len = data[pos]
pos += 1 + session_id_len
# Cipher Suites
if len(data) < pos + 2:
return None
cs_len = struct.unpack('!H', data[pos:pos+2])[0]
pos += 2
if len(data) < pos + cs_len:
return None
cipher_suites = []
for i in range(0, cs_len, 2):
cs = struct.unpack('!H', data[pos+i:pos+i+2])[0]
# Skip GREASE values
if (cs & 0x0f0f) == 0x0a0a:
continue
cipher_suites.append(str(cs))
pos += cs_len
# Compression methods
if len(data) < pos + 1:
return None
comp_len = data[pos]
pos += 1 + comp_len
# Extensions
extensions = []
elliptic_curves = []
ec_point_formats = []
supported_versions = []
sni = ""
if len(data) > pos + 2:
ext_total_len = struct.unpack('!H', data[pos:pos+2])[0]
pos += 2
ext_end = pos + ext_total_len
while pos + 4 <= ext_end and pos + 4 <= len(data):
ext_type = struct.unpack('!H', data[pos:pos+2])[0]
ext_len = struct.unpack('!H', data[pos+2:pos+4])[0]
ext_data_start = pos + 4
# Skip GREASE
if (ext_type & 0x0f0f) == 0x0a0a:
pos = ext_data_start + ext_len
continue
extensions.append(str(ext_type))
# SNI (type 0)
if ext_type == 0 and ext_len > 5:
try:
name_len = struct.unpack('!H', data[ext_data_start+3:ext_data_start+5])[0]
sni = data[ext_data_start+5:ext_data_start+5+name_len].decode('ascii', errors='replace')
except:
pass
# Supported Groups / Elliptic Curves (type 10)
if ext_type == 10 and ext_len >= 2:
curves_len = struct.unpack('!H', data[ext_data_start:ext_data_start+2])[0]
for i in range(0, curves_len, 2):
if ext_data_start + 2 + i + 2 <= len(data):
curve = struct.unpack('!H', data[ext_data_start+2+i:ext_data_start+2+i+2])[0]
if (curve & 0x0f0f) != 0x0a0a:
elliptic_curves.append(str(curve))
# EC Point Formats (type 11)
if ext_type == 11 and ext_len >= 1:
fmt_len = data[ext_data_start]
for i in range(fmt_len):
if ext_data_start + 1 + i < len(data):
ec_point_formats.append(str(data[ext_data_start+1+i]))
# Supported Versions (type 43)
if ext_type == 43 and ext_len >= 1:
sv_len = data[ext_data_start]
for i in range(0, sv_len, 2):
if ext_data_start + 1 + i + 2 <= len(data):
ver = struct.unpack('!H', data[ext_data_start+1+i:ext_data_start+1+i+2])[0]
if (ver & 0x0f0f) != 0x0a0a:
supported_versions.append(hex(ver))
pos = ext_data_start + ext_len
# Build JA3 string: TLSVersion,Ciphers,Extensions,EllipticCurves,ECPointFormats
ja3_str = ','.join([
str(ch_version),
'-'.join(cipher_suites),
'-'.join(extensions),
'-'.join(elliptic_curves),
'-'.join(ec_point_formats),
])
ja3_hash = hashlib.md5(ja3_str.encode()).hexdigest()
return {
'dst_ip': dst_ip,
'dst_port': dst_port,
'sni': sni,
'ja3_hash': ja3_hash,
'ja3_string': ja3_str,
'tls_version': hex(ch_version),
'cipher_count': len(cipher_suites),
'extension_count': len(extensions),
'supported_versions': supported_versions,
'ciphers': cipher_suites[:10], # first 10 for display
}
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: python3 ja3_extract.py <pcap_file>")
sys.exit(1)
results = parse_pcap(sys.argv[1])
if not results:
print("No TLS ClientHello found in pcap")
sys.exit(0)
# Deduplicate by JA3 hash + SNI
seen = set()
for r in results:
key = f"{r['ja3_hash']}:{r['sni']}"
if key in seen:
continue
seen.add(key)
print(f"SNI: {r['sni']}")
print(f"Dest: {r['dst_ip']}:{r['dst_port']}")
print(f"JA3 Hash: {r['ja3_hash']}")
print(f"TLS Ver: {r['tls_version']}")
print(f"Ciphers: {r['cipher_count']} suites (first 10: {r['ciphers']})")
print(f"Extensions: {r['extension_count']}")
print(f"Sup. Vers: {r['supported_versions']}")
print(f"JA3 Full: {r['ja3_string'][:200]}...")
print()

View File

@ -1,99 +0,0 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────
# run.sh - One-command capture for Claude Code / Antigravity
#
# Usage:
# ./run.sh # Start both mitmproxy + tshark
# ./run.sh mitm # mitmproxy only (HTTP layer)
# ./run.sh tls # tshark only (TLS layer)
# ./run.sh tls en0 # tshark on specific interface
# ─────────────────────────────────────────────────────────────
set -euo pipefail
cd "$(dirname "$0")"
MODE="${1:-both}"
IFACE="${2:-en0}"
# Check dependencies
check_dep() {
if ! command -v "$1" &>/dev/null; then
echo "ERROR: $1 not found. Install with: $2"
exit 1
fi
}
mkdir -p ./captures
case "$MODE" in
mitm|mitmproxy)
check_dep mitmproxy "brew install mitmproxy"
echo ""
echo "Starting mitmproxy on :8080"
echo ""
echo "To capture Claude Code traffic:"
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude login"
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude 'hello'"
echo ""
echo "To capture VS Code / Antigravity traffic:"
echo " HTTPS_PROXY=http://127.0.0.1:8080 code ."
echo ""
mitmdump -s capture_traffic.py \
--set stream_large_bodies=10m \
--set console_eventlog_verbosity=warn \
-p 8080
;;
tls|tshark)
check_dep tshark "brew install wireshark"
echo "Starting TLS capture (requires sudo)..."
sudo bash ./capture_tls.sh "$IFACE" 120
;;
both)
check_dep mitmproxy "brew install mitmproxy"
check_dep tshark "brew install wireshark"
echo ""
echo "═══════════════════════════════════════════════"
echo " MiniGravity Traffic Capture"
echo "═══════════════════════════════════════════════"
echo ""
echo " Starting two capture layers:"
echo " 1. mitmproxy (:8080) → HTTP headers/body"
echo " 2. tshark → TLS fingerprints"
echo ""
echo " Step 1: In another terminal, run:"
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude login"
echo ""
echo " Step 2: After login, run:"
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude 'hello'"
echo ""
echo " Step 3: Press Ctrl+C here when done"
echo "═══════════════════════════════════════════════"
echo ""
# Start tshark in background (needs sudo)
echo "[*] Starting tshark (may ask for sudo password)..."
sudo bash ./capture_tls.sh "$IFACE" 300 &
TSHARK_PID=$!
sleep 2
# Start mitmproxy in foreground
echo "[*] Starting mitmproxy..."
mitmdump -s capture_traffic.py \
--set stream_large_bodies=10m \
--set console_eventlog_verbosity=warn \
-p 8080
# Cleanup tshark on exit
sudo kill "$TSHARK_PID" 2>/dev/null || true
wait "$TSHARK_PID" 2>/dev/null || true
;;
*)
echo "Usage: $0 [mitm|tls|both] [interface]"
exit 1
;;
esac

View File

@ -1,204 +0,0 @@
#!/bin/bash
# sub2api Antigravity — 指纹防泄露 + macOS 特征伪装规则
#
# 功能:
# 1. QUIC/UDP 阻断 — 强制走 TCP/TLS
# 2. 出站 TCP 443 限制 — 只有 nodeproxy 用户能直连
# 3. IPv6 阻断 — 消除 IPv6 泄露
# 4. TCP TTL 伪装 — 改为 64匹配 macOS/Linux对抗 OS 识别)
# 5. TCP 时间戳重写 — 禁用内核时间戳,防止通过 TCP TS 推算 uptime/系统时间
# 6. 系统时区设置 — 设为 America/Los_Angeles加州时区匹配目标用户群
#
# 用法:
# sudo bash setup-firewall.sh [apply|remove|status|timezone]
#
# 前置条件:
# - 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"
TARGET_TZ="America/New_York"
log() { echo "[$(date '+%H:%M:%S')] $*"; }
# ─── 时区设置 ────────────────────────────────────────────────────────
set_timezone() {
log "Setting system timezone to $TARGET_TZ ..."
if command -v timedatectl &>/dev/null; then
timedatectl set-timezone "$TARGET_TZ"
log " timedatectl: timezone set to $(timedatectl show -p Timezone --value)"
elif [ -f "/usr/share/zoneinfo/$TARGET_TZ" ]; then
ln -sf "/usr/share/zoneinfo/$TARGET_TZ" /etc/localtime
echo "$TARGET_TZ" > /etc/timezone
log " /etc/localtime -> $TARGET_TZ"
else
log " WARNING: Cannot set timezone — timedatectl not found and zoneinfo missing"
fi
}
# ─── TCP 时间戳禁用 ──────────────────────────────────────────────────
# Linux TCP 时间戳会随系统 uptime 线性增长,对方可通过测量 TS 差值
# 推算服务器启动时间,识破"全天候在线的服务器"特征。
# 禁用后 TCP TS 选项不再发送,无法通过 TS 推断 uptime。
disable_tcp_timestamps() {
log "Disabling TCP timestamps (anti-uptime fingerprinting)..."
sysctl -w net.ipv4.tcp_timestamps=0 > /dev/null
# 持久化(防止重启后恢复)
if ! grep -q "net.ipv4.tcp_timestamps" /etc/sysctl.conf 2>/dev/null; then
echo "net.ipv4.tcp_timestamps=0" >> /etc/sysctl.conf
log " Written to /etc/sysctl.conf"
else
sed -i 's/net.ipv4.tcp_timestamps=.*/net.ipv4.tcp_timestamps=0/' /etc/sysctl.conf
log " Updated in /etc/sysctl.conf"
fi
log " TCP timestamps: DISABLED"
}
enable_tcp_timestamps() {
sysctl -w net.ipv4.tcp_timestamps=1 > /dev/null
sed -i 's/net.ipv4.tcp_timestamps=.*/net.ipv4.tcp_timestamps=1/' /etc/sysctl.conf 2>/dev/null || true
log " TCP timestamps: ENABLED (restored)"
}
# ─── iptables 规则 ───────────────────────────────────────────────────
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 阻断 ===
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: 允许 nodeproxy 出站 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"
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
# === Rule 5: TCP TTL 伪装 (macOS TTL = 64) ===
# macOS 和 Linux 默认 TTL 都是 64但数据中心 Linux 有些发行版是 128。
# 强制设为 64 确保一致,并防止"服务器离对方 0 跳"露馅。
iptables -t mangle -N "${CHAIN_NAME}_TTL" 2>/dev/null || iptables -t mangle -F "${CHAIN_NAME}_TTL"
iptables -t mangle -A "${CHAIN_NAME}_TTL" -p tcp --dport 443 \
-j TTL --ttl-set 64 \
-m comment --comment "MG: spoof TTL=64 (macOS)"
if ! iptables -t mangle -C OUTPUT -j "${CHAIN_NAME}_TTL" 2>/dev/null; then
iptables -t mangle -A OUTPUT -j "${CHAIN_NAME}_TTL"
fi
log "Firewall rules applied successfully."
log " - UDP 443/4433: BLOCKED (QUIC)"
log " - TCP 443: ONLY '$NODE_PROXY_USER' allowed"
log " - IPv6 outbound: BLOCKED"
log " - TCP TTL: FORCED to 64 (macOS spoof)"
# === TCP Window Size 伪装 (macOS 特征) ===
# macOS 初始 TCP 接收窗口约 65535Linux 服务器默认 29200
# 可被 p0f/Akamai 等工具区分。调整为 macOS 典型值。
log "Spoofing TCP Window Size (macOS: 65535)..."
sysctl -w net.ipv4.tcp_rmem="4096 65535 6291456" > /dev/null
sysctl -w net.ipv4.tcp_wmem="4096 65535 6291456" > /dev/null
# 持久化
for param in "net.ipv4.tcp_rmem=4096 65535 6291456" "net.ipv4.tcp_wmem=4096 65535 6291456"; do
key="${param%%=*}"
if grep -q "$key" /etc/sysctl.conf 2>/dev/null; then
sed -i "s|${key}=.*|${param}|" /etc/sysctl.conf
else
echo "$param" >> /etc/sysctl.conf
fi
done
log " TCP Window Size: SET to 65535 (macOS spoof)"
# === TCP 时间戳禁用 ===
disable_tcp_timestamps
# === 时区设置 ===
set_timezone
log ""
log "=== All anti-fingerprint measures applied ==="
log " OS Fingerprint: TTL=64, Window=65535 (macOS)"
log " TCP Timestamps: Disabled (anti-uptime leak)"
log " Timezone: $TARGET_TZ"
}
remove_rules() {
log "Removing fingerprint firewall rules..."
iptables -D OUTPUT -j "$CHAIN_NAME" 2>/dev/null || true
ip6tables -D OUTPUT -j "${CHAIN_NAME}_V6" 2>/dev/null || true
iptables -t mangle -D OUTPUT -j "${CHAIN_NAME}_TTL" 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
iptables -t mangle -F "${CHAIN_NAME}_TTL" 2>/dev/null || true
iptables -t mangle -X "${CHAIN_NAME}_TTL" 2>/dev/null || true
enable_tcp_timestamps
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 "=== IPv4 mangle TTL chain ==="
iptables -t mangle -L "${CHAIN_NAME}_TTL" -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)"
echo
log "=== TCP Timestamps ==="
sysctl net.ipv4.tcp_timestamps
echo
log "=== System Timezone ==="
timedatectl show -p Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || echo "(unknown)"
echo
log "=== Current TTL (outbound) ==="
sysctl net.ipv4.ip_default_ttl
}
case "${1:-apply}" in
apply) apply_rules ;;
remove) remove_rules ;;
status) show_status ;;
timezone) set_timezone ;;
*)
echo "Usage: $0 [apply|remove|status|timezone]"
exit 1
;;
esac

View File

@ -1,30 +0,0 @@
#!/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"

View File

@ -1,91 +0,0 @@
#!/bin/bash
# =============================================================
# 节点 1上海服务器
# 部署sub2api + node-tls-proxy + postgres + redis
# =============================================================
# 用法bash setup-node1-shanghai.sh
# 前置:已安装 Docker已克隆仓库到当前目录
set -euo pipefail
GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m'
ok() { echo -e "${GREEN}$*${NC}"; }
info() { echo -e "${YELLOW} $*${NC}"; }
echo "================================================"
echo " 节点1上海服务器 部署"
echo "================================================"
# ── 1. 检查 Docker ─────────────────────────────────
if ! command -v docker &>/dev/null; then
info "安装 Docker..."
curl -fsSL https://get.docker.com | bash
systemctl enable docker && systemctl start docker
fi
ok "Docker 已就绪"
# ── 2. 进入 deploy 目录 ─────────────────────────────
# 兼容从仓库根目录执行(/root/sub2api/或脚本原始位置antigravity/maintenance/
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -d "$SCRIPT_DIR/deploy" ]; then
DEPLOY_DIR="$SCRIPT_DIR/deploy"
elif [ -d "$(dirname "$SCRIPT_DIR")/deploy" ]; then
DEPLOY_DIR="$(dirname "$SCRIPT_DIR")/deploy"
elif [ -d "$(dirname "$(dirname "$SCRIPT_DIR")")/deploy" ]; then
DEPLOY_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")/deploy"
elif [ -d "$(pwd)/deploy" ]; then
DEPLOY_DIR="$(pwd)/deploy"
else
echo "❌ 找不到 deploy/ 目录,请在仓库根目录执行脚本"
exit 1
fi
cd "$DEPLOY_DIR"
ok "工作目录: $DEPLOY_DIR"
# ── 3. 生成 .env如不存在──────────────────────────
if [ ! -f .env ]; then
cat > .env << EOF
# ========== 必填 ==========
POSTGRES_PASSWORD=$(openssl rand -hex 16)
ADMIN_EMAIL=admin@sub2api.local
ADMIN_PASSWORD=$(openssl rand -hex 8)
JWT_SECRET=$(openssl rand -hex 32)
TOTP_ENCRYPTION_KEY=$(openssl rand -hex 32)
# ========== 时区(上海)==========
TZ=Asia/Shanghai
# ========== Gemini OAuth如有==========
GEMINI_CLI_OAUTH_CLIENT_SECRET=
ANTIGRAVITY_OAUTH_CLIENT_SECRET=
EOF
ok ".env 已生成node-tls-proxy 在本机,无需额外配置)"
fi
# ── 4. 启动服务 ─────────────────────────────────────
info "启动 sub2api + node-tls-proxy..."
docker compose -f docker-compose.yml \
-f docker-compose.tls-proxy.yml \
pull
docker compose -f docker-compose.yml \
-f docker-compose.tls-proxy.yml \
up -d
ok "服务启动完成"
# ── 5. 验证 ────────────────────────────────────────
sleep 10
echo ""
echo "【验证】"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
echo ""
if curl -sf http://127.0.0.1:8080/health >/dev/null 2>&1; then
ok "sub2api 健康检查通过(端口 8080"
else
echo "⏳ sub2api 还在启动,等 30 秒后手动检查..."
fi
echo ""
echo "================================================"
echo " 节点1 部署完成"
echo " 管理面板: http://$(curl -sf ipinfo.io/ip 2>/dev/null || echo '<服务器IP>'):8080"
echo "================================================"

View File

@ -1,97 +0,0 @@
#!/bin/bash
# =============================================================
# 节点 2海外 CN 中转机
# 部署GOST 双向中转
# 接收上海: relay+tls :3456 → 转发到美国落地 :8443
# =============================================================
# 用法bash setup-node2-cn-relay.sh
set -euo pipefail
GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m'
ok() { echo -e "${GREEN}$*${NC}"; }
info() { echo -e "${YELLOW} $*${NC}"; }
fail() { echo -e "${RED}$*${NC}"; }
# ── 配置(修改这里)──────────────────────────────────
US_LANDING_IP="${US_LANDING_IP:-}" # 美国落地机 IP
GOST_USER="${GOST_USER:-gostuser}"
GOST_PASS="${GOST_PASS:-$(openssl rand -hex 8)}"
LISTEN_PORT_FROM_SH="${LISTEN_PORT_FROM_SH:-3456}" # 接收上海的端口
LISTEN_PORT_TO_US="${LISTEN_PORT_TO_US:-8443}" # 美国落地机监听端口
echo "================================================"
echo " 节点2海外CN中转机 部署"
echo "================================================"
# 检查必填
if [ -z "$US_LANDING_IP" ]; then
read -rp "请输入美国落地机 IP: " US_LANDING_IP
fi
# ── 1. 安装 GOST ────────────────────────────────────
if ! command -v gost &>/dev/null; then
info "安装 GOST..."
ARCH=$(uname -m)
[ "$ARCH" = "x86_64" ] && GARCH="amd64" || GARCH="arm64"
LATEST=$(curl -sf https://api.github.com/repos/go-gost/gost/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
VER="${LATEST#v}"
wget -qO /tmp/gost.tar.gz \
"https://github.com/go-gost/gost/releases/download/${LATEST}/gost_${VER}_linux_${GARCH}.tar.gz"
tar xzf /tmp/gost.tar.gz -C /tmp/
mv /tmp/gost /usr/local/bin/gost
chmod +x /usr/local/bin/gost
fi
ok "GOST $(gost -V 2>/dev/null | head -1 || echo '已安装')"
# ── 2. 创建 Systemd 服务 ────────────────────────────
# 中转机职责:
# - 接收上海 sub2api 发来的 relay+tls 连接(:3456
# - 将流量通过 relay+tls 转发到美国落地机(:8443
cat > /etc/systemd/system/gost-sub2api-relay.service << EOF
[Unit]
Description=GOST sub2api CN Relay - 接收上海转发到美国落地
After=network.target
[Service]
Type=simple
User=nobody
ExecStart=/usr/local/bin/gost \\
-L "http://${GOST_USER}:${GOST_PASS}@:${LISTEN_PORT_FROM_SH}" \\
-F "relay+tls://${GOST_USER}:${GOST_PASS}@${US_LANDING_IP}:${LISTEN_PORT_TO_US}"
Restart=always
RestartSec=5
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable gost-sub2api-relay
systemctl restart gost-sub2api-relay
sleep 2
ok "GOST 中转服务已启动(服务名: gost-sub2api-relay不影响现有 gost-relay"
# ── 3. 防火墙开放端口 ───────────────────────────────
if command -v ufw &>/dev/null; then
ufw allow "${LISTEN_PORT_FROM_SH}/tcp" comment "GOST from Shanghai" 2>/dev/null || true
ufw allow ssh 2>/dev/null || true
ok "ufw 端口已开放"
fi
# ── 4. 输出上海配置 ─────────────────────────────────
MY_IP=$(curl -sf ipinfo.io/ip 2>/dev/null || echo '<本机IP>')
echo ""
echo "================================================"
echo " 节点2 部署完成"
echo "================================================"
echo ""
echo "【上海服务器 .env 填写以下值】"
echo " GATEWAY_NODE_TLS_PROXY_LISTEN_HOST=${MY_IP}"
echo " GATEWAY_NODE_TLS_PROXY_LISTEN_PORT=${LISTEN_PORT_FROM_SH}"
echo ""
echo "【GOST 认证信息(勿泄露)】"
echo " 用户名: ${GOST_USER}"
echo " 密码: ${GOST_PASS}"
echo ""
systemctl status gost-sub2api-relay --no-pager -l | tail -5

View File

@ -1,144 +0,0 @@
#!/bin/bash
# =============================================================
# 节点 3美国落地机Debian 12洛杉矶
# 部署GOST 出口 + TCP 指纹伪装
# 接收 CN中转 relay+tls :8443 → 直连 Anthropic/Google
# =============================================================
# 用法sudo bash setup-node3-us-landing.sh
set -euo pipefail
GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m'
ok() { echo -e "${GREEN}$*${NC}"; }
info() { echo -e "${YELLOW} $*${NC}"; }
fail() { echo -e "${RED}$*${NC}"; }
GOST_USER="${GOST_USER:-gostuser}"
GOST_PASS="${GOST_PASS:-}" # 与 CN中转机相同启动时填写
LISTEN_PORT="${LISTEN_PORT:-8443}"
echo "================================================"
echo " 节点3美国落地机 部署Debian 12 / LA"
echo "================================================"
[ "$(id -u)" != "0" ] && { fail "请用 sudo 执行"; exit 1; }
# ── 1. 系统更新 ─────────────────────────────────────
info "更新系统包..."
apt-get update -qq && apt-get upgrade -y -qq
ok "系统已更新"
# ── 2. TCP 指纹伪装macOS 特征)──────────────────────
info "应用 TCP 指纹伪装..."
# 实时生效
sysctl -w net.ipv4.ip_default_ttl=64 # TTL=64macOS 标准)
sysctl -w net.ipv4.tcp_timestamps=0 # 禁用 TCP 时间戳(防 uptime 推算)
sysctl -w net.ipv4.tcp_window_scaling=1 # 窗口扩展macOS 开启)
sysctl -w net.ipv4.tcp_rmem="4096 65535 6291456" # 接收窗口65535macOS默认
sysctl -w net.ipv4.tcp_wmem="4096 65535 6291456" # 发送窗口65535
sysctl -w net.ipv6.conf.all.disable_ipv6=1
sysctl -w net.ipv6.conf.default.disable_ipv6=1
# BBR 拥塞控制(降低丢包,提高吞吐)
sysctl -w net.core.default_qdisc=fq
sysctl -w net.ipv4.tcp_congestion_control=bbr
# 持久化到 sysctl.conf
cat >> /etc/sysctl.conf << 'EOF'
# ── Antigravity macOS TCP Fingerprint ──
net.ipv4.ip_default_ttl=64
net.ipv4.tcp_timestamps=0
net.ipv4.tcp_window_scaling=1
net.ipv4.tcp_rmem=4096 65535 6291456
net.ipv4.tcp_wmem=4096 65535 6291456
net.ipv6.conf.all.disable_ipv6=1
net.ipv6.conf.default.disable_ipv6=1
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbr
EOF
sysctl -p > /dev/null 2>&1 || true
ok "TCP 指纹伪装已应用TTL=64, Window=65535, 时间戳禁用)"
# ── 3. 时区(洛杉矶,匹配落地 IP 地理位置)─────────────
timedatectl set-timezone America/Los_Angeles
ok "时区已设置: $(date)"
# ── 4. 安装 GOST ────────────────────────────────────
if ! command -v gost &>/dev/null; then
info "安装 GOST..."
ARCH=$(uname -m)
[ "$ARCH" = "x86_64" ] && GARCH="amd64" || GARCH="arm64"
LATEST=$(curl -sf https://api.github.com/repos/go-gost/gost/releases/latest \
| grep '"tag_name"' | cut -d'"' -f4)
VER="${LATEST#v}"
wget -qO /tmp/gost.tar.gz \
"https://github.com/go-gost/gost/releases/download/${LATEST}/gost_${VER}_linux_${GARCH}.tar.gz"
tar xzf /tmp/gost.tar.gz -C /tmp/
mv /tmp/gost /usr/local/bin/gost
chmod +x /usr/local/bin/gost
fi
ok "GOST $(gost -V 2>/dev/null | head -1 || echo '已安装')"
# ── 5. 填写 GOST 密码 ──────────────────────────────
if [ -z "$GOST_PASS" ]; then
read -rp "请输入 GOST 密码(与 CN中转机相同: " GOST_PASS
fi
# ── 6. 创建 GOST 出口服务 ──────────────────────────
# 落地机职责:监听 CN中转机 relay+tls 连接,直接出口到 Anthropic/Google
cat > /etc/systemd/system/gost-sub2api-exit.service << EOF
[Unit]
Description=GOST sub2api US Landing Exit - 接收中转,直连 Anthropic/Google
After=network.target
[Service]
Type=simple
User=nobody
# 监听 CN中转机的连接透传到最终目标relay 模式自动解析目标地址)
ExecStart=/usr/local/bin/gost -L "relay+tls://${GOST_USER}:${GOST_PASS}@:${LISTEN_PORT}"
Restart=always
RestartSec=5
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable gost-sub2api-exit
systemctl restart gost-sub2api-exit
sleep 2
ok "GOST 出口服务已启动"
# ── 7. 防火墙 ──────────────────────────────────────
if command -v ufw &>/dev/null; then
ufw allow ssh
ufw allow "${LISTEN_PORT}/tcp" comment "GOST from CN Relay"
ufw --force enable
ok "防火墙已配置(只开放 SSH + $LISTEN_PORT"
fi
# ── 8. 验证 ───────────────────────────────────────
echo ""
echo "================================================"
echo " 节点3 部署完成"
echo "================================================"
echo ""
echo "【验证指纹伪装】"
echo " TTL: $(sysctl -n net.ipv4.ip_default_ttl) (应为 64"
echo " TCP 时间戳: $(sysctl -n net.ipv4.tcp_timestamps) (应为 0"
echo " 时区: $(timedatectl show -p Timezone --value)"
echo " 当前时间: $(date)"
echo ""
echo "【GOST 服务状态】"
systemctl status gost-sub2api-exit --no-pager -l | tail -5
echo ""
echo "【出口 IP 信息】"
curl -sf ipinfo.io 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(f' IP: {d.get(\"ip\")}')
print(f' ISP: {d.get(\"org\")}')
print(f' 城市: {d.get(\"city\")}, {d.get(\"region\")}')
" || echo " (获取 IP 信息失败)"

View File

@ -1,82 +0,0 @@
#!/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"

View File

@ -1,184 +0,0 @@
#!/bin/bash
# test-linux.sh — Linux 服务器全量指纹验证脚本
# 用途:验证所有 TCP/OS 层伪装 + Node.js proxy 状态
# 运行方式sudo bash test-linux.sh
#
# 注意sysctl 和 iptables 检查需要 sudo
set -euo pipefail
GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' NC='\033[0m'
ok() { echo -e "${GREEN}$*${NC}"; }
fail() { echo -e "${RED}$*${NC}"; }
info() { echo -e "${YELLOW} $*${NC}"; }
PROXY_PORT="${PROXY_PORT:-3456}"
echo "══════════════════════════════════════════════"
echo " Linux 服务器指纹伪装验证"
echo "══════════════════════════════════════════════"
# ── 1. 时区 ────────────────────────────────────────────────
echo ""
echo "【1】系统时区"
TZ_NOW=$(timedatectl show -p Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || date +%Z)
echo " 当前时区: $TZ_NOW"
echo " 当前时间: $(date)"
if [[ "$TZ_NOW" == "America/New_York" ]]; then
ok "时区正确America/New_York"
else
fail "时区错误(应为 America/New_York当前: $TZ_NOW"
info "修复: sudo timedatectl set-timezone America/New_York"
fi
# ── 2. TCP 时间戳 ──────────────────────────────────────────
echo ""
echo "【2】TCP 时间戳(防 uptime 推算)"
TS=$(sysctl -n net.ipv4.tcp_timestamps 2>/dev/null || echo "unknown")
echo " net.ipv4.tcp_timestamps = $TS"
if [[ "$TS" == "0" ]]; then
ok "TCP 时间戳已禁用"
else
fail "TCP 时间戳未禁用(当前: $TS,应为 0"
info "修复: sudo sysctl -w net.ipv4.tcp_timestamps=0"
fi
# ── 3. TTL ─────────────────────────────────────────────────
echo ""
echo "【3】出站 TTLmacOS 特征)"
TTL=$(sysctl -n net.ipv4.ip_default_ttl 2>/dev/null || echo "unknown")
echo " net.ipv4.ip_default_ttl = $TTL"
if [[ "$TTL" == "64" ]]; then
ok "TTL=64macOS/Linux 标准值)"
else
fail "TTL 不为 64当前: $TTL"
fi
# ── 4. TCP Window Size ─────────────────────────────────────
echo ""
echo "【4】TCP Window SizemacOS 特征)"
RMEM=$(sysctl -n net.ipv4.tcp_rmem 2>/dev/null || echo "unknown")
echo " net.ipv4.tcp_rmem = $RMEM"
if [[ "$RMEM" == *"65535"* ]]; then
ok "Window Size 包含 65535macOS 特征)"
else
fail "Window Size 未伪装(应含 65535当前: $RMEM"
info "修复: sudo sysctl -w net.ipv4.tcp_rmem='4096 65535 6291456'"
fi
# ── 5. iptables 规则 ───────────────────────────────────────
echo ""
echo "【5】iptables 指纹防护链"
if iptables -L MG_FINGERPRINT -n 2>/dev/null | grep -q "MG:"; then
ok "MG_FINGERPRINT 链存在"
RULES=$(iptables -L MG_FINGERPRINT -n 2>/dev/null | grep -c "MG:" || echo 0)
echo " 规则数: $RULES"
else
fail "MG_FINGERPRINT 链不存在,运行 setup-firewall.sh apply"
fi
if iptables -t mangle -L MG_FINGERPRINT_TTL -n 2>/dev/null | grep -q "TTL"; then
ok "TTL mangle 链存在"
else
fail "TTL mangle 链不存在"
fi
# ── 6. QUIC 阻断验证 ───────────────────────────────────────
echo ""
echo "【6】QUIC/UDP 阻断"
if iptables -L MG_FINGERPRINT -n 2>/dev/null | grep -q "udp.*443.*DROP"; then
ok "UDP 443 QUIC 已阻断"
else
fail "UDP 443 未阻断"
fi
# ── 7. Node.js 版本 ────────────────────────────────────────
echo ""
echo "【7】Node.js 版本"
if command -v node &>/dev/null; then
NODE_VER=$(node --version)
echo " Node.js: $NODE_VER"
if [[ "$NODE_VER" == v22* ]]; then
ok "Node.js v22.x — 与 Claude CLI 版本匹配"
else
fail "Node.js 不是 v22.x当前: $NODE_VERJA4 指纹可能不匹配"
fi
else
info "Node.js 未在宿主机安装Docker 部署无需宿主机 Node"
fi
# ── 8. node-tls-proxy 健康 ─────────────────────────────────
echo ""
echo "【8】node-tls-proxy 健康(端口 $PROXY_PORT"
if curl -sf "http://127.0.0.1:${PROXY_PORT}/__health" -o /tmp/health.json 2>/dev/null; then
NODE_IN_PROXY=$(python3 -c "import json; d=json.load(open('/tmp/health.json')); print(d.get('node','?'))" 2>/dev/null)
SESSIONS=$(python3 -c "import json; d=json.load(open('/tmp/health.json')); print(d.get('sessions',0))" 2>/dev/null)
H2HOSTS=$(python3 -c "import json; d=json.load(open('/tmp/health.json')); print(','.join(d.get('h2Hosts',[])))" 2>/dev/null)
TELEMETRY=$(python3 -c "import json; d=json.load(open('/tmp/health.json')); print(d.get('telemetry','?'))" 2>/dev/null)
ok "Proxy 运行正常"
echo " Node版本: $NODE_IN_PROXY"
echo " Sessions: $SESSIONS"
echo " H2已建立: ${H2HOSTS:-(无,首次请求后会建立)}"
echo " 遥测: $TELEMETRY"
if [[ "$NODE_IN_PROXY" == v22* ]]; then
ok "Proxy 内置 Node.js v22.x ✅"
else
fail "Proxy 内置 Node 版本: $NODE_IN_PROXY(应为 v22.x"
fi
else
fail "Proxy 未响应(端口 $PROXY_PORT"
info "检查: docker ps | grep node-tls-proxy"
fi
# ── 9. Node.js JA4 指纹(在 proxy 容器内测) ──────────────
echo ""
echo "【9】Node.js JA4 TLS 指纹"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "node-tls-proxy"; then
JA4=$(docker exec node-tls-proxy node -e "
const https = require('https');
https.get('https://tls.peet.ws/api/all', res => {
let d=''; res.on('data',c=>d+=c);
res.on('end',()=>{ try{console.log(JSON.parse(d).tls.ja4);}catch(e){console.log('err');} });
}).on('error',e=>console.log('err:'+e.message));
" 2>/dev/null || echo "exec_failed")
echo " Proxy JA4: $JA4"
if [[ "$JA4" == t13* ]]; then
ok "JA4 指纹正常TLS 1.3"
else
fail "JA4 获取失败: $JA4"
fi
elif command -v node &>/dev/null; then
JA4=$(node -e "
const https = require('https');
https.get('https://tls.peet.ws/api/all', res => {
let d=''; res.on('data',c=>d+=c);
res.on('end',()=>{ try{console.log(JSON.parse(d).tls.ja4);}catch(e){console.log('err');} });
}).on('error',e=>console.log('err:'+e.message));
" 2>/dev/null || echo "err")
echo " 宿主机 JA4: $JA4"
[[ "$JA4" == t13* ]] && ok "JA4 正常" || fail "JA4 失败"
else
info "跳过 JA4 测(无 docker exec 也无宿主机 node"
fi
# ── 10. 出口 IP 验证 ───────────────────────────────────────
echo ""
echo "【10】出口 IP 信息"
IP_INFO=$(curl -sf --max-time 5 "https://ipinfo.io/json" 2>/dev/null || echo '{}')
IP=$(echo "$IP_INFO" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('ip','?'))" 2>/dev/null)
ORG=$(echo "$IP_INFO" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('org','?'))" 2>/dev/null)
CITY=$(echo "$IP_INFO" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('city','?')+', '+d.get('region','?'))" 2>/dev/null)
echo " IP: $IP"
echo " ISP: $ORG"
echo " 城市: $CITY"
if echo "$ORG" | grep -qiE "residential|comcast|verizon|optimum|spectrum|altice|fios|att|xfinity"; then
ok "ISP 看起来是住宅宽带 ✅"
elif echo "$ORG" | grep -qiE "datacenter|hosting|cloud|amazon|google|microsoft|linode|vultr|digital"; then
fail "ISP 是数据中心 IP建议换住宅宽带"
else
info "ISP 未能自动判断,请人工核查: $ORG"
fi
echo ""
echo "══════════════════════════════════════════════"
echo " 验证完成"
echo "══════════════════════════════════════════════"

View File

@ -1,117 +0,0 @@
#!/bin/bash
# test-mac.sh — 本机Mac指纹验证脚本
# 用途:验证 Node.js TLS 指纹、H2 连通性
# 运行方式bash test-mac.sh
set -euo pipefail
GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' NC='\033[0m'
ok() { echo -e "${GREEN}$*${NC}"; }
fail() { echo -e "${RED}$*${NC}"; }
info() { echo -e "${YELLOW} $*${NC}"; }
echo "══════════════════════════════════════"
echo " MAC 本地指纹验证"
echo "══════════════════════════════════════"
# ── 1. Node.js 版本 ────────────────────────────────────────
echo ""
echo "【1】Node.js 版本"
NODE_VER=$(node --version 2>/dev/null || echo "未安装")
echo " 本机 Node: $NODE_VER"
if [[ "$NODE_VER" == v22* ]]; then
ok "Node.js v22.x — 与 Claude CLI 内置版本匹配"
else
fail "Node.js 版本不是 v22.x当前: $NODE_VERJA4 指纹可能不一致"
info "安装 v22: nvm install 22 && nvm use 22"
fi
# ── 2. Node.js JA4 TLS 指纹 ────────────────────────────────
echo ""
echo "【2】Node.js TLS JA4 指纹(服务器实际产生的指纹)"
JA4=$(node -e "
const https = require('https');
https.get('https://tls.peet.ws/api/all', res => {
let d=''; res.on('data',c=>d+=c);
res.on('end',()=>{
try { console.log(JSON.parse(d).tls.ja4); } catch(e){ console.log('parse_error'); }
});
}).on('error', e => console.log('error:'+e.message));
" 2>/dev/null)
echo " JA4: $JA4"
if [[ "$JA4" == t13* ]]; then
ok "TLS 1.3,看起来是合法的 Node.js 指纹"
else
fail "JA4 获取失败或格式异常: $JA4"
fi
# ── 3. H2 连接 api.anthropic.com ───────────────────────────
echo ""
echo "【3】HTTP/2 连通性Anthropic"
H2_RESULT=$(node -e "
const http2 = require('http2');
const s = http2.connect('https://api.anthropic.com', {}, () => {
console.log('ok:' + s.socket.alpnProtocol);
s.close();
});
s.on('error', e => { console.log('err:'+e.message); process.exit(0); });
setTimeout(()=>{ console.log('timeout'); process.exit(0); }, 5000);
" 2>/dev/null)
if [[ "$H2_RESULT" == ok:h2 ]]; then
ok "Anthropic H2 连接成功alpnProtocol: h2"
else
fail "H2 连接失败: $H2_RESULT"
fi
# ── 4. H2 连接 googleapis.com ──────────────────────────────
echo ""
echo "【4】HTTP/2 连通性Google / Gemini"
H2G=$(node -e "
const http2 = require('http2');
const s = http2.connect('https://generativelanguage.googleapis.com', {}, () => {
console.log('ok:' + s.socket.alpnProtocol);
s.close();
});
s.on('error', e => { console.log('err:'+e.message); process.exit(0); });
setTimeout(()=>{ console.log('timeout'); process.exit(0); }, 5000);
" 2>/dev/null)
if [[ "$H2G" == ok:h2 ]]; then
ok "Google API H2 连接成功"
else
fail "Google API H2 连接失败: $H2G"
fi
# ── 5. 本地 proxy 健康检查(如果本地起了) ─────────────────
echo ""
echo "【5】本地 node-tls-proxy 健康(端口 3456"
PROXY_PORT="${PROXY_PORT:-3456}"
if curl -sf "http://127.0.0.1:${PROXY_PORT}/__health" -o /tmp/proxy_health.json 2>/dev/null; then
SESSIONS=$(python3 -c "import json,sys; d=json.load(open('/tmp/proxy_health.json')); print(d.get('sessions',0))" 2>/dev/null || echo "?")
H2HOSTS=$(python3 -c "import json,sys; d=json.load(open('/tmp/proxy_health.json')); print(','.join(d.get('h2Hosts',[])))" 2>/dev/null || echo "?")
ok "Proxy 运行中 | sessions=$SESSIONS | h2Hosts=$H2HOSTS"
else
info "本地未运行 node-tls-proxy端口 $PROXY_PORT),跳过"
fi
# ── 6. Jitter 延迟分布(如果 proxy 运行中)────────────────
echo ""
echo "【6】Jitter 延迟测试5次请求通过本地 proxy"
if curl -sf "http://127.0.0.1:${PROXY_PORT:-3456}/__health" -o /dev/null 2>/dev/null; then
for i in {1..5}; do
START=$(date +%s%3N)
curl -sf -X POST "http://127.0.0.1:${PROXY_PORT:-3456}/v1/messages" \
-H "x-forwarded-host: api.anthropic.com" \
-H "content-type: application/json" \
-d '{"model":"claude-opus-4-5","max_tokens":1}' -o /dev/null 2>/dev/null || true
ELAPSED=$(($(date +%s%3N) - START))
echo " 请求 $i: ${ELAPSED}ms"
done
ok "延迟应在 80-1200ms 之间,非均匀分布"
else
info "本地未运行 proxy跳过 Jitter 测试"
fi
echo ""
echo "══════════════════════════════════════"
echo " Mac 验证完成"
echo " 关键指纹: $JA4"
echo "══════════════════════════════════════"

View File

@ -1,114 +0,0 @@
#!/bin/bash
# update-cli-version.sh — 自动追踪并更新 Claude CLI 版本号
#
# 原理:
# 从 npm registry 拉取 @anthropic-ai/claude-code 最新版本,
# 更新 proxy.js 和 docker-compose 中的 CLI_VERSION 环境变量。
# 建议通过 cron 每天运行一次。
#
# 用法:
# bash update-cli-version.sh # 检查并更新
# bash update-cli-version.sh --check # 仅检查,不写入
# bash update-cli-version.sh --force VER # 强制设定版本
#
# cron 示例(每天 3 点,时区 America/New_York:
# 0 3 * * * /bin/bash /path/to/update-cli-version.sh >> /var/log/cli-version.log 2>&1
set -euo pipefail
PROXY_JS="$(dirname "$0")/../node-tls-proxy/proxy.js"
LOG_FILE="/tmp/cli-version-update.log"
DRY_RUN=false
FORCE_VERSION=""
# 解析参数
case "${1:-}" in
--check) DRY_RUN=true ;;
--force) FORCE_VERSION="${2:-}" ;;
esac
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S') ET] $*" | tee -a "$LOG_FILE"; }
# ── 当前版本 ──────────────────────────────────────────────────
current_version() {
grep -oP "CLI_VERSION = process\.env\.CLI_VERSION \|\| '\K[0-9]+\.[0-9]+\.[0-9]+" "$PROXY_JS" 2>/dev/null || echo "unknown"
}
# ── 从 npm 拉取最新版本 ───────────────────────────────────────
fetch_latest_version() {
# 尝试 npm registry (JSON API)
local ver
ver=$(curl -sf --max-time 10 \
"https://registry.npmjs.org/@anthropic-ai/claude-code/latest" \
| grep -oP '"version"\s*:\s*"\K[0-9]+\.[0-9]+\.[0-9]+' \
| head -1) || true
if [ -z "$ver" ]; then
# 备用npm view需要 npm 可用)
ver=$(npm view @anthropic-ai/claude-code version 2>/dev/null) || true
fi
echo "${ver:-}"
}
# ── 版本比较:$1 > $2 时返回 0 ──────────────────────────────
version_gt() {
local a="$1" b="$2"
[ "$a" = "$b" ] && return 1
local sorted
sorted=$(printf '%s\n%s\n' "$a" "$b" | sort -V | head -1)
[ "$sorted" = "$b" ]
}
# ── 更新 proxy.js 中的版本号 ─────────────────────────────────
update_proxy_js() {
local new_ver="$1"
if [ ! -f "$PROXY_JS" ]; then
log "ERROR: proxy.js not found at $PROXY_JS"
return 1
fi
sed -i "s|CLI_VERSION = process\.env\.CLI_VERSION || '[0-9.]*'|CLI_VERSION = process.env.CLI_VERSION || '${new_ver}'|" "$PROXY_JS"
log " proxy.js: CLI_VERSION updated to $new_ver"
}
# ── 主流程 ────────────────────────────────────────────────────
main() {
local current latest
current=$(current_version)
log "Current CLI_VERSION: $current"
if [ -n "$FORCE_VERSION" ]; then
latest="$FORCE_VERSION"
log "Force mode: target version = $latest"
else
log "Fetching latest version from npm..."
latest=$(fetch_latest_version)
if [ -z "$latest" ]; then
log "ERROR: Failed to fetch version from npm. Keeping current."
exit 1
fi
log "Latest CLI_VERSION on npm: $latest"
fi
if [ "$current" = "$latest" ]; then
log "Already up to date ($current). No changes needed."
exit 0
fi
if ! version_gt "$latest" "$current" && [ -z "$FORCE_VERSION" ]; then
log "npm version ($latest) is not newer than current ($current). Skipping."
exit 0
fi
if $DRY_RUN; then
log "DRY RUN: would update $current -> $latest (use without --check to apply)"
exit 0
fi
log "Updating $current -> $latest ..."
update_proxy_js "$latest"
log "Done. Restart node-tls-proxy to apply: docker compose restart node-tls-proxy"
}
main

View File

@ -1,24 +0,0 @@
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"]

View File

@ -1,14 +0,0 @@
{
"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"
}
}

View File

@ -1,737 +0,0 @@
'use strict';
const http = require('http');
const https = require('https');
const http2 = require('http2');
const net = require('net');
const crypto = require('crypto');
// os 模块不引用 — 避免暴露真实主机信息
// ─── 配置 ───────────────────────────────────────────────
const UPSTREAM_HOST = process.env.UPSTREAM_HOST || 'api.anthropic.com';
const LISTEN_PORT = parseInt(process.env.PROXY_PORT || '3456', 10);
const LISTEN_HOST = process.env.PROXY_HOST || '127.0.0.1';
const UPSTREAM_PROXY = process.env.UPSTREAM_PROXY || '';
const CONNECT_TIMEOUT = parseInt(process.env.CONNECT_TIMEOUT || '30000', 10);
const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '600000', 10);
const TELEMETRY_ENABLED = process.env.TELEMETRY_ENABLED !== 'false'; // 默认开启
const DD_API_KEY = process.env.DD_API_KEY || 'pubbbf48e6d78dae54bceaa4acf463299bf';
const CLI_VERSION = process.env.CLI_VERSION || '2.1.84';
const BUILD_TIME = process.env.BUILD_TIME || '2026-03-25T23:49:18Z';
// 伪装的 Node 版本CLI 2.1.84 打包的 Bun 报告的 Node 兼容版本)
const FAKE_NODE_VERSION = process.env.FAKE_NODE_VERSION || 'v24.3.0';
const log = (level, msg, extra = {}) => {
const entry = { time: new Date().toISOString(), level, msg, ...extra };
process.stderr.write(JSON.stringify(entry) + '\n');
};
const HEALTH_PATH = '/__health';
const h2Hosts = new Set();
const h2Sessions = new Map();
// ─── 虚拟主机身份生成 ─────────────────────────────────────
// 每个账号基于 seed 生成全局唯一的主机身份,看起来像一台真实的个人开发机
// 匹配 CLI 的 OTEL detectResources: hostDetector + processDetector + serviceInstanceIdDetector
//
// 设计原则:
// 1. 同一账号seed永远产出同一台"机器"的特征
// 2. 不同账号的特征互不相同(无共享池、无碰撞)
// 3. 每个字段都像人手动设置的,不是程序生成的
// ─── macOS 主机身份词表 ──────────────────────────────────────────
// macOS 用户 hostname 习惯: "alex-MBP", "sam-MacBook-Pro" 等
const MBP_NAMES = ['alex','sam','chris','max','lee','kai','jamie','taylor','morgan','casey',
'drew','avery','riley','blake','jordan','ryan','parker','quinn','reese','cameron'];
const MBP_SUFFIX = ['-MBP','-MacBook','-MacBook-Pro','-MacBook-Air',"s-MBP","s-MacBook","s-MacBook-Pro"];
function generateHostIdentity(seed) {
const h = (s) => crypto.createHash('sha256').update(seed + ':' + s).digest();
// ── hostname: macOS 风格 ──
const hb = h('hostname');
const name = MBP_NAMES[hb.readUInt8(0) % MBP_NAMES.length];
const sfx = MBP_SUFFIX[hb.readUInt8(1) % MBP_SUFFIX.length];
const hostname = `${name}${sfx}`;
// ── username: 取自 hostname 名字(真实 Mac 行为) ──
const username = name;
// ── terminal: macOS 常见终端分布 ──
const termRoll = h('terminal').readUInt8(0) % 100;
const terminal = termRoll < 75 ? 'xterm-256color' :
termRoll < 88 ? 'screen-256color' :
termRoll < 96 ? 'alacritty' : 'kitty';
// ── shell: macOS 默认 zshCatalina+);部分用 bash/fish ──
const shellRoll = h('shell').readUInt8(0) % 100;
const shell = shellRoll < 65 ? '/bin/zsh' :
shellRoll < 82 ? '/usr/local/bin/zsh' :
shellRoll < 93 ? '/bin/bash' : '/opt/homebrew/bin/fish';
// ── host.id: macOS IOPlatformUUID 格式(大写 UUID ──
const mid = h('machine-id');
const machineId = [
mid.slice(0,4).toString('hex').toUpperCase(),
mid.slice(4,6).toString('hex').toUpperCase(),
mid.slice(6,8).toString('hex').toUpperCase(),
mid.slice(8,10).toString('hex').toUpperCase(),
mid.slice(10,16).toString('hex').toUpperCase(),
].join('-');
// ── PID: macOS GUI 应用 PID 通常较小 ──
const pid = 500 + Math.floor(Math.random() * 8000);
// ── macOS 版本: 13(Ventura)/14(Sonoma)/15(Sequoia) ──
const kb = h('kernel');
const macosMajor = 13 + (kb.readUInt8(0) % 3);
const macosMinor = kb.readUInt8(1) % 8;
const macosPatch = kb.readUInt8(2) % 5;
// Darwin 内核: macOS 13=22.x, 14=23.x, 15=24.x
const darwinMajor = 22 + (macosMajor - 13);
const darwinMinor = kb.readUInt8(3) % 7;
const darwinPatch = kb.readUInt8(4) % 5;
const osVersion = `${macosMajor}.${macosMinor}.${macosPatch}`;
// ── arch: Apple Silicon arm64 占 70%Intel x64 占 30% ──
const arch = h('arch').readUInt8(0) % 100 < 70 ? 'arm64' : 'x64';
// ── 可执行文件路径: macOS 常见安装位置 ──
const pathRoll = h('execpath').readUInt8(0) % 100;
const executablePath = pathRoll < 50 ? `/Users/${username}/.claude/local/claude` :
pathRoll < 80 ? '/usr/local/bin/claude' :
pathRoll < 95 ? `/Users/${username}/.local/bin/claude` :
'/opt/homebrew/bin/claude';
return {
hostname, username, terminal, shell, machineId, pid, arch,
osType: 'Darwin',
osVersion,
kernelRelease: `${darwinMajor}.${darwinMinor}.${darwinPatch}`,
serviceInstanceId: crypto.randomUUID(),
executablePath,
executableName: 'claude',
command: 'claude',
commandArgs: [],
runtimeName: 'nodejs',
runtimeVersion: FAKE_NODE_VERSION.replace('v', ''),
ripgrepVersion: (() => {
const rv = h('ripgrep');
return ['14.1.1','14.1.0','14.0.2','13.0.0','13.0.1','14.0.1','14.0.0'][rv.readUInt8(0) % 7];
})(),
ripgrepPath: (() => {
const rp = h('rgpath');
return [
'/opt/homebrew/bin/rg',
'/usr/local/bin/rg',
`/Users/${username}/.cargo/bin/rg`,
'/usr/local/opt/ripgrep/bin/rg',
][rp.readUInt8(0) % 4];
})(),
mcpServerCount: 1 + (h('mcp').readUInt8(0) % 5),
mcpFailCount: h('mcp').readUInt8(1) % 3,
};
}
// ─── 遥测模拟 ────────────────────────────────────────────
// 每个 device_id 的会话状态
const sessionStates = new Map();
function getOrCreateSession(deviceId) {
if (sessionStates.has(deviceId)) return sessionStates.get(deviceId);
const hostId = generateHostIdentity(deviceId);
const state = {
sessionId: crypto.randomUUID(),
deviceId,
hostId,
startTime: Date.now(),
requestCount: 0,
// 追踪 ripgrep 是否已上报
ripgrepReported: false,
};
sessionStates.set(deviceId, state);
return state;
}
function generateDeviceId(accountSeed) {
return crypto.createHash('sha256').update(`device:${accountSeed}`).digest('hex');
}
// ─── OTEL Resource Attributes (匹配 CLI 的 detectResources) ───
function buildEnvBlock(hostId) {
const platformStr = 'darwin';
return {
platform: platformStr,
node_version: FAKE_NODE_VERSION,
terminal: hostId.terminal,
package_managers: 'npm,pnpm',
runtimes: 'deno,node',
is_running_with_bun: true,
is_ci: false,
is_claubbit: false,
is_github_action: false,
is_claude_code_action: false,
is_claude_ai_auth: false,
version: CLI_VERSION,
arch: hostId.arch,
is_claude_code_remote: false,
deployment_environment: `unknown-${platformStr}`,
is_conductor: false,
version_base: CLI_VERSION,
build_time: BUILD_TIME,
is_local_agent_mode: false,
vcs: 'git',
platform_raw: platformStr,
};
}
function buildProcessMetrics(uptime) {
// 模拟真实 CLI 的内存曲线RSS 随 uptime 缓慢增长
const baseRss = 180_000_000 + Math.min(uptime * 50_000, 200_000_000);
const rss = Math.floor(baseRss + Math.random() * 80_000_000);
const heapTotal = Math.floor(rss * 0.6 + Math.random() * 10_000_000);
const heapUsed = Math.floor(heapTotal * 0.5 + Math.random() * heapTotal * 0.3);
return Buffer.from(JSON.stringify({
uptime,
rss,
heapTotal,
heapUsed,
external: 14_000_000 + Math.floor(Math.random() * 2_000_000),
arrayBuffers: Math.floor(Math.random() * 200_000),
constrainedMemory: 51539607552,
cpuUsage: {
user: Math.floor(uptime * 10_000 + Math.random() * 300_000),
system: Math.floor(uptime * 2_000 + Math.random() * 80_000),
},
cpuPercent: Math.random() * 200,
})).toString('base64');
}
function buildEvent(eventName, session, model, betas, extraData, timestampOverride) {
const uptime = (Date.now() - session.startTime) / 1000;
const processMetrics = buildProcessMetrics(uptime);
// 缓存最近一次的 process metrics供 DataDog 日志复用(保持两边一致)
session._lastProcessMetrics = { uptime, raw: processMetrics };
const eventData = {
event_name: eventName,
client_timestamp: timestampOverride || new Date().toISOString(),
model: model || 'claude-sonnet-4-6',
session_id: session.sessionId,
user_type: 'external',
betas: betas || 'claude-code-20250219,interleaved-thinking-2025-05-14',
env: buildEnvBlock(session.hostId),
entrypoint: 'cli',
is_interactive: true,
client_type: 'cli',
process: processMetrics,
event_id: crypto.randomUUID(),
device_id: session.deviceId,
// 注意:不加 resource 字段 — event_logging/batch 是自定义端点,
// OTEL resource attributes 由 CLI 通过单独的 OTLP exporter 发送,不在这里
};
// 合并额外字段(用于特定事件的附加数据)
if (extraData) Object.assign(eventData, extraData);
return {
event_type: 'ClaudeCodeInternalEvent',
event_data: eventData,
};
}
// 发送遥测到 api.anthropic.com/api/event_logging/batch
function sendTelemetryEvents(events, session) {
if (!TELEMETRY_ENABLED || events.length === 0) return;
const body = JSON.stringify({ events });
const headers = {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'User-Agent': `claude-code/${CLI_VERSION}`,
'x-service-name': 'claude-code',
'Content-Length': Buffer.byteLength(body),
};
// 注意:真实 CLI 2.1.84 的 event_logging/batch 不发 traceparent
// traceparent 仅在 OTLP exporter单独通道中使用不在这个端点
const opts = {
hostname: 'api.anthropic.com',
port: 443,
path: '/api/event_logging/batch',
method: 'POST',
headers,
timeout: 10000,
};
const req = https.request(opts, (res) => {
res.resume(); // drain
log('debug', 'telemetry_sent', { status: res.statusCode, events: events.length });
});
req.on('error', (err) => {
log('debug', 'telemetry_error', { error: err.message });
});
req.on('timeout', () => req.destroy());
req.end(body);
}
// 发送 DataDog 日志
function sendDatadogLog(eventName, session, model) {
if (!TELEMETRY_ENABLED) return;
const hostId = session.hostId;
const uptime = (Date.now() - session.startTime) / 1000;
// 复用 Anthropic 事件侧缓存的 process metrics保持两边数值一致
// 如果没有缓存(首次调用),现场生成
let pm;
if (session._lastProcessMetrics && Math.abs(session._lastProcessMetrics.uptime - uptime) < 2) {
pm = JSON.parse(Buffer.from(session._lastProcessMetrics.raw, 'base64').toString());
} else {
const baseRss = 180_000_000 + Math.min(uptime * 50_000, 200_000_000);
const rss = Math.floor(baseRss + Math.random() * 80_000_000);
const heapTotal = Math.floor(rss * 0.6 + Math.random() * 10_000_000);
const heapUsed = Math.floor(heapTotal * 0.5 + Math.random() * heapTotal * 0.3);
pm = {
uptime,
rss,
heapTotal,
heapUsed,
external: 14_000_000 + Math.floor(Math.random() * 2_000_000),
arrayBuffers: Math.floor(Math.random() * 10_000),
constrainedMemory: 0,
cpuUsage: {
user: Math.floor(uptime * 10_000 + Math.random() * 300_000),
system: Math.floor(uptime * 2_000 + Math.random() * 80_000),
},
};
}
const entry = {
ddsource: 'nodejs',
ddtags: `event:${eventName},arch:${hostId.arch},client_type:cli,model:${model || 'claude-sonnet-4-6'},platform:darwin,user_type:external,version:${CLI_VERSION},version_base:${CLI_VERSION}`,
message: eventName,
service: 'claude-code',
hostname: hostId.hostname,
env: 'external',
model: model || 'claude-sonnet-4-6',
session_id: session.sessionId,
user_type: 'external',
entrypoint: 'cli',
is_interactive: 'true',
client_type: 'cli',
process_metrics: pm,
platform: 'darwin',
platform_raw: 'darwin',
arch: hostId.arch,
node_version: FAKE_NODE_VERSION,
version: CLI_VERSION,
version_base: CLI_VERSION,
build_time: BUILD_TIME,
deployment_environment: 'unknown-darwin',
vcs: 'git',
};
const body = JSON.stringify([entry]);
const opts = {
hostname: 'http-intake.logs.us5.datadoghq.com',
port: 443,
path: '/api/v2/logs',
method: 'POST',
headers: {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'User-Agent': 'axios/1.13.6',
'dd-api-key': DD_API_KEY,
'Content-Length': Buffer.byteLength(body),
},
timeout: 10000,
};
const req = https.request(opts, (res) => { res.resume(); });
req.on('error', () => {});
req.on('timeout', () => req.destroy());
req.end(body);
}
// 请求前发遥测(模拟 CLI 启动 + 初始化事件)
function emitPreRequestTelemetry(reqHeaders, body) {
const accountSeed = reqHeaders['x-forwarded-host'] || 'default';
const deviceId = generateDeviceId(accountSeed + ':' + (reqHeaders['authorization'] || '').slice(-16));
const session = getOrCreateSession(deviceId);
session.requestCount++;
// 从请求体解析真实 model
let model = 'claude-sonnet-4-6';
try {
const parsed = JSON.parse(body.toString());
if (parsed.model) model = parsed.model;
} catch (_) {}
const betas = reqHeaders['anthropic-beta'] || 'claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24';
// 首次请求:发完整启动事件序列(匹配真实 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);
};
// 动态上游代理:优先使用 per-request 的 X-Upstream-Proxy回退到全局 UPSTREAM_PROXY
const upstreamProxy = reqHeaders['x-upstream-proxy'] || UPSTREAM_PROXY;
// 清除内部 header不传给上游
delete headers['x-upstream-proxy'];
if (upstreamProxy) {
connectViaProxy(upstreamProxy, targetHost, 443)
.then((socket) => { opts.socket = socket; opts.agent = false; finish(opts); })
.catch((err) => { log('error', 'tunnel_failed', { error: err.message, proxy: upstreamProxy }); 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);
}
// ── Jitter 注入 ──────────────────────────────────────────────────
// 模拟人类编码间歇80% 快速响应80-300ms20% 慢速思考400-1200ms
// 使用 -log(rand) 指数衰减使延迟尾部更接近真实键盘输入节奏
const jitterMs = (() => {
if (Math.random() < 0.80) {
return Math.floor(80 + (-Math.log(Math.random()) * 90)); // 快:~80-300ms
}
return Math.floor(400 + Math.random() * 800); // 慢400-1200ms
})();
await new Promise(r => setTimeout(r, jitterMs));
// ── H2 优先策略 ──────────────────────────────────────────────────
// Anthropic/Google API 均支持 HTTP/2。
// 直接走 H2 = Node.js 原生帧顺序,与真实 CLI 完全一致。
// 其他 host 维持原有 H1→H2 自动切换逻辑。
const H2_PREFER_HOSTS = new Set([
'api.anthropic.com',
'cloudaicompanion.googleapis.com',
'generativelanguage.googleapis.com',
'cloudcode-pa.googleapis.com',
'daily-cloudcode-pa.googleapis.com',
]);
if (H2_PREFER_HOSTS.has(targetHost) || h2Hosts.has(targetHost)) {
await sendViaH2(targetHost, req.method, req.url, req.headers, body, res, savedHeaders);
} 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,
});
});
// 定期清理过期 session1 小时无活动)
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) }));

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@ -4,7 +4,7 @@ VERSION ?= $(shell tr -d '\r\n' < ./cmd/server/VERSION)
LDFLAGS ?= -s -w -X main.Version=$(VERSION)
build:
CGO_ENABLED=1 GOEXPERIMENT=boringcrypto go build -ldflags="$(LDFLAGS)" -trimpath -o bin/server ./cmd/server
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -trimpath -o bin/server ./cmd/server
generate:
go generate ./ent

View File

@ -1 +1 @@
0.1.105
0.1.106

View File

@ -142,7 +142,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig)
antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oauthRefreshAPI, tempUnschedCache)
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService)
internal500CounterCache := repository.NewInternal500CounterCache(redisClient)
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache)
tlsFingerprintProfileRepository := repository.NewTLSFingerprintProfileRepository(client)
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
@ -153,8 +154,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
rpmCache := repository.NewRPMCache(redisClient)
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
riskRepository := service.NewRiskRepository(db, settingRepository, redisClient)
riskService := service.NewRiskService(riskRepository, settingRepository, redisClient)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
dataManagementService := service.NewDataManagementService()
@ -181,9 +180,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oauthRefreshAPI)
digestSessionStore := service.NewDigestSessionStore()
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, riskService)
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService)
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oauthRefreshAPI)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, riskService)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider)
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
opsService := service.NewOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink)
@ -219,8 +218,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db)
scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository)
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
riskHandler := admin.NewRiskHandler(riskService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, riskHandler)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)

View File

@ -456,11 +456,6 @@ type GatewayConfig struct {
// TLSFingerprint: TLS指纹伪装配置
TLSFingerprint TLSFingerprintConfig `mapstructure:"tls_fingerprint"`
// NodeTLSProxy: Node.js TLS 代理配置
// 启用后,上游请求通过本地 Node.js 进程发起 TLS 连接,
// 实现天然 JA3/JA4 指纹匹配(无需 uTLS 模拟)
NodeTLSProxy NodeTLSProxyConfig `mapstructure:"node_tls_proxy"`
// InstanceSalt: 实例级隔离盐值
// 用于 user_id 重写和 session hash 的种子混淆,
// 不同 sub2api 实例设置不同的 salt确保相同输入产生不同输出。
@ -702,28 +697,6 @@ type TLSProfileConfig struct {
Extensions []uint16 `mapstructure:"extensions"`
}
// NodeTLSProxyConfig Node.js TLS 代理配置
// 启用后,上游 HTTPS 请求不再由 Go 直接发起,而是通过本地 Node.js 进程中转。
// Node.js 使用原生 OpenSSL TLS 栈,其 JA3/JA4 指纹天然匹配 Claude CLI。
type NodeTLSProxyConfig struct {
// Enabled: 是否启用 Node.js TLS 代理(默认关闭,保持向后兼容)
Enabled bool `mapstructure:"enabled"`
// ListenPort: Node.js 代理监听端口(默认 3456
ListenPort int `mapstructure:"listen_port"`
// ListenHost: Node.js 代理监听地址(默认 127.0.0.1
ListenHost string `mapstructure:"listen_host"`
// HealthPath: 健康检查路径(默认 /__health
HealthPath string `mapstructure:"health_path"`
// UpstreamHost: 上游目标主机(默认 api.anthropic.com
// 通常不需要修改,除非需要指向不同的 API 端点
UpstreamHost string `mapstructure:"upstream_host"`
// ProxyHosts: 需要走 Node.js 代理的上游主机白名单
// 只有目标主机在此列表中的 HTTPS 请求才走 Node.js 代理
// 不在列表中的请求走原有路径uTLS 或直连)
// 为空时默认只代理 api.anthropic.com
ProxyHosts []string `mapstructure:"proxy_hosts"`
}
// FingerprintDefaultsConfig 指纹默认值配置
// 允许每个 sub2api 实例设置不同的默认指纹值,与其他实例区分。
// 所有字段为空时使用代码内置默认值。
@ -1337,8 +1310,8 @@ func setDefaults() {
viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10)
// Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit避免分支漂移
viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/c7947e9871687e664180bc971d4837f1fc2784a9/model_prices_and_context_window.json")
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/c7947e9871687e664180bc971d4837f1fc2784a9/model_prices_and_context_window.sha256")
viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.json")
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256")
viper.SetDefault("pricing.data_dir", "./data")
viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json")
viper.SetDefault("pricing.update_interval_hours", 24)
@ -1519,13 +1492,6 @@ func setDefaults() {
viper.SetDefault("gateway.user_message_queue.cleanup_interval_seconds", 60)
viper.SetDefault("gateway.tls_fingerprint.enabled", true)
// Node.js TLS Proxy 默认值
viper.SetDefault("gateway.node_tls_proxy.enabled", false)
viper.SetDefault("gateway.node_tls_proxy.listen_port", 3456)
viper.SetDefault("gateway.node_tls_proxy.listen_host", "127.0.0.1")
viper.SetDefault("gateway.node_tls_proxy.health_path", "/__health")
viper.SetDefault("gateway.node_tls_proxy.upstream_host", "api.anthropic.com")
viper.SetDefault("concurrency.ping_interval", 10)
// Sora 直连配置

View File

@ -267,6 +267,9 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
}
}
// 收集需要异步设置隐私的 Antigravity OAuth 账号
var privacyAccounts []*service.Account
for i := range dataPayload.Accounts {
item := dataPayload.Accounts[i]
if err := validateDataAccount(item); err != nil {
@ -314,7 +317,8 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
SkipDefaultGroupBind: skipDefaultGroupBind,
}
if _, err := h.adminService.CreateAccount(ctx, accountInput); err != nil {
created, err := h.adminService.CreateAccount(ctx, accountInput)
if err != nil {
result.AccountFailed++
result.Errors = append(result.Errors, DataImportError{
Kind: "account",
@ -323,9 +327,30 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
})
continue
}
// 收集 Antigravity OAuth 账号,稍后异步设置隐私
if created.Platform == service.PlatformAntigravity && created.Type == service.AccountTypeOAuth {
privacyAccounts = append(privacyAccounts, created)
}
result.AccountCreated++
}
// 异步设置 Antigravity 隐私,避免大量导入时阻塞请求
if len(privacyAccounts) > 0 {
adminSvc := h.adminService
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("import_antigravity_privacy_panic", "recover", r)
}
}()
bgCtx := context.Background()
for _, acc := range privacyAccounts {
adminSvc.ForceAntigravityPrivacy(bgCtx, acc)
}
slog.Info("import_antigravity_privacy_done", "count", len(privacyAccounts))
}()
}
return result, nil
}

View File

@ -1,114 +0,0 @@
package admin
import (
"strconv"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type RiskHandler struct {
service *service.RiskService
}
func NewRiskHandler(svc *service.RiskService) *RiskHandler {
return &RiskHandler{service: svc}
}
func (h *RiskHandler) GetSummary(c *gin.Context) {
summary, err := h.service.GetSummary(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, summary)
}
func (h *RiskHandler) ListAccounts(c *gin.Context) {
filter := service.RiskAccountFilter{
Level: c.Query("risk_level"),
Platform: c.Query("platform"),
}
if p := c.Query("page"); p != "" {
if v, err := strconv.Atoi(p); err == nil {
filter.Page = v
}
}
if l := c.Query("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil {
filter.PageSize = v
}
}
list, err := h.service.ListAccounts(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, list)
}
func (h *RiskHandler) GetAccountDetail(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
response.ErrorFrom(c, service.ErrRiskAccountNotFound)
return
}
detail, err := h.service.GetAccountDetail(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, detail)
}
type overrideRiskLevelRequest struct {
Level string `json:"level" binding:"required"`
Reason string `json:"reason" binding:"required"`
}
func (h *RiskHandler) OverrideRiskLevel(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
response.ErrorFrom(c, service.ErrRiskAccountNotFound)
return
}
var req overrideRiskLevelRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorFrom(c, err)
return
}
if err := h.service.OverrideRiskLevel(c.Request.Context(), id, req.Level, req.Reason); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, nil)
}
func (h *RiskHandler) GetSettings(c *gin.Context) {
settings, err := h.service.GetSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, settings)
}
func (h *RiskHandler) UpdateSettings(c *gin.Context) {
var req service.RiskSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorFrom(c, err)
return
}
updated, err := h.service.UpdateSettings(c.Request.Context(), &req)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, updated)
}

View File

@ -129,6 +129,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling,
BackendModeEnabled: settings.BackendModeEnabled,
EnableFingerprintUnification: settings.EnableFingerprintUnification,
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
})
}
@ -209,6 +211,10 @@ type UpdateSettingsRequest struct {
// Backend Mode
BackendModeEnabled bool `json:"backend_mode_enabled"`
// Gateway forwarding behavior
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
}
// UpdateSettings 更新系统设置
@ -601,6 +607,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.OpsMetricsIntervalSeconds
}(),
EnableFingerprintUnification: func() bool {
if req.EnableFingerprintUnification != nil {
return *req.EnableFingerprintUnification
}
return previousSettings.EnableFingerprintUnification
}(),
EnableMetadataPassthrough: func() bool {
if req.EnableMetadataPassthrough != nil {
return *req.EnableMetadataPassthrough
}
return previousSettings.EnableMetadataPassthrough
}(),
}
if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil {
@ -679,6 +697,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling,
BackendModeEnabled: updatedSettings.BackendModeEnabled,
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
})
}
@ -851,6 +871,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.CustomMenuItems != after.CustomMenuItems {
changed = append(changed, "custom_menu_items")
}
if before.EnableFingerprintUnification != after.EnableFingerprintUnification {
changed = append(changed, "enable_fingerprint_unification")
}
if before.EnableMetadataPassthrough != after.EnableMetadataPassthrough {
changed = append(changed, "enable_metadata_passthrough")
}
return changed
}
@ -1568,18 +1594,26 @@ func (h *SettingHandler) GetRectifierSettings(c *gin.Context) {
return
}
patterns := settings.APIKeySignaturePatterns
if patterns == nil {
patterns = []string{}
}
response.Success(c, dto.RectifierSettings{
Enabled: settings.Enabled,
ThinkingSignatureEnabled: settings.ThinkingSignatureEnabled,
ThinkingBudgetEnabled: settings.ThinkingBudgetEnabled,
APIKeySignatureEnabled: settings.APIKeySignatureEnabled,
APIKeySignaturePatterns: patterns,
})
}
// UpdateRectifierSettingsRequest 更新整流器配置请求
type UpdateRectifierSettingsRequest struct {
Enabled bool `json:"enabled"`
ThinkingSignatureEnabled bool `json:"thinking_signature_enabled"`
ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"`
Enabled bool `json:"enabled"`
ThinkingSignatureEnabled bool `json:"thinking_signature_enabled"`
ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"`
APIKeySignatureEnabled bool `json:"apikey_signature_enabled"`
APIKeySignaturePatterns []string `json:"apikey_signature_patterns"`
}
// UpdateRectifierSettings 更新请求整流器配置
@ -1591,10 +1625,32 @@ func (h *SettingHandler) UpdateRectifierSettings(c *gin.Context) {
return
}
// 校验并清理自定义匹配关键词
const maxPatterns = 50
const maxPatternLen = 500
if len(req.APIKeySignaturePatterns) > maxPatterns {
response.BadRequest(c, "Too many signature patterns (max 50)")
return
}
var cleanedPatterns []string
for _, p := range req.APIKeySignaturePatterns {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if len(p) > maxPatternLen {
response.BadRequest(c, "Signature pattern too long (max 500 characters)")
return
}
cleanedPatterns = append(cleanedPatterns, p)
}
settings := &service.RectifierSettings{
Enabled: req.Enabled,
ThinkingSignatureEnabled: req.ThinkingSignatureEnabled,
ThinkingBudgetEnabled: req.ThinkingBudgetEnabled,
APIKeySignatureEnabled: req.APIKeySignatureEnabled,
APIKeySignaturePatterns: cleanedPatterns,
}
if err := h.settingService.SetRectifierSettings(c.Request.Context(), settings); err != nil {
@ -1609,10 +1665,16 @@ func (h *SettingHandler) UpdateRectifierSettings(c *gin.Context) {
return
}
updatedPatterns := updatedSettings.APIKeySignaturePatterns
if updatedPatterns == nil {
updatedPatterns = []string{}
}
response.Success(c, dto.RectifierSettings{
Enabled: updatedSettings.Enabled,
ThinkingSignatureEnabled: updatedSettings.ThinkingSignatureEnabled,
ThinkingBudgetEnabled: updatedSettings.ThinkingBudgetEnabled,
APIKeySignatureEnabled: updatedSettings.APIKeySignatureEnabled,
APIKeySignaturePatterns: updatedPatterns,
})
}

View File

@ -268,6 +268,14 @@ func AccountFromServiceShallow(a *service.Account) *Account {
target := a.GetCacheTTLOverrideTarget()
out.CacheTTLOverrideTarget = &target
}
// 自定义 Base URL 中继转发
if a.IsCustomBaseURLEnabled() {
enabled := true
out.CustomBaseURLEnabled = &enabled
if customURL := a.GetCustomBaseURL(); customURL != "" {
out.CustomBaseURL = &customURL
}
}
}
// 提取账号配额限制apikey / bedrock 类型有效)

View File

@ -94,6 +94,10 @@ type SystemSettings struct {
// Backend Mode
BackendModeEnabled bool `json:"backend_mode_enabled"`
// Gateway forwarding behavior
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
}
type DefaultSubscriptionSetting struct {
@ -184,9 +188,11 @@ type StreamTimeoutSettings struct {
// RectifierSettings 请求整流器配置 DTO
type RectifierSettings struct {
Enabled bool `json:"enabled"`
ThinkingSignatureEnabled bool `json:"thinking_signature_enabled"`
ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"`
Enabled bool `json:"enabled"`
ThinkingSignatureEnabled bool `json:"thinking_signature_enabled"`
ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"`
APIKeySignatureEnabled bool `json:"apikey_signature_enabled"`
APIKeySignaturePatterns []string `json:"apikey_signature_patterns"`
}
// BetaPolicyRule Beta 策略规则 DTO

View File

@ -198,6 +198,10 @@ type Account struct {
CacheTTLOverrideEnabled *bool `json:"cache_ttl_override_enabled,omitempty"`
CacheTTLOverrideTarget *string `json:"cache_ttl_override_target,omitempty"`
// 自定义 Base URL 中继转发(仅 Anthropic OAuth/SetupToken 账号有效)
CustomBaseURLEnabled *bool `json:"custom_base_url_enabled,omitempty"`
CustomBaseURL *string `json:"custom_base_url,omitempty"`
// API Key 账号配额限制
QuotaLimit *float64 `json:"quota_limit,omitempty"`
QuotaUsed *float64 `json:"quota_used,omitempty"`

View File

@ -422,11 +422,24 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
}
wroteFallback := h.ensureForwardErrorResponse(c, streamStarted)
reqLog.Error("gateway.forward_failed",
forwardFailedFields := []zap.Field{
zap.Int64("account_id", account.ID),
zap.String("account_name", account.Name),
zap.String("account_platform", account.Platform),
zap.Bool("fallback_error_response_written", wroteFallback),
zap.Error(err),
)
}
if account.Proxy != nil {
forwardFailedFields = append(forwardFailedFields,
zap.Int64("proxy_id", account.Proxy.ID),
zap.String("proxy_name", account.Proxy.Name),
zap.String("proxy_host", account.Proxy.Host),
zap.Int("proxy_port", account.Proxy.Port),
)
} else if account.ProxyID != nil {
forwardFailedFields = append(forwardFailedFields, zap.Int64p("proxy_id", account.ProxyID))
}
reqLog.Error("gateway.forward_failed", forwardFailedFields...)
return
}
@ -741,11 +754,24 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
}
wroteFallback := h.ensureForwardErrorResponse(c, streamStarted)
reqLog.Error("gateway.forward_failed",
forwardFailedFields := []zap.Field{
zap.Int64("account_id", account.ID),
zap.String("account_name", account.Name),
zap.String("account_platform", account.Platform),
zap.Bool("fallback_error_response_written", wroteFallback),
zap.Error(err),
)
}
if account.Proxy != nil {
forwardFailedFields = append(forwardFailedFields,
zap.Int64("proxy_id", account.Proxy.ID),
zap.String("proxy_name", account.Proxy.Name),
zap.String("proxy_host", account.Proxy.Host),
zap.Int("proxy_port", account.Proxy.Port),
)
} else if account.ProxyID != nil {
forwardFailedFields = append(forwardFailedFields, zap.Int64p("proxy_id", account.ProxyID))
}
reqLog.Error("gateway.forward_failed", forwardFailedFields...)
return
}

View File

@ -30,7 +30,6 @@ type AdminHandlers struct {
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
APIKey *admin.AdminAPIKeyHandler
ScheduledTest *admin.ScheduledTestHandler
Risk *admin.RiskHandler
}
// Handlers contains all HTTP handlers

View File

@ -541,6 +541,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
return
}
reqModel := modelResult.String()
routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel)
reqStream := gjson.GetBytes(body, "stream").Bool()
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
@ -606,7 +607,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
apiKey.GroupID,
"", // no previous_response_id
sessionHash,
reqModel,
routingModel,
failedAccountIDs,
service.OpenAIUpstreamTransportAny,
)
@ -621,7 +622,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
if apiKey.Group != nil {
defaultModel = apiKey.Group.DefaultMappedModel
}
if defaultModel != "" && defaultModel != reqModel {
if defaultModel != "" && defaultModel != routingModel {
reqLog.Info("openai_messages.fallback_to_default_model",
zap.String("default_mapped_model", defaultModel),
)

View File

@ -33,7 +33,6 @@ func ProvideAdminHandlers(
tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler,
apiKeyHandler *admin.AdminAPIKeyHandler,
scheduledTestHandler *admin.ScheduledTestHandler,
riskHandler *admin.RiskHandler,
) *AdminHandlers {
return &AdminHandlers{
Dashboard: dashboardHandler,
@ -60,7 +59,6 @@ func ProvideAdminHandlers(
TLSFingerprintProfile: tlsFingerprintProfileHandler,
APIKey: apiKeyHandler,
ScheduledTest: scheduledTestHandler,
Risk: riskHandler,
}
}
@ -152,7 +150,6 @@ var ProviderSet = wire.NewSet(
admin.NewTLSFingerprintProfileHandler,
admin.NewAdminAPIKeyHandler,
admin.NewScheduledTestHandler,
admin.NewRiskHandler,
// AdminHandlers and Handlers constructors
ProvideAdminHandlers,

View File

@ -29,21 +29,6 @@ func (e *ForbiddenError) Error() string {
return fmt.Sprintf("fetchAvailableModels 失败 (HTTP %d): %s", e.StatusCode, e.Body)
}
// GetGoogAPIClient 返回 x-goog-api-client 头的值(导出供心跳等外部使用)
// 格式与真实 Antigravity 的 Go SDK 一致: gl-go/{goVersion} gax-go/v2 grpc-go/1.81.0-dev
// 注意: 不使用 runtime.Version() — 服务器编译的 Go 版本 ≠ 真实 Antigravity 的 Go 版本
func GetGoogAPIClient() string {
return "gl-go/go1.27 gax-go/v2 grpc-go/1.81.0-dev"
}
// setAntigravityHeaders 设置与真实 Antigravity IDE 一致的 HTTP 请求头
func setAntigravityHeaders(req *http.Request, accessToken string) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("User-Agent", GetUserAgent())
req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient())
}
// NewAPIRequestWithURL 使用指定的 base URL 创建 Antigravity API 请求v1internal 端点)
func NewAPIRequestWithURL(ctx context.Context, baseURL, action, accessToken string, body []byte) (*http.Request, error) {
// 构建 URL流式请求添加 ?alt=sse 参数
@ -58,8 +43,10 @@ func NewAPIRequestWithURL(ctx context.Context, baseURL, action, accessToken stri
return nil, err
}
// 设置与真实 Antigravity IDE 一致的请求头
setAntigravityHeaders(req, accessToken)
// 基础 Headers与 Antigravity-Manager 保持一致,只设置这 3 个)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("User-Agent", GetUserAgent())
return req, nil
}
@ -289,16 +276,6 @@ func NewClient(proxyURL string) (*Client, error) {
return nil, fmt.Errorf("configure proxy: %w", err)
}
client.Transport = transport
} else {
// 无显式代理时,使用支持 HTTPS_PROXY 环境变量的 Transport
// 用于 OAuth token 交换等需要访问外部服务的场景
client.Transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: proxyDialTimeout,
}).DialContext,
TLSHandshakeTimeout: proxyTLSHandshakeTimeout,
}
}
return &Client{
@ -306,11 +283,6 @@ func NewClient(proxyURL string) (*Client, error) {
}, nil
}
// DoRaw 执行原始 HTTP 请求(供心跳等内部使用)
func (c *Client) DoRaw(req *http.Request) (*http.Response, error) {
return c.httpClient.Do(req)
}
// IsConnectionError 判断是否为连接错误网络超时、DNS 失败、连接拒绝)
func IsConnectionError(err error) bool {
if err == nil {
@ -490,7 +462,6 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", GetUserAgent())
req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient())
resp, err := c.httpClient.Do(req)
if err != nil {
@ -570,7 +541,6 @@ func (c *Client) OnboardUser(ctx context.Context, accessToken, tierID string) (s
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", GetUserAgent())
req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient())
resp, err := c.httpClient.Do(req)
if err != nil {
@ -705,7 +675,6 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", GetUserAgent())
req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient())
resp, err := c.httpClient.Do(req)
if err != nil {
@ -757,6 +726,8 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
return nil, nil, lastErr
}
// ── Privacy API ──────────────────────────────────────────────────────
// privacyBaseURL 隐私设置 API 仅使用 daily 端点(与 Antigravity 客户端行为一致)
const privacyBaseURL = antigravityDailyBaseURL
@ -795,15 +766,18 @@ func (r *SetUserSettingsResponse) IsSuccess() bool {
if r == nil {
return false
}
// userSettings 为 nil 或空 map 均视为成功
if len(r.UserSettings) == 0 {
return true
}
// 如果包含 telemetryEnabled 字段,说明未成功清除
_, hasTelemetry := r.UserSettings["telemetryEnabled"]
return !hasTelemetry
}
// SetUserSettings 调用 setUserSettings API 设置用户隐私,返回解析后的响应
func (c *Client) SetUserSettings(ctx context.Context, accessToken string) (*SetUserSettingsResponse, error) {
// 发送空 user_settings 以清除隐私设置
payload := SetUserSettingsRequest{UserSettings: map[string]any{}}
bodyBytes, err := json.Marshal(payload)
if err != nil {

View File

@ -23,16 +23,13 @@ const (
UserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
// Antigravity OAuth 客户端凭证
// 注意:真实 Antigravity 主 Client ID 是 884354919052-...,但需要对应的 client_secret
// 当前使用的 1071006060591-... 同样存在于真实二进制中(可能是备用登录模式)
// 如需切换,必须同时更新 client_secret通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET
ClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
// AntigravityOAuthClientSecretEnv 是 Antigravity OAuth client_secret 的环境变量名。
AntigravityOAuthClientSecretEnv = "ANTIGRAVITY_OAUTH_CLIENT_SECRET"
// redirect_uri — 真实 Antigravity IDE 使用 localhost 动态端口 + /oauth-callback 路径
RedirectURI = "http://localhost:8085/oauth-callback"
// 固定的 redirect_uri用户需手动复制 code
RedirectURI = "http://localhost:8085/callback"
// OAuth scopes
Scopes = "https://www.googleapis.com/auth/cloud-platform " +
@ -47,22 +44,13 @@ const (
// URL 可用性 TTL不可用 URL 的恢复时间)
URLAvailabilityTTL = 5 * time.Minute
// Antigravity API 端点(真实 Antigravity 日志确认使用 daily-cloudcode-pa 无 sandbox 后缀)
// Antigravity API 端点
antigravityProdBaseURL = "https://cloudcode-pa.googleapis.com"
antigravityDailyBaseURL = "https://daily-cloudcode-pa.googleapis.com"
antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
)
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认匹配真实 extension 最新版本
// Gemini 3.1 Pro 等新模型需要较新的版本号才允许访问(上游会检查版本返回 "not available on this version"
var defaultUserAgentVersion = "1.21.6"
// defaultPlatformOS 和 defaultPlatformArch 模拟真实客户端的操作系统和架构
// 真实 Antigravity IDE 运行在用户桌面macOS/Windows不是 Linux 服务器
// 可通过环境变量 ANTIGRAVITY_PLATFORM_OS / ANTIGRAVITY_PLATFORM_ARCH 覆盖
var (
defaultPlatformOS = "darwin"
defaultPlatformArch = "arm64"
)
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.20.5
var defaultUserAgentVersion = "1.20.5"
// defaultClientSecret 可通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 配置
var defaultClientSecret = "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"
@ -76,19 +64,11 @@ func init() {
if secret := os.Getenv(AntigravityOAuthClientSecretEnv); secret != "" {
defaultClientSecret = secret
}
// 从环境变量读取模拟平台(真实 Antigravity 运行在桌面 OS不是 Linux 服务器)
if p := os.Getenv("ANTIGRAVITY_PLATFORM_OS"); p != "" {
defaultPlatformOS = p
}
if a := os.Getenv("ANTIGRAVITY_PLATFORM_ARCH"); a != "" {
defaultPlatformArch = a
}
}
// GetUserAgent 返回当前配置的 User-Agent匹配真实 Antigravity 格式: antigravity/{version} {os}/{arch}
// 注意: 不使用 runtime.GOOS/GOARCH — 服务器是 Linux但真实用户在 macOS/Windows 桌面
// GetUserAgent 返回当前配置的 User-Agent
func GetUserAgent() string {
return fmt.Sprintf("antigravity/%s %s/%s", defaultUserAgentVersion, defaultPlatformOS, defaultPlatformArch)
return fmt.Sprintf("antigravity/%s windows/amd64", defaultUserAgentVersion)
}
func getClientSecret() (string, error) {

View File

@ -262,41 +262,8 @@ func hasMCPTools(tools []ClaudeTool) bool {
return false
}
// claudeCodeSignatures Claude Code / Anthropic 特征字符串,命中任意一个即视为需要过滤的 CLI 默认 prompt
var claudeCodeSignatures = []string{
"You are Claude Code, Anthropic's official CLI",
"You are Claude Code,",
"Anthropic's official CLI",
"x-anthropic-billing-header",
"cc_entrypoint=cli",
}
// filterClaudeCodePrompt 过滤 Claude Code 默认 system prompt防止 Anthropic 特征暴露给上游
// 策略:检测到特征字符串后,尝试提取用户自定义指令部分("Instructions from:" 之后),否则返回空
func filterClaudeCodePrompt(text string) (string, bool) {
matched := false
for _, sig := range claudeCodeSignatures {
if strings.Contains(text, sig) {
matched = true
break
}
}
if !matched {
return text, false
}
// 尝试保留用户自定义指令
if idx := strings.Index(text, "Instructions from:"); idx >= 0 {
return text[idx:], true
}
return "", true
}
// filterOpenCodePrompt 过滤 OpenCode / Claude Code 默认提示词,只保留用户自定义指令
// filterOpenCodePrompt 过滤 OpenCode 默认提示词,只保留用户自定义指令
func filterOpenCodePrompt(text string) string {
// 优先检测 Claude Code 特征
if filtered, matched := filterClaudeCodePrompt(text); matched {
return filtered
}
if !strings.Contains(text, "You are an interactive CLI tool") {
return text
}

View File

@ -2,6 +2,7 @@ package antigravity
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
@ -352,7 +353,7 @@ func TestBuildGenerationConfig_ThinkingDynamicBudget(t *testing.T) {
}
}
func TestTransformClaudeToGeminiWithOptions_FiltersBillingHeaderSystemBlock(t *testing.T) {
func TestTransformClaudeToGeminiWithOptions_PreservesBillingHeaderSystemBlock(t *testing.T) {
tests := []struct {
name string
system json.RawMessage
@ -387,11 +388,15 @@ func TestTransformClaudeToGeminiWithOptions_FiltersBillingHeaderSystemBlock(t *t
require.NoError(t, json.Unmarshal(body, &req))
require.NotNil(t, req.Request.SystemInstruction)
// Claude Code / Anthropic 特征字符串不应透传给上游
found := false
for _, part := range req.Request.SystemInstruction.Parts {
require.NotContains(t, part.Text, "x-anthropic-billing-header",
"Claude Code 特征字符串不应透传给 Antigravity 上游")
if strings.Contains(part.Text, "x-anthropic-billing-header keep") {
found = true
break
}
}
require.True(t, found, "转换后的 systemInstruction 应保留 x-anthropic-billing-header 内容")
})
}
}

View File

@ -1,11 +1,7 @@
// Package geminicli provides helpers for interacting with Gemini CLI tools.
package geminicli
import (
"fmt"
"os"
"time"
)
import "time"
const (
AIStudioBaseURL = "https://generativelanguage.googleapis.com"
@ -50,70 +46,6 @@ const (
SessionTTL = 30 * time.Minute
// GeminiCLIUserAgent 静态回退值(不含 model
// 优先使用 GetGeminiCLIUserAgent(model) 获取完整格式
GeminiCLIUserAgent = "GeminiCLI/0.33.1"
// FakeNodeVersion 模拟真实 Gemini CLI 的 Node.js 版本
// 用于 x-goog-api-client 和 token exchange User-Agent
FakeNodeVersion = "24.13.1"
// GoogleAuthLibraryUA 模拟 google-auth-library 的 User-Agent
// 真实 Gemini CLI token exchange 由 google-auth-library 发起
GoogleAuthLibraryUA = "google-api-nodejs-client"
// FakePlatformOS 和 FakePlatformArch 模拟真实客户端的操作系统和架构
// 真实 Gemini CLI 运行在用户桌面,不是 Linux 服务器
// Node.js process.platform: darwin, linux, win32
// Node.js process.arch: arm64, x64 (注意: Node.js 用 x64不是 amd64)
FakePlatformOS = "darwin"
FakePlatformArch = "arm64"
// GeminiCLIUserAgent mimics Gemini CLI to maximize compatibility with internal endpoints.
GeminiCLIUserAgent = "GeminiCLI/0.1.5 (Windows; AMD64)"
)
// defaultGeminiCLIVersion 可通过环境变量 GEMINI_CLI_VERSION 覆盖
var defaultGeminiCLIVersion = "0.33.1"
// defaultFakePlatformOS/Arch 可通过环境变量覆盖
var (
defaultFakePlatformOS = FakePlatformOS
defaultFakePlatformArch = FakePlatformArch
)
func init() {
if v := os.Getenv("GEMINI_CLI_VERSION"); v != "" {
defaultGeminiCLIVersion = v
}
if p := os.Getenv("GEMINI_CLI_PLATFORM_OS"); p != "" {
defaultFakePlatformOS = p
}
if a := os.Getenv("GEMINI_CLI_PLATFORM_ARCH"); a != "" {
defaultFakePlatformArch = a
}
}
// GetGeminiCLIUserAgent 返回匹配真实 Gemini CLI 格式的 User-Agent
// 真实格式: GeminiCLI/{version}/{model} ({platform}; {arch})
// 示例: GeminiCLI/0.33.1/gemini-2.5-pro (darwin; arm64)
// 注意: 不使用 runtime.GOOS/GOARCH — 服务器是 Linux但要模拟桌面客户端
// 注意: Node.js 用 x64 不是 amd64arm64 两者一致
func GetGeminiCLIUserAgent(model ...string) string {
m := "unknown"
if len(model) > 0 && model[0] != "" {
m = model[0]
}
return fmt.Sprintf("GeminiCLI/%s/%s (%s; %s)",
defaultGeminiCLIVersion, m, defaultFakePlatformOS, defaultFakePlatformArch)
}
// GetGeminiCLIGoogAPIClient 返回 x-goog-api-client 头的值
// 真实 Gemini CLI 通过 google-auth-library DefaultTransporter 自动注入:
// gl-node/{nodeVersion}
func GetGeminiCLIGoogAPIClient() string {
return fmt.Sprintf("gl-node/%s", FakeNodeVersion)
}
// GetGeminiCLITokenExchangeUA 返回 token exchange/refresh 时的 User-Agent
// 真实 Gemini CLI 使用 google-auth-library 发起 token 交换
func GetGeminiCLITokenExchangeUA() string {
return GoogleAuthLibraryUA
}

View File

@ -24,20 +24,18 @@ const (
RedirectURI = "https://platform.claude.com/oauth/code/callback"
// Scopes - Browser URL (includes org:create_api_key for user authorization)
ScopeOAuth = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers"
ScopeOAuth = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
// Scopes - Internal API call (org:create_api_key not supported in API)
ScopeAPI = "user:profile user:inference user:sessions:claude_code user:mcp_servers"
ScopeAPI = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
// Scopes - Setup token (inference only)
ScopeInference = "user:inference"
// Code Verifier character set (RFC 7636 compliant)
codeVerifierCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
// Session TTL
SessionTTL = 30 * time.Minute
)
// OAuthSession stores OAuth flow state
type OAuthSession struct {
State string `json:"state"`
CodeVerifier string `json:"code_verifier"`
@ -147,30 +145,14 @@ func GenerateSessionID() (string, error) {
return hex.EncodeToString(bytes), nil
}
// GenerateCodeVerifier generates a PKCE code verifier using character set method
// GenerateCodeVerifier generates a PKCE code verifier (RFC 7636).
// Uses 32 random bytes → base64url-no-pad, producing a 43-char verifier.
func GenerateCodeVerifier() (string, error) {
const targetLen = 32
charsetLen := len(codeVerifierCharset)
limit := 256 - (256 % charsetLen)
result := make([]byte, 0, targetLen)
randBuf := make([]byte, targetLen*2)
for len(result) < targetLen {
if _, err := rand.Read(randBuf); err != nil {
return "", err
}
for _, b := range randBuf {
if int(b) < limit {
result = append(result, codeVerifierCharset[int(b)%charsetLen])
if len(result) >= targetLen {
break
}
}
}
bytes, err := GenerateRandomBytes(32)
if err != nil {
return "", err
}
return base64URLEncode(result), nil
return base64URLEncode(bytes), nil
}
// GenerateCodeChallenge generates a PKCE code challenge using S256 method

View File

@ -3,6 +3,7 @@ package repository
import (
"context"
"database/sql"
"fmt"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
@ -257,9 +258,12 @@ func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) erro
}
func (r *apiKeyRepository) Delete(ctx context.Context, id int64) error {
// 存在唯一键约束 生成tombstone key 用来释放原key长度远小于 128满足 schema 限制
tombstoneKey := fmt.Sprintf("__deleted__%d__%d", id, time.Now().UnixNano())
// 显式软删除:避免依赖 Hook 行为,确保 deleted_at 一定被设置。
affected, err := r.client.APIKey.Update().
Where(apikey.IDEQ(id), apikey.DeletedAtIsNil()).
SetKey(tombstoneKey).
SetDeletedAt(time.Now()).
Save(ctx)
if err != nil {

View File

@ -151,6 +151,31 @@ func (s *APIKeyRepoSuite) TestDelete() {
s.Require().Error(err, "expected error after delete")
}
func (s *APIKeyRepoSuite) TestCreate_AfterSoftDelete_AllowsSameKey() {
user := s.mustCreateUser("recreate-after-soft-delete@test.com")
const reusedKey = "sk-reuse-after-soft-delete"
first := &service.APIKey{
UserID: user.ID,
Key: reusedKey,
Name: "First Key",
Status: service.StatusActive,
}
s.Require().NoError(s.repo.Create(s.ctx, first), "create first key")
s.Require().NoError(s.repo.Delete(s.ctx, first.ID), "soft delete first key")
second := &service.APIKey{
UserID: user.ID,
Key: reusedKey,
Name: "Second Key",
Status: service.StatusActive,
}
s.Require().NoError(s.repo.Create(s.ctx, second), "create second key with same key")
s.Require().NotZero(second.ID)
s.Require().NotEqual(first.ID, second.ID, "recreated key should be a new row")
}
// --- ListByUserID / CountByUserID ---
func (s *APIKeyRepoSuite) TestListByUserID() {

View File

@ -242,7 +242,6 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
"grant_type": "refresh_token",
"refresh_token": refreshToken,
"client_id": oauth.ClientID,
"scope": oauth.ScopeAPI,
}
var tokenResp oauth.TokenResponse
@ -269,9 +268,9 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
func createReqClient(proxyURL string) (*req.Client, error) {
// 禁用 CookieJar确保每次授权都是干净的会话
// 不使用 ImpersonateChrome() — 真实 Claude CLI 用 axios (Bun fetch)TLS 指纹应为 Node.js/Bun
client := req.C().
SetTimeout(15 * time.Second).
SetTimeout(60 * time.Second).
ImpersonateChrome().
SetCookieJar(nil) // 禁用 CookieJar
trimmed, _, err := proxyurl.Parse(proxyURL)

View File

@ -68,9 +68,9 @@ func (s *claudeUsageService) FetchUsageWithOptions(ctx context.Context, opts *se
var resp *http.Response
// 如果有 TLS Mode非 off且有 HTTPUpstream使用 DoWithTLS
if opts.TLSMode != service.TLSModeOff && s.httpUpstream != nil {
resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, opts.TLSMode, opts.TLSProfile)
// 如果有 TLS Profile 且有 HTTPUpstream使用 DoWithTLS
if opts.TLSProfile != nil && s.httpUpstream != nil {
resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, opts.TLSProfile)
if err != nil {
return nil, fmt.Errorf("request with TLS fingerprint failed: %w", err)
}

View File

@ -63,8 +63,6 @@ func (c *geminiOAuthClient) ExchangeCode(ctx context.Context, oauthType, code, c
resp, err := client.R().
SetContext(ctx).
SetFormDataFromValues(formData).
SetHeader("User-Agent", geminicli.GetGeminiCLITokenExchangeUA()).
SetHeader("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient()).
SetSuccessResult(&tokenResp).
Post(c.tokenURL)
if err != nil {
@ -108,8 +106,6 @@ func (c *geminiOAuthClient) RefreshToken(ctx context.Context, oauthType, refresh
resp, err := client.R().
SetContext(ctx).
SetFormDataFromValues(formData).
SetHeader("User-Agent", geminicli.GetGeminiCLITokenExchangeUA()).
SetHeader("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient()).
SetSuccessResult(&tokenResp).
Post(c.tokenURL)
if err != nil {

View File

@ -34,8 +34,7 @@ func (c *geminiCliCodeAssistClient) LoadCodeAssist(ctx context.Context, accessTo
SetContext(ctx).
SetHeader("Authorization", "Bearer "+accessToken).
SetHeader("Content-Type", "application/json").
SetHeader("User-Agent", geminicli.GetGeminiCLIUserAgent()).
SetHeader("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient()).
SetHeader("User-Agent", geminicli.GeminiCLIUserAgent).
SetBody(reqBody).
SetSuccessResult(&out).
Post(c.baseURL + "/v1internal:loadCodeAssist")
@ -79,8 +78,7 @@ func (c *geminiCliCodeAssistClient) OnboardUser(ctx context.Context, accessToken
SetContext(ctx).
SetHeader("Authorization", "Bearer "+accessToken).
SetHeader("Content-Type", "application/json").
SetHeader("User-Agent", geminicli.GetGeminiCLIUserAgent()).
SetHeader("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient()).
SetHeader("User-Agent", geminicli.GeminiCLIUserAgent).
SetBody(reqBody).
SetSuccessResult(&out).
Post(c.baseURL + "/v1internal:onboardUser")
@ -118,7 +116,7 @@ func createGeminiCliReqClient(proxyURL string) (*req.Client, error) {
func defaultLoadCodeAssistRequest() *geminicli.LoadCodeAssistRequest {
return &geminicli.LoadCodeAssistRequest{
Metadata: geminicli.LoadCodeAssistMetadata{
IDEType: "IDE_UNSPECIFIED",
IDEType: "ANTIGRAVITY",
Platform: "PLATFORM_UNSPECIFIED",
PluginType: "GEMINI",
},
@ -129,7 +127,7 @@ func defaultOnboardUserRequest() *geminicli.OnboardUserRequest {
return &geminicli.OnboardUserRequest{
TierID: "LEGACY",
Metadata: geminicli.LoadCodeAssistMetadata{
IDEType: "IDE_UNSPECIFIED",
IDEType: "ANTIGRAVITY",
Platform: "PLATFORM_UNSPECIFIED",
PluginType: "GEMINI",
},

View File

@ -16,6 +16,7 @@ import (
"time"
"github.com/andybalholm/brotli"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
@ -127,16 +128,6 @@ func NewHTTPUpstream(cfg *config.Config) service.HTTPUpstream {
// - 调用方必须关闭 resp.Body否则会导致 inFlight 计数泄漏
// - inFlight > 0 的客户端不会被淘汰,确保活跃请求不被中断
func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
// Node.js TLS 代理:仅 Anthropic API
// Antigravity (googleapis) 使用 Go 原生 TLS更接近真实 BoringCrypto 指纹)
// proxyURL 通过 X-Upstream-Proxy header 传递给 node-tls-proxy 动态选择出口
if s.isNodeTLSProxyEnabled() && req != nil && req.URL != nil && req.URL.Scheme == "https" {
host := req.URL.Hostname()
if host == "api.anthropic.com" {
return s.doViaNodeTLSProxy(req, proxyURL, accountID, accountConcurrency)
}
}
if err := s.validateRequestHost(req); err != nil {
return nil, err
}
@ -156,6 +147,9 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
return nil, err
}
// 如果上游返回了压缩内容,解压后再交给业务层
decompressResponseBody(resp)
// 包装响应体,在关闭时自动减少计数并更新时间戳
// 这确保了流式响应(如 SSE在完全读取前不会被淘汰
resp.Body = wrapTrackedBody(resp.Body, func() {
@ -168,63 +162,51 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
//
// mode 决定指纹策略:
// - TLSModeOff / "": 不启用,行为与 Do 相同
// - TLSModeNode: 走本地 Node.js TLS 代理(需 gateway.node_tls_proxy.enabled=true
// - TLSModeUTLS: 用 profile 模拟 TLS ClientHelloprofile 为 nil 时降级为 Off
func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, mode service.TLSMode, profile *tlsfingerprint.Profile) (*http.Response, error) {
switch mode {
case service.TLSModeNode:
if s.isNodeTLSProxyEnabled() && req != nil && req.URL != nil && s.shouldRouteViaNodeProxy(req) {
return s.doViaNodeTLSProxy(req, proxyURL, accountID, accountConcurrency)
}
return s.Do(req, proxyURL, accountID, accountConcurrency)
case service.TLSModeUTLS:
if profile == nil {
return s.Do(req, proxyURL, accountID, accountConcurrency)
}
targetHost := ""
if req != nil && req.URL != nil {
targetHost = req.URL.Host
}
proxyInfo := "direct"
if proxyURL != "" {
proxyInfo = proxyURL
}
slog.Debug("tls_fingerprint_utls", "account_id", accountID, "target", targetHost, "proxy", proxyInfo, "profile", profile.Name)
if err := s.validateRequestHost(req); err != nil {
return nil, err
}
entry, err := s.acquireClientWithTLS(proxyURL, accountID, accountConcurrency, profile)
if err != nil {
slog.Debug("tls_fingerprint_acquire_client_failed", "account_id", accountID, "error", err)
return nil, err
}
resp, err := entry.client.Do(req)
if err != nil {
atomic.AddInt64(&entry.inFlight, -1)
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
slog.Debug("tls_fingerprint_request_failed", "account_id", accountID, "error", err)
return nil, err
}
slog.Debug("tls_fingerprint_request_success", "account_id", accountID, "status", resp.StatusCode)
decompressResponseBody(resp)
resp.Body = wrapTrackedBody(resp.Body, func() {
atomic.AddInt64(&entry.inFlight, -1)
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
})
return resp, nil
default: // TLSModeOff 或空字符串
// profile 为 nil 时不启用 TLS 指纹,行为与 Do 方法相同。
// profile 非 nil 时使用指定的 Profile 进行 TLS 指纹伪装。
func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
if profile == nil {
return s.Do(req, proxyURL, accountID, accountConcurrency)
}
targetHost := ""
if req != nil && req.URL != nil {
targetHost = req.URL.Host
}
proxyInfo := "direct"
if proxyURL != "" {
proxyInfo = proxyURL
}
slog.Debug("tls_fingerprint_enabled", "account_id", accountID, "target", targetHost, "proxy", proxyInfo, "profile", profile.Name)
if err := s.validateRequestHost(req); err != nil {
return nil, err
}
entry, err := s.acquireClientWithTLS(proxyURL, accountID, accountConcurrency, profile)
if err != nil {
slog.Debug("tls_fingerprint_acquire_client_failed", "account_id", accountID, "error", err)
return nil, err
}
resp, err := entry.client.Do(req)
if err != nil {
atomic.AddInt64(&entry.inFlight, -1)
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
slog.Debug("tls_fingerprint_request_failed", "account_id", accountID, "error", err)
return nil, err
}
decompressResponseBody(resp)
resp.Body = wrapTrackedBody(resp.Body, func() {
atomic.AddInt64(&entry.inFlight, -1)
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
})
return resp, nil
}
// acquireClientWithTLS 获取或创建带 TLS 指纹的客户端
func (s *httpUpstreamService) acquireClientWithTLS(proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*upstreamClientEntry, error) {
return s.getClientEntryWithTLS(proxyURL, accountID, accountConcurrency, profile, true, true)
@ -883,35 +865,55 @@ func wrapTrackedBody(body io.ReadCloser, onClose func()) io.ReadCloser {
return &trackedBody{ReadCloser: body, onClose: onClose}
}
// decompressResponseBody 根据 Content-Encoding 对响应体进行解压
// 支持 gzip、brbrotli、deflate解压后更新响应头以反映明文内容
// decompressResponseBody 根据 Content-Encoding 解压响应体。
// 当请求显式设置了 accept-encoding 时Go 的 Transport 不会自动解压,需要手动处理。
// 解压成功后会删除 Content-Encoding 和 Content-Length header长度已不准确
func decompressResponseBody(resp *http.Response) {
if resp == nil || resp.Body == nil {
return
}
enc := strings.ToLower(resp.Header.Get("Content-Encoding"))
switch enc {
ce := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Encoding")))
if ce == "" {
return
}
var reader io.Reader
switch ce {
case "gzip":
gr, err := gzip.NewReader(resp.Body)
if err != nil {
return
return // 解压失败,保持原样
}
resp.Body = io.NopCloser(gr)
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Length")
resp.ContentLength = -1
resp.Uncompressed = true
reader = gr
case "br":
resp.Body = io.NopCloser(brotli.NewReader(resp.Body))
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Length")
resp.ContentLength = -1
resp.Uncompressed = true
reader = brotli.NewReader(resp.Body)
case "deflate":
resp.Body = io.NopCloser(flate.NewReader(resp.Body))
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Length")
resp.ContentLength = -1
resp.Uncompressed = true
reader = flate.NewReader(resp.Body)
default:
return
}
originalBody := resp.Body
resp.Body = &decompressedBody{reader: reader, closer: originalBody}
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Length") // 解压后长度不确定
resp.ContentLength = -1
}
// decompressedBody 组合解压 reader 和原始 body 的 close。
type decompressedBody struct {
reader io.Reader
closer io.Closer
}
func (d *decompressedBody) Read(p []byte) (int, error) {
return d.reader.Read(p)
}
func (d *decompressedBody) Close() error {
// 如果 reader 本身也是 Closer如 gzip.Reader先关闭它
if rc, ok := d.reader.(io.Closer); ok {
_ = rc.Close()
}
return d.closer.Close()
}

View File

@ -1,100 +0,0 @@
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)
// 如果账号绑定了代理(落地机 GOST通过 header 传递给 node-tls-proxy
// node-tls-proxy 会用此代理作为上游出口,实现动态路由
if proxyURL != "" {
proxyReq.Header.Set("X-Upstream-Proxy", proxyURL)
}
// 重写请求 URLhttps://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)
}

View File

@ -0,0 +1,55 @@
package repository
import (
"context"
"fmt"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
)
const (
internal500CounterPrefix = "internal500_count:account:"
internal500CounterTTLSeconds = 86400 // 24 小时兜底
)
// internal500CounterIncrScript 使用 Lua 脚本原子性地增加计数并返回当前值
// 如果 key 不存在,则创建并设置过期时间
var internal500CounterIncrScript = redis.NewScript(`
local key = KEYS[1]
local ttl = tonumber(ARGV[1])
local count = redis.call('INCR', key)
if count == 1 then
redis.call('EXPIRE', key, ttl)
end
return count
`)
type internal500CounterCache struct {
rdb *redis.Client
}
// NewInternal500CounterCache 创建 INTERNAL 500 连续失败计数器缓存实例
func NewInternal500CounterCache(rdb *redis.Client) service.Internal500CounterCache {
return &internal500CounterCache{rdb: rdb}
}
// IncrementInternal500Count 原子递增计数并返回当前值
func (c *internal500CounterCache) IncrementInternal500Count(ctx context.Context, accountID int64) (int64, error) {
key := fmt.Sprintf("%s%d", internal500CounterPrefix, accountID)
result, err := internal500CounterIncrScript.Run(ctx, c.rdb, []string{key}, internal500CounterTTLSeconds).Int64()
if err != nil {
return 0, fmt.Errorf("increment internal500 count: %w", err)
}
return result, nil
}
// ResetInternal500Count 清零计数器(成功响应时调用)
func (c *internal500CounterCache) ResetInternal500Count(ctx context.Context, accountID int64) error {
key := fmt.Sprintf("%s%d", internal500CounterPrefix, accountID)
return c.rdb.Del(ctx, key).Err()
}

View File

@ -81,6 +81,7 @@ var ProviderSet = wire.NewSet(
NewAPIKeyCache,
NewTempUnschedCache,
NewTimeoutCounterCache,
NewInternal500CounterCache,
ProvideConcurrencyCache,
ProvideSessionLimitCache,
NewRPMCache,

View File

@ -540,6 +540,8 @@ func TestAPIContracts(t *testing.T) {
"max_claude_code_version": "",
"allow_ungrouped_key_scheduling": false,
"backend_mode_enabled": false,
"enable_fingerprint_unification": true,
"enable_metadata_passthrough": false,
"custom_menu_items": [],
"custom_endpoints": []
}

View File

@ -87,9 +87,6 @@ func RegisterAdminRoutes(
// 定时测试计划
registerScheduledTestRoutes(admin, h)
// 风控中心
registerRiskRoutes(admin, h)
}
}
@ -263,6 +260,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts.POST("/:id/test", h.Admin.Account.Test)
accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState)
accounts.POST("/:id/refresh", h.Admin.Account.Refresh)
accounts.POST("/:id/set-privacy", h.Admin.Account.SetPrivacy)
accounts.POST("/:id/refresh-tier", h.Admin.Account.RefreshTier)
accounts.GET("/:id/stats", h.Admin.Account.GetStats)
accounts.POST("/:id/clear-error", h.Admin.Account.ClearError)
@ -569,15 +567,3 @@ func registerTLSFingerprintProfileRoutes(admin *gin.RouterGroup, h *handler.Hand
profiles.DELETE("/:id", h.Admin.TLSFingerprintProfile.Delete)
}
}
func registerRiskRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
risk := admin.Group("/risk")
{
risk.GET("/summary", h.Admin.Risk.GetSummary)
risk.GET("/accounts", h.Admin.Risk.ListAccounts)
risk.GET("/accounts/:id", h.Admin.Risk.GetAccountDetail)
risk.PUT("/accounts/:id/override", h.Admin.Risk.OverrideRiskLevel)
risk.GET("/settings", h.Admin.Risk.GetSettings)
risk.PUT("/settings", h.Admin.Risk.UpdateSettings)
}
}

View File

@ -1147,23 +1147,22 @@ func (a *Account) IsAnthropicOAuthOrSetupToken() bool {
}
// IsTLSFingerprintEnabled 检查是否启用 TLS 指纹伪装
// 支持 Anthropic OAuth/SetupToken 和 Gemini OAuth 账号(扩展见 account_antigravity.go
// 启用后将通过 node-tls-proxy 路由流量,获得真实 Node.js TLS 握手特征
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
// 启用后将模拟 Claude Code (Node.js) 客户端的 TLS 握手特征
func (a *Account) IsTLSFingerprintEnabled() bool {
// 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
}
}
// 仅支持 Anthropic OAuth/SetupToken 账号
if !a.IsAnthropicOAuthOrSetupToken() {
return false
}
// Gemini OAuth — 扩展(实现在 account_antigravity.go
return geminiTLSFingerprintEnabled(a)
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
}
// GetTLSFingerprintProfileID 获取账号绑定的 TLS 指纹模板 ID
@ -1191,24 +1190,6 @@ func (a *Account) GetTLSFingerprintProfileID() int64 {
return 0
}
// GetTLSMode 获取账号配置的 TLS 指纹模式
// 返回值: TLSModeNode / TLSModeUTLS / TLSModeOff
// 存储在 Extra["tls_mode"],未设置时:支持指纹的账号默认 node其余 off
func (a *Account) GetTLSMode() TLSMode {
if a.Extra != nil {
if m, ok := a.Extra["tls_mode"].(string); ok && m != "" {
switch TLSMode(m) {
case TLSModeNode, TLSModeUTLS, TLSModeOff:
return TLSMode(m)
}
}
}
if a.IsTLSFingerprintEnabled() {
return TLSModeNode
}
return TLSModeOff
}
// GetUserMsgQueueMode 获取用户消息队列模式
// "serialize" = 串行队列, "throttle" = 软性限速, "" = 未设置(使用全局配置)
func (a *Account) GetUserMsgQueueMode() string {
@ -1248,6 +1229,28 @@ func (a *Account) IsSessionIDMaskingEnabled() bool {
return false
}
// IsCustomBaseURLEnabled 检查是否启用自定义 base URL 中继转发
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
func (a *Account) IsCustomBaseURLEnabled() bool {
if !a.IsAnthropicOAuthOrSetupToken() {
return false
}
if a.Extra == nil {
return false
}
if v, ok := a.Extra["custom_base_url_enabled"]; ok {
if enabled, ok := v.(bool); ok {
return enabled
}
}
return false
}
// GetCustomBaseURL 返回自定义中继服务的 base URL
func (a *Account) GetCustomBaseURL() string {
return a.GetExtraString("custom_base_url")
}
// IsCacheTTLOverrideEnabled 检查是否启用缓存 TTL 强制替换
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
// 启用后将所有 cache creation tokens 归入指定的 TTL 类型5m 或 1h

View File

@ -1,30 +0,0 @@
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
}

View File

@ -304,7 +304,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
}
@ -394,7 +394,7 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, TLSModeOff, nil)
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, nil)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
}
@ -524,7 +524,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
}
@ -614,7 +614,7 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
}
@ -887,7 +887,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
}
soraTLSProfile := s.resolveSoraTLSProfile()
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), soraTLSProfile)
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, soraTLSProfile)
if err != nil {
recorder.addStep("me", "failed", 0, "network_error", err.Error())
s.emitSoraProbeSummary(c, recorder)
@ -952,7 +952,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
subReq.Header.Set("Origin", "https://sora.chatgpt.com")
subReq.Header.Set("Referer", "https://sora.chatgpt.com/")
subResp, subErr := s.httpUpstream.DoWithTLS(subReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), soraTLSProfile)
subResp, subErr := s.httpUpstream.DoWithTLS(subReq, proxyURL, account.ID, account.Concurrency, soraTLSProfile)
if subErr != nil {
recorder.addStep("subscription", "failed", 0, "network_error", subErr.Error())
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Subscription check skipped: %s", subErr.Error())})
@ -1139,7 +1139,7 @@ func (s *AccountTestService) fetchSoraTestEndpoint(
req.Header.Set("Origin", "https://sora.chatgpt.com")
req.Header.Set("Referer", "https://sora.chatgpt.com/")
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), tlsProfile)
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, tlsProfile)
if err != nil {
return 0, nil, nil, err
}
@ -1469,8 +1469,7 @@ func (s *AccountTestService) buildCodeAssistRequest(ctx context.Context, accessT
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent())
req.Header.Set("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient())
req.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent)
return req, nil
}

View File

@ -245,7 +245,6 @@ type ClaudeUsageFetchOptions struct {
AccessToken string // OAuth access token
ProxyURL string // 代理 URL可选
AccountID int64 // 账号 ID用于连接池隔离
TLSMode TLSMode // TLS 模式off/node/utls
TLSProfile *tlsfingerprint.Profile // TLS 指纹 Profilenil 表示不启用)
Fingerprint *Fingerprint // 缓存的指纹信息User-Agent 等)
}
@ -1163,7 +1162,6 @@ func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *A
AccessToken: accessToken,
ProxyURL: proxyURL,
AccountID: account.ID,
TLSMode: account.GetTLSMode(),
TLSProfile: s.tlsFPProfileService.ResolveTLSProfile(account),
}

View File

@ -1866,6 +1866,18 @@ func (s *adminServiceImpl) ClearAccountError(ctx context.Context, id int64) (*Ac
if err := s.accountRepo.ClearError(ctx, id); err != nil {
return nil, err
}
if err := s.accountRepo.ClearRateLimit(ctx, id); err != nil {
return nil, err
}
if err := s.accountRepo.ClearAntigravityQuotaScopes(ctx, id); err != nil {
return nil, err
}
if err := s.accountRepo.ClearModelRateLimits(ctx, id); err != nil {
return nil, err
}
if err := s.accountRepo.ClearTempUnschedulable(ctx, id); err != nil {
return nil, err
}
return s.accountRepo.GetByID(ctx, id)
}
@ -2641,10 +2653,8 @@ func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Acc
if s.privacyClientFactory == nil {
return ""
}
if account.Extra != nil {
if _, ok := account.Extra["privacy_mode"]; ok {
return ""
}
if shouldSkipOpenAIPrivacyEnsure(account.Extra) {
return ""
}
token, _ := account.Credentials["access_token"].(string)
@ -2707,10 +2717,13 @@ func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Acco
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。
// 如果 Extra["privacy_mode"] 已存在(无论成功或失败),直接跳过。
// 仅对从未设置过隐私的账号执行 setUserSettings + fetchUserInfo 流程。
// 用户可通过前端 ForceAntigravityPrivacySetPrivacy 按钮)强制重新设置。
func (s *adminServiceImpl) EnsureAntigravityPrivacy(ctx context.Context, account *Account) string {
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
return ""
}
// 已设置过则跳过(无论成功或失败),用户可通过 Force 手动重试
if account.Extra != nil {
if existing, ok := account.Extra["privacy_mode"].(string); ok && existing != "" {
return existing

View File

@ -0,0 +1,86 @@
//go:build unit
package service
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type accountRepoStubForClearAccountError struct {
mockAccountRepoForGemini
account *Account
clearErrorCalls int
clearRateLimitCalls int
clearAntigravityCalls int
clearModelRateLimitCalls int
clearTempUnschedCalls int
}
func (r *accountRepoStubForClearAccountError) GetByID(ctx context.Context, id int64) (*Account, error) {
return r.account, nil
}
func (r *accountRepoStubForClearAccountError) ClearError(ctx context.Context, id int64) error {
r.clearErrorCalls++
r.account.Status = StatusActive
r.account.ErrorMessage = ""
return nil
}
func (r *accountRepoStubForClearAccountError) ClearRateLimit(ctx context.Context, id int64) error {
r.clearRateLimitCalls++
r.account.RateLimitedAt = nil
r.account.RateLimitResetAt = nil
return nil
}
func (r *accountRepoStubForClearAccountError) ClearAntigravityQuotaScopes(ctx context.Context, id int64) error {
r.clearAntigravityCalls++
return nil
}
func (r *accountRepoStubForClearAccountError) ClearModelRateLimits(ctx context.Context, id int64) error {
r.clearModelRateLimitCalls++
return nil
}
func (r *accountRepoStubForClearAccountError) ClearTempUnschedulable(ctx context.Context, id int64) error {
r.clearTempUnschedCalls++
r.account.TempUnschedulableUntil = nil
r.account.TempUnschedulableReason = ""
return nil
}
func TestAdminService_ClearAccountError_AlsoClearsRecoverableRuntimeState(t *testing.T) {
until := time.Now().Add(10 * time.Minute)
resetAt := time.Now().Add(5 * time.Minute)
repo := &accountRepoStubForClearAccountError{
account: &Account{
ID: 31,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusError,
ErrorMessage: "refresh failed",
RateLimitResetAt: &resetAt,
TempUnschedulableUntil: &until,
TempUnschedulableReason: "missing refresh token",
},
}
svc := &adminServiceImpl{accountRepo: repo}
updated, err := svc.ClearAccountError(context.Background(), 31)
require.NoError(t, err)
require.NotNil(t, updated)
require.Equal(t, 1, repo.clearErrorCalls)
require.Equal(t, 1, repo.clearRateLimitCalls)
require.Equal(t, 1, repo.clearAntigravityCalls)
require.Equal(t, 1, repo.clearModelRateLimitCalls)
require.Equal(t, 1, repo.clearTempUnschedCalls)
require.Nil(t, updated.RateLimitResetAt)
require.Nil(t, updated.TempUnschedulableUntil)
require.Empty(t, updated.TempUnschedulableReason)
}

View File

@ -614,6 +614,7 @@ func (s *AntigravityGatewayService) antigravityRetryLoop(p antigravityRetryLoopP
urlFallbackLoop:
for urlIdx, baseURL := range availableURLs {
usedBaseURL = baseURL
allAttemptsInternal500 := true // 追踪本轮所有 attempt 是否全部命中 INTERNAL 500
for attempt := 1; attempt <= antigravityMaxRetries; attempt++ {
select {
case <-p.ctx.Done():
@ -766,10 +767,19 @@ urlFallbackLoop:
logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled_during_backoff", p.prefix)
return nil, p.ctx.Err()
}
// 追踪 INTERNAL 500非匹配的 attempt 清除标记
if !isAntigravityInternalServerError(resp.StatusCode, respBody) {
allAttemptsInternal500 = false
}
continue
}
}
// INTERNAL 500 渐进惩罚3 次重试全部命中特定 500 时递增计数器并惩罚
if allAttemptsInternal500 && isAntigravityInternalServerError(resp.StatusCode, respBody) {
s.handleInternal500RetryExhausted(p.ctx, p.prefix, p.account)
}
// 其他 4xx 错误或重试用尽,直接返回
resp = &http.Response{
StatusCode: resp.StatusCode,
@ -788,6 +798,11 @@ urlFallbackLoop:
antigravity.DefaultURLAvailability.MarkSuccess(usedBaseURL)
}
// 成功响应时清零 INTERNAL 500 连续失败计数器(覆盖所有成功路径,含 smart retry
if resp != nil && resp.StatusCode < 400 {
s.resetInternal500Counter(p.ctx, p.prefix, p.account.ID)
}
return &antigravityRetryLoopResult{resp: resp}, nil
}
@ -862,7 +877,7 @@ type AntigravityGatewayService struct {
settingService *SettingService
cache GatewayCache // 用于模型级限流时清除粘性会话绑定
schedulerSnapshot *SchedulerSnapshotService
heartbeat *AntigravityHeartbeat
internal500Cache Internal500CounterCache // INTERNAL 500 渐进惩罚计数器
}
func NewAntigravityGatewayService(
@ -873,6 +888,7 @@ func NewAntigravityGatewayService(
rateLimitService *RateLimitService,
httpUpstream HTTPUpstream,
settingService *SettingService,
internal500Cache Internal500CounterCache,
) *AntigravityGatewayService {
return &AntigravityGatewayService{
accountRepo: accountRepo,
@ -882,7 +898,7 @@ func NewAntigravityGatewayService(
settingService: settingService,
cache: cache,
schedulerSnapshot: schedulerSnapshot,
heartbeat: NewAntigravityHeartbeat(),
internal500Cache: internal500Cache,
}
}
@ -1379,11 +1395,6 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
proxyURL = account.Proxy.URL()
}
// 注册心跳(首次 API 调用时自动注册,后续更新 token
if s.heartbeat != nil && projectID != "" {
s.heartbeat.Register(account.ID, accessToken, projectID, proxyURL)
}
// 获取转换选项
// Antigravity 上游要求必须包含身份提示词,否则会返回 429
transformOpts := s.getClaudeTransformOptions(ctx)
@ -3572,7 +3583,11 @@ func mergeTextPartsToResponse(response map[string]any, textParts []string) map[s
}
func (s *AntigravityGatewayService) writeClaudeError(c *gin.Context, status int, errType, message string) error {
return WriteClaudeErrorResponse(c, status, errType, message)
c.JSON(status, gin.H{
"type": "error",
"error": gin.H{"type": errType, "message": message},
})
return fmt.Errorf("%s", message)
}
// WriteMappedClaudeError 导出版本,供 handler 层使用(如 fallback 错误处理)
@ -3658,7 +3673,28 @@ func (s *AntigravityGatewayService) writeMappedClaudeError(c *gin.Context, accou
}
func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int, message string) error {
return WriteGoogleErrorResponse(c, status, message)
statusStr := "UNKNOWN"
switch status {
case 400:
statusStr = "INVALID_ARGUMENT"
case 404:
statusStr = "NOT_FOUND"
case 429:
statusStr = "RESOURCE_EXHAUSTED"
case 500:
statusStr = "INTERNAL"
case 502, 503:
statusStr = "UNAVAILABLE"
}
c.JSON(status, gin.H{
"error": gin.H{
"code": status,
"message": message,
"status": statusStr,
},
})
return fmt.Errorf("%s", message)
}
// handleClaudeStreamToNonStreaming 收集上游流式响应,转换为 Claude 非流式格式返回

View File

@ -1,204 +0,0 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
)
// AntigravityHeartbeat 模拟真实 Antigravity IDE 的心跳行为
// 真实 IDE 每 5 分钟发送 loadCodeAssist + fetchAvailableModels
type AntigravityHeartbeat struct {
mu sync.Mutex
sessions map[int64]*heartbeatSession // accountID -> session
stopCh chan struct{}
}
type heartbeatSession struct {
accountID int64
accessToken string
projectID string
proxyURL string
lastBeat time.Time
}
// NewAntigravityHeartbeat 创建心跳管理器
func NewAntigravityHeartbeat() *AntigravityHeartbeat {
hb := &AntigravityHeartbeat{
sessions: make(map[int64]*heartbeatSession),
stopCh: make(chan struct{}),
}
go hb.loop()
return hb
}
// Register 注册账号心跳(首次 API 调用时调用)
func (h *AntigravityHeartbeat) Register(accountID int64, accessToken, projectID, proxyURL string) {
h.mu.Lock()
defer h.mu.Unlock()
if _, exists := h.sessions[accountID]; exists {
// 更新 token可能已刷新
h.sessions[accountID].accessToken = accessToken
return
}
h.sessions[accountID] = &heartbeatSession{
accountID: accountID,
accessToken: accessToken,
projectID: projectID,
proxyURL: proxyURL,
lastBeat: time.Now(),
}
log.Printf("[antigravity-heartbeat] registered account %d (project: %s)", accountID, projectID)
}
// UpdateToken 更新账号的 access tokentoken 刷新后调用)
func (h *AntigravityHeartbeat) UpdateToken(accountID int64, accessToken string) {
h.mu.Lock()
defer h.mu.Unlock()
if s, ok := h.sessions[accountID]; ok {
s.accessToken = accessToken
}
}
// Unregister 移除账号心跳
func (h *AntigravityHeartbeat) Unregister(accountID int64) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.sessions, accountID)
}
// Stop 停止心跳
func (h *AntigravityHeartbeat) Stop() {
select {
case <-h.stopCh:
default:
close(h.stopCh)
}
}
func (h *AntigravityHeartbeat) loop() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-h.stopCh:
return
case <-ticker.C:
h.tick()
}
}
}
func (h *AntigravityHeartbeat) tick() {
h.mu.Lock()
// 收集需要心跳的 session
var toSend []*heartbeatSession
now := time.Now()
for _, s := range h.sessions {
if now.Sub(s.lastBeat) >= 5*time.Minute {
s.lastBeat = now
// 复制一份避免持锁时发请求
cp := *s
toSend = append(toSend, &cp)
}
}
h.mu.Unlock()
for _, s := range toSend {
go h.sendHeartbeat(s)
}
}
func (h *AntigravityHeartbeat) sendHeartbeat(s *heartbeatSession) {
client, err := antigravity.NewClient(s.proxyURL)
if err != nil {
log.Printf("[antigravity-heartbeat] account %d: client error: %v", s.accountID, err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// 1. loadCodeAssist
h.doLoadCodeAssist(ctx, client, s)
// 模拟真实 IDE 的延迟(~500ms
time.Sleep(time.Duration(400+rand.Intn(200)) * time.Millisecond)
// 2. fetchAvailableModels
h.doFetchAvailableModels(ctx, client, s)
}
func (h *AntigravityHeartbeat) doLoadCodeAssist(ctx context.Context, client *antigravity.Client, s *heartbeatSession) {
reqBody := map[string]any{
"metadata": map[string]string{
"ideType": "ANTIGRAVITY",
},
}
body, _ := json.Marshal(reqBody)
for _, baseURL := range antigravity.BaseURLs {
apiURL := fmt.Sprintf("%s/v1internal:loadCodeAssist", baseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.accessToken)
req.Header.Set("User-Agent", antigravity.GetUserAgent())
req.Header.Set("X-Goog-Api-Client", antigravity.GetGoogAPIClient())
resp, err := client.DoRaw(req)
if err != nil {
continue
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return
}
}
}
func (h *AntigravityHeartbeat) doFetchAvailableModels(ctx context.Context, client *antigravity.Client, s *heartbeatSession) {
reqBody := map[string]string{
"project": s.projectID,
}
body, _ := json.Marshal(reqBody)
for _, baseURL := range antigravity.BaseURLs {
apiURL := fmt.Sprintf("%s/v1internal:fetchAvailableModels", baseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.accessToken)
req.Header.Set("User-Agent", antigravity.GetUserAgent())
req.Header.Set("X-Goog-Api-Client", antigravity.GetGoogAPIClient())
resp, err := client.DoRaw(req)
if err != nil {
continue
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return
}
}
}

View File

@ -0,0 +1,97 @@
package service
import (
"context"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/tidwall/gjson"
)
// INTERNAL 500 渐进惩罚:连续多轮全部返回特定 500 错误时的惩罚时长
const (
internal500PenaltyTier1Duration = 30 * time.Minute // 第 1 轮:临时不可调度 30 分钟
internal500PenaltyTier2Duration = 2 * time.Hour // 第 2 轮:临时不可调度 2 小时
internal500PenaltyTier3Threshold = 3 // 第 3+ 轮:永久禁用
)
// isAntigravityInternalServerError 检测特定的 INTERNAL 500 错误
// 必须同时匹配 error.code==500, error.message=="Internal error encountered.", error.status=="INTERNAL"
func isAntigravityInternalServerError(statusCode int, body []byte) bool {
if statusCode != http.StatusInternalServerError {
return false
}
return gjson.GetBytes(body, "error.code").Int() == 500 &&
gjson.GetBytes(body, "error.message").String() == "Internal error encountered." &&
gjson.GetBytes(body, "error.status").String() == "INTERNAL"
}
// applyInternal500Penalty 根据连续 INTERNAL 500 轮次数应用渐进惩罚
// count=1: temp_unschedulable 10 分钟
// count=2: temp_unschedulable 10 小时
// count>=3: SetError 永久禁用
func (s *AntigravityGatewayService) applyInternal500Penalty(
ctx context.Context, prefix string, account *Account, count int64,
) {
switch {
case count >= int64(internal500PenaltyTier3Threshold):
reason := fmt.Sprintf("INTERNAL 500 consecutive failures: %d rounds", count)
if err := s.accountRepo.SetError(ctx, account.ID, reason); err != nil {
slog.Error("internal500_set_error_failed", "account_id", account.ID, "error", err)
return
}
slog.Warn("internal500_account_disabled",
"account_id", account.ID, "account_name", account.Name, "consecutive_count", count)
case count == 2:
until := time.Now().Add(internal500PenaltyTier2Duration)
reason := fmt.Sprintf("INTERNAL 500 x%d (temp unsched %v)", count, internal500PenaltyTier2Duration)
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil {
slog.Error("internal500_temp_unsched_failed", "account_id", account.ID, "error", err)
return
}
slog.Warn("internal500_temp_unschedulable",
"account_id", account.ID, "account_name", account.Name,
"duration", internal500PenaltyTier2Duration, "consecutive_count", count)
case count == 1:
until := time.Now().Add(internal500PenaltyTier1Duration)
reason := fmt.Sprintf("INTERNAL 500 x%d (temp unsched %v)", count, internal500PenaltyTier1Duration)
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil {
slog.Error("internal500_temp_unsched_failed", "account_id", account.ID, "error", err)
return
}
slog.Info("internal500_temp_unschedulable",
"account_id", account.ID, "account_name", account.Name,
"duration", internal500PenaltyTier1Duration, "consecutive_count", count)
}
}
// handleInternal500RetryExhausted 处理 INTERNAL 500 重试耗尽:递增计数器并应用惩罚
func (s *AntigravityGatewayService) handleInternal500RetryExhausted(
ctx context.Context, prefix string, account *Account,
) {
if s.internal500Cache == nil {
return
}
count, err := s.internal500Cache.IncrementInternal500Count(ctx, account.ID)
if err != nil {
slog.Error("internal500_counter_increment_failed",
"prefix", prefix, "account_id", account.ID, "error", err)
return
}
s.applyInternal500Penalty(ctx, prefix, account, count)
}
// resetInternal500Counter 成功响应时清零 INTERNAL 500 计数器
func (s *AntigravityGatewayService) resetInternal500Counter(
ctx context.Context, prefix string, accountID int64,
) {
if s.internal500Cache == nil {
return
}
if err := s.internal500Cache.ResetInternal500Count(ctx, accountID); err != nil {
slog.Error("internal500_counter_reset_failed",
"prefix", prefix, "account_id", accountID, "error", err)
}
}

View File

@ -0,0 +1,321 @@
//go:build unit
package service
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// --- mock: Internal500CounterCache ---
type mockInternal500Cache struct {
incrementCount int64
incrementErr error
resetErr error
incrementCalls []int64 // 记录 IncrementInternal500Count 被调用时的 accountID
resetCalls []int64 // 记录 ResetInternal500Count 被调用时的 accountID
}
func (m *mockInternal500Cache) IncrementInternal500Count(_ context.Context, accountID int64) (int64, error) {
m.incrementCalls = append(m.incrementCalls, accountID)
return m.incrementCount, m.incrementErr
}
func (m *mockInternal500Cache) ResetInternal500Count(_ context.Context, accountID int64) error {
m.resetCalls = append(m.resetCalls, accountID)
return m.resetErr
}
// --- mock: 专用于 internal500 惩罚测试的 AccountRepository ---
type internal500AccountRepoStub struct {
AccountRepository // 嵌入接口,未实现的方法会 panic不应被调用
tempUnschedCalls []tempUnschedCall
setErrorCalls []setErrorCall
}
type tempUnschedCall struct {
accountID int64
until time.Time
reason string
}
type setErrorCall struct {
accountID int64
reason string
}
func (r *internal500AccountRepoStub) SetTempUnschedulable(_ context.Context, id int64, until time.Time, reason string) error {
r.tempUnschedCalls = append(r.tempUnschedCalls, tempUnschedCall{accountID: id, until: until, reason: reason})
return nil
}
func (r *internal500AccountRepoStub) SetError(_ context.Context, id int64, errorMsg string) error {
r.setErrorCalls = append(r.setErrorCalls, setErrorCall{accountID: id, reason: errorMsg})
return nil
}
// =============================================================================
// TestIsAntigravityInternalServerError
// =============================================================================
func TestIsAntigravityInternalServerError(t *testing.T) {
t.Run("匹配完整的 INTERNAL 500 body", func(t *testing.T) {
body := []byte(`{"error":{"code":500,"message":"Internal error encountered.","status":"INTERNAL"}}`)
require.True(t, isAntigravityInternalServerError(500, body))
})
t.Run("statusCode 不是 500", func(t *testing.T) {
body := []byte(`{"error":{"code":500,"message":"Internal error encountered.","status":"INTERNAL"}}`)
require.False(t, isAntigravityInternalServerError(429, body))
require.False(t, isAntigravityInternalServerError(503, body))
require.False(t, isAntigravityInternalServerError(200, body))
})
t.Run("body 中 message 不匹配", func(t *testing.T) {
body := []byte(`{"error":{"code":500,"message":"Some other error","status":"INTERNAL"}}`)
require.False(t, isAntigravityInternalServerError(500, body))
})
t.Run("body 中 status 不匹配", func(t *testing.T) {
body := []byte(`{"error":{"code":500,"message":"Internal error encountered.","status":"UNAVAILABLE"}}`)
require.False(t, isAntigravityInternalServerError(500, body))
})
t.Run("body 中 code 不匹配", func(t *testing.T) {
body := []byte(`{"error":{"code":503,"message":"Internal error encountered.","status":"INTERNAL"}}`)
require.False(t, isAntigravityInternalServerError(500, body))
})
t.Run("空 body", func(t *testing.T) {
require.False(t, isAntigravityInternalServerError(500, []byte{}))
require.False(t, isAntigravityInternalServerError(500, nil))
})
t.Run("其他 500 错误格式(纯文本)", func(t *testing.T) {
body := []byte(`Internal Server Error`)
require.False(t, isAntigravityInternalServerError(500, body))
})
t.Run("其他 500 错误格式(不同 JSON 结构)", func(t *testing.T) {
body := []byte(`{"message":"Internal Server Error","statusCode":500}`)
require.False(t, isAntigravityInternalServerError(500, body))
})
}
// =============================================================================
// TestApplyInternal500Penalty
// =============================================================================
func TestApplyInternal500Penalty(t *testing.T) {
t.Run("count=1 → SetTempUnschedulable 10 分钟", func(t *testing.T) {
repo := &internal500AccountRepoStub{}
svc := &AntigravityGatewayService{accountRepo: repo}
account := &Account{ID: 1, Name: "acc-1"}
before := time.Now()
svc.applyInternal500Penalty(context.Background(), "[test]", account, 1)
after := time.Now()
require.Len(t, repo.tempUnschedCalls, 1)
require.Empty(t, repo.setErrorCalls)
call := repo.tempUnschedCalls[0]
require.Equal(t, int64(1), call.accountID)
require.Contains(t, call.reason, "INTERNAL 500")
// until 应在 [before+10m, after+10m] 范围内
require.True(t, call.until.After(before.Add(internal500PenaltyTier1Duration).Add(-time.Second)))
require.True(t, call.until.Before(after.Add(internal500PenaltyTier1Duration).Add(time.Second)))
})
t.Run("count=2 → SetTempUnschedulable 10 小时", func(t *testing.T) {
repo := &internal500AccountRepoStub{}
svc := &AntigravityGatewayService{accountRepo: repo}
account := &Account{ID: 2, Name: "acc-2"}
before := time.Now()
svc.applyInternal500Penalty(context.Background(), "[test]", account, 2)
after := time.Now()
require.Len(t, repo.tempUnschedCalls, 1)
require.Empty(t, repo.setErrorCalls)
call := repo.tempUnschedCalls[0]
require.Equal(t, int64(2), call.accountID)
require.Contains(t, call.reason, "INTERNAL 500")
require.True(t, call.until.After(before.Add(internal500PenaltyTier2Duration).Add(-time.Second)))
require.True(t, call.until.Before(after.Add(internal500PenaltyTier2Duration).Add(time.Second)))
})
t.Run("count=3 → SetError 永久禁用", func(t *testing.T) {
repo := &internal500AccountRepoStub{}
svc := &AntigravityGatewayService{accountRepo: repo}
account := &Account{ID: 3, Name: "acc-3"}
svc.applyInternal500Penalty(context.Background(), "[test]", account, 3)
require.Empty(t, repo.tempUnschedCalls)
require.Len(t, repo.setErrorCalls, 1)
call := repo.setErrorCalls[0]
require.Equal(t, int64(3), call.accountID)
require.Contains(t, call.reason, "INTERNAL 500 consecutive failures: 3")
})
t.Run("count=5 → SetError 永久禁用(>=3 都走永久禁用)", func(t *testing.T) {
repo := &internal500AccountRepoStub{}
svc := &AntigravityGatewayService{accountRepo: repo}
account := &Account{ID: 5, Name: "acc-5"}
svc.applyInternal500Penalty(context.Background(), "[test]", account, 5)
require.Empty(t, repo.tempUnschedCalls)
require.Len(t, repo.setErrorCalls, 1)
call := repo.setErrorCalls[0]
require.Equal(t, int64(5), call.accountID)
require.Contains(t, call.reason, "INTERNAL 500 consecutive failures: 5")
})
t.Run("count=0 → 不调用任何方法", func(t *testing.T) {
repo := &internal500AccountRepoStub{}
svc := &AntigravityGatewayService{accountRepo: repo}
account := &Account{ID: 10, Name: "acc-10"}
svc.applyInternal500Penalty(context.Background(), "[test]", account, 0)
require.Empty(t, repo.tempUnschedCalls)
require.Empty(t, repo.setErrorCalls)
})
}
// =============================================================================
// TestHandleInternal500RetryExhausted
// =============================================================================
func TestHandleInternal500RetryExhausted(t *testing.T) {
t.Run("internal500Cache 为 nil → 不 panic不调用任何方法", func(t *testing.T) {
repo := &internal500AccountRepoStub{}
svc := &AntigravityGatewayService{
accountRepo: repo,
internal500Cache: nil,
}
account := &Account{ID: 1, Name: "acc-1"}
// 不应 panic
require.NotPanics(t, func() {
svc.handleInternal500RetryExhausted(context.Background(), "[test]", account)
})
require.Empty(t, repo.tempUnschedCalls)
require.Empty(t, repo.setErrorCalls)
})
t.Run("IncrementInternal500Count 返回 error → 不调用惩罚方法", func(t *testing.T) {
repo := &internal500AccountRepoStub{}
cache := &mockInternal500Cache{
incrementErr: errors.New("redis connection error"),
}
svc := &AntigravityGatewayService{
accountRepo: repo,
internal500Cache: cache,
}
account := &Account{ID: 2, Name: "acc-2"}
svc.handleInternal500RetryExhausted(context.Background(), "[test]", account)
require.Len(t, cache.incrementCalls, 1)
require.Equal(t, int64(2), cache.incrementCalls[0])
require.Empty(t, repo.tempUnschedCalls)
require.Empty(t, repo.setErrorCalls)
})
t.Run("IncrementInternal500Count 返回 count=1 → 触发 tier1 惩罚", func(t *testing.T) {
repo := &internal500AccountRepoStub{}
cache := &mockInternal500Cache{
incrementCount: 1,
}
svc := &AntigravityGatewayService{
accountRepo: repo,
internal500Cache: cache,
}
account := &Account{ID: 3, Name: "acc-3"}
svc.handleInternal500RetryExhausted(context.Background(), "[test]", account)
require.Len(t, cache.incrementCalls, 1)
require.Equal(t, int64(3), cache.incrementCalls[0])
// tier1: SetTempUnschedulable
require.Len(t, repo.tempUnschedCalls, 1)
require.Equal(t, int64(3), repo.tempUnschedCalls[0].accountID)
require.Empty(t, repo.setErrorCalls)
})
t.Run("IncrementInternal500Count 返回 count=3 → 触发 tier3 永久禁用", func(t *testing.T) {
repo := &internal500AccountRepoStub{}
cache := &mockInternal500Cache{
incrementCount: 3,
}
svc := &AntigravityGatewayService{
accountRepo: repo,
internal500Cache: cache,
}
account := &Account{ID: 4, Name: "acc-4"}
svc.handleInternal500RetryExhausted(context.Background(), "[test]", account)
require.Len(t, cache.incrementCalls, 1)
require.Empty(t, repo.tempUnschedCalls)
require.Len(t, repo.setErrorCalls, 1)
require.Equal(t, int64(4), repo.setErrorCalls[0].accountID)
})
}
// =============================================================================
// TestResetInternal500Counter
// =============================================================================
func TestResetInternal500Counter(t *testing.T) {
t.Run("internal500Cache 为 nil → 不 panic", func(t *testing.T) {
svc := &AntigravityGatewayService{
internal500Cache: nil,
}
require.NotPanics(t, func() {
svc.resetInternal500Counter(context.Background(), "[test]", 1)
})
})
t.Run("ResetInternal500Count 返回 error → 不 panic仅日志", func(t *testing.T) {
cache := &mockInternal500Cache{
resetErr: errors.New("redis timeout"),
}
svc := &AntigravityGatewayService{
internal500Cache: cache,
}
require.NotPanics(t, func() {
svc.resetInternal500Counter(context.Background(), "[test]", 42)
})
require.Len(t, cache.resetCalls, 1)
require.Equal(t, int64(42), cache.resetCalls[0])
})
t.Run("正常调用 → 调用 ResetInternal500Count", func(t *testing.T) {
cache := &mockInternal500Cache{}
svc := &AntigravityGatewayService{
internal500Cache: cache,
}
svc.resetInternal500Counter(context.Background(), "[test]", 99)
require.Len(t, cache.resetCalls, 1)
require.Equal(t, int64(99), cache.resetCalls[0])
})
}

View File

@ -322,7 +322,7 @@ func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, accou
// loadCodeAssistResult 封装 loadProjectIDWithRetry 的返回结果,
// 同时携带从 LoadCodeAssist 响应中提取的 plan_type 信息。
type loadCodeAssistResult struct {
ProjectID string
ProjectID string
Subscription *AntigravitySubscriptionResult
}

View File

@ -16,10 +16,10 @@ const (
// setAntigravityPrivacy 调用 Antigravity API 设置隐私并验证结果。
// 流程:
// 1. setUserSettings 清空设置 → 检查返回值 {\"userSettings\":{}}
// 1. setUserSettings 清空设置 → 检查返回值 {"userSettings":{}}
// 2. fetchUserInfo 二次验证隐私是否已生效(需要 project_id
//
// 返回 privacy_mode 值:\"privacy_set\" 成功,\"privacy_set_failed\" 失败,空串表示无法执行。
// 返回 privacy_mode 值:"privacy_set" 成功,"privacy_set_failed" 失败,空串表示无法执行。
func setAntigravityPrivacy(ctx context.Context, accessToken, projectID, proxyURL string) string {
if accessToken == "" {
return ""

View File

@ -2,7 +2,33 @@
package service
import "testing"
import (
"testing"
)
func applyAntigravitySubscriptionResult(account *Account, result AntigravitySubscriptionResult) (map[string]any, map[string]any) {
credentials := make(map[string]any)
for k, v := range account.Credentials {
credentials[k] = v
}
credentials["plan_type"] = result.PlanType
extra := make(map[string]any)
for k, v := range account.Extra {
extra[k] = v
}
if result.SubscriptionStatus != "" {
extra["subscription_status"] = result.SubscriptionStatus
} else {
delete(extra, "subscription_status")
}
if result.SubscriptionError != "" {
extra["subscription_error"] = result.SubscriptionError
} else {
delete(extra, "subscription_error")
}
return credentials, extra
}
func TestApplyAntigravityPrivacyMode_SetsInMemoryExtra(t *testing.T) {
account := &Account{}

View File

@ -36,27 +36,3 @@ func NormalizeAntigravitySubscription(resp *antigravity.LoadCodeAssistResponse)
PlanType: antigravity.TierIDToPlanType(tierID),
}
}
func applyAntigravitySubscriptionResult(account *Account, result AntigravitySubscriptionResult) (map[string]any, map[string]any) {
credentials := make(map[string]any)
for k, v := range account.Credentials {
credentials[k] = v
}
credentials["plan_type"] = result.PlanType
extra := make(map[string]any)
for k, v := range account.Extra {
extra[k] = v
}
if result.SubscriptionStatus != "" {
extra["subscription_status"] = result.SubscriptionStatus
} else {
delete(extra, "subscription_status")
}
if result.SubscriptionError != "" {
extra["subscription_error"] = result.SubscriptionError
} else {
delete(extra, "subscription_error")
}
return credentials, extra
}

View File

@ -11,7 +11,7 @@ import (
)
const (
antigravityTokenRefreshSkew = 5 * time.Minute
antigravityTokenRefreshSkew = 3 * time.Minute
antigravityTokenCacheSkew = 5 * time.Minute
antigravityBackfillCooldown = 5 * time.Minute
// antigravityRequestRefreshTimeout 请求路径上 token 刷新的最大等待时间。

View File

@ -36,8 +36,7 @@ func (r *AntigravityTokenRefresher) CanRefresh(account *Account) bool {
}
// NeedsRefresh 检查账户是否需要刷新
// Deprecated: Antigravity 已改为请求路径按需刷新,不再注册后台定时刷新器。
// 此方法仅保留以满足 TokenRefresher 接口,不会被 TokenRefreshService 调用。
// Antigravity 使用固定的15分钟刷新窗口忽略全局配置
func (r *AntigravityTokenRefresher) NeedsRefresh(account *Account, _ time.Duration) bool {
if !r.CanRefresh(account) {
return false

View File

@ -236,8 +236,11 @@ const (
// SettingKeyBackendModeEnabled Backend 模式:禁用用户注册和自助服务,仅管理员可登录
SettingKeyBackendModeEnabled = "backend_mode_enabled"
// SettingKeyRiskSettings 风控系统配置 (JSON)
SettingKeyRiskSettings = "risk_settings"
// Gateway Forwarding Behavior
// SettingKeyEnableFingerprintUnification 是否统一 OAuth 账号的 X-Stainless-* 指纹头(默认 true
SettingKeyEnableFingerprintUnification = "enable_fingerprint_unification"
// SettingKeyEnableMetadataPassthrough 是否透传客户端原始 metadata.user_id默认 false
SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough"
)
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).

View File

@ -176,13 +176,13 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamPreservesBodyAnd
require.Equal(t, "claude-3-haiku-20240307", gjson.GetBytes(upstream.lastBody, "model").String(), "透传模式应应用账号级模型映射")
require.Equal(t, "upstream-anthropic-key", upstream.lastReq.Header.Get("x-api-key"))
require.Empty(t, upstream.lastReq.Header.Get("authorization"))
require.Empty(t, upstream.lastReq.Header.Get("x-goog-api-key"))
require.Empty(t, upstream.lastReq.Header.Get("cookie"))
require.Equal(t, "2023-06-01", upstream.lastReq.Header.Get("anthropic-version"))
require.Equal(t, "interleaved-thinking-2025-05-14", upstream.lastReq.Header.Get("anthropic-beta"))
require.Empty(t, upstream.lastReq.Header.Get("x-stainless-lang"), "API Key 透传不应注入 OAuth 指纹头")
require.Equal(t, "upstream-anthropic-key", getHeaderRaw(upstream.lastReq.Header, "x-api-key"))
require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "authorization"))
require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-goog-api-key"))
require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "cookie"))
require.Equal(t, "2023-06-01", getHeaderRaw(upstream.lastReq.Header, "anthropic-version"))
require.Equal(t, "interleaved-thinking-2025-05-14", getHeaderRaw(upstream.lastReq.Header, "anthropic-beta"))
require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-stainless-lang"), "API Key 透传不应注入 OAuth 指纹头")
require.Contains(t, rec.Body.String(), `"cached_tokens":7`)
require.NotContains(t, rec.Body.String(), `"cache_read_input_tokens":7`, "透传输出不应被网关改写")
@ -258,9 +258,9 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardCountTokensPreservesBo
require.NoError(t, err)
require.Equal(t, "claude-3-opus-20240229", gjson.GetBytes(upstream.lastBody, "model").String(), "count_tokens 透传模式应应用账号级模型映射")
require.Equal(t, "upstream-anthropic-key", upstream.lastReq.Header.Get("x-api-key"))
require.Empty(t, upstream.lastReq.Header.Get("authorization"))
require.Empty(t, upstream.lastReq.Header.Get("cookie"))
require.Equal(t, "upstream-anthropic-key", getHeaderRaw(upstream.lastReq.Header, "x-api-key"))
require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "authorization"))
require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "cookie"))
require.Equal(t, http.StatusOK, rec.Code)
require.JSONEq(t, upstreamRespBody, rec.Body.String())
require.Empty(t, rec.Header().Get("Set-Cookie"))
@ -685,8 +685,8 @@ func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *t
req, err := svc.buildUpstreamRequest(context.Background(), c, account, []byte(`{"model":"claude-3-7-sonnet-20250219"}`), "oauth-token", "oauth", "claude-3-7-sonnet-20250219", true, false)
require.NoError(t, err)
require.Equal(t, "Bearer oauth-token", req.Header.Get("authorization"))
require.Contains(t, req.Header.Get("anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta")
require.Equal(t, "Bearer oauth-token", getHeaderRaw(req.Header, "authorization"))
require.Contains(t, getHeaderRaw(req.Header, "anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta")
}
func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(t *testing.T) {
@ -756,8 +756,8 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, upstream.lastReq)
require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("authorization"))
require.Contains(t, upstream.lastReq.Header.Get("anthropic-beta"), claude.BetaOAuth)
require.Equal(t, "Bearer oauth-token", getHeaderRaw(upstream.lastReq.Header, "authorization"))
require.Contains(t, getHeaderRaw(upstream.lastReq.Header, "anthropic-beta"), claude.BetaOAuth)
system := gjson.GetBytes(upstream.lastBody, "system")
require.True(t, system.Exists())

View File

@ -2,31 +2,28 @@ package service
import "testing"
func TestDebugGatewayBodyLoggingEnabled(t *testing.T) {
t.Run("default disabled", func(t *testing.T) {
t.Setenv(debugGatewayBodyEnv, "")
if debugGatewayBodyLoggingEnabled() {
t.Fatalf("expected debug gateway body logging to be disabled by default")
func TestParseDebugEnvBool(t *testing.T) {
t.Run("empty is false", func(t *testing.T) {
if parseDebugEnvBool("") {
t.Fatalf("expected false for empty string")
}
})
t.Run("enabled with true-like values", func(t *testing.T) {
t.Run("true-like values", func(t *testing.T) {
for _, value := range []string{"1", "true", "TRUE", "yes", "on"} {
t.Run(value, func(t *testing.T) {
t.Setenv(debugGatewayBodyEnv, value)
if !debugGatewayBodyLoggingEnabled() {
t.Fatalf("expected debug gateway body logging to be enabled for %q", value)
if !parseDebugEnvBool(value) {
t.Fatalf("expected true for %q", value)
}
})
}
})
t.Run("disabled with other values", func(t *testing.T) {
t.Run("false-like values", func(t *testing.T) {
for _, value := range []string{"0", "false", "off", "debug"} {
t.Run(value, func(t *testing.T) {
t.Setenv(debugGatewayBodyEnv, value)
if debugGatewayBodyLoggingEnabled() {
t.Fatalf("expected debug gateway body logging to be disabled for %q", value)
if parseDebugEnvBool(value) {
t.Fatalf("expected false for %q", value)
}
})
}

View File

@ -1,31 +0,0 @@
package service
import (
"fmt"
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
"github.com/gin-gonic/gin"
)
// WriteClaudeErrorResponse 写入 Claude 格式的错误响应(共享实现)
// 用于 AntigravityGatewayService 和 GeminiMessagesCompatService
func WriteClaudeErrorResponse(c *gin.Context, status int, errType, message string) error {
c.JSON(status, gin.H{
"type": "error",
"error": gin.H{"type": errType, "message": message},
})
return fmt.Errorf("%s", message)
}
// WriteGoogleErrorResponse 写入 Google 格式的错误响应(共享实现)
// 使用 googleapi.HTTPStatusToGoogleStatus 统一映射 HTTP 状态码
func WriteGoogleErrorResponse(c *gin.Context, status int, message string) error {
c.JSON(status, gin.H{
"error": gin.H{
"code": status,
"message": message,
"status": googleapi.HTTPStatusToGoogleStatus(status),
},
})
return fmt.Errorf("%s", message)
}

View File

@ -120,7 +120,7 @@ func (s *GatewayService) ForwardAsChatCompletions(
}
// 11. Send request
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()

View File

@ -117,7 +117,7 @@ func (s *GatewayService) ForwardAsResponses(
}
// 11. Send request
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()

View File

@ -12,7 +12,9 @@ import (
"log/slog"
mathrand "math/rand"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
@ -366,6 +368,9 @@ var allowedHeaders = map[string]bool{
"sec-fetch-mode": true,
"user-agent": true,
"content-type": true,
"accept-encoding": true,
"x-claude-code-session-id": true,
"x-client-request-id": true,
}
// GatewayCache 定义网关服务的缓存操作接口。
@ -565,7 +570,6 @@ type GatewayService struct {
debugClaudeMimic atomic.Bool
debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set
tlsFPProfileService *TLSFingerprintProfileService
riskService *RiskService
}
// NewGatewayService creates a new GatewayService
@ -593,7 +597,6 @@ func NewGatewayService(
digestStore *DigestSessionStore,
settingService *SettingService,
tlsFPProfileService *TLSFingerprintProfileService,
riskService *RiskService,
) *GatewayService {
userGroupRateTTL := resolveUserGroupRateCacheTTL(cfg)
modelsListTTL := resolveModelsListCacheTTL(cfg)
@ -626,7 +629,6 @@ func NewGatewayService(
modelsListCacheTTL: modelsListTTL,
responseHeaderFilter: compileResponseHeaderFilter(cfg),
tlsFPProfileService: tlsFPProfileService,
riskService: riskService,
}
svc.userGroupRateResolver = newUserGroupRateResolver(
userGroupRateRepo,
@ -637,6 +639,9 @@ func NewGatewayService(
)
svc.debugModelRouting.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_MODEL_ROUTING")))
svc.debugClaudeMimic.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_CLAUDE_MIMIC")))
if path := strings.TrimSpace(os.Getenv(debugGatewayBodyEnv)); path != "" {
svc.initDebugGatewayBodyFile(path)
}
return svc
}
@ -4075,8 +4080,15 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
reqStream := parsed.Stream
originalModel := reqModel
// === DEBUG: 打印客户端原始请求 body ===
debugLogRequestBody("CLIENT_ORIGINAL", body)
// === DEBUG: 打印客户端原始请求headers + body 摘要)===
if c != nil {
s.debugLogGatewaySnapshot("CLIENT_ORIGINAL", c.Request.Header, body, map[string]string{
"account": fmt.Sprintf("%d(%s)", account.ID, account.Name),
"account_type": string(account.Type),
"model": reqModel,
"stream": strconv.FormatBool(reqStream),
})
}
isClaudeCode := isClaudeCodeRequest(ctx, c, parsed)
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
@ -4093,9 +4105,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
if s.identityService != nil {
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
if err == nil && fp != nil {
if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" {
normalizeOpts.injectMetadata = true
normalizeOpts.metadataUserID = metadataUserID
// metadata 透传开启时跳过 metadata 注入
_, mimicMPT := s.settingService.GetGatewayForwardingSettings(ctx)
if !mimicMPT {
if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" {
normalizeOpts.injectMetadata = true
normalizeOpts.metadataUserID = metadataUserID
}
}
}
}
@ -4137,14 +4153,15 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
return nil, err
}
// 获取代理URL
// 获取代理URL(自定义 base URL 模式下proxy 通过 buildCustomRelayURL 作为查询参数传递)
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" {
proxyURL = account.Proxy.URL()
}
}
// 解析 TLS 模式和指纹 profile同一请求生命周期内不变避免重试循环中重复解析
tlsMode := account.GetTLSMode()
// 解析 TLS 指纹 profile同一请求生命周期内不变避免重试循环中重复解析
tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account)
// 调试日志:记录即将转发的账号信息
@ -4169,7 +4186,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
// 发送请求
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsMode, tlsProfile)
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
@ -4202,7 +4219,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
if readErr == nil {
_ = resp.Body.Close()
if s.isThinkingBlockSignatureError(respBody) && s.settingService.IsSignatureRectifierEnabled(ctx) {
if s.shouldRectifySignatureError(ctx, account, respBody) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
@ -4247,7 +4264,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
retryReq, buildErr := s.buildUpstreamRequest(retryCtx, c, account, filteredBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
releaseRetryCtx()
if buildErr == nil {
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, tlsMode, tlsProfile)
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
if retryErr == nil {
if retryResp.StatusCode < 400 {
logger.LegacyPrintf("service.gateway", "Account %d: thinking block retry succeeded (blocks downgraded)", account.ID)
@ -4257,7 +4274,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
retryRespBody, retryReadErr := io.ReadAll(io.LimitReader(retryResp.Body, 2<<20))
_ = retryResp.Body.Close()
if retryReadErr == nil && retryResp.StatusCode == 400 && s.isThinkingBlockSignatureError(retryRespBody) {
if retryReadErr == nil && retryResp.StatusCode == 400 && s.isSignatureErrorPattern(ctx, account, retryRespBody) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
@ -4282,7 +4299,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
retryReq2, buildErr2 := s.buildUpstreamRequest(retryCtx2, c, account, filteredBody2, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
releaseRetryCtx2()
if buildErr2 == nil {
retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsMode, tlsProfile)
retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsProfile)
if retryErr2 == nil {
resp = retryResp2
break
@ -4353,7 +4370,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
budgetRetryReq, buildErr := s.buildUpstreamRequest(budgetRetryCtx, c, account, rectifiedBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
releaseBudgetRetryCtx()
if buildErr == nil {
budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsMode, tlsProfile)
budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
if retryErr == nil {
resp = budgetRetryResp
break
@ -4659,7 +4676,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
return nil, err
}
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
@ -4871,8 +4888,9 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
if !allowedHeaders[lowerKey] {
continue
}
wireKey := resolveWireCasing(key)
for _, v := range values {
req.Header.Add(key, v)
addHeaderRaw(req.Header, wireKey, v)
}
}
}
@ -4882,13 +4900,13 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
req.Header.Del("x-api-key")
req.Header.Del("x-goog-api-key")
req.Header.Del("cookie")
req.Header.Set("x-api-key", token)
setHeaderRaw(req.Header, "x-api-key", token)
if req.Header.Get("content-type") == "" {
req.Header.Set("content-type", "application/json")
if getHeaderRaw(req.Header, "content-type") == "" {
setHeaderRaw(req.Header, "content-type", "application/json")
}
if req.Header.Get("anthropic-version") == "" {
req.Header.Set("anthropic-version", "2023-06-01")
if getHeaderRaw(req.Header, "anthropic-version") == "" {
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
}
return req, nil
@ -5377,7 +5395,7 @@ func (s *GatewayService) executeBedrockUpstream(
return nil, err
}
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, TLSModeOff, nil)
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, nil)
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
@ -5615,6 +5633,16 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
targetURL = validatedURL + "/v1/messages?beta=true"
}
} else if account.IsCustomBaseURLEnabled() {
customURL := account.GetCustomBaseURL()
if customURL == "" {
return nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID)
}
validatedURL, err := s.validateUpstreamBaseURL(customURL)
if err != nil {
return nil, err
}
targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages", account)
}
clientHeaders := http.Header{}
@ -5622,8 +5650,12 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
clientHeaders = c.Request.Header
}
// OAuth账号应用统一指纹
// OAuth账号应用统一指纹和metadata重写受设置开关控制
var fingerprint *Fingerprint
enableFP, enableMPT := true, false
if s.settingService != nil {
enableFP, enableMPT = s.settingService.GetGatewayForwardingSettings(ctx)
}
if account.IsOAuth() && s.identityService != nil {
// 1. 获取或创建指纹包含随机生成的ClientID
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
@ -5631,40 +5663,43 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
logger.LegacyPrintf("service.gateway", "Warning: failed to get fingerprint for account %d: %v", account.ID, err)
// 失败时降级为透传原始headers
} else {
fingerprint = fp
if enableFP {
fingerprint = fp
}
// 2. 重写metadata.user_id需要指纹中的ClientID和账号的account_uuid
// 如果启用了会话ID伪装会在重写后替换 session 部分为固定值
accountUUID := account.GetExtraString("account_uuid")
if accountUUID != "" && fp.ClientID != "" {
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
body = newBody
// 当 metadata 透传开启时跳过重写
if !enableMPT {
accountUUID := account.GetExtraString("account_uuid")
if accountUUID != "" && fp.ClientID != "" {
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
body = newBody
}
}
}
}
}
// === DEBUG: 打印转发给上游的 bodymetadata 已重写) ===
debugLogRequestBody("UPSTREAM_FORWARD", body)
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
// 设置认证头
// 设置认证头(保持原始大小写)
if tokenType == "oauth" {
req.Header.Set("authorization", "Bearer "+token)
setHeaderRaw(req.Header, "authorization", "Bearer "+token)
} else {
req.Header.Set("x-api-key", token)
setHeaderRaw(req.Header, "x-api-key", token)
}
// 白名单透传headers
// 白名单透传headers(恢复真实 wire casing
for key, values := range clientHeaders {
lowerKey := strings.ToLower(key)
if allowedHeaders[lowerKey] {
wireKey := resolveWireCasing(key)
for _, v := range values {
req.Header.Add(key, v)
addHeaderRaw(req.Header, wireKey, v)
}
}
}
@ -5674,15 +5709,15 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
s.identityService.ApplyFingerprint(req, fingerprint)
}
// 确保必要的headers存在
if req.Header.Get("content-type") == "" {
req.Header.Set("content-type", "application/json")
// 确保必要的headers存在(保持原始大小写)
if getHeaderRaw(req.Header, "content-type") == "" {
setHeaderRaw(req.Header, "content-type", "application/json")
}
if req.Header.Get("anthropic-version") == "" {
req.Header.Set("anthropic-version", "2023-06-01")
if getHeaderRaw(req.Header, "anthropic-version") == "" {
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
}
if tokenType == "oauth" {
applyClaudeOAuthHeaderDefaults(req, reqStream)
applyClaudeOAuthHeaderDefaults(req)
}
// Build effective drop set: merge static defaults with dynamic beta policy filter rules
@ -5698,31 +5733,50 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
// - 保留 incoming beta 的同时,确保 OAuth 所需 beta 存在
applyClaudeCodeMimicHeaders(req, reqStream)
incomingBeta := req.Header.Get("anthropic-beta")
incomingBeta := getHeaderRaw(req.Header, "anthropic-beta")
// Match real Claude CLI traffic (per mitmproxy reports):
// messages requests typically use only oauth + interleaved-thinking.
// Also drop claude-code beta if a downstream client added it.
requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking}
req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropWithClaudeCodeSet))
setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropWithClaudeCodeSet))
} else {
// Claude Code 客户端:尽量透传原始 header仅补齐 oauth beta
clientBetaHeader := req.Header.Get("anthropic-beta")
req.Header.Set("anthropic-beta", stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBetaHeader), effectiveDropSet))
clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")
setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBetaHeader), effectiveDropSet))
}
} else {
// API-key accounts: apply beta policy filter to strip controlled tokens
if existingBeta := req.Header.Get("anthropic-beta"); existingBeta != "" {
req.Header.Set("anthropic-beta", stripBetaTokensWithSet(existingBeta, effectiveDropSet))
if existingBeta := getHeaderRaw(req.Header, "anthropic-beta"); existingBeta != "" {
setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(existingBeta, effectiveDropSet))
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
// API-key仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭)
if requestNeedsBetaFeatures(body) {
if beta := defaultAPIKeyBetaHeader(body); beta != "" {
req.Header.Set("anthropic-beta", beta)
setHeaderRaw(req.Header, "anthropic-beta", beta)
}
}
}
}
// 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
if parsed := ParseMetadataUserID(uid); parsed != nil {
setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
}
}
}
// === DEBUG: 打印上游转发请求headers + body 摘要),与 CLIENT_ORIGINAL 对比 ===
s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{
"url": req.URL.String(),
"token_type": tokenType,
"mimic_claude_code": strconv.FormatBool(mimicClaudeCode),
"fingerprint_applied": strconv.FormatBool(fingerprint != nil),
"enable_fp": strconv.FormatBool(enableFP),
"enable_mpt": strconv.FormatBool(enableMPT),
})
// Always capture a compact fingerprint line for later error diagnostics.
// We only print it when needed (or when the explicit debug flag is enabled).
if c != nil && tokenType == "oauth" {
@ -5802,24 +5856,21 @@ func defaultAPIKeyBetaHeader(body []byte) string {
return claude.APIKeyBetaHeader
}
func applyClaudeOAuthHeaderDefaults(req *http.Request, isStream bool) {
func applyClaudeOAuthHeaderDefaults(req *http.Request) {
if req == nil {
return
}
if req.Header.Get("accept") == "" {
req.Header.Set("accept", "application/json")
if getHeaderRaw(req.Header, "Accept") == "" {
setHeaderRaw(req.Header, "Accept", "application/json")
}
for key, value := range claude.DefaultHeaders {
if value == "" {
continue
}
if req.Header.Get(key) == "" {
req.Header.Set(key, value)
if getHeaderRaw(req.Header, key) == "" {
setHeaderRaw(req.Header, resolveWireCasing(key), value)
}
}
if isStream && req.Header.Get("x-stainless-helper-method") == "" {
req.Header.Set("x-stainless-helper-method", "stream")
}
}
func mergeAnthropicBeta(required []string, incoming string) string {
@ -6114,18 +6165,19 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
return
}
// Start with the standard defaults (fill missing).
applyClaudeOAuthHeaderDefaults(req, isStream)
applyClaudeOAuthHeaderDefaults(req)
// Then force key headers to match Claude Code fingerprint regardless of what the client sent.
// 使用 resolveWireCasing 确保 key 与真实 wire format 一致(如 "x-app" 而非 "X-App"
for key, value := range claude.DefaultHeaders {
if value == "" {
continue
}
req.Header.Set(key, value)
setHeaderRaw(req.Header, resolveWireCasing(key), value)
}
// Real Claude CLI uses Accept: application/json (even for streaming).
req.Header.Set("accept", "application/json")
setHeaderRaw(req.Header, "Accept", "application/json")
if isStream {
req.Header.Set("x-stainless-helper-method", "stream")
setHeaderRaw(req.Header, "x-stainless-helper-method", "stream")
}
}
@ -6143,6 +6195,59 @@ func truncateForLog(b []byte, maxBytes int) string {
return s
}
// shouldRectifySignatureError 统一判断是否应触发签名整流strip thinking blocks 并重试)。
// 根据账号类型检查对应的开关和匹配模式。
func (s *GatewayService) shouldRectifySignatureError(ctx context.Context, account *Account, respBody []byte) bool {
if account.Type == AccountTypeAPIKey {
// API Key 账号:独立开关,一次读取配置
settings, err := s.settingService.GetRectifierSettings(ctx)
if err != nil || !settings.Enabled || !settings.APIKeySignatureEnabled {
return false
}
// 先检查内置模式(同 OAuth再检查自定义关键词
if s.isThinkingBlockSignatureError(respBody) {
return true
}
return matchSignaturePatterns(respBody, settings.APIKeySignaturePatterns)
}
// OAuth/SetupToken/Upstream/Bedrock 等:保持原有行为(内置模式 + 原开关)
return s.isThinkingBlockSignatureError(respBody) && s.settingService.IsSignatureRectifierEnabled(ctx)
}
// isSignatureErrorPattern 仅做模式匹配,不检查开关。
// 用于已进入重试流程后的二阶段检测(此时开关已在首次调用时验证过)。
func (s *GatewayService) isSignatureErrorPattern(ctx context.Context, account *Account, respBody []byte) bool {
if s.isThinkingBlockSignatureError(respBody) {
return true
}
if account.Type == AccountTypeAPIKey {
settings, err := s.settingService.GetRectifierSettings(ctx)
if err != nil {
return false
}
return matchSignaturePatterns(respBody, settings.APIKeySignaturePatterns)
}
return false
}
// matchSignaturePatterns 检查响应体是否匹配自定义关键词列表(不区分大小写)。
func matchSignaturePatterns(respBody []byte, patterns []string) bool {
if len(patterns) == 0 {
return false
}
bodyLower := strings.ToLower(string(respBody))
for _, p := range patterns {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if strings.Contains(bodyLower, strings.ToLower(p)) {
return true
}
}
return false
}
// isThinkingBlockSignatureError 检测是否是thinking block相关错误
// 这类错误可以通过过滤thinking blocks并重试来解决
func (s *GatewayService) isThinkingBlockSignatureError(respBody []byte) bool {
@ -7686,7 +7791,6 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
logger.LegacyPrintf("service.gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
s.deferredService.ScheduleLastUsedUpdate(account.ID)
return nil
}
@ -7710,7 +7814,6 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
return billingErr
}
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
return nil
}
@ -7871,7 +7974,6 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
logger.LegacyPrintf("service.gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
s.deferredService.ScheduleLastUsedUpdate(account.ID)
return nil
}
@ -7895,7 +7997,6 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
return billingErr
}
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
return nil
}
@ -7986,14 +8087,16 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
return err
}
// 获取代理URL
// 获取代理URL(自定义 base URL 模式下proxy 通过 buildCustomRelayURL 作为查询参数传递)
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" {
proxyURL = account.Proxy.URL()
}
}
// 发送请求
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed")
@ -8015,13 +8118,13 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
}
// 检测 thinking block 签名错误400并重试一次过滤 thinking blocks
if resp.StatusCode == 400 && s.isThinkingBlockSignatureError(respBody) && s.settingService.IsSignatureRectifierEnabled(ctx) {
if resp.StatusCode == 400 && s.shouldRectifySignatureError(ctx, account, respBody) {
logger.LegacyPrintf("service.gateway", "Account %d: detected thinking block signature error on count_tokens, retrying with filtered thinking blocks", account.ID)
filteredBody := FilterThinkingBlocksForRetry(body)
retryReq, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel, shouldMimicClaudeCode)
if buildErr == nil {
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if retryErr == nil {
resp = retryResp
respBody, err = readUpstreamResponseBodyLimited(resp.Body, maxReadBytes)
@ -8110,7 +8213,7 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
@ -8232,8 +8335,9 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
if !allowedHeaders[lowerKey] {
continue
}
wireKey := resolveWireCasing(key)
for _, v := range values {
req.Header.Add(key, v)
addHeaderRaw(req.Header, wireKey, v)
}
}
}
@ -8267,6 +8371,16 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
targetURL = validatedURL + "/v1/messages/count_tokens?beta=true"
}
} else if account.IsCustomBaseURLEnabled() {
customURL := account.GetCustomBaseURL()
if customURL == "" {
return nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID)
}
validatedURL, err := s.validateUpstreamBaseURL(customURL)
if err != nil {
return nil, err
}
targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages/count_tokens", account)
}
clientHeaders := http.Header{}
@ -8274,15 +8388,23 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
clientHeaders = c.Request.Header
}
// OAuth 账号:应用统一指纹和重写 userID
// OAuth 账号:应用统一指纹和重写 userID(受设置开关控制)
// 如果启用了会话ID伪装会在重写后替换 session 部分为固定值
ctEnableFP, ctEnableMPT := true, false
if s.settingService != nil {
ctEnableFP, ctEnableMPT = s.settingService.GetGatewayForwardingSettings(ctx)
}
var ctFingerprint *Fingerprint
if account.IsOAuth() && s.identityService != nil {
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
if err == nil {
accountUUID := account.GetExtraString("account_uuid")
if accountUUID != "" && fp.ClientID != "" {
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
body = newBody
ctFingerprint = fp
if !ctEnableMPT {
accountUUID := account.GetExtraString("account_uuid")
if accountUUID != "" && fp.ClientID != "" {
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
body = newBody
}
}
}
}
@ -8293,40 +8415,38 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
return nil, err
}
// 设置认证头
// 设置认证头(保持原始大小写)
if tokenType == "oauth" {
req.Header.Set("authorization", "Bearer "+token)
setHeaderRaw(req.Header, "authorization", "Bearer "+token)
} else {
req.Header.Set("x-api-key", token)
setHeaderRaw(req.Header, "x-api-key", token)
}
// 白名单透传 headers
// 白名单透传 headers(恢复真实 wire casing
for key, values := range clientHeaders {
lowerKey := strings.ToLower(key)
if allowedHeaders[lowerKey] {
wireKey := resolveWireCasing(key)
for _, v := range values {
req.Header.Add(key, v)
addHeaderRaw(req.Header, wireKey, v)
}
}
}
// OAuth 账号:应用指纹到请求头
if account.IsOAuth() && s.identityService != nil {
fp, _ := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
if fp != nil {
s.identityService.ApplyFingerprint(req, fp)
}
// OAuth 账号:应用指纹到请求头(受设置开关控制)
if ctEnableFP && ctFingerprint != nil {
s.identityService.ApplyFingerprint(req, ctFingerprint)
}
// 确保必要的 headers 存在
if req.Header.Get("content-type") == "" {
req.Header.Set("content-type", "application/json")
// 确保必要的 headers 存在(保持原始大小写)
if getHeaderRaw(req.Header, "content-type") == "" {
setHeaderRaw(req.Header, "content-type", "application/json")
}
if req.Header.Get("anthropic-version") == "" {
req.Header.Set("anthropic-version", "2023-06-01")
if getHeaderRaw(req.Header, "anthropic-version") == "" {
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
}
if tokenType == "oauth" {
applyClaudeOAuthHeaderDefaults(req, false)
applyClaudeOAuthHeaderDefaults(req)
}
// Build effective drop set for count_tokens: merge static defaults with dynamic beta policy filter rules
@ -8337,35 +8457,44 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
if mimicClaudeCode {
applyClaudeCodeMimicHeaders(req, false)
incomingBeta := req.Header.Get("anthropic-beta")
incomingBeta := getHeaderRaw(req.Header, "anthropic-beta")
requiredBetas := []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking, claude.BetaTokenCounting}
req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, ctEffectiveDropSet))
setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, ctEffectiveDropSet))
} else {
clientBetaHeader := req.Header.Get("anthropic-beta")
clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")
if clientBetaHeader == "" {
req.Header.Set("anthropic-beta", claude.CountTokensBetaHeader)
setHeaderRaw(req.Header, "anthropic-beta", claude.CountTokensBetaHeader)
} else {
beta := s.getBetaHeader(modelID, clientBetaHeader)
if !strings.Contains(beta, claude.BetaTokenCounting) {
beta = beta + "," + claude.BetaTokenCounting
}
req.Header.Set("anthropic-beta", stripBetaTokensWithSet(beta, ctEffectiveDropSet))
setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(beta, ctEffectiveDropSet))
}
}
} else {
// API-key accounts: apply beta policy filter to strip controlled tokens
if existingBeta := req.Header.Get("anthropic-beta"); existingBeta != "" {
req.Header.Set("anthropic-beta", stripBetaTokensWithSet(existingBeta, ctEffectiveDropSet))
if existingBeta := getHeaderRaw(req.Header, "anthropic-beta"); existingBeta != "" {
setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(existingBeta, ctEffectiveDropSet))
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
// API-key与 messages 同步的按需 beta 注入(默认关闭)
if requestNeedsBetaFeatures(body) {
if beta := defaultAPIKeyBetaHeader(body); beta != "" {
req.Header.Set("anthropic-beta", beta)
setHeaderRaw(req.Header, "anthropic-beta", beta)
}
}
}
}
// 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
if parsed := ParseMetadataUserID(uid); parsed != nil {
setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
}
}
}
if c != nil && tokenType == "oauth" {
c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
}
@ -8387,6 +8516,19 @@ func (s *GatewayService) countTokensError(c *gin.Context, status int, errType, m
})
}
// buildCustomRelayURL 构建自定义中继转发 URL
// 在 path 后附加 beta=true 和可选的 proxy 查询参数
func (s *GatewayService) buildCustomRelayURL(baseURL, path string, account *Account) string {
u := strings.TrimRight(baseURL, "/") + path + "?beta=true"
if account.ProxyID != nil && account.Proxy != nil {
proxyURL := account.Proxy.URL()
if proxyURL != "" {
u += "&proxy=" + url.QueryEscape(proxyURL)
}
}
return u
}
func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) {
if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
@ -8531,42 +8673,94 @@ func reconcileCachedTokens(usage map[string]any) bool {
return true
}
func debugGatewayBodyLoggingEnabled() bool {
raw := strings.TrimSpace(os.Getenv(debugGatewayBodyEnv))
if raw == "" {
return false
}
const debugGatewayBodyDefaultFilename = "gateway_debug.log"
switch strings.ToLower(raw) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// debugLogRequestBody 打印请求 body 用于调试 metadata.user_id 重写。
// 默认关闭,仅在设置环境变量时启用:
// initDebugGatewayBodyFile 初始化网关调试日志文件。
//
// SUB2API_DEBUG_GATEWAY_BODY=1
func debugLogRequestBody(tag string, body []byte) {
if !debugGatewayBodyLoggingEnabled() {
// - "1"/"true" 等布尔值 → 当前目录下 gateway_debug.log
// - 已有目录路径 → 该目录下 gateway_debug.log
// - 其他 → 视为完整文件路径
func (s *GatewayService) initDebugGatewayBodyFile(path string) {
if parseDebugEnvBool(path) {
path = debugGatewayBodyDefaultFilename
}
// 如果 path 指向一个已存在的目录,自动追加默认文件名
if info, err := os.Stat(path); err == nil && info.IsDir() {
path = filepath.Join(path, debugGatewayBodyDefaultFilename)
}
// 确保父目录存在
if dir := filepath.Dir(path); dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
slog.Error("failed to create gateway debug log directory", "dir", dir, "error", err)
return
}
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
slog.Error("failed to open gateway debug log file", "path", path, "error", err)
return
}
if len(body) == 0 {
logger.LegacyPrintf("service.gateway", "[DEBUG_%s] body is empty", tag)
return
}
// 提取 metadata 字段完整打印
metadataResult := gjson.GetBytes(body, "metadata")
if metadataResult.Exists() {
logger.LegacyPrintf("service.gateway", "[DEBUG_%s] metadata = %s", tag, metadataResult.Raw)
} else {
logger.LegacyPrintf("service.gateway", "[DEBUG_%s] metadata field not found", tag)
}
// 全量打印 body
logger.LegacyPrintf("service.gateway", "[DEBUG_%s] body (%d bytes) = %s", tag, len(body), string(body))
s.debugGatewayBodyFile.Store(f)
slog.Info("gateway debug logging enabled", "path", path)
}
// debugLogGatewaySnapshot 将网关请求的完整快照headers + body写入独立的调试日志文件
// 用于对比客户端原始请求和上游转发请求。
//
// 启用方式(环境变量):
//
// SUB2API_DEBUG_GATEWAY_BODY=1 # 写入 gateway_debug.log
// SUB2API_DEBUG_GATEWAY_BODY=/tmp/gateway_debug.log # 写入指定路径
//
// tag: "CLIENT_ORIGINAL" 或 "UPSTREAM_FORWARD"
func (s *GatewayService) debugLogGatewaySnapshot(tag string, headers http.Header, body []byte, extra map[string]string) {
f := s.debugGatewayBodyFile.Load()
if f == nil {
return
}
var buf strings.Builder
ts := time.Now().Format("2006-01-02 15:04:05.000")
fmt.Fprintf(&buf, "\n========== [%s] %s ==========\n", ts, tag)
// 1. context
if len(extra) > 0 {
fmt.Fprint(&buf, "--- context ---\n")
extraKeys := make([]string, 0, len(extra))
for k := range extra {
extraKeys = append(extraKeys, k)
}
sort.Strings(extraKeys)
for _, k := range extraKeys {
fmt.Fprintf(&buf, " %s: %s\n", k, extra[k])
}
}
// 2. headers按真实 Claude CLI wire 顺序排列便于与抓包对比auth 脱敏)
fmt.Fprint(&buf, "--- headers ---\n")
for _, k := range sortHeadersByWireOrder(headers) {
for _, v := range headers[k] {
fmt.Fprintf(&buf, " %s: %s\n", k, safeHeaderValueForLog(k, v))
}
}
// 3. body完整输出格式化 JSON 便于 diff
fmt.Fprint(&buf, "--- body ---\n")
if len(body) == 0 {
fmt.Fprint(&buf, " (empty)\n")
} else {
var pretty bytes.Buffer
if json.Indent(&pretty, body, " ", " ") == nil {
fmt.Fprintf(&buf, " %s\n", pretty.Bytes())
} else {
// JSON 格式化失败时原样输出
fmt.Fprintf(&buf, " %s\n", body)
}
}
// 写入文件(调试用,并发写入可能交错但不影响可读性)
_, _ = f.WriteString(buf.String())
}

View File

@ -21,6 +21,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
@ -669,8 +670,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
}
upstreamReq.Header.Set("Content-Type", "application/json")
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent(mappedModel))
upstreamReq.Header.Set("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient())
upstreamReq.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent)
return upstreamReq, "x-request-id", nil
} else {
// Mode 2: AI Studio API with OAuth (like API key mode, but using Bearer token)
@ -691,8 +691,6 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
}
upstreamReq.Header.Set("Content-Type", "application/json")
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent(mappedModel))
upstreamReq.Header.Set("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient())
return upstreamReq, "x-request-id", nil
}
}
@ -724,7 +722,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
c.Set(OpsUpstreamRequestBodyKey, string(body))
}
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), nil)
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
if err != nil {
safeErr := sanitizeUpstreamErrorMessage(err.Error())
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
@ -1173,8 +1171,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
}
upstreamReq.Header.Set("Content-Type", "application/json")
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent(mappedModel))
upstreamReq.Header.Set("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient())
upstreamReq.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent)
return upstreamReq, "x-request-id", nil
} else {
// Mode 2: AI Studio API with OAuth (like API key mode, but using Bearer token)
@ -1195,8 +1192,6 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
}
upstreamReq.Header.Set("Content-Type", "application/json")
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent(mappedModel))
upstreamReq.Header.Set("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient())
return upstreamReq, "x-request-id", nil
}
}
@ -1227,7 +1222,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
c.Set(OpsUpstreamRequestBodyKey, string(body))
}
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), nil)
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
if err != nil {
safeErr := sanitizeUpstreamErrorMessage(err.Error())
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
@ -2153,11 +2148,22 @@ func randomHex(nBytes int) string {
}
func (s *GeminiMessagesCompatService) writeClaudeError(c *gin.Context, status int, errType, message string) error {
return WriteClaudeErrorResponse(c, status, errType, message)
c.JSON(status, gin.H{
"type": "error",
"error": gin.H{"type": errType, "message": message},
})
return fmt.Errorf("%s", message)
}
func (s *GeminiMessagesCompatService) writeGoogleError(c *gin.Context, status int, message string) error {
return WriteGoogleErrorResponse(c, status, message)
c.JSON(status, gin.H{
"error": gin.H{
"code": status,
"message": message,
"status": googleapi.HTTPStatusToGoogleStatus(status),
},
})
return fmt.Errorf("%s", message)
}
func unwrapIfNeeded(isOAuth bool, raw []byte) []byte {
@ -2583,7 +2589,7 @@ func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, ac
return nil, fmt.Errorf("unsupported account type: %s", account.Type)
}
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), nil)
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
if err != nil {
return nil, err
}

View File

@ -1037,8 +1037,7 @@ func fetchProjectIDFromResourceManager(ctx context.Context, accessToken, proxyUR
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent())
req.Header.Set("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient())
req.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent)
client, err := httpclient.GetClient(httpclient.Options{
ProxyURL: strings.TrimSpace(proxyURL),

View File

@ -0,0 +1,165 @@
package service
import (
"net/http"
"strings"
)
// headerWireCasing 定义每个白名单 header 在真实 Claude CLI 抓包中的准确大小写。
// Go 的 HTTP server 解析请求时会将所有 header key 转为 Canonical 形式(如 x-app → X-App
// 此 map 用于在转发时恢复到真实的 wire format。
//
// 来源:对真实 Claude CLI (claude-cli/2.1.81) 到 api.anthropic.com 的 HTTPS 流量抓包。
var headerWireCasing = map[string]string{
// Title case
"accept": "Accept",
"user-agent": "User-Agent",
// X-Stainless-* 保持 SDK 原始大小写
"x-stainless-retry-count": "X-Stainless-Retry-Count",
"x-stainless-timeout": "X-Stainless-Timeout",
"x-stainless-lang": "X-Stainless-Lang",
"x-stainless-package-version": "X-Stainless-Package-Version",
"x-stainless-os": "X-Stainless-OS",
"x-stainless-arch": "X-Stainless-Arch",
"x-stainless-runtime": "X-Stainless-Runtime",
"x-stainless-runtime-version": "X-Stainless-Runtime-Version",
"x-stainless-helper-method": "x-stainless-helper-method",
// Anthropic SDK 自身设置的 header全小写
"anthropic-dangerous-direct-browser-access": "anthropic-dangerous-direct-browser-access",
"anthropic-version": "anthropic-version",
"anthropic-beta": "anthropic-beta",
"x-app": "x-app",
"content-type": "content-type",
"accept-language": "accept-language",
"sec-fetch-mode": "sec-fetch-mode",
"accept-encoding": "accept-encoding",
"authorization": "authorization",
// Claude Code 2.1.87+ 新增 header
"x-claude-code-session-id": "X-Claude-Code-Session-Id",
"x-client-request-id": "x-client-request-id",
"content-length": "content-length",
}
// headerWireOrder 定义真实 Claude CLI 发送 header 的顺序(基于抓包)。
// 用于 debug log 按此顺序输出,便于与抓包结果直接对比。
var headerWireOrder = []string{
"Accept",
"X-Stainless-Retry-Count",
"X-Stainless-Timeout",
"X-Stainless-Lang",
"X-Stainless-Package-Version",
"X-Stainless-OS",
"X-Stainless-Arch",
"X-Stainless-Runtime",
"X-Stainless-Runtime-Version",
"anthropic-dangerous-direct-browser-access",
"anthropic-version",
"authorization",
"x-app",
"User-Agent",
"X-Claude-Code-Session-Id",
"content-type",
"anthropic-beta",
"x-client-request-id",
"accept-language",
"sec-fetch-mode",
"accept-encoding",
"content-length",
"x-stainless-helper-method",
}
// headerWireOrderSet 用于快速判断某个 key 是否在 headerWireOrder 中(按 lowercase 匹配)。
var headerWireOrderSet map[string]struct{}
func init() {
headerWireOrderSet = make(map[string]struct{}, len(headerWireOrder))
for _, k := range headerWireOrder {
headerWireOrderSet[strings.ToLower(k)] = struct{}{}
}
}
// resolveWireCasing 将 Go canonical key如 X-Stainless-Os映射为真实 wire casing如 X-Stainless-OS
// 如果 map 中没有对应条目,返回原始 key 不变。
func resolveWireCasing(key string) string {
if wk, ok := headerWireCasing[strings.ToLower(key)]; ok {
return wk
}
return key
}
// setHeaderRaw sets a header bypassing Go's canonical-case normalization.
// The key is stored exactly as provided, preserving original casing.
//
// It first removes any existing value under the canonical key, the wire casing key,
// and the exact raw key, preventing duplicates from any source.
func setHeaderRaw(h http.Header, key, value string) {
h.Del(key) // remove canonical form (e.g. "Anthropic-Beta")
if wk := resolveWireCasing(key); wk != key {
delete(h, wk) // remove wire casing form if different
}
delete(h, key) // remove exact raw key if it differs from canonical
h[key] = []string{value}
}
// addHeaderRaw appends a header value bypassing Go's canonical-case normalization.
func addHeaderRaw(h http.Header, key, value string) {
h[key] = append(h[key], value)
}
// getHeaderRaw reads a header value, trying multiple key forms to handle the mismatch
// between Go canonical keys, wire casing keys, and raw keys:
// 1. exact key as provided
// 2. wire casing form (from headerWireCasing)
// 3. Go canonical form (via http.Header.Get)
func getHeaderRaw(h http.Header, key string) string {
// 1. exact key
if vals := h[key]; len(vals) > 0 {
return vals[0]
}
// 2. wire casing (e.g. looking up "Anthropic-Dangerous-Direct-Browser-Access" finds "anthropic-dangerous-direct-browser-access")
if wk := resolveWireCasing(key); wk != key {
if vals := h[wk]; len(vals) > 0 {
return vals[0]
}
}
// 3. canonical fallback
return h.Get(key)
}
// sortHeadersByWireOrder 按照真实 Claude CLI 的 header 顺序返回排序后的 key 列表。
// 在 headerWireOrder 中定义的 key 按其顺序排列,未定义的 key 追加到末尾。
func sortHeadersByWireOrder(h http.Header) []string {
// 构建 lowercase -> actual map key 的映射
present := make(map[string]string, len(h))
for k := range h {
present[strings.ToLower(k)] = k
}
result := make([]string, 0, len(h))
seen := make(map[string]struct{}, len(h))
// 先按 wire order 输出
for _, wk := range headerWireOrder {
lk := strings.ToLower(wk)
if actual, ok := present[lk]; ok {
if _, dup := seen[lk]; !dup {
result = append(result, actual)
seen[lk] = struct{}{}
}
}
}
// 再追加不在 wire order 中的 header
for k := range h {
lk := strings.ToLower(k)
if _, ok := seen[lk]; !ok {
result = append(result, k)
seen[lk] = struct{}{}
}
}
return result
}

View File

@ -6,18 +6,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
)
// TLSMode 定义账号级别的 TLS 指纹模式
type TLSMode string
const (
// TLSModeOff 不启用 TLS 指纹,直接使用标准 Go HTTP 客户端
TLSModeOff TLSMode = "off"
// TLSModeNode 通过本地 Node.js TLS 代理发请求,天然匹配 Claude CLI 指纹
TLSModeNode TLSMode = "node"
// TLSModeUTLS 使用 uTLS 库模拟指定 Profile 的 TLS ClientHello
TLSModeUTLS TLSMode = "utls"
)
// HTTPUpstream 上游 HTTP 请求接口
// 用于向上游 APIClaude、OpenAI、Gemini 等)发送请求
type HTTPUpstream interface {
@ -26,11 +14,11 @@ type HTTPUpstream interface {
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
//
// mode 参数决定指纹策略:
// - TLSModeOff / "": 不启用,行为与 Do 相同
// - TLSModeNode: 走本地 Node.js TLS 代理(需 gateway.node_tls_proxy.enabled=true
// - TLSModeUTLS: 用 profile 模拟 TLS ClientHelloprofile 为 nil 时降级为 Off
// profile 参数:
// - nil: 不启用 TLS 指纹,行为与 Do 方法相同
// - non-nil: 使用指定的 Profile 进行 TLS 指纹伪装
//
// profile 仅在 mode=TLSModeUTLS 时生效,来自数据库或内置默认值。
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, mode TLSMode, profile *tlsfingerprint.Profile) (*http.Response, error)
// Profile 由调用方通过 TLSFingerprintProfileService 解析后传入,
// 支持按账号绑定的数据库 profile 或内置默认 profile。
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error)
}

View File

@ -35,8 +35,6 @@ var defaultFingerprint = Fingerprint{
StainlessRuntimeVersion: "v24.3.0",
}
// Fingerprint represents account fingerprint data
type Fingerprint struct {
ClientID string
@ -74,8 +72,6 @@ func NewIdentityService(cache IdentityCache) *IdentityService {
return &IdentityService{cache: cache}
}
// GetOrCreateFingerprint 获取或创建账号的指纹
// 如果缓存存在检测user-agent版本新版本则更新
// 如果缓存不存在生成随机ClientID并从请求头创建指纹然后缓存
@ -179,6 +175,7 @@ func getHeaderOrDefault(headers http.Header, key, defaultValue string) string {
}
// ApplyFingerprint 将指纹应用到请求头覆盖原有的x-stainless-*头)
// 使用 setHeaderRaw 保持原始大小写(如 X-Stainless-OS 而非 X-Stainless-Os
func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) {
if fp == nil {
return
@ -186,27 +183,27 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) {
// 设置user-agent
if fp.UserAgent != "" {
req.Header.Set("user-agent", fp.UserAgent)
setHeaderRaw(req.Header, "User-Agent", fp.UserAgent)
}
// 设置x-stainless-*头
// 设置x-stainless-*头(保持与 claude.DefaultHeaders 一致的大小写)
if fp.StainlessLang != "" {
req.Header.Set("X-Stainless-Lang", fp.StainlessLang)
setHeaderRaw(req.Header, "X-Stainless-Lang", fp.StainlessLang)
}
if fp.StainlessPackageVersion != "" {
req.Header.Set("X-Stainless-Package-Version", fp.StainlessPackageVersion)
setHeaderRaw(req.Header, "X-Stainless-Package-Version", fp.StainlessPackageVersion)
}
if fp.StainlessOS != "" {
req.Header.Set("X-Stainless-OS", fp.StainlessOS)
setHeaderRaw(req.Header, "X-Stainless-OS", fp.StainlessOS)
}
if fp.StainlessArch != "" {
req.Header.Set("X-Stainless-Arch", fp.StainlessArch)
setHeaderRaw(req.Header, "X-Stainless-Arch", fp.StainlessArch)
}
if fp.StainlessRuntime != "" {
req.Header.Set("X-Stainless-Runtime", fp.StainlessRuntime)
setHeaderRaw(req.Header, "X-Stainless-Runtime", fp.StainlessRuntime)
}
if fp.StainlessRuntimeVersion != "" {
req.Header.Set("X-Stainless-Runtime-Version", fp.StainlessRuntimeVersion)
setHeaderRaw(req.Header, "X-Stainless-Runtime-Version", fp.StainlessRuntimeVersion)
}
}

View File

@ -7,9 +7,8 @@ package service
// 新增了实例级隔离盐值和指纹默认值覆盖功能。
//
// 对上游文件 identity_service.go 的最小化改动:
// - defaultFingerprint 版本号更新L29/L31claude-cli 2.1.81 / sdk 0.80.0
// - IdentityService struct 新增 instanceSalt 字段L86
// [以上两处改动仍在原文件中,因为是对已有定义的修改,无法完全抽离]
// - defaultFingerprint 版本号更新
// - IdentityService struct 新增 instanceSalt 字段
// ==============================================================
// ApplyDefaultFingerprintOverrides 用配置覆盖 identity_service 的默认指纹

View File

@ -0,0 +1,11 @@
package service
import "context"
// Internal500CounterCache 追踪 Antigravity 账号连续 INTERNAL 500 失败轮数
type Internal500CounterCache interface {
// IncrementInternal500Count 原子递增计数并返回当前值
IncrementInternal500Count(ctx context.Context, accountID int64) (int64, error)
// ResetInternal500Count 清零计数器(成功响应时调用)
ResetInternal500Count(ctx context.Context, accountID int64) error
}

View File

@ -0,0 +1,103 @@
package service
import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
)
func NormalizeOpenAICompatRequestedModel(model string) string {
trimmed := strings.TrimSpace(model)
if trimmed == "" {
return ""
}
normalized, _, ok := splitOpenAICompatReasoningModel(trimmed)
if !ok || normalized == "" {
return trimmed
}
return normalized
}
func applyOpenAICompatModelNormalization(req *apicompat.AnthropicRequest) {
if req == nil {
return
}
originalModel := strings.TrimSpace(req.Model)
if originalModel == "" {
return
}
normalizedModel, derivedEffort, hasReasoningSuffix := splitOpenAICompatReasoningModel(originalModel)
if hasReasoningSuffix && normalizedModel != "" {
req.Model = normalizedModel
}
if req.OutputConfig != nil && strings.TrimSpace(req.OutputConfig.Effort) != "" {
return
}
claudeEffort := openAIReasoningEffortToClaudeOutputEffort(derivedEffort)
if claudeEffort == "" {
return
}
if req.OutputConfig == nil {
req.OutputConfig = &apicompat.AnthropicOutputConfig{}
}
req.OutputConfig.Effort = claudeEffort
}
func splitOpenAICompatReasoningModel(model string) (normalizedModel string, reasoningEffort string, ok bool) {
trimmed := strings.TrimSpace(model)
if trimmed == "" {
return "", "", false
}
modelID := trimmed
if strings.Contains(modelID, "/") {
parts := strings.Split(modelID, "/")
modelID = parts[len(parts)-1]
}
modelID = strings.TrimSpace(modelID)
if !strings.HasPrefix(strings.ToLower(modelID), "gpt-") {
return trimmed, "", false
}
parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool {
switch r {
case '-', '_', ' ':
return true
default:
return false
}
})
if len(parts) == 0 {
return trimmed, "", false
}
last := strings.NewReplacer("-", "", "_", "", " ", "").Replace(parts[len(parts)-1])
switch last {
case "none", "minimal":
case "low", "medium", "high":
reasoningEffort = last
case "xhigh", "extrahigh":
reasoningEffort = "xhigh"
default:
return trimmed, "", false
}
return normalizeCodexModel(modelID), reasoningEffort, true
}
func openAIReasoningEffortToClaudeOutputEffort(effort string) string {
switch strings.TrimSpace(effort) {
case "low", "medium", "high":
return effort
case "xhigh":
return "max"
default:
return ""
}
}

View File

@ -0,0 +1,129 @@
package service
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestNormalizeOpenAICompatRequestedModel(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
}{
{name: "gpt reasoning alias strips xhigh", input: "gpt-5.4-xhigh", want: "gpt-5.4"},
{name: "gpt reasoning alias strips none", input: "gpt-5.4-none", want: "gpt-5.4"},
{name: "codex max model stays intact", input: "gpt-5.1-codex-max", want: "gpt-5.1-codex-max"},
{name: "non openai model unchanged", input: "claude-opus-4-6", want: "claude-opus-4-6"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, NormalizeOpenAICompatRequestedModel(tt.input))
})
}
}
func TestApplyOpenAICompatModelNormalization(t *testing.T) {
t.Parallel()
t.Run("derives xhigh from model suffix when output config missing", func(t *testing.T) {
req := &apicompat.AnthropicRequest{Model: "gpt-5.4-xhigh"}
applyOpenAICompatModelNormalization(req)
require.Equal(t, "gpt-5.4", req.Model)
require.NotNil(t, req.OutputConfig)
require.Equal(t, "max", req.OutputConfig.Effort)
})
t.Run("explicit output config wins over model suffix", func(t *testing.T) {
req := &apicompat.AnthropicRequest{
Model: "gpt-5.4-xhigh",
OutputConfig: &apicompat.AnthropicOutputConfig{Effort: "low"},
}
applyOpenAICompatModelNormalization(req)
require.Equal(t, "gpt-5.4", req.Model)
require.NotNil(t, req.OutputConfig)
require.Equal(t, "low", req.OutputConfig.Effort)
})
t.Run("non openai model is untouched", func(t *testing.T) {
req := &apicompat.AnthropicRequest{Model: "claude-opus-4-6"}
applyOpenAICompatModelNormalization(req)
require.Equal(t, "claude-opus-4-6", req.Model)
require.Nil(t, req.OutputConfig)
})
}
func TestForwardAsAnthropic_NormalizesRoutingAndEffortForGpt54XHigh(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := []byte(`{"model":"gpt-5.4-xhigh","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstreamBody := strings.Join([]string{
`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
"",
"data: [DONE]",
"",
}, "\n")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_compat"}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}}
svc := &OpenAIGatewayService{httpUpstream: upstream}
account := &Account{
ID: 1,
Name: "openai-oauth",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "oauth-token",
"chatgpt_account_id": "chatgpt-acc",
"model_mapping": map[string]any{
"gpt-5.4": "gpt-5.4",
},
},
}
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "gpt-5.4-xhigh", result.Model)
require.Equal(t, "gpt-5.4", result.UpstreamModel)
require.Equal(t, "gpt-5.4", result.BillingModel)
require.NotNil(t, result.ReasoningEffort)
require.Equal(t, "xhigh", *result.ReasoningEffort)
require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "xhigh", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "gpt-5.4-xhigh", gjson.GetBytes(rec.Body.Bytes(), "model").String())
require.Equal(t, "ok", gjson.GetBytes(rec.Body.Bytes(), "content.0.text").String())
t.Logf("upstream body: %s", string(upstream.lastBody))
t.Logf("response body: %s", rec.Body.String())
}

View File

@ -40,6 +40,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
return nil, fmt.Errorf("parse anthropic request: %w", err)
}
originalModel := anthropicReq.Model
applyOpenAICompatModelNormalization(&anthropicReq)
clientStream := anthropicReq.Stream // client's original stream preference
// 2. Convert Anthropic → Responses
@ -59,7 +60,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
}
// 3. Model mapping
mappedModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel)
mappedModel := resolveOpenAIForwardModel(account, anthropicReq.Model, defaultMappedModel)
responsesReq.Model = mappedModel
logger.L().Debug("openai messages: model mapping applied",

View File

@ -895,14 +895,16 @@ func TestOpenAIGatewayServiceRecordUsage_UsesRequestedModelAndUpstreamModelMetad
require.Equal(t, 1, userRepo.deductCalls)
}
func TestOpenAIGatewayServiceRecordUsage_BillsMappedRequestsUsingUpstreamModelFallback(t *testing.T) {
func TestOpenAIGatewayServiceRecordUsage_BillsMappedRequestsUsingRequestedModel(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
subRepo := &openAIRecordUsageSubRepoStub{}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
usage := OpenAIUsage{InputTokens: 20, OutputTokens: 10}
expectedCost, err := svc.billingService.CalculateCost("gpt-5.1-codex", UsageTokens{
// Billing should use the requested model ("gpt-5.1"), not the upstream mapped model ("gpt-5.1-codex").
// This ensures pricing is always based on the model the user requested.
expectedCost, err := svc.billingService.CalculateCost("gpt-5.1", UsageTokens{
InputTokens: 20,
OutputTokens: 10,
}, 1.1)

View File

@ -337,7 +337,6 @@ type OpenAIGatewayService struct {
openaiWSRetryMetrics openAIWSRetryMetrics
responseHeaderFilter *responseheaders.CompiledHeaderFilter
codexSnapshotThrottle *accountWriteThrottle
riskService *RiskService
}
// NewOpenAIGatewayService creates a new OpenAIGatewayService
@ -358,7 +357,6 @@ func NewOpenAIGatewayService(
httpUpstream HTTPUpstream,
deferredService *DeferredService,
openAITokenProvider *OpenAITokenProvider,
riskService *RiskService,
) *OpenAIGatewayService {
svc := &OpenAIGatewayService{
accountRepo: accountRepo,
@ -388,7 +386,6 @@ func NewOpenAIGatewayService(
openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
responseHeaderFilter: compileResponseHeaderFilter(cfg),
codexSnapshotThrottle: newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval),
riskService: riskService,
}
svc.logOpenAIWSModeBootstrap()
return svc
@ -4156,9 +4153,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
}
billingModel := forwardResultBillingModel(result.Model, result.UpstreamModel)
if result.BillingModel != "" {
billingModel = strings.TrimSpace(result.BillingModel)
}
serviceTier := ""
if result.ServiceTier != nil {
serviceTier = strings.TrimSpace(*result.ServiceTier)
@ -4230,7 +4224,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway")
logger.LegacyPrintf("service.openai_gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
s.deferredService.ScheduleLastUsedUpdate(account.ID)
return nil
}
@ -4254,7 +4247,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
return billingErr
}
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway")
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
return nil
}

View File

@ -502,6 +502,25 @@ func (s *OpenAIOAuthService) RefreshAccountToken(ctx context.Context, account *A
refreshToken := account.GetCredential("refresh_token")
if refreshToken == "" {
accessToken := account.GetCredential("access_token")
if accessToken != "" {
tokenInfo := &OpenAITokenInfo{
AccessToken: accessToken,
RefreshToken: "",
IDToken: account.GetCredential("id_token"),
ClientID: account.GetCredential("client_id"),
Email: account.GetCredential("email"),
ChatGPTAccountID: account.GetCredential("chatgpt_account_id"),
ChatGPTUserID: account.GetCredential("chatgpt_user_id"),
OrganizationID: account.GetCredential("organization_id"),
PlanType: account.GetCredential("plan_type"),
}
if expiresAt := account.GetCredentialAsTime("expires_at"); expiresAt != nil {
tokenInfo.ExpiresAt = expiresAt.Unix()
tokenInfo.ExpiresIn = int64(time.Until(*expiresAt).Seconds())
}
return tokenInfo, nil
}
return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_NO_REFRESH_TOKEN", "no refresh token available")
}

View File

@ -0,0 +1,54 @@
package service
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/stretchr/testify/require"
)
type openaiOAuthClientRefreshStub struct {
refreshCalls int32
}
func (s *openaiOAuthClientRefreshStub) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*openai.TokenResponse, error) {
return nil, errors.New("not implemented")
}
func (s *openaiOAuthClientRefreshStub) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*openai.TokenResponse, error) {
atomic.AddInt32(&s.refreshCalls, 1)
return nil, errors.New("not implemented")
}
func (s *openaiOAuthClientRefreshStub) RefreshTokenWithClientID(ctx context.Context, refreshToken, proxyURL string, clientID string) (*openai.TokenResponse, error) {
atomic.AddInt32(&s.refreshCalls, 1)
return nil, errors.New("not implemented")
}
func TestOpenAIOAuthService_RefreshAccountToken_NoRefreshTokenUsesExistingAccessToken(t *testing.T) {
client := &openaiOAuthClientRefreshStub{}
svc := NewOpenAIOAuthService(nil, client)
expiresAt := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
account := &Account{
ID: 77,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{
"access_token": "existing-access-token",
"expires_at": expiresAt,
"client_id": "client-id-1",
},
}
info, err := svc.RefreshAccountToken(context.Background(), account)
require.NoError(t, err)
require.NotNil(t, info)
require.Equal(t, "existing-access-token", info.AccessToken)
require.Equal(t, "client-id-1", info.ClientID)
require.Zero(t, atomic.LoadInt32(&client.refreshCalls), "existing access token should be reused without calling refresh")
}

View File

@ -0,0 +1,89 @@
//go:build unit
package service
import (
"context"
"errors"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/imroc/req/v3"
"github.com/stretchr/testify/require"
)
func TestAdminService_EnsureOpenAIPrivacy_RetriesNonSuccessModes(t *testing.T) {
t.Parallel()
for _, mode := range []string{PrivacyModeFailed, PrivacyModeCFBlocked} {
t.Run(mode, func(t *testing.T) {
t.Parallel()
privacyCalls := 0
svc := &adminServiceImpl{
accountRepo: &mockAccountRepoForGemini{},
privacyClientFactory: func(proxyURL string) (*req.Client, error) {
privacyCalls++
return nil, errors.New("factory failed")
},
}
account := &Account{
ID: 101,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{
"access_token": "token-1",
},
Extra: map[string]any{
"privacy_mode": mode,
},
}
got := svc.EnsureOpenAIPrivacy(context.Background(), account)
require.Equal(t, PrivacyModeFailed, got)
require.Equal(t, 1, privacyCalls)
})
}
}
func TestTokenRefreshService_ensureOpenAIPrivacy_RetriesNonSuccessModes(t *testing.T) {
t.Parallel()
cfg := &config.Config{
TokenRefresh: config.TokenRefreshConfig{
MaxRetries: 1,
RetryBackoffSeconds: 0,
},
}
for _, mode := range []string{PrivacyModeFailed, PrivacyModeCFBlocked} {
t.Run(mode, func(t *testing.T) {
t.Parallel()
service := NewTokenRefreshService(&tokenRefreshAccountRepo{}, nil, nil, nil, nil, nil, nil, cfg, nil)
privacyCalls := 0
service.SetPrivacyDeps(func(proxyURL string) (*req.Client, error) {
privacyCalls++
return nil, errors.New("factory failed")
}, nil)
account := &Account{
ID: 202,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{
"access_token": "token-2",
},
Extra: map[string]any{
"privacy_mode": mode,
},
}
service.ensureOpenAIPrivacy(context.Background(), account)
require.Equal(t, 1, privacyCalls)
})
}
}

View File

@ -22,6 +22,19 @@ const (
PrivacyModeCFBlocked = "training_set_cf_blocked"
)
func shouldSkipOpenAIPrivacyEnsure(extra map[string]any) bool {
if extra == nil {
return false
}
raw, ok := extra["privacy_mode"]
if !ok {
return false
}
mode, _ := raw.(string)
mode = strings.TrimSpace(mode)
return mode != PrivacyModeFailed && mode != PrivacyModeCFBlocked
}
// disableOpenAITraining calls ChatGPT settings API to turn off "Improve the model for everyone".
// Returns privacy_mode value: "training_off" on success, "cf_blocked" / "failed" on failure.
func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL string) string {

View File

@ -166,7 +166,7 @@ type opsCleanupDeletedCounts struct {
func (c opsCleanupDeletedCounts) String() string {
return fmt.Sprintf(
"err_logs=%d retry_attempts=%d alert_events=%d sys_logs=%d log_audits=%d sys_metrics=%d hourly_preagg=%d daily_preagg=%d",
"error_logs=%d retry_attempts=%d alert_events=%d system_logs=%d log_audits=%d system_metrics=%d hourly_preagg=%d daily_preagg=%d",
c.errorLogs,
c.retryAttempts,
c.alertEvents,

Some files were not shown because too many files have changed in this diff Show More