Compare commits

...

55 Commits

Author SHA1 Message Date
win
cdbf05bf1c fix: align risk frontend field names with API response
Some checks failed
CI / test (push) Failing after 4s
CI / golangci-lint (push) Failing after 4s
Security Scan / backend-security (push) Failing after 6m40s
Security Scan / frontend-security (push) Failing after 1m32s
- email -> account_name
- is_overridden -> idle_override
2026-03-28 11:11:14 +08:00
win
3333307ec1 feat: add i18n for Risk Control pages
Some checks failed
CI / test (push) Failing after 4s
CI / golangci-lint (push) Failing after 3s
Security Scan / backend-security (push) Failing after 3s
Security Scan / frontend-security (push) Failing after 3s
- Add nav.risk key (en: Risk Control, zh: 风控中心)
- Add admin.risk.* keys for all UI text in view and components
- Replace all hardcoded English strings with t() calls
2026-03-28 10:40:40 +08:00
win
52ad76e6a4 fix: remove +migrate Down section from 081 migration
Some checks failed
CI / test (push) Failing after 1m31s
CI / golangci-lint (push) Failing after 32s
Security Scan / backend-security (push) Failing after 32s
Security Scan / frontend-security (push) Failing after 31s
The migrations runner executes the entire file content in a single
transaction, so the Down section DROP statements were being executed
immediately after the Up section, causing tables to be created then
dropped within the same transaction.
2026-03-28 03:25:54 +08:00
win
f25dd04e0b feat(risk): 风控数据管道与风控中心
Some checks failed
CI / test (push) Failing after 1m31s
CI / golangci-lint (push) Failing after 3s
Security Scan / backend-security (push) Failing after 3s
Security Scan / frontend-security (push) Failing after 2s
- DB Migration 081: 新增 account_behavior_hourly / account_risk_scores 表
- 行为采集:Gateway/OpenAI Gateway RecordUsage 注入 fire-and-forget CollectBehaviorAsync
- SQL 打分引擎:CTE 加权特征向量 → risk_score [0-1],UPSERT 保留 idle_override
- RiskSettings:Redis 缓存 → DB fallback → 默认值(observe 模式)
- REST API:/admin/risk/summary|accounts|accounts/:id|settings
- 前端:Pinia store + RiskControlView + 6 子组件(donut/radar/line 纯 SVG 图表)
- 侧边栏新增 Risk Control 入口(ShieldExclamationIcon)
- 反风控优化:移除 Antigravity 后台定时刷新,改为按需刷新避免 idle 封号
2026-03-28 03:07:17 +08:00
win
85ed193ff0 feat(tls): 更新 DoWithTLS 所有调用点至新三模式签名
Some checks failed
CI / test (push) Failing after 10s
CI / golangci-lint (push) Failing after 6s
Security Scan / backend-security (push) Failing after 8s
Security Scan / frontend-security (push) Failing after 7s
- DoWithTLS 签名变更:(bool/profile) → (TLSMode, profile)
- 所有调用方传入 account.GetTLSMode() 以支持 node/utls/off 三模式
- gateway_service.go、gemini_messages_compat、forward_as_* 全部更新
- claude_usage_service 的 ClaudeUsageFetchOptions 新增 TLSMode 字段
- 新增 decompressResponseBody(gzip/brotli/deflate)到 http_upstream.go
- 新增 antigravity_privacy_service.go(setAntigravityPrivacy)
- admin_service 新增 ForceOpenAIPrivacy/EnsureAntigravityPrivacy/ForceAntigravityPrivacy
- antigravity.Client 新增 SetUserSettings/FetchUserInfo API
2026-03-27 22:29:17 +08:00
shaw
574fa9dfbd 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 22:00:07 +08:00
win
d0a242df03 chore: sync VERSION to 0.1.105 2026-03-27 21:34:12 +08:00
QTom
c2cf79154a 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-27 21:34:01 +08:00
QTom
96b71a1399 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-27 21:33:31 +08:00
QTom
71ade841fb fix(privacy): 刷新令牌失败时也尝试设置 OpenAI 隐私模式
刷新失败不代表 access_token 无效,在后台定时刷新(不可重试错误 +
重试耗尽)和前端批量/单次刷新的失败路径中,均利用可能仍有效的
access_token 调用隐私设置。

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:33:20 +08:00
QTom
dcc341b846 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 21:33:02 +08:00
win
d827560223 fix: Antigravity User-Agent 版本号升至 1.21.6 修复 Gemini 3.1 Pro 访问限制
Google 后端对 User-Agent 中的版本号做版本门控,低于阈值的请求返回
"not available on this version"。将 defaultUserAgentVersion 从 0.2.0
升至与当前真实 Antigravity IDE 一致的 1.21.6。
支持通过 ANTIGRAVITY_USER_AGENT_VERSION 环境变量在运行时覆盖版本号。
2026-03-27 20:57:40 +08:00
win
e4697aa421 fix: 去掉 runtime.GOOS/GOARCH — 服务器是 Linux,真实用户在桌面
Some checks failed
CI / test (push) Failing after 5s
CI / golangci-lint (push) Failing after 5s
Security Scan / backend-security (push) Failing after 6s
Security Scan / frontend-security (push) Failing after 5s
Antigravity:
- UA: 固定 darwin/arm64(不用 runtime),匹配真实 macOS 桌面用户
- x-goog-api-client: 硬编码 go1.27(不用 runtime.Version())
- 环境变量 ANTIGRAVITY_PLATFORM_OS/ARCH 可覆盖

Gemini CLI:
- UA: 固定 darwin/arm64(Node.js process.platform/arch 格式)
- 注意: Node.js 用 x64 不是 amd64,arm64 两者一致
- 环境变量 GEMINI_CLI_PLATFORM_OS/ARCH 可覆盖

删除 runtime 包依赖,所有平台标识改为可配置的硬编码默认值。
2026-03-27 13:13:59 +08:00
win
088a508e60 fix: Gemini CLI 指纹全面修复
Some checks failed
CI / test (push) Failing after 1m33s
CI / golangci-lint (push) Failing after 6s
Security Scan / backend-security (push) Failing after 5s
Security Scan / frontend-security (push) Failing after 6s
- User-Agent: GeminiCLI/0.1.5 → GeminiCLI/0.33.1/{model} ({platform}; {arch})
  格式、版本、大小写全部对齐真实 Gemini CLI 0.33.1
- 新增 x-goog-api-client: gl-node/24.13.1 (匹配 google-auth-library DefaultTransporter)
- ideType: ANTIGRAVITY → IDE_UNSPECIFIED (修复身份泄露,真实 Gemini CLI 用 IDE_UNSPECIFIED)
- Token 交换/刷新: 添加 google-api-nodejs-client UA + x-goog-api-client
- 版本号可通过环境变量 GEMINI_CLI_VERSION 覆盖
2026-03-27 13:07:18 +08:00
win
2279bde564 fix: 心跳接入启动 + 网关错误去重
Some checks failed
CI / test (push) Failing after 1m32s
CI / golangci-lint (push) Failing after 32s
Security Scan / backend-security (push) Failing after 32s
Security Scan / frontend-security (push) Failing after 1m32s
- AntigravityGatewayService 嵌入心跳,构造时自动启动
- Forward() 方法中注册心跳(首次 API 调用触发,后续更新 token)
- 新建 gateway_errors.go: WriteClaudeErrorResponse/WriteGoogleErrorResponse 共享实现
- antigravity writeGoogleError 去掉手写映射,统一用 googleapi.HTTPStatusToGoogleStatus()
- gemini writeClaudeError/writeGoogleError 委托到共享实现
- 新增 docs/antigravity-fingerprint-diagnostic.md 诊断手册
2026-03-27 12:11:22 +08:00
win
ffe6a5e331 feat: Antigravity 100% 指纹还原 + BoringCrypto TLS
Some checks failed
CI / test (push) Failing after 4s
CI / golangci-lint (push) Failing after 3s
Security Scan / backend-security (push) Failing after 1m0s
Security Scan / frontend-security (push) Failing after 32s
Antigravity:
- Client ID 保留双 ID 支持(二进制确认两个都存在)
- Daily URL 去掉 .sandbox 后缀(日志确认)
- Redirect URI /callback → /oauth-callback(extension.js 确认)
- User-Agent 动态 OS/arch: antigravity/{ver} {os}/{arch}
- 新增 x-goog-api-client: gl-go/{goVer} gax-go/v2 grpc-go/1.81.0-dev
- googleapis 不再走 Node.js proxy → Go 原生 TLS(匹配真实 BoringCrypto)
- 新增 Go 后端心跳服务(每5分钟 loadCodeAssist + fetchAvailableModels)
- Dockerfile 切换 BoringCrypto 编译(CGO_ENABLED=1 GOEXPERIMENT=boringcrypto)

GeminiCLI:
- User-Agent 动态化: GeminiCLI/0.1.5 ({OS}; {ARCH})
- AI Studio 请求补上 User-Agent

Claude:
- CLI 版本 2.1.84, 包版本 0.74.0, 运行时 v24.3.0
- Token 交换 axios/1.13.6, timeout 15s
- proxy.js 仅服务 api.anthropic.com(Claude 专属)

架构变更:
- Node.js proxy 仅用于 Claude (api.anthropic.com)
- Antigravity (googleapis) 走 Go 原生 HTTP + GOST proxy
- TLS 指纹: Go BoringCrypto ≈ 真实 Antigravity BoringCrypto
2026-03-27 02:24:03 +08:00
win
8c6e578a84 feat: IP管理代理与 node-tls-proxy 指纹伪装共存
Some checks failed
CI / test (push) Failing after 6s
CI / golangci-lint (push) Failing after 6s
Security Scan / backend-security (push) Failing after 6s
Security Scan / frontend-security (push) Failing after 6s
- Do()/DoWithTLS() 移除 proxyURL=="" 条件,绑了代理也走 node-tls-proxy
- doViaNodeTLSProxy 通过 X-Upstream-Proxy header 传递账号代理给 node-tls-proxy
- node-tls-proxy 支持 per-request 动态上游代理,优先 X-Upstream-Proxy,回退全局 UPSTREAM_PROXY
- 效果:IP管理 = 落地机网络,账号绑代理后指纹伪装仍然生效
2026-03-26 14:00:17 +08:00
win
e1de3a7b21 fix: antigravity client 无显式代理时自动读取 HTTPS_PROXY 环境变量
Some checks failed
CI / test (push) Failing after 1m32s
CI / golangci-lint (push) Failing after 1m32s
Security Scan / backend-security (push) Failing after 32s
Security Scan / frontend-security (push) Failing after 32s
解决 OAuth token 交换在国内服务器超时的问题:
当账号未绑定代理时,NewClient 现在显式使用 http.ProxyFromEnvironment,
使得 HTTPS_PROXY 环境变量对 oauth2.googleapis.com 等外部请求生效。
API 调用仍然走 node-tls-proxy(指纹伪装不受影响)。
2026-03-26 12:31:01 +08:00
win
f5abc62fd3 fix: 三节点部署脚本修复 + sub2api 容器代理透传
Some checks failed
CI / test (push) Failing after 12s
CI / golangci-lint (push) Failing after 6s
Security Scan / backend-security (push) Failing after 5s
Security Scan / frontend-security (push) Failing after 5s
- GOST 下载 URL 修复:补全版本号 (gost_3.2.6_linux_amd64.tar.gz)
- CN 中转机服务名改为 gost-sub2api-relay,避免与现有 gost-relay 冲突
- CN 中转机监听协议改为 http(兼容 node-tls-proxy 的 HTTP CONNECT)
- 美国落地机服务名改为 gost-sub2api-exit
- sub2api 容器透传 HTTPS_PROXY/HTTP_PROXY 环境变量(解决 OAuth 超时)
- ops_cleanup 日志字段名避免触发 ERROR 误判
- 添加密码重置脚本和 SOCKS5 服务文件
2026-03-26 12:09:05 +08:00
win
b3cb3ea345 fix: 构建脚本路径改为 antigravity/node-tls-proxy
Some checks failed
CI / test (push) Failing after 31s
CI / golangci-lint (push) Failing after 32s
Security Scan / backend-security (push) Failing after 14s
Security Scan / frontend-security (push) Failing after 32s
2026-03-25 14:36:56 +08:00
win
a9dbceba32 fix: 去掉错误的 CN中转机 IP 配置,node-tls-proxy 在本机 Docker 内自动发现
Some checks failed
CI / test (push) Failing after 12s
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
CI / golangci-lint (push) Failing after 2m47s
2026-03-25 14:30:31 +08:00
win
b64997ae17 fix: 修复 setup-node1 deploy 目录路径查找逻辑,兼容从仓库根目录执行
Some checks failed
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-25 14:28:50 +08:00
win
4f6c5d7b5c feat: 三节点部署脚本(上海/CN中转/美国落地) 2026-03-25 14:06:06 +08:00
win
91600c4abe feat: 添加 Mac 和 Linux 全量指纹验证脚本 2026-03-25 13:00:52 +08:00
win
324483eabd feat: TCP Window Size 伪装 + CLI 版本自动追踪
Some checks failed
CI / test (push) Failing after 3s
CI / golangci-lint (push) Failing after 4s
Security Scan / backend-security (push) Failing after 6s
Security Scan / frontend-security (push) Failing after 5s
firewall.sh:
- TCP Window Size 设为 65535(macOS 默认,Linux 服务器默认 29200)
- 持久化到 /etc/sysctl.conf

maintenance/update-cli-version.sh:
- 从 npm registry 获取 @anthropic-ai/claude-code 最新版本
- 自动更新 proxy.js 中的 CLI_VERSION
- 支持 --check(仅检查)/ --force VER(强制指定)
- 建议 cron 每天 03:00 ET 运行
2026-03-25 11:55:24 +08:00
win
eeca6c90a4 fix: 时区改为 America/New_York(出口 IP 在纽约) 2026-03-25 11:52:14 +08:00
win
6958b0dedb feat: macOS 指纹伪装 — TCP TTL/时间戳/时区 + H2优先 + Jitter增强
proxy.js:
- 主机身份全面改为 macOS (hostname: alex-MBP, osType: Darwin)
- macOS 版本号 (Ventura/Sonoma/Sequoia), Darwin 内核 22/23/24.x
- machineId 改为 IOPlatformUUID 格式(大写 UUID)
- arch: 70% arm64 / 30% x64(Apple Silicon 主流)
- 遥测 platform/ddtags 改为 darwin,路径改为 /Users/
- Jitter: 指数分布,80% 快(80-300ms) / 20% 慢(400-1200ms)
- H2 优先: api.anthropic.com/cloudaicompanion/generativelanguage 直接走 H2

setup-firewall.sh:
- 新增 TCP TTL 强制 = 64 (iptables mangle TTL)
- 新增 TCP 时间戳禁用 (net.ipv4.tcp_timestamps=0 + 持久化)
- 新增系统时区设置 America/Los_Angeles
- 新增 timezone 子命令、完整 status 输出
2026-03-25 11:50:41 +08:00
win
e5d78f8e56 refactor: 将自定义代码集中到 antigravity/ 目录和 *_antigravity.go 文件
Some checks failed
CI / test (push) Failing after 39s
CI / golangci-lint (push) Failing after 3s
Security Scan / backend-security (push) Failing after 4s
Security Scan / frontend-security (push) Failing after 3s
- antigravity/node-tls-proxy/     ← 原 tools/node-tls-proxy
- antigravity/firewall/           ← 原 tools/firewall
- antigravity/maintenance/        ← 原 tools/maintenance
- repository/http_upstream_antigravity.go  ← Node.js 代理 3 个方法(原在 http_upstream.go)
- service/identity_service_antigravity.go  ← ApplyDefaultFingerprintOverrides + NewIdentityServiceWithSalt
- service/account_antigravity.go  ← Gemini TLS 指纹扩展函数

对上游文件 http_upstream.go 的钩子调用精简为 2 处 if 块(共 14 行)
对上游文件 account.go Gemini 分支精简为 1 行函数调用
便于 upstream rebase 时快速识别和保留自定义改动
2026-03-25 11:37:27 +08:00
win
8b71fa1bf3 feat: fork 同步工具链 — workflow + sync/patch 脚本 2026-03-25 11:37:27 +08:00
win
49388f11b7 fix: proxy.js 修复 2026-03-25 11:37:27 +08:00
win
44539d5b32 feat: Antigravity (googleapis.com) 也走 Node.js TLS 代理,消除 Go 指纹 2026-03-25 11:37:27 +08:00
win
1ccf6613e2 feat: 遥测优化 — 丰富事件 + 双批次 + model 解析 + beta 常量 2026-03-25 11:37:27 +08:00
win
75c3c01f46 feat: 遥测模拟 — 模拟 Claude CLI 的 event_logging + DataDog 日志
基于真实 Claude CLI 2.1.81 抓包数据实现:
- POST api.anthropic.com/api/event_logging/batch(请求前后自动发送)
- POST http-intake.logs.us5.datadoghq.com/api/v2/logs
- 事件类型:tengu_started, tengu_init, tengu_api_request_started/completed
- 每个账号独立 session_id + device_id
- process_metrics base64 编码(匹配真实格式)
- 可通过 TELEMETRY_ENABLED=false 关闭
2026-03-25 11:37:27 +08:00
win
068b0cbc39 revert: 移除 Sora sidecar,还原 sora_sdk_client.go 到原版 2026-03-25 11:37:27 +08:00
win
3c8ffd3efc fix: 双模型审查 Critical 修复
1. Sora session_key 按 accountID 隔离(消除跨账号指纹关联)
2. 有 per-account 代理的 Sora 账号跳过 sidecar(保持代理 IP)
3. 请求体用 base64 编码传输(防止二进制数据损坏)
4. Node.js 代理 Body 用 GetBody 安全复制(修复重试时 Body 枯竭)
2026-03-25 11:37:27 +08:00
win
4a92f1903f fix: 架构审查修复 3 个 bug
1. instanceSalt 空值兼容:salt 为空时保持原始 hash 格式不变
   避免升级后所有 user_id hash 突变触发 Anthropic 检测
2. doViaNodeTLSProxy 克隆请求:不修改原始 req 对象
   修复重试时 URL 已被改写导致请求失败
3. Sora doSoraBackendJSON 漏改:补上 sidecar 路由
2026-03-25 11:37:27 +08:00
win
99c77c4641 fix: 有 per-account 代理的账号不走 Node.js 代理,防止 IP 变化触发风控 2026-03-25 11:37:27 +08:00
win
4037eebd37 feat: Sora 请求优先走 curl_cffi sidecar(Chrome 指纹绕过 Cloudflare) 2026-03-25 11:37:27 +08:00
win
60c532ea7f feat: Sora curl_cffi sidecar — Chrome TLS 指纹绕过 Cloudflare
- 新增 sora-curl-cffi-sidecar 容器(Python + curl_cffi + chrome131)
- docker-compose.tls-proxy.yml 集成 sidecar,sub2api 自动连接
- 会话池复用,避免重复 TLS 握手
- 镜像 zfc931912343/sora-curl-cffi-sidecar:latest (amd64+arm64)
2026-03-25 11:37:27 +08:00
win
4bca447e33 fix: Node.js TLS 代理仅拦截 api.anthropic.com,修复 Sora 404 2026-03-25 11:37:27 +08:00
win
f5fdd41aea feat: 实例级隔离 — salt + 指纹版本可配置
- 新增 gateway.instance_salt: 不同 sub2api 实例对相同输入产生不同 hash
  影响 user_id 重写和 session hash,防止跨实例指纹关联
- 新增 gateway.fingerprint_defaults: CLI 版本号/SDK 版本/OS/Arch 可配置
  每个实例可设不同值,与其他 sub2api 部署区分
- constants.go + identity_service.go 支持启动时覆盖默认指纹
- wire_gen.go 启动时读取配置并应用覆盖
2026-03-25 11:37:27 +08:00
win
f68dc13a1a fix: 更新 Claude CLI 指纹版本 2.1.22→2.1.81, SDK 0.70.0→0.80.0 2026-03-25 11:37:27 +08:00
win
d38b672d54 fix: Node.js TLS 代理仅拦截 Anthropic 请求(DoWithTLS 路径)
- Do() 去掉 Node.js 代理拦截,Antigravity/Google 请求走原路径
- 只有 DoWithTLS 且 enableTLSFingerprint=true 时走 Node.js 代理
- 按平台分治:Anthropic → Node.js 原生 TLS,Google → 原有 uTLS/直连
2026-03-25 11:37:27 +08:00
win
3b42e71b12 fix: 重写 proxy.js — 预收集 body + H1/H2 自适应,本地测试 4/4 通过 2026-03-25 11:37:27 +08:00
win
37a1b404e9 feat: 智能 H1/H2 自适应 — 首次 H1 秒挂自动切 H2 并缓存
- 首次请求走 HTTP/1.1,如果 socket hang up < 2s 自动切 HTTP/2
- H2 主机缓存在内存中,后续请求直接走 H2(如 googleapis.com)
- H2 session 池复用 + 空闲超时自动清理
- 详细日志:proxy_request → proxy_response/error,含协议标识
- 解决 googleapis.com 强制 H2 导致请求失败的问题
2026-03-25 11:37:27 +08:00
win
9586cf1110 fix: 去掉 H2/ALPN 复杂度,回到纯 https.request + 动态主机 + 响应日志 2026-03-25 11:37:27 +08:00
win
4f82ce23a9 feat: Node.js TLS 代理支持 HTTP/2 + 动态主机路由
- proxy.js: 自动探测上游 ALPN (h2/http1.1),按需选择协议
- proxy.js: X-Forwarded-Host 动态路由,支持任意上游主机
- proxy.js: HTTP/2 session 缓存 + 空闲超时自动清理
- Go: 所有 HTTPS 上游请求统一走 Node.js 代理,无域名白名单
- 解决 googleapis.com 要求 HTTP/2 导致 socket hang up
2026-03-25 11:37:27 +08:00
win
0086cfdfe8 fix: Node.js TLS 代理对所有 HTTPS 上游生效,去掉域名白名单
- 移除 proxy_hosts 白名单限制和 shouldRouteViaNodeProxy
- 所有 HTTPS 上游请求统一走 Node.js 代理
- 通过 X-Forwarded-Host 动态识别目标主机
- Anthropic / Gemini / 任意上游自动适配
- 移除诊断日志(已定位问题)
2026-03-25 11:37:27 +08:00
win
cb035e4637 diag: 在 DoWithTLS 路径也添加诊断日志 2026-03-25 11:37:27 +08:00
win
47fba12a75 fix: Node.js TLS 代理按 proxy_hosts 白名单过滤 + 诊断日志
- 新增 proxy_hosts 配置:可配置需要走 Node.js 代理的主机列表
- 默认仅代理 api.anthropic.com,Gemini/Sora 走原路径
- 添加 warn 级别诊断日志,输出请求的 scheme/host/hostname/should_route
- 用于定位 Anthropic 请求未命中 Node.js 代理的原因
2026-03-25 11:37:27 +08:00
win
45c90b22eb fix: Node.js TLS 代理按主机白名单过滤,Gemini 走原路径
- 新增 proxy_hosts 配置:白名单内的主机走 Node.js 代理
- 默认仅代理 api.anthropic.com
- Gemini/Sora 等非 Anthropic 请求自动走原有 uTLS 路径
- 解决 Gemini 请求经 Node.js 代理后 socket hang up 的问题
2026-03-25 11:37:27 +08:00
win
5de1618e08 fix: Node.js TLS 代理动态识别上游主机
- Go: 通过 X-Forwarded-Host 传递原始目标主机给 Node.js 代理
- Node.js: 读取 X-Forwarded-Host 动态连接到正确的上游主机
- 所有 HTTPS 上游请求统一走代理,不再固定绑定 api.anthropic.com
- Gemini/Sora 等不同上游自动识别,无需手动配置
2026-03-25 11:37:27 +08:00
win
71a068c193 fix: Node.js TLS 代理对所有 HTTPS 上游请求生效
Do() 方法新增 Node.js 代理检查,不再依赖账号级 TLS 指纹开关。
当 node_tls_proxy.enabled=true 时,所有 HTTPS 上游请求统一走
Node.js 代理,确保 JA3/JA4 指纹一致。
2026-03-25 11:37:26 +08:00
win
8cac4269aa feat: Node.js TLS 指纹代理 + 网络隔离防泄露
- 新增 Node.js TLS Forward Proxy (tools/node-tls-proxy/)
  原生 Node.js TLS 栈发起上游 HTTPS,JA3/JA4 天然匹配 Claude CLI
  SSE 流式透传,支持上游 HTTP CONNECT 代理
  零依赖,Node.js 24.13.0 锁定版本

- Go 集成 (config.go + http_upstream.go)
  新增 NodeTLSProxyConfig 配置
  DoWithTLS 优先走 Node.js 代理模式,URL 重写 https→http://localhost:3456

- Docker 网络隔离 (docker-compose.tls-proxy.yml)
  sub2api 容器仅 internal 网络,物理隔离外网
  node-tls-proxy 唯一出站通道,IPv6 内核级禁用

- iptables 防泄露脚本 (tools/firewall/)
  QUIC/UDP 443 全局 DROP,仅 nodeproxy 用户可出站 TCP 443

- 镜像切换为 zfc931912343/ 仓库
2026-03-25 11:37:26 +08:00
159 changed files with 16166 additions and 1237 deletions

View File

@ -0,0 +1,102 @@
---
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-alpine
ARG GOLANG_IMAGE=golang:1.26.1
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
RUN apk add --no-cache git ca-certificates tzdata
# 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/*
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 (BuildType=release for CI builds, embed frontend)
# Version precedence: build arg VERSION > cmd/server/VERSION
# Build the binary with BoringCrypto (matches real Antigravity TLS fingerprint)
# CGO_ENABLED=1 required for BoringCrypto; static linking via -extldflags for scratch-like deployment
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=0 GOOS=linux go build \
CGO_ENABLED=1 GOEXPERIMENT=boringcrypto GOOS=linux go build \
-tags embed \
-ldflags="-s -w -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \
-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" \
-trimpath \
-o /app/sub2api \
./cmd/server

View File

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

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

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

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

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

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

@ -0,0 +1,68 @@
================================================================================
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

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

@ -0,0 +1,240 @@
#!/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()

99
antigravity/capture/run.sh Executable file
View File

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

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

@ -0,0 +1,30 @@
#!/usr/bin/env bash
# save-patches.sh — 将 Antigravity 自定义改动导出为 patch 文件
# 用法: ./tools/scripts/save-patches.sh [输出目录]
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
OUTPUT_DIR="${1:-$REPO_ROOT/tools/patches}"
UPSTREAM="origin/main"
cd "$REPO_ROOT"
# 检查是否有新的 upstream commits
DIVERGED=$(git log --oneline "$UPSTREAM"..HEAD 2>/dev/null | wc -l | tr -d ' ')
if [ "$DIVERGED" -eq 0 ]; then
echo "[save-patches] 没有领先 upstream 的 commits无需保存。"
exit 0
fi
mkdir -p "$OUTPUT_DIR"
# 导出 patches
git format-patch "$UPSTREAM"..HEAD --output-directory "$OUTPUT_DIR" --no-stat
COUNT=$(ls "$OUTPUT_DIR"/*.patch 2>/dev/null | wc -l | tr -d ' ')
echo "[save-patches] ✅ 已导出 $COUNT 个 patch 到 $OUTPUT_DIR/"
echo ""
echo "恢复方法(在全新 upstream checkout 上):"
echo " git am $OUTPUT_DIR/*.patch"
echo " # 或逐一应用:"
echo " for p in $OUTPUT_DIR/*.patch; do git am \"\$p\" || git am --skip; done"

View File

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

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

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

@ -0,0 +1,82 @@
#!/usr/bin/env bash
# sync-upstream.sh — 从 upstream (origin/main) 同步更新,保留自定义改动
# 用法: ./tools/scripts/sync-upstream.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
UPSTREAM="origin/main"
cd "$REPO_ROOT"
echo "========================================"
echo " Antigravity Fork — Upstream Sync Tool"
echo "========================================"
echo ""
# Step 1: 检查工作区
if ! git diff --quiet || ! git diff --staged --quiet; then
echo "❌ 工作区有未提交的改动,请先 git stash 或 git commit"
git status --short
exit 1
fi
# Step 2: Fetch
echo "[1/4] Fetching upstream..."
git fetch origin
# Step 3: 检查是否有新 commits
NEW=$(git log --oneline HEAD.."$UPSTREAM" 2>/dev/null | wc -l | tr -d ' ')
if [ "$NEW" -eq 0 ]; then
echo "✅ 已是最新,无需同步。"
exit 0
fi
echo ""
echo "上游有 $NEW 个新 commits:"
git log --oneline HEAD.."$UPSTREAM"
echo ""
# Step 4: 备份当前 patches
PATCH_DIR="/tmp/antigravity-patches-$(date +%Y%m%d-%H%M%S)"
echo "[2/4] 备份自定义 patches 到 $PATCH_DIR ..."
mkdir -p "$PATCH_DIR"
git format-patch "$UPSTREAM"..HEAD -o "$PATCH_DIR/" --no-stat
BACKED=$(ls "$PATCH_DIR"/*.patch 2>/dev/null | wc -l | tr -d ' ')
echo " 已备份 $BACKED 个 patch"
# Step 5: Rebase
echo ""
echo "[3/4] 执行 rebase (git rebase $UPSTREAM)..."
echo " 如果出现冲突,请参考 .agents/workflows/sync-upstream.md 中的冲突解决指南"
echo ""
if ! git rebase "$UPSTREAM"; then
echo ""
echo "❌ Rebase 出现冲突!"
echo ""
echo "请按以下步骤处理:"
echo " 1. 查看冲突文件: git diff --name-only --diff-filter=U"
echo " 2. 解决冲突(参考 .agents/workflows/sync-upstream.md"
echo " 3. git add <解决的文件>"
echo " 4. git rebase --continue"
echo ""
echo "备份的 patches 在: $PATCH_DIR"
exit 1
fi
# Step 6: 编译验证
echo "[4/4] 编译验证..."
if ! (cd "$REPO_ROOT/backend" && go build ./... 2>&1); then
echo ""
echo "❌ 编译失败rebase 后有破坏性改动需要修复。"
echo "备份的 patches 在: $PATCH_DIR"
exit 1
fi
echo ""
echo "✅ 同步完成!"
echo ""
echo "自定义改动(我方 commits已成功移植到最新 upstream 上。"
echo "请运行以下命令推送:"
echo " git push origin main --force-with-lease"
echo ""
echo "备份路径(可删除): $PATCH_DIR"

View File

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

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

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

@ -0,0 +1,24 @@
FROM node:24.13.0-slim
LABEL maintainer="Wei-Shaw <github.com/Wei-Shaw>"
LABEL description="Node.js TLS Forward Proxy - native JA3/JA4 fingerprint matching"
LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api"
WORKDIR /app
COPY proxy.js package.json ./
# 零依赖,不需要 npm install
ENV PROXY_PORT=3456
ENV PROXY_HOST=0.0.0.0
ENV UPSTREAM_HOST=api.anthropic.com
EXPOSE 3456
# 健康检查:用 Node.js 内置 http 模块,不依赖 curl
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=5s \
CMD node -e "const http=require('http');const r=http.get('http://127.0.0.1:'+(process.env.PROXY_PORT||3456)+'/__health',s=>{process.exit(s.statusCode===200?0:1)});r.on('error',()=>process.exit(1));r.setTimeout(3000,()=>{r.destroy();process.exit(1)})"
USER node
CMD ["node", "proxy.js"]

View File

@ -0,0 +1,14 @@
{
"name": "node-tls-proxy",
"version": "1.0.0",
"private": true,
"description": "Node.js TLS forward proxy for native JA3/JA4 fingerprint matching",
"main": "proxy.js",
"scripts": {
"start": "node proxy.js",
"health": "curl -s http://127.0.0.1:${PROXY_PORT:-3456}/__health | jq ."
},
"engines": {
"node": ">=20.0.0"
}
}

View File

@ -0,0 +1,737 @@
'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) }));

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=0 go build -ldflags="$(LDFLAGS)" -trimpath -o bin/server ./cmd/server
CGO_ENABLED=1 GOEXPERIMENT=boringcrypto go build -ldflags="$(LDFLAGS)" -trimpath -o bin/server ./cmd/server
generate:
go generate ./ent

View File

@ -1 +1 @@
0.1.104
0.1.105

View File

@ -12,6 +12,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/repository"
"github.com/Wei-Shaw/sub2api/internal/server"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
@ -35,6 +36,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
if err != nil {
return nil, err
}
// 应用实例级指纹覆盖(不同 sub2api 实例可设不同的默认版本号)
fpd := configConfig.Gateway.FingerprintDefaults
claude.ApplyFingerprintOverrides(fpd.ClaudeCLIVersion, fpd.StainlessPackageVersion, fpd.StainlessRuntimeVersion, fpd.StainlessOS, fpd.StainlessArch)
service.ApplyDefaultFingerprintOverrides(fpd.ClaudeCLIVersion, fpd.StainlessPackageVersion, fpd.StainlessRuntimeVersion, fpd.StainlessOS, fpd.StainlessArch)
client, err := repository.ProvideEnt(configConfig)
if err != nil {
return nil, err
@ -132,19 +137,24 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
usageCache := service.NewUsageCache()
identityCache := repository.NewIdentityCache(redisClient)
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache)
geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oauthRefreshAPI)
gatewayCache := repository.NewGatewayCache(redisClient)
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)
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig)
tlsFingerprintProfileRepository := repository.NewTLSFingerprintProfileRepository(client)
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig)
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()
@ -167,13 +177,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
return nil, err
}
billingService := service.NewBillingService(configConfig, pricingService)
identityService := service.NewIdentityService(identityCache)
identityService := service.NewIdentityServiceWithSalt(identityCache, configConfig.Gateway.InstanceSalt)
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)
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)
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)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, riskService)
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)
@ -203,12 +213,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
errorPassthroughCache := repository.NewErrorPassthroughCache(redisClient)
errorPassthroughService := service.NewErrorPassthroughService(errorPassthroughRepository, errorPassthroughCache)
errorPassthroughHandler := admin.NewErrorPassthroughHandler(errorPassthroughService)
tlsFingerprintProfileHandler := admin.NewTLSFingerprintProfileHandler(tlsFingerprintProfileService)
adminAPIKeyHandler := admin.NewAdminAPIKeyHandler(adminService)
scheduledTestPlanRepository := repository.NewScheduledTestPlanRepository(db)
scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db)
scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository)
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, adminAPIKeyHandler, scheduledTestHandler)
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)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)

View File

@ -29,6 +29,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
"github.com/Wei-Shaw/sub2api/ent/setting"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
@ -73,6 +74,8 @@ type Client struct {
SecuritySecret *SecuritySecretClient
// Setting is the client for interacting with the Setting builders.
Setting *SettingClient
// TLSFingerprintProfile is the client for interacting with the TLSFingerprintProfile builders.
TLSFingerprintProfile *TLSFingerprintProfileClient
// UsageCleanupTask is the client for interacting with the UsageCleanupTask builders.
UsageCleanupTask *UsageCleanupTaskClient
// UsageLog is the client for interacting with the UsageLog builders.
@ -112,6 +115,7 @@ func (c *Client) init() {
c.RedeemCode = NewRedeemCodeClient(c.config)
c.SecuritySecret = NewSecuritySecretClient(c.config)
c.Setting = NewSettingClient(c.config)
c.TLSFingerprintProfile = NewTLSFingerprintProfileClient(c.config)
c.UsageCleanupTask = NewUsageCleanupTaskClient(c.config)
c.UsageLog = NewUsageLogClient(c.config)
c.User = NewUserClient(c.config)
@ -225,6 +229,7 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) {
RedeemCode: NewRedeemCodeClient(cfg),
SecuritySecret: NewSecuritySecretClient(cfg),
Setting: NewSettingClient(cfg),
TLSFingerprintProfile: NewTLSFingerprintProfileClient(cfg),
UsageCleanupTask: NewUsageCleanupTaskClient(cfg),
UsageLog: NewUsageLogClient(cfg),
User: NewUserClient(cfg),
@ -265,6 +270,7 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error)
RedeemCode: NewRedeemCodeClient(cfg),
SecuritySecret: NewSecuritySecretClient(cfg),
Setting: NewSettingClient(cfg),
TLSFingerprintProfile: NewTLSFingerprintProfileClient(cfg),
UsageCleanupTask: NewUsageCleanupTaskClient(cfg),
UsageLog: NewUsageLogClient(cfg),
User: NewUserClient(cfg),
@ -304,8 +310,9 @@ func (c *Client) Use(hooks ...Hook) {
c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead,
c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, c.PromoCode,
c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting,
c.UsageCleanupTask, c.UsageLog, c.User, c.UserAllowedGroup,
c.UserAttributeDefinition, c.UserAttributeValue, c.UserSubscription,
c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User,
c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
c.UserSubscription,
} {
n.Use(hooks...)
}
@ -318,8 +325,9 @@ func (c *Client) Intercept(interceptors ...Interceptor) {
c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead,
c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, c.PromoCode,
c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting,
c.UsageCleanupTask, c.UsageLog, c.User, c.UserAllowedGroup,
c.UserAttributeDefinition, c.UserAttributeValue, c.UserSubscription,
c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User,
c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
c.UserSubscription,
} {
n.Intercept(interceptors...)
}
@ -356,6 +364,8 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) {
return c.SecuritySecret.mutate(ctx, m)
case *SettingMutation:
return c.Setting.mutate(ctx, m)
case *TLSFingerprintProfileMutation:
return c.TLSFingerprintProfile.mutate(ctx, m)
case *UsageCleanupTaskMutation:
return c.UsageCleanupTask.mutate(ctx, m)
case *UsageLogMutation:
@ -2612,6 +2622,139 @@ func (c *SettingClient) mutate(ctx context.Context, m *SettingMutation) (Value,
}
}
// TLSFingerprintProfileClient is a client for the TLSFingerprintProfile schema.
type TLSFingerprintProfileClient struct {
config
}
// NewTLSFingerprintProfileClient returns a client for the TLSFingerprintProfile from the given config.
func NewTLSFingerprintProfileClient(c config) *TLSFingerprintProfileClient {
return &TLSFingerprintProfileClient{config: c}
}
// Use adds a list of mutation hooks to the hooks stack.
// A call to `Use(f, g, h)` equals to `tlsfingerprintprofile.Hooks(f(g(h())))`.
func (c *TLSFingerprintProfileClient) Use(hooks ...Hook) {
c.hooks.TLSFingerprintProfile = append(c.hooks.TLSFingerprintProfile, hooks...)
}
// Intercept adds a list of query interceptors to the interceptors stack.
// A call to `Intercept(f, g, h)` equals to `tlsfingerprintprofile.Intercept(f(g(h())))`.
func (c *TLSFingerprintProfileClient) Intercept(interceptors ...Interceptor) {
c.inters.TLSFingerprintProfile = append(c.inters.TLSFingerprintProfile, interceptors...)
}
// Create returns a builder for creating a TLSFingerprintProfile entity.
func (c *TLSFingerprintProfileClient) Create() *TLSFingerprintProfileCreate {
mutation := newTLSFingerprintProfileMutation(c.config, OpCreate)
return &TLSFingerprintProfileCreate{config: c.config, hooks: c.Hooks(), mutation: mutation}
}
// CreateBulk returns a builder for creating a bulk of TLSFingerprintProfile entities.
func (c *TLSFingerprintProfileClient) CreateBulk(builders ...*TLSFingerprintProfileCreate) *TLSFingerprintProfileCreateBulk {
return &TLSFingerprintProfileCreateBulk{config: c.config, builders: builders}
}
// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates
// a builder and applies setFunc on it.
func (c *TLSFingerprintProfileClient) MapCreateBulk(slice any, setFunc func(*TLSFingerprintProfileCreate, int)) *TLSFingerprintProfileCreateBulk {
rv := reflect.ValueOf(slice)
if rv.Kind() != reflect.Slice {
return &TLSFingerprintProfileCreateBulk{err: fmt.Errorf("calling to TLSFingerprintProfileClient.MapCreateBulk with wrong type %T, need slice", slice)}
}
builders := make([]*TLSFingerprintProfileCreate, rv.Len())
for i := 0; i < rv.Len(); i++ {
builders[i] = c.Create()
setFunc(builders[i], i)
}
return &TLSFingerprintProfileCreateBulk{config: c.config, builders: builders}
}
// Update returns an update builder for TLSFingerprintProfile.
func (c *TLSFingerprintProfileClient) Update() *TLSFingerprintProfileUpdate {
mutation := newTLSFingerprintProfileMutation(c.config, OpUpdate)
return &TLSFingerprintProfileUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation}
}
// UpdateOne returns an update builder for the given entity.
func (c *TLSFingerprintProfileClient) UpdateOne(_m *TLSFingerprintProfile) *TLSFingerprintProfileUpdateOne {
mutation := newTLSFingerprintProfileMutation(c.config, OpUpdateOne, withTLSFingerprintProfile(_m))
return &TLSFingerprintProfileUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation}
}
// UpdateOneID returns an update builder for the given id.
func (c *TLSFingerprintProfileClient) UpdateOneID(id int64) *TLSFingerprintProfileUpdateOne {
mutation := newTLSFingerprintProfileMutation(c.config, OpUpdateOne, withTLSFingerprintProfileID(id))
return &TLSFingerprintProfileUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation}
}
// Delete returns a delete builder for TLSFingerprintProfile.
func (c *TLSFingerprintProfileClient) Delete() *TLSFingerprintProfileDelete {
mutation := newTLSFingerprintProfileMutation(c.config, OpDelete)
return &TLSFingerprintProfileDelete{config: c.config, hooks: c.Hooks(), mutation: mutation}
}
// DeleteOne returns a builder for deleting the given entity.
func (c *TLSFingerprintProfileClient) DeleteOne(_m *TLSFingerprintProfile) *TLSFingerprintProfileDeleteOne {
return c.DeleteOneID(_m.ID)
}
// DeleteOneID returns a builder for deleting the given entity by its id.
func (c *TLSFingerprintProfileClient) DeleteOneID(id int64) *TLSFingerprintProfileDeleteOne {
builder := c.Delete().Where(tlsfingerprintprofile.ID(id))
builder.mutation.id = &id
builder.mutation.op = OpDeleteOne
return &TLSFingerprintProfileDeleteOne{builder}
}
// Query returns a query builder for TLSFingerprintProfile.
func (c *TLSFingerprintProfileClient) Query() *TLSFingerprintProfileQuery {
return &TLSFingerprintProfileQuery{
config: c.config,
ctx: &QueryContext{Type: TypeTLSFingerprintProfile},
inters: c.Interceptors(),
}
}
// Get returns a TLSFingerprintProfile entity by its id.
func (c *TLSFingerprintProfileClient) Get(ctx context.Context, id int64) (*TLSFingerprintProfile, error) {
return c.Query().Where(tlsfingerprintprofile.ID(id)).Only(ctx)
}
// GetX is like Get, but panics if an error occurs.
func (c *TLSFingerprintProfileClient) GetX(ctx context.Context, id int64) *TLSFingerprintProfile {
obj, err := c.Get(ctx, id)
if err != nil {
panic(err)
}
return obj
}
// Hooks returns the client hooks.
func (c *TLSFingerprintProfileClient) Hooks() []Hook {
return c.hooks.TLSFingerprintProfile
}
// Interceptors returns the client interceptors.
func (c *TLSFingerprintProfileClient) Interceptors() []Interceptor {
return c.inters.TLSFingerprintProfile
}
func (c *TLSFingerprintProfileClient) mutate(ctx context.Context, m *TLSFingerprintProfileMutation) (Value, error) {
switch m.Op() {
case OpCreate:
return (&TLSFingerprintProfileCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
case OpUpdate:
return (&TLSFingerprintProfileUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
case OpUpdateOne:
return (&TLSFingerprintProfileUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
case OpDelete, OpDeleteOne:
return (&TLSFingerprintProfileDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx)
default:
return nil, fmt.Errorf("ent: unknown TLSFingerprintProfile mutation op: %q", m.Op())
}
}
// UsageCleanupTaskClient is a client for the UsageCleanupTask schema.
type UsageCleanupTaskClient struct {
config
@ -3889,16 +4032,16 @@ type (
hooks struct {
APIKey, Account, AccountGroup, Announcement, AnnouncementRead,
ErrorPassthroughRule, Group, IdempotencyRecord, PromoCode, PromoCodeUsage,
Proxy, RedeemCode, SecuritySecret, Setting, UsageCleanupTask, UsageLog, User,
UserAllowedGroup, UserAttributeDefinition, UserAttributeValue,
UserSubscription []ent.Hook
Proxy, RedeemCode, SecuritySecret, Setting, TLSFingerprintProfile,
UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition,
UserAttributeValue, UserSubscription []ent.Hook
}
inters struct {
APIKey, Account, AccountGroup, Announcement, AnnouncementRead,
ErrorPassthroughRule, Group, IdempotencyRecord, PromoCode, PromoCodeUsage,
Proxy, RedeemCode, SecuritySecret, Setting, UsageCleanupTask, UsageLog, User,
UserAllowedGroup, UserAttributeDefinition, UserAttributeValue,
UserSubscription []ent.Interceptor
Proxy, RedeemCode, SecuritySecret, Setting, TLSFingerprintProfile,
UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition,
UserAttributeValue, UserSubscription []ent.Interceptor
}
)

View File

@ -26,6 +26,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
"github.com/Wei-Shaw/sub2api/ent/setting"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
@ -107,6 +108,7 @@ func checkColumn(t, c string) error {
redeemcode.Table: redeemcode.ValidColumn,
securitysecret.Table: securitysecret.ValidColumn,
setting.Table: setting.ValidColumn,
tlsfingerprintprofile.Table: tlsfingerprintprofile.ValidColumn,
usagecleanuptask.Table: usagecleanuptask.ValidColumn,
usagelog.Table: usagelog.ValidColumn,
user.Table: user.ValidColumn,

View File

@ -177,6 +177,18 @@ func (f SettingFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, err
return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.SettingMutation", m)
}
// The TLSFingerprintProfileFunc type is an adapter to allow the use of ordinary
// function as TLSFingerprintProfile mutator.
type TLSFingerprintProfileFunc func(context.Context, *ent.TLSFingerprintProfileMutation) (ent.Value, error)
// Mutate calls f(ctx, m).
func (f TLSFingerprintProfileFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) {
if mv, ok := m.(*ent.TLSFingerprintProfileMutation); ok {
return f(ctx, mv)
}
return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.TLSFingerprintProfileMutation", m)
}
// The UsageCleanupTaskFunc type is an adapter to allow the use of ordinary
// function as UsageCleanupTask mutator.
type UsageCleanupTaskFunc func(context.Context, *ent.UsageCleanupTaskMutation) (ent.Value, error)

View File

@ -23,6 +23,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
"github.com/Wei-Shaw/sub2api/ent/setting"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
@ -466,6 +467,33 @@ func (f TraverseSetting) Traverse(ctx context.Context, q ent.Query) error {
return fmt.Errorf("unexpected query type %T. expect *ent.SettingQuery", q)
}
// The TLSFingerprintProfileFunc type is an adapter to allow the use of ordinary function as a Querier.
type TLSFingerprintProfileFunc func(context.Context, *ent.TLSFingerprintProfileQuery) (ent.Value, error)
// Query calls f(ctx, q).
func (f TLSFingerprintProfileFunc) Query(ctx context.Context, q ent.Query) (ent.Value, error) {
if q, ok := q.(*ent.TLSFingerprintProfileQuery); ok {
return f(ctx, q)
}
return nil, fmt.Errorf("unexpected query type %T. expect *ent.TLSFingerprintProfileQuery", q)
}
// The TraverseTLSFingerprintProfile type is an adapter to allow the use of ordinary function as Traverser.
type TraverseTLSFingerprintProfile func(context.Context, *ent.TLSFingerprintProfileQuery) error
// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline.
func (f TraverseTLSFingerprintProfile) Intercept(next ent.Querier) ent.Querier {
return next
}
// Traverse calls f(ctx, q).
func (f TraverseTLSFingerprintProfile) Traverse(ctx context.Context, q ent.Query) error {
if q, ok := q.(*ent.TLSFingerprintProfileQuery); ok {
return f(ctx, q)
}
return fmt.Errorf("unexpected query type %T. expect *ent.TLSFingerprintProfileQuery", q)
}
// The UsageCleanupTaskFunc type is an adapter to allow the use of ordinary function as a Querier.
type UsageCleanupTaskFunc func(context.Context, *ent.UsageCleanupTaskQuery) (ent.Value, error)
@ -686,6 +714,8 @@ func NewQuery(q ent.Query) (Query, error) {
return &query[*ent.SecuritySecretQuery, predicate.SecuritySecret, securitysecret.OrderOption]{typ: ent.TypeSecuritySecret, tq: q}, nil
case *ent.SettingQuery:
return &query[*ent.SettingQuery, predicate.Setting, setting.OrderOption]{typ: ent.TypeSetting, tq: q}, nil
case *ent.TLSFingerprintProfileQuery:
return &query[*ent.TLSFingerprintProfileQuery, predicate.TLSFingerprintProfile, tlsfingerprintprofile.OrderOption]{typ: ent.TypeTLSFingerprintProfile, tq: q}, nil
case *ent.UsageCleanupTaskQuery:
return &query[*ent.UsageCleanupTaskQuery, predicate.UsageCleanupTask, usagecleanuptask.OrderOption]{typ: ent.TypeUsageCleanupTask, tq: q}, nil
case *ent.UsageLogQuery:

View File

@ -673,6 +673,30 @@ var (
Columns: SettingsColumns,
PrimaryKey: []*schema.Column{SettingsColumns[0]},
}
// TLSFingerprintProfilesColumns holds the columns for the "tls_fingerprint_profiles" table.
TLSFingerprintProfilesColumns = []*schema.Column{
{Name: "id", Type: field.TypeInt64, Increment: true},
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Unique: true, Size: 100},
{Name: "description", Type: field.TypeString, Nullable: true, Size: 2147483647},
{Name: "enable_grease", Type: field.TypeBool, Default: false},
{Name: "cipher_suites", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "curves", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "point_formats", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "signature_algorithms", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "alpn_protocols", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "supported_versions", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "key_share_groups", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "psk_modes", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "extensions", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
}
// TLSFingerprintProfilesTable holds the schema information for the "tls_fingerprint_profiles" table.
TLSFingerprintProfilesTable = &schema.Table{
Name: "tls_fingerprint_profiles",
Columns: TLSFingerprintProfilesColumns,
PrimaryKey: []*schema.Column{TLSFingerprintProfilesColumns[0]},
}
// UsageCleanupTasksColumns holds the columns for the "usage_cleanup_tasks" table.
UsageCleanupTasksColumns = []*schema.Column{
{Name: "id", Type: field.TypeInt64, Increment: true},
@ -1111,6 +1135,7 @@ var (
RedeemCodesTable,
SecuritySecretsTable,
SettingsTable,
TLSFingerprintProfilesTable,
UsageCleanupTasksTable,
UsageLogsTable,
UsersTable,
@ -1175,6 +1200,9 @@ func init() {
SettingsTable.Annotation = &entsql.Annotation{
Table: "settings",
}
TLSFingerprintProfilesTable.Annotation = &entsql.Annotation{
Table: "tls_fingerprint_profiles",
}
UsageCleanupTasksTable.Annotation = &entsql.Annotation{
Table: "usage_cleanup_tasks",
}

File diff suppressed because it is too large Load Diff

View File

@ -48,6 +48,9 @@ type SecuritySecret func(*sql.Selector)
// Setting is the predicate function for setting builders.
type Setting func(*sql.Selector)
// TLSFingerprintProfile is the predicate function for tlsfingerprintprofile builders.
type TLSFingerprintProfile func(*sql.Selector)
// UsageCleanupTask is the predicate function for usagecleanuptask builders.
type UsageCleanupTask func(*sql.Selector)

View File

@ -20,6 +20,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/schema"
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
"github.com/Wei-Shaw/sub2api/ent/setting"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
@ -746,6 +747,43 @@ func init() {
setting.DefaultUpdatedAt = settingDescUpdatedAt.Default.(func() time.Time)
// setting.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
setting.UpdateDefaultUpdatedAt = settingDescUpdatedAt.UpdateDefault.(func() time.Time)
tlsfingerprintprofileMixin := schema.TLSFingerprintProfile{}.Mixin()
tlsfingerprintprofileMixinFields0 := tlsfingerprintprofileMixin[0].Fields()
_ = tlsfingerprintprofileMixinFields0
tlsfingerprintprofileFields := schema.TLSFingerprintProfile{}.Fields()
_ = tlsfingerprintprofileFields
// tlsfingerprintprofileDescCreatedAt is the schema descriptor for created_at field.
tlsfingerprintprofileDescCreatedAt := tlsfingerprintprofileMixinFields0[0].Descriptor()
// tlsfingerprintprofile.DefaultCreatedAt holds the default value on creation for the created_at field.
tlsfingerprintprofile.DefaultCreatedAt = tlsfingerprintprofileDescCreatedAt.Default.(func() time.Time)
// tlsfingerprintprofileDescUpdatedAt is the schema descriptor for updated_at field.
tlsfingerprintprofileDescUpdatedAt := tlsfingerprintprofileMixinFields0[1].Descriptor()
// tlsfingerprintprofile.DefaultUpdatedAt holds the default value on creation for the updated_at field.
tlsfingerprintprofile.DefaultUpdatedAt = tlsfingerprintprofileDescUpdatedAt.Default.(func() time.Time)
// tlsfingerprintprofile.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
tlsfingerprintprofile.UpdateDefaultUpdatedAt = tlsfingerprintprofileDescUpdatedAt.UpdateDefault.(func() time.Time)
// tlsfingerprintprofileDescName is the schema descriptor for name field.
tlsfingerprintprofileDescName := tlsfingerprintprofileFields[0].Descriptor()
// tlsfingerprintprofile.NameValidator is a validator for the "name" field. It is called by the builders before save.
tlsfingerprintprofile.NameValidator = func() func(string) error {
validators := tlsfingerprintprofileDescName.Validators
fns := [...]func(string) error{
validators[0].(func(string) error),
validators[1].(func(string) error),
}
return func(name string) error {
for _, fn := range fns {
if err := fn(name); err != nil {
return err
}
}
return nil
}
}()
// tlsfingerprintprofileDescEnableGrease is the schema descriptor for enable_grease field.
tlsfingerprintprofileDescEnableGrease := tlsfingerprintprofileFields[2].Descriptor()
// tlsfingerprintprofile.DefaultEnableGrease holds the default value on creation for the enable_grease field.
tlsfingerprintprofile.DefaultEnableGrease = tlsfingerprintprofileDescEnableGrease.Default.(bool)
usagecleanuptaskMixin := schema.UsageCleanupTask{}.Mixin()
usagecleanuptaskMixinFields0 := usagecleanuptaskMixin[0].Fields()
_ = usagecleanuptaskMixinFields0

View File

@ -0,0 +1,100 @@
// Package schema 定义 Ent ORM 的数据库 schema。
package schema
import (
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
"entgo.io/ent"
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema"
"entgo.io/ent/schema/field"
)
// TLSFingerprintProfile 定义 TLS 指纹配置模板的 schema。
//
// TLS 指纹模板用于模拟特定客户端(如 Claude Code / Node.js的 TLS 握手特征。
// 每个模板包含完整的 ClientHello 参数:加密套件、曲线、扩展等。
// 通过 Account.Extra.tls_fingerprint_profile_id 绑定到具体账号。
type TLSFingerprintProfile struct {
ent.Schema
}
// Annotations 返回 schema 的注解配置。
func (TLSFingerprintProfile) Annotations() []schema.Annotation {
return []schema.Annotation{
entsql.Annotation{Table: "tls_fingerprint_profiles"},
}
}
// Mixin 返回该 schema 使用的混入组件。
func (TLSFingerprintProfile) Mixin() []ent.Mixin {
return []ent.Mixin{
mixins.TimeMixin{},
}
}
// Fields 定义 TLS 指纹模板实体的所有字段。
func (TLSFingerprintProfile) Fields() []ent.Field {
return []ent.Field{
// name: 模板名称,唯一标识
field.String("name").
MaxLen(100).
NotEmpty().
Unique(),
// description: 模板描述
field.Text("description").
Optional().
Nillable(),
// enable_grease: 是否启用 GREASE 扩展Chrome 使用Node.js 不使用)
field.Bool("enable_grease").
Default(false),
// cipher_suites: TLS 加密套件列表(顺序敏感,影响 JA3
field.JSON("cipher_suites", []uint16{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
// curves: 椭圆曲线/支持的组列表
field.JSON("curves", []uint16{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
// point_formats: EC 点格式列表
field.JSON("point_formats", []uint16{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
// signature_algorithms: 签名算法列表
field.JSON("signature_algorithms", []uint16{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
// alpn_protocols: ALPN 协议列表(如 ["http/1.1"]
field.JSON("alpn_protocols", []string{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
// supported_versions: 支持的 TLS 版本列表(如 [0x0304, 0x0303]
field.JSON("supported_versions", []uint16{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
// key_share_groups: Key Share 中发送的曲线组(如 [29] 即 X25519
field.JSON("key_share_groups", []uint16{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
// psk_modes: PSK 密钥交换模式(如 [1] 即 psk_dhe_ke
field.JSON("psk_modes", []uint16{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
// extensions: TLS 扩展类型 ID 列表,按发送顺序排列
field.JSON("extensions", []uint16{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
}
}

View File

@ -0,0 +1,275 @@
// Code generated by ent, DO NOT EDIT.
package ent
import (
"encoding/json"
"fmt"
"strings"
"time"
"entgo.io/ent"
"entgo.io/ent/dialect/sql"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
)
// TLSFingerprintProfile is the model entity for the TLSFingerprintProfile schema.
type TLSFingerprintProfile struct {
config `json:"-"`
// ID of the ent.
ID int64 `json:"id,omitempty"`
// CreatedAt holds the value of the "created_at" field.
CreatedAt time.Time `json:"created_at,omitempty"`
// UpdatedAt holds the value of the "updated_at" field.
UpdatedAt time.Time `json:"updated_at,omitempty"`
// Name holds the value of the "name" field.
Name string `json:"name,omitempty"`
// Description holds the value of the "description" field.
Description *string `json:"description,omitempty"`
// EnableGrease holds the value of the "enable_grease" field.
EnableGrease bool `json:"enable_grease,omitempty"`
// CipherSuites holds the value of the "cipher_suites" field.
CipherSuites []uint16 `json:"cipher_suites,omitempty"`
// Curves holds the value of the "curves" field.
Curves []uint16 `json:"curves,omitempty"`
// PointFormats holds the value of the "point_formats" field.
PointFormats []uint16 `json:"point_formats,omitempty"`
// SignatureAlgorithms holds the value of the "signature_algorithms" field.
SignatureAlgorithms []uint16 `json:"signature_algorithms,omitempty"`
// AlpnProtocols holds the value of the "alpn_protocols" field.
AlpnProtocols []string `json:"alpn_protocols,omitempty"`
// SupportedVersions holds the value of the "supported_versions" field.
SupportedVersions []uint16 `json:"supported_versions,omitempty"`
// KeyShareGroups holds the value of the "key_share_groups" field.
KeyShareGroups []uint16 `json:"key_share_groups,omitempty"`
// PskModes holds the value of the "psk_modes" field.
PskModes []uint16 `json:"psk_modes,omitempty"`
// Extensions holds the value of the "extensions" field.
Extensions []uint16 `json:"extensions,omitempty"`
selectValues sql.SelectValues
}
// scanValues returns the types for scanning values from sql.Rows.
func (*TLSFingerprintProfile) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case tlsfingerprintprofile.FieldCipherSuites, tlsfingerprintprofile.FieldCurves, tlsfingerprintprofile.FieldPointFormats, tlsfingerprintprofile.FieldSignatureAlgorithms, tlsfingerprintprofile.FieldAlpnProtocols, tlsfingerprintprofile.FieldSupportedVersions, tlsfingerprintprofile.FieldKeyShareGroups, tlsfingerprintprofile.FieldPskModes, tlsfingerprintprofile.FieldExtensions:
values[i] = new([]byte)
case tlsfingerprintprofile.FieldEnableGrease:
values[i] = new(sql.NullBool)
case tlsfingerprintprofile.FieldID:
values[i] = new(sql.NullInt64)
case tlsfingerprintprofile.FieldName, tlsfingerprintprofile.FieldDescription:
values[i] = new(sql.NullString)
case tlsfingerprintprofile.FieldCreatedAt, tlsfingerprintprofile.FieldUpdatedAt:
values[i] = new(sql.NullTime)
default:
values[i] = new(sql.UnknownType)
}
}
return values, nil
}
// assignValues assigns the values that were returned from sql.Rows (after scanning)
// to the TLSFingerprintProfile fields.
func (_m *TLSFingerprintProfile) assignValues(columns []string, values []any) error {
if m, n := len(values), len(columns); m < n {
return fmt.Errorf("mismatch number of scan values: %d != %d", m, n)
}
for i := range columns {
switch columns[i] {
case tlsfingerprintprofile.FieldID:
value, ok := values[i].(*sql.NullInt64)
if !ok {
return fmt.Errorf("unexpected type %T for field id", value)
}
_m.ID = int64(value.Int64)
case tlsfingerprintprofile.FieldCreatedAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field created_at", values[i])
} else if value.Valid {
_m.CreatedAt = value.Time
}
case tlsfingerprintprofile.FieldUpdatedAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field updated_at", values[i])
} else if value.Valid {
_m.UpdatedAt = value.Time
}
case tlsfingerprintprofile.FieldName:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field name", values[i])
} else if value.Valid {
_m.Name = value.String
}
case tlsfingerprintprofile.FieldDescription:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field description", values[i])
} else if value.Valid {
_m.Description = new(string)
*_m.Description = value.String
}
case tlsfingerprintprofile.FieldEnableGrease:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field enable_grease", values[i])
} else if value.Valid {
_m.EnableGrease = value.Bool
}
case tlsfingerprintprofile.FieldCipherSuites:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field cipher_suites", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.CipherSuites); err != nil {
return fmt.Errorf("unmarshal field cipher_suites: %w", err)
}
}
case tlsfingerprintprofile.FieldCurves:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field curves", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.Curves); err != nil {
return fmt.Errorf("unmarshal field curves: %w", err)
}
}
case tlsfingerprintprofile.FieldPointFormats:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field point_formats", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.PointFormats); err != nil {
return fmt.Errorf("unmarshal field point_formats: %w", err)
}
}
case tlsfingerprintprofile.FieldSignatureAlgorithms:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field signature_algorithms", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.SignatureAlgorithms); err != nil {
return fmt.Errorf("unmarshal field signature_algorithms: %w", err)
}
}
case tlsfingerprintprofile.FieldAlpnProtocols:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field alpn_protocols", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.AlpnProtocols); err != nil {
return fmt.Errorf("unmarshal field alpn_protocols: %w", err)
}
}
case tlsfingerprintprofile.FieldSupportedVersions:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field supported_versions", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.SupportedVersions); err != nil {
return fmt.Errorf("unmarshal field supported_versions: %w", err)
}
}
case tlsfingerprintprofile.FieldKeyShareGroups:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field key_share_groups", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.KeyShareGroups); err != nil {
return fmt.Errorf("unmarshal field key_share_groups: %w", err)
}
}
case tlsfingerprintprofile.FieldPskModes:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field psk_modes", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.PskModes); err != nil {
return fmt.Errorf("unmarshal field psk_modes: %w", err)
}
}
case tlsfingerprintprofile.FieldExtensions:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field extensions", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.Extensions); err != nil {
return fmt.Errorf("unmarshal field extensions: %w", err)
}
}
default:
_m.selectValues.Set(columns[i], values[i])
}
}
return nil
}
// Value returns the ent.Value that was dynamically selected and assigned to the TLSFingerprintProfile.
// This includes values selected through modifiers, order, etc.
func (_m *TLSFingerprintProfile) Value(name string) (ent.Value, error) {
return _m.selectValues.Get(name)
}
// Update returns a builder for updating this TLSFingerprintProfile.
// Note that you need to call TLSFingerprintProfile.Unwrap() before calling this method if this TLSFingerprintProfile
// was returned from a transaction, and the transaction was committed or rolled back.
func (_m *TLSFingerprintProfile) Update() *TLSFingerprintProfileUpdateOne {
return NewTLSFingerprintProfileClient(_m.config).UpdateOne(_m)
}
// Unwrap unwraps the TLSFingerprintProfile entity that was returned from a transaction after it was closed,
// so that all future queries will be executed through the driver which created the transaction.
func (_m *TLSFingerprintProfile) Unwrap() *TLSFingerprintProfile {
_tx, ok := _m.config.driver.(*txDriver)
if !ok {
panic("ent: TLSFingerprintProfile is not a transactional entity")
}
_m.config.driver = _tx.drv
return _m
}
// String implements the fmt.Stringer.
func (_m *TLSFingerprintProfile) String() string {
var builder strings.Builder
builder.WriteString("TLSFingerprintProfile(")
builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID))
builder.WriteString("created_at=")
builder.WriteString(_m.CreatedAt.Format(time.ANSIC))
builder.WriteString(", ")
builder.WriteString("updated_at=")
builder.WriteString(_m.UpdatedAt.Format(time.ANSIC))
builder.WriteString(", ")
builder.WriteString("name=")
builder.WriteString(_m.Name)
builder.WriteString(", ")
if v := _m.Description; v != nil {
builder.WriteString("description=")
builder.WriteString(*v)
}
builder.WriteString(", ")
builder.WriteString("enable_grease=")
builder.WriteString(fmt.Sprintf("%v", _m.EnableGrease))
builder.WriteString(", ")
builder.WriteString("cipher_suites=")
builder.WriteString(fmt.Sprintf("%v", _m.CipherSuites))
builder.WriteString(", ")
builder.WriteString("curves=")
builder.WriteString(fmt.Sprintf("%v", _m.Curves))
builder.WriteString(", ")
builder.WriteString("point_formats=")
builder.WriteString(fmt.Sprintf("%v", _m.PointFormats))
builder.WriteString(", ")
builder.WriteString("signature_algorithms=")
builder.WriteString(fmt.Sprintf("%v", _m.SignatureAlgorithms))
builder.WriteString(", ")
builder.WriteString("alpn_protocols=")
builder.WriteString(fmt.Sprintf("%v", _m.AlpnProtocols))
builder.WriteString(", ")
builder.WriteString("supported_versions=")
builder.WriteString(fmt.Sprintf("%v", _m.SupportedVersions))
builder.WriteString(", ")
builder.WriteString("key_share_groups=")
builder.WriteString(fmt.Sprintf("%v", _m.KeyShareGroups))
builder.WriteString(", ")
builder.WriteString("psk_modes=")
builder.WriteString(fmt.Sprintf("%v", _m.PskModes))
builder.WriteString(", ")
builder.WriteString("extensions=")
builder.WriteString(fmt.Sprintf("%v", _m.Extensions))
builder.WriteByte(')')
return builder.String()
}
// TLSFingerprintProfiles is a parsable slice of TLSFingerprintProfile.
type TLSFingerprintProfiles []*TLSFingerprintProfile

View File

@ -0,0 +1,121 @@
// Code generated by ent, DO NOT EDIT.
package tlsfingerprintprofile
import (
"time"
"entgo.io/ent/dialect/sql"
)
const (
// Label holds the string label denoting the tlsfingerprintprofile type in the database.
Label = "tls_fingerprint_profile"
// FieldID holds the string denoting the id field in the database.
FieldID = "id"
// FieldCreatedAt holds the string denoting the created_at field in the database.
FieldCreatedAt = "created_at"
// FieldUpdatedAt holds the string denoting the updated_at field in the database.
FieldUpdatedAt = "updated_at"
// FieldName holds the string denoting the name field in the database.
FieldName = "name"
// FieldDescription holds the string denoting the description field in the database.
FieldDescription = "description"
// FieldEnableGrease holds the string denoting the enable_grease field in the database.
FieldEnableGrease = "enable_grease"
// FieldCipherSuites holds the string denoting the cipher_suites field in the database.
FieldCipherSuites = "cipher_suites"
// FieldCurves holds the string denoting the curves field in the database.
FieldCurves = "curves"
// FieldPointFormats holds the string denoting the point_formats field in the database.
FieldPointFormats = "point_formats"
// FieldSignatureAlgorithms holds the string denoting the signature_algorithms field in the database.
FieldSignatureAlgorithms = "signature_algorithms"
// FieldAlpnProtocols holds the string denoting the alpn_protocols field in the database.
FieldAlpnProtocols = "alpn_protocols"
// FieldSupportedVersions holds the string denoting the supported_versions field in the database.
FieldSupportedVersions = "supported_versions"
// FieldKeyShareGroups holds the string denoting the key_share_groups field in the database.
FieldKeyShareGroups = "key_share_groups"
// FieldPskModes holds the string denoting the psk_modes field in the database.
FieldPskModes = "psk_modes"
// FieldExtensions holds the string denoting the extensions field in the database.
FieldExtensions = "extensions"
// Table holds the table name of the tlsfingerprintprofile in the database.
Table = "tls_fingerprint_profiles"
)
// Columns holds all SQL columns for tlsfingerprintprofile fields.
var Columns = []string{
FieldID,
FieldCreatedAt,
FieldUpdatedAt,
FieldName,
FieldDescription,
FieldEnableGrease,
FieldCipherSuites,
FieldCurves,
FieldPointFormats,
FieldSignatureAlgorithms,
FieldAlpnProtocols,
FieldSupportedVersions,
FieldKeyShareGroups,
FieldPskModes,
FieldExtensions,
}
// ValidColumn reports if the column name is valid (part of the table columns).
func ValidColumn(column string) bool {
for i := range Columns {
if column == Columns[i] {
return true
}
}
return false
}
var (
// DefaultCreatedAt holds the default value on creation for the "created_at" field.
DefaultCreatedAt func() time.Time
// DefaultUpdatedAt holds the default value on creation for the "updated_at" field.
DefaultUpdatedAt func() time.Time
// UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field.
UpdateDefaultUpdatedAt func() time.Time
// NameValidator is a validator for the "name" field. It is called by the builders before save.
NameValidator func(string) error
// DefaultEnableGrease holds the default value on creation for the "enable_grease" field.
DefaultEnableGrease bool
)
// OrderOption defines the ordering options for the TLSFingerprintProfile queries.
type OrderOption func(*sql.Selector)
// ByID orders the results by the id field.
func ByID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldID, opts...).ToFunc()
}
// ByCreatedAt orders the results by the created_at field.
func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCreatedAt, opts...).ToFunc()
}
// ByUpdatedAt orders the results by the updated_at field.
func ByUpdatedAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpdatedAt, opts...).ToFunc()
}
// ByName orders the results by the name field.
func ByName(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldName, opts...).ToFunc()
}
// ByDescription orders the results by the description field.
func ByDescription(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldDescription, opts...).ToFunc()
}
// ByEnableGrease orders the results by the enable_grease field.
func ByEnableGrease(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldEnableGrease, opts...).ToFunc()
}

View File

@ -0,0 +1,415 @@
// Code generated by ent, DO NOT EDIT.
package tlsfingerprintprofile
import (
"time"
"entgo.io/ent/dialect/sql"
"github.com/Wei-Shaw/sub2api/ent/predicate"
)
// ID filters vertices based on their ID field.
func ID(id int64) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldID, id))
}
// IDEQ applies the EQ predicate on the ID field.
func IDEQ(id int64) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldID, id))
}
// IDNEQ applies the NEQ predicate on the ID field.
func IDNEQ(id int64) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldID, id))
}
// IDIn applies the In predicate on the ID field.
func IDIn(ids ...int64) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldID, ids...))
}
// IDNotIn applies the NotIn predicate on the ID field.
func IDNotIn(ids ...int64) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldID, ids...))
}
// IDGT applies the GT predicate on the ID field.
func IDGT(id int64) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldID, id))
}
// IDGTE applies the GTE predicate on the ID field.
func IDGTE(id int64) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldID, id))
}
// IDLT applies the LT predicate on the ID field.
func IDLT(id int64) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldID, id))
}
// IDLTE applies the LTE predicate on the ID field.
func IDLTE(id int64) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldID, id))
}
// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ.
func CreatedAt(v time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldCreatedAt, v))
}
// UpdatedAt applies equality check predicate on the "updated_at" field. It's identical to UpdatedAtEQ.
func UpdatedAt(v time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldUpdatedAt, v))
}
// Name applies equality check predicate on the "name" field. It's identical to NameEQ.
func Name(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldName, v))
}
// Description applies equality check predicate on the "description" field. It's identical to DescriptionEQ.
func Description(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldDescription, v))
}
// EnableGrease applies equality check predicate on the "enable_grease" field. It's identical to EnableGreaseEQ.
func EnableGrease(v bool) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldEnableGrease, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldCreatedAt, v))
}
// CreatedAtNEQ applies the NEQ predicate on the "created_at" field.
func CreatedAtNEQ(v time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldCreatedAt, v))
}
// CreatedAtIn applies the In predicate on the "created_at" field.
func CreatedAtIn(vs ...time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldCreatedAt, vs...))
}
// CreatedAtNotIn applies the NotIn predicate on the "created_at" field.
func CreatedAtNotIn(vs ...time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldCreatedAt, vs...))
}
// CreatedAtGT applies the GT predicate on the "created_at" field.
func CreatedAtGT(v time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldCreatedAt, v))
}
// CreatedAtGTE applies the GTE predicate on the "created_at" field.
func CreatedAtGTE(v time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldCreatedAt, v))
}
// CreatedAtLT applies the LT predicate on the "created_at" field.
func CreatedAtLT(v time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldCreatedAt, v))
}
// CreatedAtLTE applies the LTE predicate on the "created_at" field.
func CreatedAtLTE(v time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldCreatedAt, v))
}
// UpdatedAtEQ applies the EQ predicate on the "updated_at" field.
func UpdatedAtEQ(v time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldUpdatedAt, v))
}
// UpdatedAtNEQ applies the NEQ predicate on the "updated_at" field.
func UpdatedAtNEQ(v time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldUpdatedAt, v))
}
// UpdatedAtIn applies the In predicate on the "updated_at" field.
func UpdatedAtIn(vs ...time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldUpdatedAt, vs...))
}
// UpdatedAtNotIn applies the NotIn predicate on the "updated_at" field.
func UpdatedAtNotIn(vs ...time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldUpdatedAt, vs...))
}
// UpdatedAtGT applies the GT predicate on the "updated_at" field.
func UpdatedAtGT(v time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldUpdatedAt, v))
}
// UpdatedAtGTE applies the GTE predicate on the "updated_at" field.
func UpdatedAtGTE(v time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldUpdatedAt, v))
}
// UpdatedAtLT applies the LT predicate on the "updated_at" field.
func UpdatedAtLT(v time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldUpdatedAt, v))
}
// UpdatedAtLTE applies the LTE predicate on the "updated_at" field.
func UpdatedAtLTE(v time.Time) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldUpdatedAt, v))
}
// NameEQ applies the EQ predicate on the "name" field.
func NameEQ(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldName, v))
}
// NameNEQ applies the NEQ predicate on the "name" field.
func NameNEQ(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldName, v))
}
// NameIn applies the In predicate on the "name" field.
func NameIn(vs ...string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldName, vs...))
}
// NameNotIn applies the NotIn predicate on the "name" field.
func NameNotIn(vs ...string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldName, vs...))
}
// NameGT applies the GT predicate on the "name" field.
func NameGT(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldName, v))
}
// NameGTE applies the GTE predicate on the "name" field.
func NameGTE(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldName, v))
}
// NameLT applies the LT predicate on the "name" field.
func NameLT(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldName, v))
}
// NameLTE applies the LTE predicate on the "name" field.
func NameLTE(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldName, v))
}
// NameContains applies the Contains predicate on the "name" field.
func NameContains(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldContains(FieldName, v))
}
// NameHasPrefix applies the HasPrefix predicate on the "name" field.
func NameHasPrefix(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldHasPrefix(FieldName, v))
}
// NameHasSuffix applies the HasSuffix predicate on the "name" field.
func NameHasSuffix(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldHasSuffix(FieldName, v))
}
// NameEqualFold applies the EqualFold predicate on the "name" field.
func NameEqualFold(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldEqualFold(FieldName, v))
}
// NameContainsFold applies the ContainsFold predicate on the "name" field.
func NameContainsFold(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldContainsFold(FieldName, v))
}
// DescriptionEQ applies the EQ predicate on the "description" field.
func DescriptionEQ(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldDescription, v))
}
// DescriptionNEQ applies the NEQ predicate on the "description" field.
func DescriptionNEQ(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldDescription, v))
}
// DescriptionIn applies the In predicate on the "description" field.
func DescriptionIn(vs ...string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldDescription, vs...))
}
// DescriptionNotIn applies the NotIn predicate on the "description" field.
func DescriptionNotIn(vs ...string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldDescription, vs...))
}
// DescriptionGT applies the GT predicate on the "description" field.
func DescriptionGT(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldDescription, v))
}
// DescriptionGTE applies the GTE predicate on the "description" field.
func DescriptionGTE(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldDescription, v))
}
// DescriptionLT applies the LT predicate on the "description" field.
func DescriptionLT(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldDescription, v))
}
// DescriptionLTE applies the LTE predicate on the "description" field.
func DescriptionLTE(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldDescription, v))
}
// DescriptionContains applies the Contains predicate on the "description" field.
func DescriptionContains(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldContains(FieldDescription, v))
}
// DescriptionHasPrefix applies the HasPrefix predicate on the "description" field.
func DescriptionHasPrefix(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldHasPrefix(FieldDescription, v))
}
// DescriptionHasSuffix applies the HasSuffix predicate on the "description" field.
func DescriptionHasSuffix(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldHasSuffix(FieldDescription, v))
}
// DescriptionIsNil applies the IsNil predicate on the "description" field.
func DescriptionIsNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldDescription))
}
// DescriptionNotNil applies the NotNil predicate on the "description" field.
func DescriptionNotNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldDescription))
}
// DescriptionEqualFold applies the EqualFold predicate on the "description" field.
func DescriptionEqualFold(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldEqualFold(FieldDescription, v))
}
// DescriptionContainsFold applies the ContainsFold predicate on the "description" field.
func DescriptionContainsFold(v string) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldContainsFold(FieldDescription, v))
}
// EnableGreaseEQ applies the EQ predicate on the "enable_grease" field.
func EnableGreaseEQ(v bool) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldEnableGrease, v))
}
// EnableGreaseNEQ applies the NEQ predicate on the "enable_grease" field.
func EnableGreaseNEQ(v bool) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldEnableGrease, v))
}
// CipherSuitesIsNil applies the IsNil predicate on the "cipher_suites" field.
func CipherSuitesIsNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldCipherSuites))
}
// CipherSuitesNotNil applies the NotNil predicate on the "cipher_suites" field.
func CipherSuitesNotNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldCipherSuites))
}
// CurvesIsNil applies the IsNil predicate on the "curves" field.
func CurvesIsNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldCurves))
}
// CurvesNotNil applies the NotNil predicate on the "curves" field.
func CurvesNotNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldCurves))
}
// PointFormatsIsNil applies the IsNil predicate on the "point_formats" field.
func PointFormatsIsNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldPointFormats))
}
// PointFormatsNotNil applies the NotNil predicate on the "point_formats" field.
func PointFormatsNotNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldPointFormats))
}
// SignatureAlgorithmsIsNil applies the IsNil predicate on the "signature_algorithms" field.
func SignatureAlgorithmsIsNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldSignatureAlgorithms))
}
// SignatureAlgorithmsNotNil applies the NotNil predicate on the "signature_algorithms" field.
func SignatureAlgorithmsNotNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldSignatureAlgorithms))
}
// AlpnProtocolsIsNil applies the IsNil predicate on the "alpn_protocols" field.
func AlpnProtocolsIsNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldAlpnProtocols))
}
// AlpnProtocolsNotNil applies the NotNil predicate on the "alpn_protocols" field.
func AlpnProtocolsNotNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldAlpnProtocols))
}
// SupportedVersionsIsNil applies the IsNil predicate on the "supported_versions" field.
func SupportedVersionsIsNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldSupportedVersions))
}
// SupportedVersionsNotNil applies the NotNil predicate on the "supported_versions" field.
func SupportedVersionsNotNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldSupportedVersions))
}
// KeyShareGroupsIsNil applies the IsNil predicate on the "key_share_groups" field.
func KeyShareGroupsIsNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldKeyShareGroups))
}
// KeyShareGroupsNotNil applies the NotNil predicate on the "key_share_groups" field.
func KeyShareGroupsNotNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldKeyShareGroups))
}
// PskModesIsNil applies the IsNil predicate on the "psk_modes" field.
func PskModesIsNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldPskModes))
}
// PskModesNotNil applies the NotNil predicate on the "psk_modes" field.
func PskModesNotNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldPskModes))
}
// ExtensionsIsNil applies the IsNil predicate on the "extensions" field.
func ExtensionsIsNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldExtensions))
}
// ExtensionsNotNil applies the NotNil predicate on the "extensions" field.
func ExtensionsNotNil() predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldExtensions))
}
// And groups predicates with the AND operator between them.
func And(predicates ...predicate.TLSFingerprintProfile) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.AndPredicates(predicates...))
}
// Or groups predicates with the OR operator between them.
func Or(predicates ...predicate.TLSFingerprintProfile) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.OrPredicates(predicates...))
}
// Not applies the not operator on the given predicate.
func Not(p predicate.TLSFingerprintProfile) predicate.TLSFingerprintProfile {
return predicate.TLSFingerprintProfile(sql.NotPredicates(p))
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,88 @@
// Code generated by ent, DO NOT EDIT.
package ent
import (
"context"
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/sqlgraph"
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
)
// TLSFingerprintProfileDelete is the builder for deleting a TLSFingerprintProfile entity.
type TLSFingerprintProfileDelete struct {
config
hooks []Hook
mutation *TLSFingerprintProfileMutation
}
// Where appends a list predicates to the TLSFingerprintProfileDelete builder.
func (_d *TLSFingerprintProfileDelete) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileDelete {
_d.mutation.Where(ps...)
return _d
}
// Exec executes the deletion query and returns how many vertices were deleted.
func (_d *TLSFingerprintProfileDelete) Exec(ctx context.Context) (int, error) {
return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks)
}
// ExecX is like Exec, but panics if an error occurs.
func (_d *TLSFingerprintProfileDelete) ExecX(ctx context.Context) int {
n, err := _d.Exec(ctx)
if err != nil {
panic(err)
}
return n
}
func (_d *TLSFingerprintProfileDelete) sqlExec(ctx context.Context) (int, error) {
_spec := sqlgraph.NewDeleteSpec(tlsfingerprintprofile.Table, sqlgraph.NewFieldSpec(tlsfingerprintprofile.FieldID, field.TypeInt64))
if ps := _d.mutation.predicates; len(ps) > 0 {
_spec.Predicate = func(selector *sql.Selector) {
for i := range ps {
ps[i](selector)
}
}
}
affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec)
if err != nil && sqlgraph.IsConstraintError(err) {
err = &ConstraintError{msg: err.Error(), wrap: err}
}
_d.mutation.done = true
return affected, err
}
// TLSFingerprintProfileDeleteOne is the builder for deleting a single TLSFingerprintProfile entity.
type TLSFingerprintProfileDeleteOne struct {
_d *TLSFingerprintProfileDelete
}
// Where appends a list predicates to the TLSFingerprintProfileDelete builder.
func (_d *TLSFingerprintProfileDeleteOne) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileDeleteOne {
_d._d.mutation.Where(ps...)
return _d
}
// Exec executes the deletion query.
func (_d *TLSFingerprintProfileDeleteOne) Exec(ctx context.Context) error {
n, err := _d._d.Exec(ctx)
switch {
case err != nil:
return err
case n == 0:
return &NotFoundError{tlsfingerprintprofile.Label}
default:
return nil
}
}
// ExecX is like Exec, but panics if an error occurs.
func (_d *TLSFingerprintProfileDeleteOne) ExecX(ctx context.Context) {
if err := _d.Exec(ctx); err != nil {
panic(err)
}
}

View File

@ -0,0 +1,564 @@
// Code generated by ent, DO NOT EDIT.
package ent
import (
"context"
"fmt"
"math"
"entgo.io/ent"
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/sqlgraph"
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
)
// TLSFingerprintProfileQuery is the builder for querying TLSFingerprintProfile entities.
type TLSFingerprintProfileQuery struct {
config
ctx *QueryContext
order []tlsfingerprintprofile.OrderOption
inters []Interceptor
predicates []predicate.TLSFingerprintProfile
modifiers []func(*sql.Selector)
// intermediate query (i.e. traversal path).
sql *sql.Selector
path func(context.Context) (*sql.Selector, error)
}
// Where adds a new predicate for the TLSFingerprintProfileQuery builder.
func (_q *TLSFingerprintProfileQuery) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileQuery {
_q.predicates = append(_q.predicates, ps...)
return _q
}
// Limit the number of records to be returned by this query.
func (_q *TLSFingerprintProfileQuery) Limit(limit int) *TLSFingerprintProfileQuery {
_q.ctx.Limit = &limit
return _q
}
// Offset to start from.
func (_q *TLSFingerprintProfileQuery) Offset(offset int) *TLSFingerprintProfileQuery {
_q.ctx.Offset = &offset
return _q
}
// Unique configures the query builder to filter duplicate records on query.
// By default, unique is set to true, and can be disabled using this method.
func (_q *TLSFingerprintProfileQuery) Unique(unique bool) *TLSFingerprintProfileQuery {
_q.ctx.Unique = &unique
return _q
}
// Order specifies how the records should be ordered.
func (_q *TLSFingerprintProfileQuery) Order(o ...tlsfingerprintprofile.OrderOption) *TLSFingerprintProfileQuery {
_q.order = append(_q.order, o...)
return _q
}
// First returns the first TLSFingerprintProfile entity from the query.
// Returns a *NotFoundError when no TLSFingerprintProfile was found.
func (_q *TLSFingerprintProfileQuery) First(ctx context.Context) (*TLSFingerprintProfile, error) {
nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst))
if err != nil {
return nil, err
}
if len(nodes) == 0 {
return nil, &NotFoundError{tlsfingerprintprofile.Label}
}
return nodes[0], nil
}
// FirstX is like First, but panics if an error occurs.
func (_q *TLSFingerprintProfileQuery) FirstX(ctx context.Context) *TLSFingerprintProfile {
node, err := _q.First(ctx)
if err != nil && !IsNotFound(err) {
panic(err)
}
return node
}
// FirstID returns the first TLSFingerprintProfile ID from the query.
// Returns a *NotFoundError when no TLSFingerprintProfile ID was found.
func (_q *TLSFingerprintProfileQuery) FirstID(ctx context.Context) (id int64, err error) {
var ids []int64
if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil {
return
}
if len(ids) == 0 {
err = &NotFoundError{tlsfingerprintprofile.Label}
return
}
return ids[0], nil
}
// FirstIDX is like FirstID, but panics if an error occurs.
func (_q *TLSFingerprintProfileQuery) FirstIDX(ctx context.Context) int64 {
id, err := _q.FirstID(ctx)
if err != nil && !IsNotFound(err) {
panic(err)
}
return id
}
// Only returns a single TLSFingerprintProfile entity found by the query, ensuring it only returns one.
// Returns a *NotSingularError when more than one TLSFingerprintProfile entity is found.
// Returns a *NotFoundError when no TLSFingerprintProfile entities are found.
func (_q *TLSFingerprintProfileQuery) Only(ctx context.Context) (*TLSFingerprintProfile, error) {
nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly))
if err != nil {
return nil, err
}
switch len(nodes) {
case 1:
return nodes[0], nil
case 0:
return nil, &NotFoundError{tlsfingerprintprofile.Label}
default:
return nil, &NotSingularError{tlsfingerprintprofile.Label}
}
}
// OnlyX is like Only, but panics if an error occurs.
func (_q *TLSFingerprintProfileQuery) OnlyX(ctx context.Context) *TLSFingerprintProfile {
node, err := _q.Only(ctx)
if err != nil {
panic(err)
}
return node
}
// OnlyID is like Only, but returns the only TLSFingerprintProfile ID in the query.
// Returns a *NotSingularError when more than one TLSFingerprintProfile ID is found.
// Returns a *NotFoundError when no entities are found.
func (_q *TLSFingerprintProfileQuery) OnlyID(ctx context.Context) (id int64, err error) {
var ids []int64
if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil {
return
}
switch len(ids) {
case 1:
id = ids[0]
case 0:
err = &NotFoundError{tlsfingerprintprofile.Label}
default:
err = &NotSingularError{tlsfingerprintprofile.Label}
}
return
}
// OnlyIDX is like OnlyID, but panics if an error occurs.
func (_q *TLSFingerprintProfileQuery) OnlyIDX(ctx context.Context) int64 {
id, err := _q.OnlyID(ctx)
if err != nil {
panic(err)
}
return id
}
// All executes the query and returns a list of TLSFingerprintProfiles.
func (_q *TLSFingerprintProfileQuery) All(ctx context.Context) ([]*TLSFingerprintProfile, error) {
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll)
if err := _q.prepareQuery(ctx); err != nil {
return nil, err
}
qr := querierAll[[]*TLSFingerprintProfile, *TLSFingerprintProfileQuery]()
return withInterceptors[[]*TLSFingerprintProfile](ctx, _q, qr, _q.inters)
}
// AllX is like All, but panics if an error occurs.
func (_q *TLSFingerprintProfileQuery) AllX(ctx context.Context) []*TLSFingerprintProfile {
nodes, err := _q.All(ctx)
if err != nil {
panic(err)
}
return nodes
}
// IDs executes the query and returns a list of TLSFingerprintProfile IDs.
func (_q *TLSFingerprintProfileQuery) IDs(ctx context.Context) (ids []int64, err error) {
if _q.ctx.Unique == nil && _q.path != nil {
_q.Unique(true)
}
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs)
if err = _q.Select(tlsfingerprintprofile.FieldID).Scan(ctx, &ids); err != nil {
return nil, err
}
return ids, nil
}
// IDsX is like IDs, but panics if an error occurs.
func (_q *TLSFingerprintProfileQuery) IDsX(ctx context.Context) []int64 {
ids, err := _q.IDs(ctx)
if err != nil {
panic(err)
}
return ids
}
// Count returns the count of the given query.
func (_q *TLSFingerprintProfileQuery) Count(ctx context.Context) (int, error) {
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount)
if err := _q.prepareQuery(ctx); err != nil {
return 0, err
}
return withInterceptors[int](ctx, _q, querierCount[*TLSFingerprintProfileQuery](), _q.inters)
}
// CountX is like Count, but panics if an error occurs.
func (_q *TLSFingerprintProfileQuery) CountX(ctx context.Context) int {
count, err := _q.Count(ctx)
if err != nil {
panic(err)
}
return count
}
// Exist returns true if the query has elements in the graph.
func (_q *TLSFingerprintProfileQuery) Exist(ctx context.Context) (bool, error) {
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist)
switch _, err := _q.FirstID(ctx); {
case IsNotFound(err):
return false, nil
case err != nil:
return false, fmt.Errorf("ent: check existence: %w", err)
default:
return true, nil
}
}
// ExistX is like Exist, but panics if an error occurs.
func (_q *TLSFingerprintProfileQuery) ExistX(ctx context.Context) bool {
exist, err := _q.Exist(ctx)
if err != nil {
panic(err)
}
return exist
}
// Clone returns a duplicate of the TLSFingerprintProfileQuery builder, including all associated steps. It can be
// used to prepare common query builders and use them differently after the clone is made.
func (_q *TLSFingerprintProfileQuery) Clone() *TLSFingerprintProfileQuery {
if _q == nil {
return nil
}
return &TLSFingerprintProfileQuery{
config: _q.config,
ctx: _q.ctx.Clone(),
order: append([]tlsfingerprintprofile.OrderOption{}, _q.order...),
inters: append([]Interceptor{}, _q.inters...),
predicates: append([]predicate.TLSFingerprintProfile{}, _q.predicates...),
// clone intermediate query.
sql: _q.sql.Clone(),
path: _q.path,
}
}
// GroupBy is used to group vertices by one or more fields/columns.
// It is often used with aggregate functions, like: count, max, mean, min, sum.
//
// Example:
//
// var v []struct {
// CreatedAt time.Time `json:"created_at,omitempty"`
// Count int `json:"count,omitempty"`
// }
//
// client.TLSFingerprintProfile.Query().
// GroupBy(tlsfingerprintprofile.FieldCreatedAt).
// Aggregate(ent.Count()).
// Scan(ctx, &v)
func (_q *TLSFingerprintProfileQuery) GroupBy(field string, fields ...string) *TLSFingerprintProfileGroupBy {
_q.ctx.Fields = append([]string{field}, fields...)
grbuild := &TLSFingerprintProfileGroupBy{build: _q}
grbuild.flds = &_q.ctx.Fields
grbuild.label = tlsfingerprintprofile.Label
grbuild.scan = grbuild.Scan
return grbuild
}
// Select allows the selection one or more fields/columns for the given query,
// instead of selecting all fields in the entity.
//
// Example:
//
// var v []struct {
// CreatedAt time.Time `json:"created_at,omitempty"`
// }
//
// client.TLSFingerprintProfile.Query().
// Select(tlsfingerprintprofile.FieldCreatedAt).
// Scan(ctx, &v)
func (_q *TLSFingerprintProfileQuery) Select(fields ...string) *TLSFingerprintProfileSelect {
_q.ctx.Fields = append(_q.ctx.Fields, fields...)
sbuild := &TLSFingerprintProfileSelect{TLSFingerprintProfileQuery: _q}
sbuild.label = tlsfingerprintprofile.Label
sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan
return sbuild
}
// Aggregate returns a TLSFingerprintProfileSelect configured with the given aggregations.
func (_q *TLSFingerprintProfileQuery) Aggregate(fns ...AggregateFunc) *TLSFingerprintProfileSelect {
return _q.Select().Aggregate(fns...)
}
func (_q *TLSFingerprintProfileQuery) prepareQuery(ctx context.Context) error {
for _, inter := range _q.inters {
if inter == nil {
return fmt.Errorf("ent: uninitialized interceptor (forgotten import ent/runtime?)")
}
if trv, ok := inter.(Traverser); ok {
if err := trv.Traverse(ctx, _q); err != nil {
return err
}
}
}
for _, f := range _q.ctx.Fields {
if !tlsfingerprintprofile.ValidColumn(f) {
return &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)}
}
}
if _q.path != nil {
prev, err := _q.path(ctx)
if err != nil {
return err
}
_q.sql = prev
}
return nil
}
func (_q *TLSFingerprintProfileQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*TLSFingerprintProfile, error) {
var (
nodes = []*TLSFingerprintProfile{}
_spec = _q.querySpec()
)
_spec.ScanValues = func(columns []string) ([]any, error) {
return (*TLSFingerprintProfile).scanValues(nil, columns)
}
_spec.Assign = func(columns []string, values []any) error {
node := &TLSFingerprintProfile{config: _q.config}
nodes = append(nodes, node)
return node.assignValues(columns, values)
}
if len(_q.modifiers) > 0 {
_spec.Modifiers = _q.modifiers
}
for i := range hooks {
hooks[i](ctx, _spec)
}
if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil {
return nil, err
}
if len(nodes) == 0 {
return nodes, nil
}
return nodes, nil
}
func (_q *TLSFingerprintProfileQuery) sqlCount(ctx context.Context) (int, error) {
_spec := _q.querySpec()
if len(_q.modifiers) > 0 {
_spec.Modifiers = _q.modifiers
}
_spec.Node.Columns = _q.ctx.Fields
if len(_q.ctx.Fields) > 0 {
_spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique
}
return sqlgraph.CountNodes(ctx, _q.driver, _spec)
}
func (_q *TLSFingerprintProfileQuery) querySpec() *sqlgraph.QuerySpec {
_spec := sqlgraph.NewQuerySpec(tlsfingerprintprofile.Table, tlsfingerprintprofile.Columns, sqlgraph.NewFieldSpec(tlsfingerprintprofile.FieldID, field.TypeInt64))
_spec.From = _q.sql
if unique := _q.ctx.Unique; unique != nil {
_spec.Unique = *unique
} else if _q.path != nil {
_spec.Unique = true
}
if fields := _q.ctx.Fields; len(fields) > 0 {
_spec.Node.Columns = make([]string, 0, len(fields))
_spec.Node.Columns = append(_spec.Node.Columns, tlsfingerprintprofile.FieldID)
for i := range fields {
if fields[i] != tlsfingerprintprofile.FieldID {
_spec.Node.Columns = append(_spec.Node.Columns, fields[i])
}
}
}
if ps := _q.predicates; len(ps) > 0 {
_spec.Predicate = func(selector *sql.Selector) {
for i := range ps {
ps[i](selector)
}
}
}
if limit := _q.ctx.Limit; limit != nil {
_spec.Limit = *limit
}
if offset := _q.ctx.Offset; offset != nil {
_spec.Offset = *offset
}
if ps := _q.order; len(ps) > 0 {
_spec.Order = func(selector *sql.Selector) {
for i := range ps {
ps[i](selector)
}
}
}
return _spec
}
func (_q *TLSFingerprintProfileQuery) sqlQuery(ctx context.Context) *sql.Selector {
builder := sql.Dialect(_q.driver.Dialect())
t1 := builder.Table(tlsfingerprintprofile.Table)
columns := _q.ctx.Fields
if len(columns) == 0 {
columns = tlsfingerprintprofile.Columns
}
selector := builder.Select(t1.Columns(columns...)...).From(t1)
if _q.sql != nil {
selector = _q.sql
selector.Select(selector.Columns(columns...)...)
}
if _q.ctx.Unique != nil && *_q.ctx.Unique {
selector.Distinct()
}
for _, m := range _q.modifiers {
m(selector)
}
for _, p := range _q.predicates {
p(selector)
}
for _, p := range _q.order {
p(selector)
}
if offset := _q.ctx.Offset; offset != nil {
// limit is mandatory for offset clause. We start
// with default value, and override it below if needed.
selector.Offset(*offset).Limit(math.MaxInt32)
}
if limit := _q.ctx.Limit; limit != nil {
selector.Limit(*limit)
}
return selector
}
// ForUpdate locks the selected rows against concurrent updates, and prevent them from being
// updated, deleted or "selected ... for update" by other sessions, until the transaction is
// either committed or rolled-back.
func (_q *TLSFingerprintProfileQuery) ForUpdate(opts ...sql.LockOption) *TLSFingerprintProfileQuery {
if _q.driver.Dialect() == dialect.Postgres {
_q.Unique(false)
}
_q.modifiers = append(_q.modifiers, func(s *sql.Selector) {
s.ForUpdate(opts...)
})
return _q
}
// ForShare behaves similarly to ForUpdate, except that it acquires a shared mode lock
// on any rows that are read. Other sessions can read the rows, but cannot modify them
// until your transaction commits.
func (_q *TLSFingerprintProfileQuery) ForShare(opts ...sql.LockOption) *TLSFingerprintProfileQuery {
if _q.driver.Dialect() == dialect.Postgres {
_q.Unique(false)
}
_q.modifiers = append(_q.modifiers, func(s *sql.Selector) {
s.ForShare(opts...)
})
return _q
}
// TLSFingerprintProfileGroupBy is the group-by builder for TLSFingerprintProfile entities.
type TLSFingerprintProfileGroupBy struct {
selector
build *TLSFingerprintProfileQuery
}
// Aggregate adds the given aggregation functions to the group-by query.
func (_g *TLSFingerprintProfileGroupBy) Aggregate(fns ...AggregateFunc) *TLSFingerprintProfileGroupBy {
_g.fns = append(_g.fns, fns...)
return _g
}
// Scan applies the selector query and scans the result into the given value.
func (_g *TLSFingerprintProfileGroupBy) Scan(ctx context.Context, v any) error {
ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy)
if err := _g.build.prepareQuery(ctx); err != nil {
return err
}
return scanWithInterceptors[*TLSFingerprintProfileQuery, *TLSFingerprintProfileGroupBy](ctx, _g.build, _g, _g.build.inters, v)
}
func (_g *TLSFingerprintProfileGroupBy) sqlScan(ctx context.Context, root *TLSFingerprintProfileQuery, v any) error {
selector := root.sqlQuery(ctx).Select()
aggregation := make([]string, 0, len(_g.fns))
for _, fn := range _g.fns {
aggregation = append(aggregation, fn(selector))
}
if len(selector.SelectedColumns()) == 0 {
columns := make([]string, 0, len(*_g.flds)+len(_g.fns))
for _, f := range *_g.flds {
columns = append(columns, selector.C(f))
}
columns = append(columns, aggregation...)
selector.Select(columns...)
}
selector.GroupBy(selector.Columns(*_g.flds...)...)
if err := selector.Err(); err != nil {
return err
}
rows := &sql.Rows{}
query, args := selector.Query()
if err := _g.build.driver.Query(ctx, query, args, rows); err != nil {
return err
}
defer rows.Close()
return sql.ScanSlice(rows, v)
}
// TLSFingerprintProfileSelect is the builder for selecting fields of TLSFingerprintProfile entities.
type TLSFingerprintProfileSelect struct {
*TLSFingerprintProfileQuery
selector
}
// Aggregate adds the given aggregation functions to the selector query.
func (_s *TLSFingerprintProfileSelect) Aggregate(fns ...AggregateFunc) *TLSFingerprintProfileSelect {
_s.fns = append(_s.fns, fns...)
return _s
}
// Scan applies the selector query and scans the result into the given value.
func (_s *TLSFingerprintProfileSelect) Scan(ctx context.Context, v any) error {
ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect)
if err := _s.prepareQuery(ctx); err != nil {
return err
}
return scanWithInterceptors[*TLSFingerprintProfileQuery, *TLSFingerprintProfileSelect](ctx, _s.TLSFingerprintProfileQuery, _s, _s.inters, v)
}
func (_s *TLSFingerprintProfileSelect) sqlScan(ctx context.Context, root *TLSFingerprintProfileQuery, v any) error {
selector := root.sqlQuery(ctx)
aggregation := make([]string, 0, len(_s.fns))
for _, fn := range _s.fns {
aggregation = append(aggregation, fn(selector))
}
switch n := len(*_s.selector.flds); {
case n == 0 && len(aggregation) > 0:
selector.Select(aggregation...)
case n != 0 && len(aggregation) > 0:
selector.AppendSelect(aggregation...)
}
rows := &sql.Rows{}
query, args := selector.Query()
if err := _s.driver.Query(ctx, query, args, rows); err != nil {
return err
}
defer rows.Close()
return sql.ScanSlice(rows, v)
}

View File

@ -0,0 +1,881 @@
// Code generated by ent, DO NOT EDIT.
package ent
import (
"context"
"errors"
"fmt"
"time"
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/sqlgraph"
"entgo.io/ent/dialect/sql/sqljson"
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
)
// TLSFingerprintProfileUpdate is the builder for updating TLSFingerprintProfile entities.
type TLSFingerprintProfileUpdate struct {
config
hooks []Hook
mutation *TLSFingerprintProfileMutation
}
// Where appends a list predicates to the TLSFingerprintProfileUpdate builder.
func (_u *TLSFingerprintProfileUpdate) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileUpdate {
_u.mutation.Where(ps...)
return _u
}
// SetUpdatedAt sets the "updated_at" field.
func (_u *TLSFingerprintProfileUpdate) SetUpdatedAt(v time.Time) *TLSFingerprintProfileUpdate {
_u.mutation.SetUpdatedAt(v)
return _u
}
// SetName sets the "name" field.
func (_u *TLSFingerprintProfileUpdate) SetName(v string) *TLSFingerprintProfileUpdate {
_u.mutation.SetName(v)
return _u
}
// SetNillableName sets the "name" field if the given value is not nil.
func (_u *TLSFingerprintProfileUpdate) SetNillableName(v *string) *TLSFingerprintProfileUpdate {
if v != nil {
_u.SetName(*v)
}
return _u
}
// SetDescription sets the "description" field.
func (_u *TLSFingerprintProfileUpdate) SetDescription(v string) *TLSFingerprintProfileUpdate {
_u.mutation.SetDescription(v)
return _u
}
// SetNillableDescription sets the "description" field if the given value is not nil.
func (_u *TLSFingerprintProfileUpdate) SetNillableDescription(v *string) *TLSFingerprintProfileUpdate {
if v != nil {
_u.SetDescription(*v)
}
return _u
}
// ClearDescription clears the value of the "description" field.
func (_u *TLSFingerprintProfileUpdate) ClearDescription() *TLSFingerprintProfileUpdate {
_u.mutation.ClearDescription()
return _u
}
// SetEnableGrease sets the "enable_grease" field.
func (_u *TLSFingerprintProfileUpdate) SetEnableGrease(v bool) *TLSFingerprintProfileUpdate {
_u.mutation.SetEnableGrease(v)
return _u
}
// SetNillableEnableGrease sets the "enable_grease" field if the given value is not nil.
func (_u *TLSFingerprintProfileUpdate) SetNillableEnableGrease(v *bool) *TLSFingerprintProfileUpdate {
if v != nil {
_u.SetEnableGrease(*v)
}
return _u
}
// SetCipherSuites sets the "cipher_suites" field.
func (_u *TLSFingerprintProfileUpdate) SetCipherSuites(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.SetCipherSuites(v)
return _u
}
// AppendCipherSuites appends value to the "cipher_suites" field.
func (_u *TLSFingerprintProfileUpdate) AppendCipherSuites(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.AppendCipherSuites(v)
return _u
}
// ClearCipherSuites clears the value of the "cipher_suites" field.
func (_u *TLSFingerprintProfileUpdate) ClearCipherSuites() *TLSFingerprintProfileUpdate {
_u.mutation.ClearCipherSuites()
return _u
}
// SetCurves sets the "curves" field.
func (_u *TLSFingerprintProfileUpdate) SetCurves(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.SetCurves(v)
return _u
}
// AppendCurves appends value to the "curves" field.
func (_u *TLSFingerprintProfileUpdate) AppendCurves(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.AppendCurves(v)
return _u
}
// ClearCurves clears the value of the "curves" field.
func (_u *TLSFingerprintProfileUpdate) ClearCurves() *TLSFingerprintProfileUpdate {
_u.mutation.ClearCurves()
return _u
}
// SetPointFormats sets the "point_formats" field.
func (_u *TLSFingerprintProfileUpdate) SetPointFormats(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.SetPointFormats(v)
return _u
}
// AppendPointFormats appends value to the "point_formats" field.
func (_u *TLSFingerprintProfileUpdate) AppendPointFormats(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.AppendPointFormats(v)
return _u
}
// ClearPointFormats clears the value of the "point_formats" field.
func (_u *TLSFingerprintProfileUpdate) ClearPointFormats() *TLSFingerprintProfileUpdate {
_u.mutation.ClearPointFormats()
return _u
}
// SetSignatureAlgorithms sets the "signature_algorithms" field.
func (_u *TLSFingerprintProfileUpdate) SetSignatureAlgorithms(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.SetSignatureAlgorithms(v)
return _u
}
// AppendSignatureAlgorithms appends value to the "signature_algorithms" field.
func (_u *TLSFingerprintProfileUpdate) AppendSignatureAlgorithms(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.AppendSignatureAlgorithms(v)
return _u
}
// ClearSignatureAlgorithms clears the value of the "signature_algorithms" field.
func (_u *TLSFingerprintProfileUpdate) ClearSignatureAlgorithms() *TLSFingerprintProfileUpdate {
_u.mutation.ClearSignatureAlgorithms()
return _u
}
// SetAlpnProtocols sets the "alpn_protocols" field.
func (_u *TLSFingerprintProfileUpdate) SetAlpnProtocols(v []string) *TLSFingerprintProfileUpdate {
_u.mutation.SetAlpnProtocols(v)
return _u
}
// AppendAlpnProtocols appends value to the "alpn_protocols" field.
func (_u *TLSFingerprintProfileUpdate) AppendAlpnProtocols(v []string) *TLSFingerprintProfileUpdate {
_u.mutation.AppendAlpnProtocols(v)
return _u
}
// ClearAlpnProtocols clears the value of the "alpn_protocols" field.
func (_u *TLSFingerprintProfileUpdate) ClearAlpnProtocols() *TLSFingerprintProfileUpdate {
_u.mutation.ClearAlpnProtocols()
return _u
}
// SetSupportedVersions sets the "supported_versions" field.
func (_u *TLSFingerprintProfileUpdate) SetSupportedVersions(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.SetSupportedVersions(v)
return _u
}
// AppendSupportedVersions appends value to the "supported_versions" field.
func (_u *TLSFingerprintProfileUpdate) AppendSupportedVersions(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.AppendSupportedVersions(v)
return _u
}
// ClearSupportedVersions clears the value of the "supported_versions" field.
func (_u *TLSFingerprintProfileUpdate) ClearSupportedVersions() *TLSFingerprintProfileUpdate {
_u.mutation.ClearSupportedVersions()
return _u
}
// SetKeyShareGroups sets the "key_share_groups" field.
func (_u *TLSFingerprintProfileUpdate) SetKeyShareGroups(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.SetKeyShareGroups(v)
return _u
}
// AppendKeyShareGroups appends value to the "key_share_groups" field.
func (_u *TLSFingerprintProfileUpdate) AppendKeyShareGroups(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.AppendKeyShareGroups(v)
return _u
}
// ClearKeyShareGroups clears the value of the "key_share_groups" field.
func (_u *TLSFingerprintProfileUpdate) ClearKeyShareGroups() *TLSFingerprintProfileUpdate {
_u.mutation.ClearKeyShareGroups()
return _u
}
// SetPskModes sets the "psk_modes" field.
func (_u *TLSFingerprintProfileUpdate) SetPskModes(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.SetPskModes(v)
return _u
}
// AppendPskModes appends value to the "psk_modes" field.
func (_u *TLSFingerprintProfileUpdate) AppendPskModes(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.AppendPskModes(v)
return _u
}
// ClearPskModes clears the value of the "psk_modes" field.
func (_u *TLSFingerprintProfileUpdate) ClearPskModes() *TLSFingerprintProfileUpdate {
_u.mutation.ClearPskModes()
return _u
}
// SetExtensions sets the "extensions" field.
func (_u *TLSFingerprintProfileUpdate) SetExtensions(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.SetExtensions(v)
return _u
}
// AppendExtensions appends value to the "extensions" field.
func (_u *TLSFingerprintProfileUpdate) AppendExtensions(v []uint16) *TLSFingerprintProfileUpdate {
_u.mutation.AppendExtensions(v)
return _u
}
// ClearExtensions clears the value of the "extensions" field.
func (_u *TLSFingerprintProfileUpdate) ClearExtensions() *TLSFingerprintProfileUpdate {
_u.mutation.ClearExtensions()
return _u
}
// Mutation returns the TLSFingerprintProfileMutation object of the builder.
func (_u *TLSFingerprintProfileUpdate) Mutation() *TLSFingerprintProfileMutation {
return _u.mutation
}
// Save executes the query and returns the number of nodes affected by the update operation.
func (_u *TLSFingerprintProfileUpdate) Save(ctx context.Context) (int, error) {
_u.defaults()
return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks)
}
// SaveX is like Save, but panics if an error occurs.
func (_u *TLSFingerprintProfileUpdate) SaveX(ctx context.Context) int {
affected, err := _u.Save(ctx)
if err != nil {
panic(err)
}
return affected
}
// Exec executes the query.
func (_u *TLSFingerprintProfileUpdate) Exec(ctx context.Context) error {
_, err := _u.Save(ctx)
return err
}
// ExecX is like Exec, but panics if an error occurs.
func (_u *TLSFingerprintProfileUpdate) ExecX(ctx context.Context) {
if err := _u.Exec(ctx); err != nil {
panic(err)
}
}
// defaults sets the default values of the builder before save.
func (_u *TLSFingerprintProfileUpdate) defaults() {
if _, ok := _u.mutation.UpdatedAt(); !ok {
v := tlsfingerprintprofile.UpdateDefaultUpdatedAt()
_u.mutation.SetUpdatedAt(v)
}
}
// check runs all checks and user-defined validators on the builder.
func (_u *TLSFingerprintProfileUpdate) check() error {
if v, ok := _u.mutation.Name(); ok {
if err := tlsfingerprintprofile.NameValidator(v); err != nil {
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "TLSFingerprintProfile.name": %w`, err)}
}
}
return nil
}
func (_u *TLSFingerprintProfileUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if err := _u.check(); err != nil {
return _node, err
}
_spec := sqlgraph.NewUpdateSpec(tlsfingerprintprofile.Table, tlsfingerprintprofile.Columns, sqlgraph.NewFieldSpec(tlsfingerprintprofile.FieldID, field.TypeInt64))
if ps := _u.mutation.predicates; len(ps) > 0 {
_spec.Predicate = func(selector *sql.Selector) {
for i := range ps {
ps[i](selector)
}
}
}
if value, ok := _u.mutation.UpdatedAt(); ok {
_spec.SetField(tlsfingerprintprofile.FieldUpdatedAt, field.TypeTime, value)
}
if value, ok := _u.mutation.Name(); ok {
_spec.SetField(tlsfingerprintprofile.FieldName, field.TypeString, value)
}
if value, ok := _u.mutation.Description(); ok {
_spec.SetField(tlsfingerprintprofile.FieldDescription, field.TypeString, value)
}
if _u.mutation.DescriptionCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldDescription, field.TypeString)
}
if value, ok := _u.mutation.EnableGrease(); ok {
_spec.SetField(tlsfingerprintprofile.FieldEnableGrease, field.TypeBool, value)
}
if value, ok := _u.mutation.CipherSuites(); ok {
_spec.SetField(tlsfingerprintprofile.FieldCipherSuites, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedCipherSuites(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldCipherSuites, value)
})
}
if _u.mutation.CipherSuitesCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldCipherSuites, field.TypeJSON)
}
if value, ok := _u.mutation.Curves(); ok {
_spec.SetField(tlsfingerprintprofile.FieldCurves, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedCurves(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldCurves, value)
})
}
if _u.mutation.CurvesCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldCurves, field.TypeJSON)
}
if value, ok := _u.mutation.PointFormats(); ok {
_spec.SetField(tlsfingerprintprofile.FieldPointFormats, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedPointFormats(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldPointFormats, value)
})
}
if _u.mutation.PointFormatsCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldPointFormats, field.TypeJSON)
}
if value, ok := _u.mutation.SignatureAlgorithms(); ok {
_spec.SetField(tlsfingerprintprofile.FieldSignatureAlgorithms, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedSignatureAlgorithms(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldSignatureAlgorithms, value)
})
}
if _u.mutation.SignatureAlgorithmsCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldSignatureAlgorithms, field.TypeJSON)
}
if value, ok := _u.mutation.AlpnProtocols(); ok {
_spec.SetField(tlsfingerprintprofile.FieldAlpnProtocols, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedAlpnProtocols(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldAlpnProtocols, value)
})
}
if _u.mutation.AlpnProtocolsCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldAlpnProtocols, field.TypeJSON)
}
if value, ok := _u.mutation.SupportedVersions(); ok {
_spec.SetField(tlsfingerprintprofile.FieldSupportedVersions, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedSupportedVersions(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldSupportedVersions, value)
})
}
if _u.mutation.SupportedVersionsCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldSupportedVersions, field.TypeJSON)
}
if value, ok := _u.mutation.KeyShareGroups(); ok {
_spec.SetField(tlsfingerprintprofile.FieldKeyShareGroups, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedKeyShareGroups(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldKeyShareGroups, value)
})
}
if _u.mutation.KeyShareGroupsCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldKeyShareGroups, field.TypeJSON)
}
if value, ok := _u.mutation.PskModes(); ok {
_spec.SetField(tlsfingerprintprofile.FieldPskModes, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedPskModes(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldPskModes, value)
})
}
if _u.mutation.PskModesCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldPskModes, field.TypeJSON)
}
if value, ok := _u.mutation.Extensions(); ok {
_spec.SetField(tlsfingerprintprofile.FieldExtensions, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedExtensions(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldExtensions, value)
})
}
if _u.mutation.ExtensionsCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldExtensions, field.TypeJSON)
}
if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil {
if _, ok := err.(*sqlgraph.NotFoundError); ok {
err = &NotFoundError{tlsfingerprintprofile.Label}
} else if sqlgraph.IsConstraintError(err) {
err = &ConstraintError{msg: err.Error(), wrap: err}
}
return 0, err
}
_u.mutation.done = true
return _node, nil
}
// TLSFingerprintProfileUpdateOne is the builder for updating a single TLSFingerprintProfile entity.
type TLSFingerprintProfileUpdateOne struct {
config
fields []string
hooks []Hook
mutation *TLSFingerprintProfileMutation
}
// SetUpdatedAt sets the "updated_at" field.
func (_u *TLSFingerprintProfileUpdateOne) SetUpdatedAt(v time.Time) *TLSFingerprintProfileUpdateOne {
_u.mutation.SetUpdatedAt(v)
return _u
}
// SetName sets the "name" field.
func (_u *TLSFingerprintProfileUpdateOne) SetName(v string) *TLSFingerprintProfileUpdateOne {
_u.mutation.SetName(v)
return _u
}
// SetNillableName sets the "name" field if the given value is not nil.
func (_u *TLSFingerprintProfileUpdateOne) SetNillableName(v *string) *TLSFingerprintProfileUpdateOne {
if v != nil {
_u.SetName(*v)
}
return _u
}
// SetDescription sets the "description" field.
func (_u *TLSFingerprintProfileUpdateOne) SetDescription(v string) *TLSFingerprintProfileUpdateOne {
_u.mutation.SetDescription(v)
return _u
}
// SetNillableDescription sets the "description" field if the given value is not nil.
func (_u *TLSFingerprintProfileUpdateOne) SetNillableDescription(v *string) *TLSFingerprintProfileUpdateOne {
if v != nil {
_u.SetDescription(*v)
}
return _u
}
// ClearDescription clears the value of the "description" field.
func (_u *TLSFingerprintProfileUpdateOne) ClearDescription() *TLSFingerprintProfileUpdateOne {
_u.mutation.ClearDescription()
return _u
}
// SetEnableGrease sets the "enable_grease" field.
func (_u *TLSFingerprintProfileUpdateOne) SetEnableGrease(v bool) *TLSFingerprintProfileUpdateOne {
_u.mutation.SetEnableGrease(v)
return _u
}
// SetNillableEnableGrease sets the "enable_grease" field if the given value is not nil.
func (_u *TLSFingerprintProfileUpdateOne) SetNillableEnableGrease(v *bool) *TLSFingerprintProfileUpdateOne {
if v != nil {
_u.SetEnableGrease(*v)
}
return _u
}
// SetCipherSuites sets the "cipher_suites" field.
func (_u *TLSFingerprintProfileUpdateOne) SetCipherSuites(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.SetCipherSuites(v)
return _u
}
// AppendCipherSuites appends value to the "cipher_suites" field.
func (_u *TLSFingerprintProfileUpdateOne) AppendCipherSuites(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.AppendCipherSuites(v)
return _u
}
// ClearCipherSuites clears the value of the "cipher_suites" field.
func (_u *TLSFingerprintProfileUpdateOne) ClearCipherSuites() *TLSFingerprintProfileUpdateOne {
_u.mutation.ClearCipherSuites()
return _u
}
// SetCurves sets the "curves" field.
func (_u *TLSFingerprintProfileUpdateOne) SetCurves(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.SetCurves(v)
return _u
}
// AppendCurves appends value to the "curves" field.
func (_u *TLSFingerprintProfileUpdateOne) AppendCurves(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.AppendCurves(v)
return _u
}
// ClearCurves clears the value of the "curves" field.
func (_u *TLSFingerprintProfileUpdateOne) ClearCurves() *TLSFingerprintProfileUpdateOne {
_u.mutation.ClearCurves()
return _u
}
// SetPointFormats sets the "point_formats" field.
func (_u *TLSFingerprintProfileUpdateOne) SetPointFormats(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.SetPointFormats(v)
return _u
}
// AppendPointFormats appends value to the "point_formats" field.
func (_u *TLSFingerprintProfileUpdateOne) AppendPointFormats(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.AppendPointFormats(v)
return _u
}
// ClearPointFormats clears the value of the "point_formats" field.
func (_u *TLSFingerprintProfileUpdateOne) ClearPointFormats() *TLSFingerprintProfileUpdateOne {
_u.mutation.ClearPointFormats()
return _u
}
// SetSignatureAlgorithms sets the "signature_algorithms" field.
func (_u *TLSFingerprintProfileUpdateOne) SetSignatureAlgorithms(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.SetSignatureAlgorithms(v)
return _u
}
// AppendSignatureAlgorithms appends value to the "signature_algorithms" field.
func (_u *TLSFingerprintProfileUpdateOne) AppendSignatureAlgorithms(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.AppendSignatureAlgorithms(v)
return _u
}
// ClearSignatureAlgorithms clears the value of the "signature_algorithms" field.
func (_u *TLSFingerprintProfileUpdateOne) ClearSignatureAlgorithms() *TLSFingerprintProfileUpdateOne {
_u.mutation.ClearSignatureAlgorithms()
return _u
}
// SetAlpnProtocols sets the "alpn_protocols" field.
func (_u *TLSFingerprintProfileUpdateOne) SetAlpnProtocols(v []string) *TLSFingerprintProfileUpdateOne {
_u.mutation.SetAlpnProtocols(v)
return _u
}
// AppendAlpnProtocols appends value to the "alpn_protocols" field.
func (_u *TLSFingerprintProfileUpdateOne) AppendAlpnProtocols(v []string) *TLSFingerprintProfileUpdateOne {
_u.mutation.AppendAlpnProtocols(v)
return _u
}
// ClearAlpnProtocols clears the value of the "alpn_protocols" field.
func (_u *TLSFingerprintProfileUpdateOne) ClearAlpnProtocols() *TLSFingerprintProfileUpdateOne {
_u.mutation.ClearAlpnProtocols()
return _u
}
// SetSupportedVersions sets the "supported_versions" field.
func (_u *TLSFingerprintProfileUpdateOne) SetSupportedVersions(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.SetSupportedVersions(v)
return _u
}
// AppendSupportedVersions appends value to the "supported_versions" field.
func (_u *TLSFingerprintProfileUpdateOne) AppendSupportedVersions(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.AppendSupportedVersions(v)
return _u
}
// ClearSupportedVersions clears the value of the "supported_versions" field.
func (_u *TLSFingerprintProfileUpdateOne) ClearSupportedVersions() *TLSFingerprintProfileUpdateOne {
_u.mutation.ClearSupportedVersions()
return _u
}
// SetKeyShareGroups sets the "key_share_groups" field.
func (_u *TLSFingerprintProfileUpdateOne) SetKeyShareGroups(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.SetKeyShareGroups(v)
return _u
}
// AppendKeyShareGroups appends value to the "key_share_groups" field.
func (_u *TLSFingerprintProfileUpdateOne) AppendKeyShareGroups(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.AppendKeyShareGroups(v)
return _u
}
// ClearKeyShareGroups clears the value of the "key_share_groups" field.
func (_u *TLSFingerprintProfileUpdateOne) ClearKeyShareGroups() *TLSFingerprintProfileUpdateOne {
_u.mutation.ClearKeyShareGroups()
return _u
}
// SetPskModes sets the "psk_modes" field.
func (_u *TLSFingerprintProfileUpdateOne) SetPskModes(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.SetPskModes(v)
return _u
}
// AppendPskModes appends value to the "psk_modes" field.
func (_u *TLSFingerprintProfileUpdateOne) AppendPskModes(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.AppendPskModes(v)
return _u
}
// ClearPskModes clears the value of the "psk_modes" field.
func (_u *TLSFingerprintProfileUpdateOne) ClearPskModes() *TLSFingerprintProfileUpdateOne {
_u.mutation.ClearPskModes()
return _u
}
// SetExtensions sets the "extensions" field.
func (_u *TLSFingerprintProfileUpdateOne) SetExtensions(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.SetExtensions(v)
return _u
}
// AppendExtensions appends value to the "extensions" field.
func (_u *TLSFingerprintProfileUpdateOne) AppendExtensions(v []uint16) *TLSFingerprintProfileUpdateOne {
_u.mutation.AppendExtensions(v)
return _u
}
// ClearExtensions clears the value of the "extensions" field.
func (_u *TLSFingerprintProfileUpdateOne) ClearExtensions() *TLSFingerprintProfileUpdateOne {
_u.mutation.ClearExtensions()
return _u
}
// Mutation returns the TLSFingerprintProfileMutation object of the builder.
func (_u *TLSFingerprintProfileUpdateOne) Mutation() *TLSFingerprintProfileMutation {
return _u.mutation
}
// Where appends a list predicates to the TLSFingerprintProfileUpdate builder.
func (_u *TLSFingerprintProfileUpdateOne) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileUpdateOne {
_u.mutation.Where(ps...)
return _u
}
// Select allows selecting one or more fields (columns) of the returned entity.
// The default is selecting all fields defined in the entity schema.
func (_u *TLSFingerprintProfileUpdateOne) Select(field string, fields ...string) *TLSFingerprintProfileUpdateOne {
_u.fields = append([]string{field}, fields...)
return _u
}
// Save executes the query and returns the updated TLSFingerprintProfile entity.
func (_u *TLSFingerprintProfileUpdateOne) Save(ctx context.Context) (*TLSFingerprintProfile, error) {
_u.defaults()
return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks)
}
// SaveX is like Save, but panics if an error occurs.
func (_u *TLSFingerprintProfileUpdateOne) SaveX(ctx context.Context) *TLSFingerprintProfile {
node, err := _u.Save(ctx)
if err != nil {
panic(err)
}
return node
}
// Exec executes the query on the entity.
func (_u *TLSFingerprintProfileUpdateOne) Exec(ctx context.Context) error {
_, err := _u.Save(ctx)
return err
}
// ExecX is like Exec, but panics if an error occurs.
func (_u *TLSFingerprintProfileUpdateOne) ExecX(ctx context.Context) {
if err := _u.Exec(ctx); err != nil {
panic(err)
}
}
// defaults sets the default values of the builder before save.
func (_u *TLSFingerprintProfileUpdateOne) defaults() {
if _, ok := _u.mutation.UpdatedAt(); !ok {
v := tlsfingerprintprofile.UpdateDefaultUpdatedAt()
_u.mutation.SetUpdatedAt(v)
}
}
// check runs all checks and user-defined validators on the builder.
func (_u *TLSFingerprintProfileUpdateOne) check() error {
if v, ok := _u.mutation.Name(); ok {
if err := tlsfingerprintprofile.NameValidator(v); err != nil {
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "TLSFingerprintProfile.name": %w`, err)}
}
}
return nil
}
func (_u *TLSFingerprintProfileUpdateOne) sqlSave(ctx context.Context) (_node *TLSFingerprintProfile, err error) {
if err := _u.check(); err != nil {
return _node, err
}
_spec := sqlgraph.NewUpdateSpec(tlsfingerprintprofile.Table, tlsfingerprintprofile.Columns, sqlgraph.NewFieldSpec(tlsfingerprintprofile.FieldID, field.TypeInt64))
id, ok := _u.mutation.ID()
if !ok {
return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "TLSFingerprintProfile.id" for update`)}
}
_spec.Node.ID.Value = id
if fields := _u.fields; len(fields) > 0 {
_spec.Node.Columns = make([]string, 0, len(fields))
_spec.Node.Columns = append(_spec.Node.Columns, tlsfingerprintprofile.FieldID)
for _, f := range fields {
if !tlsfingerprintprofile.ValidColumn(f) {
return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)}
}
if f != tlsfingerprintprofile.FieldID {
_spec.Node.Columns = append(_spec.Node.Columns, f)
}
}
}
if ps := _u.mutation.predicates; len(ps) > 0 {
_spec.Predicate = func(selector *sql.Selector) {
for i := range ps {
ps[i](selector)
}
}
}
if value, ok := _u.mutation.UpdatedAt(); ok {
_spec.SetField(tlsfingerprintprofile.FieldUpdatedAt, field.TypeTime, value)
}
if value, ok := _u.mutation.Name(); ok {
_spec.SetField(tlsfingerprintprofile.FieldName, field.TypeString, value)
}
if value, ok := _u.mutation.Description(); ok {
_spec.SetField(tlsfingerprintprofile.FieldDescription, field.TypeString, value)
}
if _u.mutation.DescriptionCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldDescription, field.TypeString)
}
if value, ok := _u.mutation.EnableGrease(); ok {
_spec.SetField(tlsfingerprintprofile.FieldEnableGrease, field.TypeBool, value)
}
if value, ok := _u.mutation.CipherSuites(); ok {
_spec.SetField(tlsfingerprintprofile.FieldCipherSuites, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedCipherSuites(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldCipherSuites, value)
})
}
if _u.mutation.CipherSuitesCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldCipherSuites, field.TypeJSON)
}
if value, ok := _u.mutation.Curves(); ok {
_spec.SetField(tlsfingerprintprofile.FieldCurves, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedCurves(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldCurves, value)
})
}
if _u.mutation.CurvesCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldCurves, field.TypeJSON)
}
if value, ok := _u.mutation.PointFormats(); ok {
_spec.SetField(tlsfingerprintprofile.FieldPointFormats, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedPointFormats(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldPointFormats, value)
})
}
if _u.mutation.PointFormatsCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldPointFormats, field.TypeJSON)
}
if value, ok := _u.mutation.SignatureAlgorithms(); ok {
_spec.SetField(tlsfingerprintprofile.FieldSignatureAlgorithms, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedSignatureAlgorithms(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldSignatureAlgorithms, value)
})
}
if _u.mutation.SignatureAlgorithmsCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldSignatureAlgorithms, field.TypeJSON)
}
if value, ok := _u.mutation.AlpnProtocols(); ok {
_spec.SetField(tlsfingerprintprofile.FieldAlpnProtocols, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedAlpnProtocols(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldAlpnProtocols, value)
})
}
if _u.mutation.AlpnProtocolsCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldAlpnProtocols, field.TypeJSON)
}
if value, ok := _u.mutation.SupportedVersions(); ok {
_spec.SetField(tlsfingerprintprofile.FieldSupportedVersions, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedSupportedVersions(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldSupportedVersions, value)
})
}
if _u.mutation.SupportedVersionsCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldSupportedVersions, field.TypeJSON)
}
if value, ok := _u.mutation.KeyShareGroups(); ok {
_spec.SetField(tlsfingerprintprofile.FieldKeyShareGroups, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedKeyShareGroups(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldKeyShareGroups, value)
})
}
if _u.mutation.KeyShareGroupsCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldKeyShareGroups, field.TypeJSON)
}
if value, ok := _u.mutation.PskModes(); ok {
_spec.SetField(tlsfingerprintprofile.FieldPskModes, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedPskModes(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldPskModes, value)
})
}
if _u.mutation.PskModesCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldPskModes, field.TypeJSON)
}
if value, ok := _u.mutation.Extensions(); ok {
_spec.SetField(tlsfingerprintprofile.FieldExtensions, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedExtensions(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, tlsfingerprintprofile.FieldExtensions, value)
})
}
if _u.mutation.ExtensionsCleared() {
_spec.ClearField(tlsfingerprintprofile.FieldExtensions, field.TypeJSON)
}
_node = &TLSFingerprintProfile{config: _u.config}
_spec.Assign = _node.assignValues
_spec.ScanValues = _node.scanValues
if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil {
if _, ok := err.(*sqlgraph.NotFoundError); ok {
err = &NotFoundError{tlsfingerprintprofile.Label}
} else if sqlgraph.IsConstraintError(err) {
err = &ConstraintError{msg: err.Error(), wrap: err}
}
return nil, err
}
_u.mutation.done = true
return _node, nil
}

View File

@ -42,6 +42,8 @@ type Tx struct {
SecuritySecret *SecuritySecretClient
// Setting is the client for interacting with the Setting builders.
Setting *SettingClient
// TLSFingerprintProfile is the client for interacting with the TLSFingerprintProfile builders.
TLSFingerprintProfile *TLSFingerprintProfileClient
// UsageCleanupTask is the client for interacting with the UsageCleanupTask builders.
UsageCleanupTask *UsageCleanupTaskClient
// UsageLog is the client for interacting with the UsageLog builders.
@ -201,6 +203,7 @@ func (tx *Tx) init() {
tx.RedeemCode = NewRedeemCodeClient(tx.config)
tx.SecuritySecret = NewSecuritySecretClient(tx.config)
tx.Setting = NewSettingClient(tx.config)
tx.TLSFingerprintProfile = NewTLSFingerprintProfileClient(tx.config)
tx.UsageCleanupTask = NewUsageCleanupTaskClient(tx.config)
tx.UsageLog = NewUsageLogClient(tx.config)
tx.User = NewUserClient(tx.config)

View File

@ -456,6 +456,23 @@ 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确保相同输入产生不同输出。
// 为空时使用默认行为(无 salt建议生产环境必须配置。
// 生成方法: openssl rand -hex 32
InstanceSalt string `mapstructure:"instance_salt"`
// FingerprintDefaults: 指纹默认值覆盖
// 允许每个实例配置不同的 Claude CLI 版本号,与其他 sub2api 实例区分。
// 为空时使用代码内置默认值。
FingerprintDefaults FingerprintDefaultsConfig `mapstructure:"fingerprint_defaults"`
// UsageRecord: 使用量记录异步队列配置(有界队列 + 固定 worker
UsageRecord GatewayUsageRecordConfig `mapstructure:"usage_record"`
@ -656,17 +673,72 @@ type TLSFingerprintConfig struct {
}
// TLSProfileConfig 单个TLS指纹模板的配置
// 所有列表字段为空时使用内置默认值Claude CLI 2.x / Node.js 20.x
// 建议通过 TLS 指纹采集工具 (tests/tls-fingerprint-web) 获取完整配置
type TLSProfileConfig struct {
// Name: 模板显示名称
Name string `mapstructure:"name"`
// EnableGREASE: 是否启用GREASE扩展Chrome使用Node.js不使用
EnableGREASE bool `mapstructure:"enable_grease"`
// CipherSuites: TLS加密套件列表(空则使用内置默认值)
// CipherSuites: TLS加密套件列表
CipherSuites []uint16 `mapstructure:"cipher_suites"`
// Curves: 椭圆曲线列表(空则使用内置默认值)
// Curves: 椭圆曲线列表
Curves []uint16 `mapstructure:"curves"`
// PointFormats: 点格式列表(空则使用内置默认值)
PointFormats []uint8 `mapstructure:"point_formats"`
// PointFormats: 点格式列表
PointFormats []uint16 `mapstructure:"point_formats"`
// SignatureAlgorithms: 签名算法列表
SignatureAlgorithms []uint16 `mapstructure:"signature_algorithms"`
// ALPNProtocols: ALPN协议列表如 ["h2", "http/1.1"]
ALPNProtocols []string `mapstructure:"alpn_protocols"`
// SupportedVersions: 支持的TLS版本列表如 [0x0304, 0x0303] 即 TLS1.3, TLS1.2
SupportedVersions []uint16 `mapstructure:"supported_versions"`
// KeyShareGroups: Key Share中发送的曲线组如 [29] 即 X25519
KeyShareGroups []uint16 `mapstructure:"key_share_groups"`
// PSKModes: PSK密钥交换模式如 [1] 即 psk_dhe_ke
PSKModes []uint16 `mapstructure:"psk_modes"`
// Extensions: TLS扩展类型ID列表按发送顺序排列
// 空则使用内置默认顺序 [0,11,10,35,16,22,23,13,43,45,51]
// GREASE值(如0x0a0a)会自动插入GREASE扩展
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 实例设置不同的默认指纹值,与其他实例区分。
// 所有字段为空时使用代码内置默认值。
type FingerprintDefaultsConfig struct {
// ClaudeCLIVersion: Claude CLI 版本号(如 "2.1.81"
// 最终 User-Agent 为 "claude-cli/{version} (external, cli)"
ClaudeCLIVersion string `mapstructure:"claude_cli_version"`
// StainlessPackageVersion: @anthropic-ai/sdk 版本(如 "0.80.0"
StainlessPackageVersion string `mapstructure:"stainless_package_version"`
// StainlessRuntimeVersion: Node.js 版本(如 "v24.13.0"
StainlessRuntimeVersion string `mapstructure:"stainless_runtime_version"`
// StainlessOS: 操作系统(如 "Linux", "Darwin"
StainlessOS string `mapstructure:"stainless_os"`
// StainlessArch: 架构(如 "arm64", "x64"
StainlessArch string `mapstructure:"stainless_arch"`
}
// GatewaySchedulingConfig accounts scheduling configuration.
@ -1447,6 +1519,13 @@ 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

@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"strconv"
"strings"
@ -536,6 +537,10 @@ func (h *AccountHandler) Create(c *gin.Context) {
if execErr != nil {
return nil, execErr
}
// Antigravity OAuth: 新账号直接设置隐私
h.adminService.ForceAntigravityPrivacy(ctx, account)
// OpenAI OAuth: 新账号直接设置隐私
h.adminService.ForceOpenAIPrivacy(ctx, account)
return h.buildAccountResponseWithRuntime(ctx, account), nil
})
if err != nil {
@ -782,6 +787,8 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
if account.IsOpenAI() {
tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(ctx, account)
if err != nil {
// 刷新失败但 access_token 可能仍有效,尝试设置隐私
h.adminService.EnsureOpenAIPrivacy(ctx, account)
return nil, "", err
}
@ -883,6 +890,8 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
// OpenAI OAuth: 刷新成功后检查并设置 privacy_mode
h.adminService.EnsureOpenAIPrivacy(ctx, updatedAccount)
// Antigravity OAuth: 刷新成功后检查并设置 privacy_mode
h.adminService.EnsureAntigravityPrivacy(ctx, updatedAccount)
return updatedAccount, "", nil
}
@ -1154,6 +1163,9 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
success := 0
failed := 0
results := make([]gin.H, 0, len(req.Accounts))
// 收集需要异步设置隐私的 OAuth 账号
var antigravityPrivacyAccounts []*service.Account
var openaiPrivacyAccounts []*service.Account
for _, item := range req.Accounts {
if item.RateMultiplier != nil && *item.RateMultiplier < 0 {
@ -1196,6 +1208,15 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
})
continue
}
// 收集需要异步设置隐私的 OAuth 账号
if account.Type == service.AccountTypeOAuth {
switch account.Platform {
case service.PlatformAntigravity:
antigravityPrivacyAccounts = append(antigravityPrivacyAccounts, account)
case service.PlatformOpenAI:
openaiPrivacyAccounts = append(openaiPrivacyAccounts, account)
}
}
success++
results = append(results, gin.H{
"name": item.Name,
@ -1204,6 +1225,37 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
})
}
// 异步设置隐私,避免批量创建时阻塞请求
adminSvc := h.adminService
if len(antigravityPrivacyAccounts) > 0 {
accounts := antigravityPrivacyAccounts
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("batch_create_antigravity_privacy_panic", "recover", r)
}
}()
bgCtx := context.Background()
for _, acc := range accounts {
adminSvc.ForceAntigravityPrivacy(bgCtx, acc)
}
}()
}
if len(openaiPrivacyAccounts) > 0 {
accounts := openaiPrivacyAccounts
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("batch_create_openai_privacy_panic", "recover", r)
}
}()
bgCtx := context.Background()
for _, acc := range accounts {
adminSvc.ForceOpenAIPrivacy(bgCtx, acc)
}
}()
}
return gin.H{
"success": success,
"failed": failed,
@ -1869,6 +1921,51 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
response.Success(c, models)
}
// SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account
// POST /api/v1/admin/accounts/:id/set-privacy
func (h *AccountHandler) SetPrivacy(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
if err != nil {
response.NotFound(c, "Account not found")
return
}
if account.Type != service.AccountTypeOAuth {
response.BadRequest(c, "Only OAuth accounts support privacy setting")
return
}
var mode string
switch account.Platform {
case service.PlatformOpenAI:
mode = h.adminService.ForceOpenAIPrivacy(c.Request.Context(), account)
case service.PlatformAntigravity:
mode = h.adminService.ForceAntigravityPrivacy(c.Request.Context(), account)
default:
response.BadRequest(c, "Only OpenAI and Antigravity OAuth accounts support privacy setting")
return
}
if mode == "" {
response.BadRequest(c, "Cannot set privacy: missing access_token")
return
}
// 从 DB 重新读取以确保返回最新状态
updated, err := h.adminService.GetAccount(c.Request.Context(), accountID)
if err != nil {
// 隐私已设置成功但读取失败,回退到内存更新
if account.Extra == nil {
account.Extra = make(map[string]any)
}
account.Extra["privacy_mode"] = mode
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
return
}
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updated))
}
// RefreshTier handles refreshing Google One tier for a single account
// POST /api/v1/admin/accounts/:id/refresh-tier
func (h *AccountHandler) RefreshTier(c *gin.Context) {

View File

@ -445,6 +445,18 @@ func (s *stubAdminService) EnsureOpenAIPrivacy(ctx context.Context, account *ser
return ""
}
func (s *stubAdminService) EnsureAntigravityPrivacy(ctx context.Context, account *service.Account) string {
return ""
}
func (s *stubAdminService) ForceOpenAIPrivacy(ctx context.Context, account *service.Account) string {
return ""
}
func (s *stubAdminService) ForceAntigravityPrivacy(ctx context.Context, account *service.Account) string {
return ""
}
func (s *stubAdminService) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*service.ReplaceUserGroupResult, error) {
return &service.ReplaceUserGroupResult{MigratedKeys: 0}, nil
}

View File

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

@ -0,0 +1,234 @@
package admin
import (
"strconv"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// TLSFingerprintProfileHandler 处理 TLS 指纹模板的 HTTP 请求
type TLSFingerprintProfileHandler struct {
service *service.TLSFingerprintProfileService
}
// NewTLSFingerprintProfileHandler 创建 TLS 指纹模板处理器
func NewTLSFingerprintProfileHandler(service *service.TLSFingerprintProfileService) *TLSFingerprintProfileHandler {
return &TLSFingerprintProfileHandler{service: service}
}
// CreateTLSFingerprintProfileRequest 创建模板请求
type CreateTLSFingerprintProfileRequest struct {
Name string `json:"name" binding:"required"`
Description *string `json:"description"`
EnableGREASE *bool `json:"enable_grease"`
CipherSuites []uint16 `json:"cipher_suites"`
Curves []uint16 `json:"curves"`
PointFormats []uint16 `json:"point_formats"`
SignatureAlgorithms []uint16 `json:"signature_algorithms"`
ALPNProtocols []string `json:"alpn_protocols"`
SupportedVersions []uint16 `json:"supported_versions"`
KeyShareGroups []uint16 `json:"key_share_groups"`
PSKModes []uint16 `json:"psk_modes"`
Extensions []uint16 `json:"extensions"`
}
// UpdateTLSFingerprintProfileRequest 更新模板请求(部分更新)
type UpdateTLSFingerprintProfileRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
EnableGREASE *bool `json:"enable_grease"`
CipherSuites []uint16 `json:"cipher_suites"`
Curves []uint16 `json:"curves"`
PointFormats []uint16 `json:"point_formats"`
SignatureAlgorithms []uint16 `json:"signature_algorithms"`
ALPNProtocols []string `json:"alpn_protocols"`
SupportedVersions []uint16 `json:"supported_versions"`
KeyShareGroups []uint16 `json:"key_share_groups"`
PSKModes []uint16 `json:"psk_modes"`
Extensions []uint16 `json:"extensions"`
}
// List 获取所有模板
// GET /api/v1/admin/tls-fingerprint-profiles
func (h *TLSFingerprintProfileHandler) List(c *gin.Context) {
profiles, err := h.service.List(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, profiles)
}
// GetByID 根据 ID 获取模板
// GET /api/v1/admin/tls-fingerprint-profiles/:id
func (h *TLSFingerprintProfileHandler) GetByID(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid profile ID")
return
}
profile, err := h.service.GetByID(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
if profile == nil {
response.NotFound(c, "Profile not found")
return
}
response.Success(c, profile)
}
// Create 创建模板
// POST /api/v1/admin/tls-fingerprint-profiles
func (h *TLSFingerprintProfileHandler) Create(c *gin.Context) {
var req CreateTLSFingerprintProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
profile := &model.TLSFingerprintProfile{
Name: req.Name,
Description: req.Description,
CipherSuites: req.CipherSuites,
Curves: req.Curves,
PointFormats: req.PointFormats,
SignatureAlgorithms: req.SignatureAlgorithms,
ALPNProtocols: req.ALPNProtocols,
SupportedVersions: req.SupportedVersions,
KeyShareGroups: req.KeyShareGroups,
PSKModes: req.PSKModes,
Extensions: req.Extensions,
}
if req.EnableGREASE != nil {
profile.EnableGREASE = *req.EnableGREASE
}
created, err := h.service.Create(c.Request.Context(), profile)
if err != nil {
if _, ok := err.(*model.ValidationError); ok {
response.BadRequest(c, err.Error())
return
}
response.ErrorFrom(c, err)
return
}
response.Success(c, created)
}
// Update 更新模板(支持部分更新)
// PUT /api/v1/admin/tls-fingerprint-profiles/:id
func (h *TLSFingerprintProfileHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid profile ID")
return
}
var req UpdateTLSFingerprintProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
existing, err := h.service.GetByID(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
if existing == nil {
response.NotFound(c, "Profile not found")
return
}
// 部分更新
profile := &model.TLSFingerprintProfile{
ID: id,
Name: existing.Name,
Description: existing.Description,
EnableGREASE: existing.EnableGREASE,
CipherSuites: existing.CipherSuites,
Curves: existing.Curves,
PointFormats: existing.PointFormats,
SignatureAlgorithms: existing.SignatureAlgorithms,
ALPNProtocols: existing.ALPNProtocols,
SupportedVersions: existing.SupportedVersions,
KeyShareGroups: existing.KeyShareGroups,
PSKModes: existing.PSKModes,
Extensions: existing.Extensions,
}
if req.Name != nil {
profile.Name = *req.Name
}
if req.Description != nil {
profile.Description = req.Description
}
if req.EnableGREASE != nil {
profile.EnableGREASE = *req.EnableGREASE
}
if req.CipherSuites != nil {
profile.CipherSuites = req.CipherSuites
}
if req.Curves != nil {
profile.Curves = req.Curves
}
if req.PointFormats != nil {
profile.PointFormats = req.PointFormats
}
if req.SignatureAlgorithms != nil {
profile.SignatureAlgorithms = req.SignatureAlgorithms
}
if req.ALPNProtocols != nil {
profile.ALPNProtocols = req.ALPNProtocols
}
if req.SupportedVersions != nil {
profile.SupportedVersions = req.SupportedVersions
}
if req.KeyShareGroups != nil {
profile.KeyShareGroups = req.KeyShareGroups
}
if req.PSKModes != nil {
profile.PSKModes = req.PSKModes
}
if req.Extensions != nil {
profile.Extensions = req.Extensions
}
updated, err := h.service.Update(c.Request.Context(), profile)
if err != nil {
if _, ok := err.(*model.ValidationError); ok {
response.BadRequest(c, err.Error())
return
}
response.ErrorFrom(c, err)
return
}
response.Success(c, updated)
}
// Delete 删除模板
// DELETE /api/v1/admin/tls-fingerprint-profiles/:id
func (h *TLSFingerprintProfileHandler) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid profile ID")
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"message": "Profile deleted successfully"})
}

View File

@ -252,6 +252,10 @@ func AccountFromServiceShallow(a *service.Account) *Account {
enabled := true
out.EnableTLSFingerprint = &enabled
}
// TLS指纹模板ID
if profileID := a.GetTLSFingerprintProfileID(); profileID > 0 {
out.TLSFingerprintProfileID = &profileID
}
// 会话ID伪装开关
if a.IsSessionIDMaskingEnabled() {
enabled := true

View File

@ -185,7 +185,8 @@ type Account struct {
// TLS指纹伪装仅 Anthropic OAuth/SetupToken 账号有效)
// 从 extra 字段提取,方便前端显示和编辑
EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"`
EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"`
TLSFingerprintProfileID *int64 `json:"tls_fingerprint_profile_id,omitempty"`
// 会话ID伪装仅 Anthropic OAuth/SetupToken 账号有效)
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID

View File

@ -75,8 +75,10 @@ func (f *fakeGroupRepo) ListActive(context.Context) ([]service.Group, error) { r
func (f *fakeGroupRepo) ListActiveByPlatform(context.Context, string) ([]service.Group, error) {
return nil, nil
}
func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil }
func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, int64, error) { return 0, 0, nil }
func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil }
func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, int64, error) {
return 0, 0, nil
}
func (f *fakeGroupRepo) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
return 0, nil
}
@ -158,6 +160,7 @@ func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*servi
nil, // rpmCache
nil, // digestStore
nil, // settingService
nil, // tlsFPProfileService
)
// RunModeSimple跳过计费检查避免引入 repo/cache 依赖。

View File

@ -6,29 +6,31 @@ import (
// AdminHandlers contains all admin-related HTTP handlers
type AdminHandlers struct {
Dashboard *admin.DashboardHandler
User *admin.UserHandler
Group *admin.GroupHandler
Account *admin.AccountHandler
Announcement *admin.AnnouncementHandler
DataManagement *admin.DataManagementHandler
Backup *admin.BackupHandler
OAuth *admin.OAuthHandler
OpenAIOAuth *admin.OpenAIOAuthHandler
GeminiOAuth *admin.GeminiOAuthHandler
AntigravityOAuth *admin.AntigravityOAuthHandler
Proxy *admin.ProxyHandler
Redeem *admin.RedeemHandler
Promo *admin.PromoHandler
Setting *admin.SettingHandler
Ops *admin.OpsHandler
System *admin.SystemHandler
Subscription *admin.SubscriptionHandler
Usage *admin.UsageHandler
UserAttribute *admin.UserAttributeHandler
ErrorPassthrough *admin.ErrorPassthroughHandler
APIKey *admin.AdminAPIKeyHandler
ScheduledTest *admin.ScheduledTestHandler
Dashboard *admin.DashboardHandler
User *admin.UserHandler
Group *admin.GroupHandler
Account *admin.AccountHandler
Announcement *admin.AnnouncementHandler
DataManagement *admin.DataManagementHandler
Backup *admin.BackupHandler
OAuth *admin.OAuthHandler
OpenAIOAuth *admin.OpenAIOAuthHandler
GeminiOAuth *admin.GeminiOAuthHandler
AntigravityOAuth *admin.AntigravityOAuthHandler
Proxy *admin.ProxyHandler
Redeem *admin.RedeemHandler
Promo *admin.PromoHandler
Setting *admin.SettingHandler
Ops *admin.OpsHandler
System *admin.SystemHandler
Subscription *admin.SubscriptionHandler
Usage *admin.UsageHandler
UserAttribute *admin.UserAttributeHandler
ErrorPassthrough *admin.ErrorPassthroughHandler
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
APIKey *admin.AdminAPIKeyHandler
ScheduledTest *admin.ScheduledTestHandler
Risk *admin.RiskHandler
}
// Handlers contains all HTTP handlers

View File

@ -2224,7 +2224,7 @@ func (s *stubSoraClientForHandler) GetVideoTask(_ context.Context, _ *service.Ac
func newMinimalGatewayService(accountRepo service.AccountRepository) *service.GatewayService {
return service.NewGatewayService(
accountRepo, nil, nil, nil, nil, nil, nil, nil, nil,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
)
}

View File

@ -464,6 +464,7 @@ func TestSoraGatewayHandler_ChatCompletions(t *testing.T) {
nil, // rpmCache
nil, // digestStore
nil, // settingService
nil, // tlsFPProfileService
)
soraClient := &stubSoraClient{imageURLs: []string{"https://example.com/a.png"}}

View File

@ -30,33 +30,37 @@ func ProvideAdminHandlers(
usageHandler *admin.UsageHandler,
userAttributeHandler *admin.UserAttributeHandler,
errorPassthroughHandler *admin.ErrorPassthroughHandler,
tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler,
apiKeyHandler *admin.AdminAPIKeyHandler,
scheduledTestHandler *admin.ScheduledTestHandler,
riskHandler *admin.RiskHandler,
) *AdminHandlers {
return &AdminHandlers{
Dashboard: dashboardHandler,
User: userHandler,
Group: groupHandler,
Account: accountHandler,
Announcement: announcementHandler,
DataManagement: dataManagementHandler,
Backup: backupHandler,
OAuth: oauthHandler,
OpenAIOAuth: openaiOAuthHandler,
GeminiOAuth: geminiOAuthHandler,
AntigravityOAuth: antigravityOAuthHandler,
Proxy: proxyHandler,
Redeem: redeemHandler,
Promo: promoHandler,
Setting: settingHandler,
Ops: opsHandler,
System: systemHandler,
Subscription: subscriptionHandler,
Usage: usageHandler,
UserAttribute: userAttributeHandler,
ErrorPassthrough: errorPassthroughHandler,
APIKey: apiKeyHandler,
ScheduledTest: scheduledTestHandler,
Dashboard: dashboardHandler,
User: userHandler,
Group: groupHandler,
Account: accountHandler,
Announcement: announcementHandler,
DataManagement: dataManagementHandler,
Backup: backupHandler,
OAuth: oauthHandler,
OpenAIOAuth: openaiOAuthHandler,
GeminiOAuth: geminiOAuthHandler,
AntigravityOAuth: antigravityOAuthHandler,
Proxy: proxyHandler,
Redeem: redeemHandler,
Promo: promoHandler,
Setting: settingHandler,
Ops: opsHandler,
System: systemHandler,
Subscription: subscriptionHandler,
Usage: usageHandler,
UserAttribute: userAttributeHandler,
ErrorPassthrough: errorPassthroughHandler,
TLSFingerprintProfile: tlsFingerprintProfileHandler,
APIKey: apiKeyHandler,
ScheduledTest: scheduledTestHandler,
Risk: riskHandler,
}
}
@ -145,8 +149,10 @@ var ProviderSet = wire.NewSet(
admin.NewUsageHandler,
admin.NewUserAttributeHandler,
admin.NewErrorPassthroughHandler,
admin.NewTLSFingerprintProfileHandler,
admin.NewAdminAPIKeyHandler,
admin.NewScheduledTestHandler,
admin.NewRiskHandler,
// AdminHandlers and Handlers constructors
ProvideAdminHandlers,

View File

@ -0,0 +1,54 @@
// Package model 定义服务层使用的数据模型。
package model
import (
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
)
// TLSFingerprintProfile TLS 指纹配置模板
// 包含完整的 ClientHello 参数,用于模拟特定客户端的 TLS 握手特征
type TLSFingerprintProfile struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
EnableGREASE bool `json:"enable_grease"`
CipherSuites []uint16 `json:"cipher_suites"`
Curves []uint16 `json:"curves"`
PointFormats []uint16 `json:"point_formats"`
SignatureAlgorithms []uint16 `json:"signature_algorithms"`
ALPNProtocols []string `json:"alpn_protocols"`
SupportedVersions []uint16 `json:"supported_versions"`
KeyShareGroups []uint16 `json:"key_share_groups"`
PSKModes []uint16 `json:"psk_modes"`
Extensions []uint16 `json:"extensions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Validate 验证模板配置的有效性
func (p *TLSFingerprintProfile) Validate() error {
if p.Name == "" {
return &ValidationError{Field: "name", Message: "name is required"}
}
return nil
}
// ToTLSProfile 将领域模型转换为运行时使用的 tlsfingerprint.Profile
// 空切片字段会在 dialer 中 fallback 到内置默认值
func (p *TLSFingerprintProfile) ToTLSProfile() *tlsfingerprint.Profile {
return &tlsfingerprint.Profile{
Name: p.Name,
EnableGREASE: p.EnableGREASE,
CipherSuites: p.CipherSuites,
Curves: p.Curves,
PointFormats: p.PointFormats,
SignatureAlgorithms: p.SignatureAlgorithms,
ALPNProtocols: p.ALPNProtocols,
SupportedVersions: p.SupportedVersions,
KeyShareGroups: p.KeyShareGroups,
PSKModes: p.PSKModes,
Extensions: p.Extensions,
}
}

View File

@ -29,6 +29,21 @@ 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 参数
@ -43,10 +58,8 @@ func NewAPIRequestWithURL(ctx context.Context, baseURL, action, accessToken stri
return nil, err
}
// 基础 Headers与 Antigravity-Manager 保持一致,只设置这 3 个)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("User-Agent", GetUserAgent())
// 设置与真实 Antigravity IDE 一致的请求头
setAntigravityHeaders(req, accessToken)
return req, nil
}
@ -78,7 +91,9 @@ type UserInfo struct {
// LoadCodeAssistRequest loadCodeAssist 请求
type LoadCodeAssistRequest struct {
Metadata struct {
IDEType string `json:"ideType"`
IDEType string `json:"ideType"`
IDEVersion string `json:"ideVersion"`
IDEName string `json:"ideName"`
} `json:"metadata"`
}
@ -223,6 +238,23 @@ func (r *LoadCodeAssistResponse) GetAvailableCredits() []AvailableCredit {
return r.PaidTier.AvailableCredits
}
// TierIDToPlanType 将 tier ID 映射为用户可见的套餐名。
func TierIDToPlanType(tierID string) string {
switch strings.ToLower(strings.TrimSpace(tierID)) {
case "free-tier":
return "Free"
case "g1-pro-tier":
return "Pro"
case "g1-ultra-tier":
return "Ultra"
default:
if tierID == "" {
return "Free"
}
return tierID
}
}
// Client Antigravity API 客户端
type Client struct {
httpClient *http.Client
@ -257,6 +289,16 @@ 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{
@ -264,6 +306,11 @@ 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 {
@ -421,6 +468,8 @@ func (c *Client) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo
func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadCodeAssistResponse, map[string]any, error) {
reqBody := LoadCodeAssistRequest{}
reqBody.Metadata.IDEType = "ANTIGRAVITY"
reqBody.Metadata.IDEVersion = "1.20.6"
reqBody.Metadata.IDEName = "antigravity"
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
@ -441,6 +490,7 @@ 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 {
@ -520,6 +570,7 @@ 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 {
@ -654,6 +705,7 @@ 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 {
@ -704,3 +756,134 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
return nil, nil, lastErr
}
// privacyBaseURL 隐私设置 API 仅使用 daily 端点(与 Antigravity 客户端行为一致)
const privacyBaseURL = antigravityDailyBaseURL
// SetUserSettingsRequest setUserSettings 请求体
type SetUserSettingsRequest struct {
UserSettings map[string]any `json:"user_settings"`
}
// FetchUserInfoRequest fetchUserInfo 请求体
type FetchUserInfoRequest struct {
Project string `json:"project"`
}
// FetchUserInfoResponse fetchUserInfo 响应体
type FetchUserInfoResponse struct {
UserSettings map[string]any `json:"userSettings,omitempty"`
RegionCode string `json:"regionCode,omitempty"`
}
// IsPrivate 判断隐私是否已设置userSettings 为空或不含 telemetryEnabled 表示已设置
func (r *FetchUserInfoResponse) IsPrivate() bool {
if r == nil || r.UserSettings == nil {
return true
}
_, hasTelemetry := r.UserSettings["telemetryEnabled"]
return !hasTelemetry
}
// SetUserSettingsResponse setUserSettings 响应体
type SetUserSettingsResponse struct {
UserSettings map[string]any `json:"userSettings,omitempty"`
}
// IsSuccess 判断 setUserSettings 是否成功:返回 {"userSettings":{}} 且无 telemetryEnabled
func (r *SetUserSettingsResponse) IsSuccess() bool {
if r == nil {
return false
}
if len(r.UserSettings) == 0 {
return true
}
_, hasTelemetry := r.UserSettings["telemetryEnabled"]
return !hasTelemetry
}
// SetUserSettings 调用 setUserSettings API 设置用户隐私,返回解析后的响应
func (c *Client) SetUserSettings(ctx context.Context, accessToken string) (*SetUserSettingsResponse, error) {
payload := SetUserSettingsRequest{UserSettings: map[string]any{}}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
apiURL := privacyBaseURL + "/v1internal:setUserSettings"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", GetUserAgent())
req.Header.Set("X-Goog-Api-Client", "gl-node/22.21.1")
req.Host = "daily-cloudcode-pa.googleapis.com"
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("setUserSettings 请求失败: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("setUserSettings 失败 (HTTP %d): %s", resp.StatusCode, string(respBody))
}
var result SetUserSettingsResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("响应解析失败: %w", err)
}
return &result, nil
}
// FetchUserInfo 调用 fetchUserInfo API 获取用户隐私设置状态
func (c *Client) FetchUserInfo(ctx context.Context, accessToken, projectID string) (*FetchUserInfoResponse, error) {
reqBody := FetchUserInfoRequest{Project: projectID}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
apiURL := privacyBaseURL + "/v1internal:fetchUserInfo"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", GetUserAgent())
req.Header.Set("X-Goog-Api-Client", "gl-node/22.21.1")
req.Host = "daily-cloudcode-pa.googleapis.com"
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetchUserInfo 请求失败: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetchUserInfo 失败 (HTTP %d): %s", resp.StatusCode, string(respBody))
}
var result FetchUserInfoResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("响应解析失败: %w", err)
}
return &result, nil
}

View File

@ -250,6 +250,27 @@ func TestGetTier_两者都为nil(t *testing.T) {
}
}
func TestTierIDToPlanType(t *testing.T) {
tests := []struct {
tierID string
want string
}{
{"free-tier", "Free"},
{"g1-pro-tier", "Pro"},
{"g1-ultra-tier", "Ultra"},
{"FREE-TIER", "Free"},
{"", "Free"},
{"unknown-tier", "unknown-tier"},
}
for _, tt := range tests {
t.Run(tt.tierID, func(t *testing.T) {
if got := TierIDToPlanType(tt.tierID); got != tt.want {
t.Errorf("TierIDToPlanType(%q) = %q, want %q", tt.tierID, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// NewClient
// ---------------------------------------------------------------------------
@ -800,6 +821,12 @@ type redirectRoundTripper struct {
transport http.RoundTripper
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func (rt *redirectRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
originalURL := req.URL.String()
for prefix, target := range rt.redirects {
@ -1271,6 +1298,12 @@ func TestClient_LoadCodeAssist_Success_RealCall(t *testing.T) {
if reqBody.Metadata.IDEType != "ANTIGRAVITY" {
t.Errorf("IDEType 不匹配: got %s, want ANTIGRAVITY", reqBody.Metadata.IDEType)
}
if strings.TrimSpace(reqBody.Metadata.IDEVersion) == "" {
t.Errorf("IDEVersion 不应为空")
}
if reqBody.Metadata.IDEName != "antigravity" {
t.Errorf("IDEName 不匹配: got %s, want antigravity", reqBody.Metadata.IDEName)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)

View File

@ -23,13 +23,16 @@ 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用户需手动复制 code
RedirectURI = "http://localhost:8085/callback"
// redirect_uri — 真实 Antigravity IDE 使用 localhost 动态端口 + /oauth-callback 路径
RedirectURI = "http://localhost:8085/oauth-callback"
// OAuth scopes
Scopes = "https://www.googleapis.com/auth/cloud-platform " +
@ -44,13 +47,22 @@ const (
// URL 可用性 TTL不可用 URL 的恢复时间)
URLAvailabilityTTL = 5 * time.Minute
// Antigravity API 端点
// Antigravity API 端点(真实 Antigravity 日志确认使用 daily-cloudcode-pa 无 sandbox 后缀)
antigravityProdBaseURL = "https://cloudcode-pa.googleapis.com"
antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
antigravityDailyBaseURL = "https://daily-cloudcode-pa.googleapis.com"
)
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.20.5
var defaultUserAgentVersion = "1.20.5"
// 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"
)
// defaultClientSecret 可通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 配置
var defaultClientSecret = "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"
@ -64,11 +76,19 @@ 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
// GetUserAgent 返回当前配置的 User-Agent匹配真实 Antigravity 格式: antigravity/{version} {os}/{arch}
// 注意: 不使用 runtime.GOOS/GOARCH — 服务器是 Linux但真实用户在 macOS/Windows 桌面
func GetUserAgent() string {
return fmt.Sprintf("antigravity/%s windows/amd64", defaultUserAgentVersion)
return fmt.Sprintf("antigravity/%s %s/%s", defaultUserAgentVersion, defaultPlatformOS, defaultPlatformArch)
}
func getClientSecret() (string, error) {

View File

@ -262,8 +262,41 @@ func hasMCPTools(tools []ClaudeTool) bool {
return false
}
// filterOpenCodePrompt 过滤 OpenCode 默认提示词,只保留用户自定义指令
// 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 默认提示词,只保留用户自定义指令
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,7 +2,6 @@ package antigravity
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
@ -353,7 +352,7 @@ func TestBuildGenerationConfig_ThinkingDynamicBudget(t *testing.T) {
}
}
func TestTransformClaudeToGeminiWithOptions_PreservesBillingHeaderSystemBlock(t *testing.T) {
func TestTransformClaudeToGeminiWithOptions_FiltersBillingHeaderSystemBlock(t *testing.T) {
tests := []struct {
name string
system json.RawMessage
@ -388,15 +387,11 @@ func TestTransformClaudeToGeminiWithOptions_PreservesBillingHeaderSystemBlock(t
require.NoError(t, json.Unmarshal(body, &req))
require.NotNil(t, req.Request.SystemInstruction)
found := false
// Claude Code / Anthropic 特征字符串不应透传给上游
for _, part := range req.Request.SystemInstruction.Parts {
if strings.Contains(part.Text, "x-anthropic-billing-header keep") {
found = true
break
}
require.NotContains(t, part.Text, "x-anthropic-billing-header",
"Claude Code 特征字符串不应透传给 Antigravity 上游")
}
require.True(t, found, "转换后的 systemInstruction 应保留 x-anthropic-billing-header 内容")
})
}
}

View File

@ -12,6 +12,10 @@ const (
BetaTokenCounting = "token-counting-2024-11-01"
BetaContext1M = "context-1m-2025-08-07"
BetaFastMode = "fast-mode-2026-02-01"
BetaRedactThinking = "redact-thinking-2026-02-12"
BetaContextManagement = "context-management-2025-06-27"
BetaPromptCachingScope = "prompt-caching-scope-2026-01-05"
BetaEffort = "effort-2025-11-24"
)
// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。
@ -48,19 +52,43 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking
var DefaultHeaders = map[string]string{
// Keep these in sync with recent Claude CLI traffic to reduce the chance
// that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
"User-Agent": "claude-cli/2.1.22 (external, cli)",
"User-Agent": "claude-cli/2.1.84 (external, cli)",
"X-Stainless-Lang": "js",
"X-Stainless-Package-Version": "0.70.0",
"X-Stainless-OS": "Linux",
"X-Stainless-Package-Version": "0.74.0",
"X-Stainless-OS": "MacOS",
"X-Stainless-Arch": "arm64",
"X-Stainless-Runtime": "node",
"X-Stainless-Runtime-Version": "v24.13.0",
"X-Stainless-Runtime-Version": "v24.3.0",
"X-Stainless-Retry-Count": "0",
"X-Stainless-Timeout": "600",
"X-App": "cli",
"Anthropic-Dangerous-Direct-Browser-Access": "true",
}
// ApplyFingerprintOverrides 用配置覆盖默认指纹值(每个实例可设不同值)
// cliVersion: Claude CLI 版本(如 "2.1.81"
// pkgVersion: SDK 版本(如 "0.80.0"
// runtimeVersion: Node.js 版本(如 "v24.13.0"
// os_: 操作系统(如 "Linux"
// arch: 架构(如 "arm64"
func ApplyFingerprintOverrides(cliVersion, pkgVersion, runtimeVersion, os_, arch string) {
if cliVersion != "" {
DefaultHeaders["User-Agent"] = "claude-cli/" + cliVersion + " (external, cli)"
}
if pkgVersion != "" {
DefaultHeaders["X-Stainless-Package-Version"] = pkgVersion
}
if runtimeVersion != "" {
DefaultHeaders["X-Stainless-Runtime-Version"] = runtimeVersion
}
if os_ != "" {
DefaultHeaders["X-Stainless-OS"] = os_
}
if arch != "" {
DefaultHeaders["X-Stainless-Arch"] = arch
}
}
// Model 表示一个 Claude 模型
type Model struct {
ID string `json:"id"`

View File

@ -1,7 +1,11 @@
// Package geminicli provides helpers for interacting with Gemini CLI tools.
package geminicli
import "time"
import (
"fmt"
"os"
"time"
)
const (
AIStudioBaseURL = "https://generativelanguage.googleapis.com"
@ -46,6 +50,70 @@ const (
SessionTTL = 30 * time.Minute
// GeminiCLIUserAgent mimics Gemini CLI to maximize compatibility with internal endpoints.
GeminiCLIUserAgent = "GeminiCLI/0.1.5 (Windows; AMD64)"
// 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"
)
// 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

@ -17,12 +17,19 @@ import (
)
// Profile contains TLS fingerprint configuration.
// All slice fields use built-in defaults when empty.
type Profile struct {
Name string // Profile name for identification
CipherSuites []uint16
Curves []uint16
PointFormats []uint8
EnableGREASE bool
Name string // Profile name for identification
CipherSuites []uint16
Curves []uint16
PointFormats []uint16
EnableGREASE bool
SignatureAlgorithms []uint16 // Empty uses defaultSignatureAlgorithms
ALPNProtocols []string // Empty uses ["http/1.1"]
SupportedVersions []uint16 // Empty uses [TLS1.3, TLS1.2]
KeyShareGroups []uint16 // Empty uses [X25519]
PSKModes []uint16 // Empty uses [psk_dhe_ke]
Extensions []uint16 // Extension type IDs in order; empty uses default Node.js 24.x order
}
// Dialer creates TLS connections with custom fingerprints.
@ -45,154 +52,67 @@ type SOCKS5ProxyDialer struct {
proxyURL *url.URL
}
// Default TLS fingerprint values captured from Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)
// Captured using: tshark -i lo -f "tcp port 8443" -Y "tls.handshake.type == 1" -V
// JA3 Hash: 1a28e69016765d92e3b381168d68922c
//
// Note: JA3/JA4 may have slight variations due to:
// - Session ticket presence/absence
// - Extension negotiation state
// Default TLS fingerprint values captured from Claude Code (Node.js 24.x)
// Captured via tls-fingerprint-web capture server
// JA3 Hash: 44f88fca027f27bab4bb08d4af15f23e
// JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
var (
// defaultCipherSuites contains all 59 cipher suites from Claude CLI
// defaultCipherSuites contains the 17 cipher suites from Node.js 24.x
// Order is critical for JA3 fingerprint matching
defaultCipherSuites = []uint16{
// TLS 1.3 cipher suites (MUST be first)
// TLS 1.3 cipher suites
0x1301, // TLS_AES_128_GCM_SHA256
0x1302, // TLS_AES_256_GCM_SHA384
0x1303, // TLS_CHACHA20_POLY1305_SHA256
0x1301, // TLS_AES_128_GCM_SHA256
// ECDHE + AES-GCM
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
0xc02b, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
0xc02c, // TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
// DHE + AES-GCM
0x009e, // TLS_DHE_RSA_WITH_AES_128_GCM_SHA256
// ECDHE/DHE + AES-CBC-SHA256/384
0xc027, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256
0x0067, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA256
0xc028, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384
0x006b, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA256
// DHE-DSS/RSA + AES-GCM
0x00a3, // TLS_DHE_DSS_WITH_AES_256_GCM_SHA384
0x009f, // TLS_DHE_RSA_WITH_AES_256_GCM_SHA384
// ChaCha20-Poly1305
// ECDHE + ChaCha20-Poly1305
0xcca9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
0xcca8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
0xccaa, // TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256
// AES-CCM (256-bit)
0xc0af, // TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8
0xc0ad, // TLS_ECDHE_ECDSA_WITH_AES_256_CCM
0xc0a3, // TLS_DHE_RSA_WITH_AES_256_CCM_8
0xc09f, // TLS_DHE_RSA_WITH_AES_256_CCM
// ARIA (256-bit)
0xc05d, // TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384
0xc061, // TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384
0xc057, // TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384
0xc053, // TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384
// DHE-DSS + AES-GCM (128-bit)
0x00a2, // TLS_DHE_DSS_WITH_AES_128_GCM_SHA256
// AES-CCM (128-bit)
0xc0ae, // TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8
0xc0ac, // TLS_ECDHE_ECDSA_WITH_AES_128_CCM
0xc0a2, // TLS_DHE_RSA_WITH_AES_128_CCM_8
0xc09e, // TLS_DHE_RSA_WITH_AES_128_CCM
// ARIA (128-bit)
0xc05c, // TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256
0xc060, // TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256
0xc056, // TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256
0xc052, // TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256
// ECDHE/DHE + AES-CBC-SHA384/256 (more)
0xc024, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384
0x006a, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA256
0xc023, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
0x0040, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA256
// ECDHE/DHE + AES-CBC-SHA (legacy)
0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
0x0039, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA
0x0038, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA
// ECDHE + AES-CBC-SHA (legacy fallback)
0xc009, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
0xc013, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
0x0033, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA
0x0032, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA
0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
// RSA + AES-GCM/CCM/ARIA (non-PFS, 256-bit)
0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384
0xc0a1, // TLS_RSA_WITH_AES_256_CCM_8
0xc09d, // TLS_RSA_WITH_AES_256_CCM
0xc051, // TLS_RSA_WITH_ARIA_256_GCM_SHA384
// RSA + AES-GCM/CCM/ARIA (non-PFS, 128-bit)
// RSA + AES-GCM (non-PFS)
0x009c, // TLS_RSA_WITH_AES_128_GCM_SHA256
0xc0a0, // TLS_RSA_WITH_AES_128_CCM_8
0xc09c, // TLS_RSA_WITH_AES_128_CCM
0xc050, // TLS_RSA_WITH_ARIA_128_GCM_SHA256
0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384
// RSA + AES-CBC (non-PFS, legacy)
0x003d, // TLS_RSA_WITH_AES_256_CBC_SHA256
0x003c, // TLS_RSA_WITH_AES_128_CBC_SHA256
0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA
// RSA + AES-CBC-SHA (non-PFS, legacy)
0x002f, // TLS_RSA_WITH_AES_128_CBC_SHA
// Renegotiation indication
0x00ff, // TLS_EMPTY_RENEGOTIATION_INFO_SCSV
0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA
}
// defaultCurves contains the 10 supported groups from Claude CLI (including FFDHE)
// defaultCurves contains the 3 supported groups from Node.js 24.x
defaultCurves = []utls.CurveID{
utls.X25519, // 0x001d
utls.CurveP256, // 0x0017 (secp256r1)
utls.CurveID(0x001e), // x448
utls.CurveP521, // 0x0019 (secp521r1)
utls.CurveP384, // 0x0018 (secp384r1)
utls.CurveID(0x0100), // ffdhe2048
utls.CurveID(0x0101), // ffdhe3072
utls.CurveID(0x0102), // ffdhe4096
utls.CurveID(0x0103), // ffdhe6144
utls.CurveID(0x0104), // ffdhe8192
utls.X25519, // 0x001d
utls.CurveP256, // 0x0017 (secp256r1)
utls.CurveP384, // 0x0018 (secp384r1)
}
// defaultPointFormats contains all 3 point formats from Claude CLI
defaultPointFormats = []uint8{
// defaultPointFormats contains point formats from Node.js 24.x
defaultPointFormats = []uint16{
0, // uncompressed
1, // ansiX962_compressed_prime
2, // ansiX962_compressed_char2
}
// defaultSignatureAlgorithms contains the 20 signature algorithms from Claude CLI
// defaultSignatureAlgorithms contains the 9 signature algorithms from Node.js 24.x
defaultSignatureAlgorithms = []utls.SignatureScheme{
0x0403, // ecdsa_secp256r1_sha256
0x0503, // ecdsa_secp384r1_sha384
0x0603, // ecdsa_secp521r1_sha512
0x0807, // ed25519
0x0808, // ed448
0x0809, // rsa_pss_pss_sha256
0x080a, // rsa_pss_pss_sha384
0x080b, // rsa_pss_pss_sha512
0x0804, // rsa_pss_rsae_sha256
0x0805, // rsa_pss_rsae_sha384
0x0806, // rsa_pss_rsae_sha512
0x0401, // rsa_pkcs1_sha256
0x0503, // ecdsa_secp384r1_sha384
0x0805, // rsa_pss_rsae_sha384
0x0501, // rsa_pkcs1_sha384
0x0806, // rsa_pss_rsae_sha512
0x0601, // rsa_pkcs1_sha512
0x0303, // ecdsa_sha224
0x0301, // rsa_pkcs1_sha224
0x0302, // dsa_sha224
0x0402, // dsa_sha256
0x0502, // dsa_sha384
0x0602, // dsa_sha512
0x0201, // rsa_pkcs1_sha1
}
)
@ -256,49 +176,7 @@ func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr st
slog.Debug("tls_fingerprint_socks5_tunnel_established")
// Step 3: Perform TLS handshake on the tunnel with utls fingerprint
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
slog.Debug("tls_fingerprint_socks5_starting_handshake", "host", host)
// Build ClientHello specification from profile (Node.js/Claude CLI fingerprint)
spec := buildClientHelloSpecFromProfile(d.profile)
slog.Debug("tls_fingerprint_socks5_clienthello_spec",
"cipher_suites", len(spec.CipherSuites),
"extensions", len(spec.Extensions),
"compression_methods", spec.CompressionMethods,
"tls_vers_max", spec.TLSVersMax,
"tls_vers_min", spec.TLSVersMin)
if d.profile != nil {
slog.Debug("tls_fingerprint_socks5_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE)
}
// Create uTLS connection on the tunnel
tlsConn := utls.UClient(conn, &utls.Config{
ServerName: host,
}, utls.HelloCustom)
if err := tlsConn.ApplyPreset(spec); err != nil {
slog.Debug("tls_fingerprint_socks5_apply_preset_failed", "error", err)
_ = conn.Close()
return nil, fmt.Errorf("apply TLS preset: %w", err)
}
if err := tlsConn.HandshakeContext(ctx); err != nil {
slog.Debug("tls_fingerprint_socks5_handshake_failed", "error", err)
_ = conn.Close()
return nil, fmt.Errorf("TLS handshake failed: %w", err)
}
state := tlsConn.ConnectionState()
slog.Debug("tls_fingerprint_socks5_handshake_success",
"version", state.Version,
"cipher_suite", state.CipherSuite,
"alpn", state.NegotiatedProtocol)
return tlsConn, nil
return performTLSHandshake(ctx, conn, d.profile, addr)
}
// DialTLSContext establishes a TLS connection through HTTP proxy with the configured fingerprint.
@ -358,7 +236,8 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri
slog.Debug("tls_fingerprint_http_proxy_read_response_failed", "error", err)
return nil, fmt.Errorf("read CONNECT response: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// CONNECT response has no body; do not defer resp.Body.Close() as it wraps the
// same conn that will be used for the TLS handshake.
if resp.StatusCode != http.StatusOK {
_ = conn.Close()
@ -368,47 +247,7 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri
slog.Debug("tls_fingerprint_http_proxy_tunnel_established")
// Step 4: Perform TLS handshake on the tunnel with utls fingerprint
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
slog.Debug("tls_fingerprint_http_proxy_starting_handshake", "host", host)
// Build ClientHello specification (reuse the shared method)
spec := buildClientHelloSpecFromProfile(d.profile)
slog.Debug("tls_fingerprint_http_proxy_clienthello_spec",
"cipher_suites", len(spec.CipherSuites),
"extensions", len(spec.Extensions))
if d.profile != nil {
slog.Debug("tls_fingerprint_http_proxy_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE)
}
// Create uTLS connection on the tunnel
// Note: TLS 1.3 cipher suites are handled automatically by utls when TLS 1.3 is in SupportedVersions
tlsConn := utls.UClient(conn, &utls.Config{
ServerName: host,
}, utls.HelloCustom)
if err := tlsConn.ApplyPreset(spec); err != nil {
slog.Debug("tls_fingerprint_http_proxy_apply_preset_failed", "error", err)
_ = conn.Close()
return nil, fmt.Errorf("apply TLS preset: %w", err)
}
if err := tlsConn.HandshakeContext(ctx); err != nil {
slog.Debug("tls_fingerprint_http_proxy_handshake_failed", "error", err)
_ = conn.Close()
return nil, fmt.Errorf("TLS handshake failed: %w", err)
}
state := tlsConn.ConnectionState()
slog.Debug("tls_fingerprint_http_proxy_handshake_success",
"version", state.Version,
"cipher_suite", state.CipherSuite,
"alpn", state.NegotiatedProtocol)
return tlsConn, nil
return performTLSHandshake(ctx, conn, d.profile, addr)
}
// DialTLSContext establishes a TLS connection with the configured fingerprint.
@ -423,53 +262,35 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.
}
slog.Debug("tls_fingerprint_tcp_connected", "addr", addr)
// Extract hostname for SNI
// Perform TLS handshake with utls fingerprint
return performTLSHandshake(ctx, conn, d.profile, addr)
}
// performTLSHandshake performs the uTLS handshake on an established connection.
// It builds a ClientHello spec from the profile, applies it, and completes the handshake.
// On failure, conn is closed and an error is returned.
func performTLSHandshake(ctx context.Context, conn net.Conn, profile *Profile, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
slog.Debug("tls_fingerprint_sni_hostname", "host", host)
// Build ClientHello specification
spec := d.buildClientHelloSpec()
slog.Debug("tls_fingerprint_clienthello_spec",
"cipher_suites", len(spec.CipherSuites),
"extensions", len(spec.Extensions))
spec := buildClientHelloSpecFromProfile(profile)
tlsConn := utls.UClient(conn, &utls.Config{ServerName: host}, utls.HelloCustom)
// Log profile info
if d.profile != nil {
slog.Debug("tls_fingerprint_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE)
} else {
slog.Debug("tls_fingerprint_using_default_profile")
}
// Create uTLS connection
// Note: TLS 1.3 cipher suites are handled automatically by utls when TLS 1.3 is in SupportedVersions
tlsConn := utls.UClient(conn, &utls.Config{
ServerName: host,
}, utls.HelloCustom)
// Apply fingerprint
if err := tlsConn.ApplyPreset(spec); err != nil {
slog.Debug("tls_fingerprint_apply_preset_failed", "error", err)
_ = conn.Close()
return nil, err
return nil, fmt.Errorf("apply TLS preset: %w", err)
}
slog.Debug("tls_fingerprint_preset_applied")
// Perform TLS handshake
if err := tlsConn.HandshakeContext(ctx); err != nil {
slog.Debug("tls_fingerprint_handshake_failed",
"error", err,
"local_addr", conn.LocalAddr(),
"remote_addr", conn.RemoteAddr())
_ = conn.Close()
return nil, fmt.Errorf("TLS handshake failed: %w", err)
}
// Log successful handshake details
state := tlsConn.ConnectionState()
slog.Debug("tls_fingerprint_handshake_success",
"host", host,
"version", state.Version,
"cipher_suite", state.CipherSuite,
"alpn", state.NegotiatedProtocol)
@ -477,11 +298,6 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.
return tlsConn, nil
}
// buildClientHelloSpec constructs the ClientHello specification based on the profile.
func (d *Dialer) buildClientHelloSpec() *utls.ClientHelloSpec {
return buildClientHelloSpecFromProfile(d.profile)
}
// toUTLSCurves converts uint16 slice to utls.CurveID slice.
func toUTLSCurves(curves []uint16) []utls.CurveID {
result := make([]utls.CurveID, len(curves))
@ -491,70 +307,143 @@ func toUTLSCurves(curves []uint16) []utls.CurveID {
return result
}
// defaultExtensionOrder is the Node.js 24.x extension order.
// Used when Profile.Extensions is empty.
var defaultExtensionOrder = []uint16{
0, // server_name
65037, // encrypted_client_hello
23, // extended_master_secret
65281, // renegotiation_info
10, // supported_groups
11, // ec_point_formats
35, // session_ticket
16, // alpn
5, // status_request
13, // signature_algorithms
18, // signed_certificate_timestamp
51, // key_share
45, // psk_key_exchange_modes
43, // supported_versions
}
// isGREASEValue checks if a uint16 value matches the TLS GREASE pattern (0x?a?a).
func isGREASEValue(v uint16) bool {
return v&0x0f0f == 0x0a0a && v>>8 == v&0xff
}
// buildClientHelloSpecFromProfile constructs ClientHelloSpec from a Profile.
// This is a standalone function that can be used by both Dialer and HTTPProxyDialer.
func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
// Get cipher suites
var cipherSuites []uint16
// Resolve effective values (profile overrides or built-in defaults)
cipherSuites := defaultCipherSuites
if profile != nil && len(profile.CipherSuites) > 0 {
cipherSuites = profile.CipherSuites
} else {
cipherSuites = defaultCipherSuites
}
// Get curves
var curves []utls.CurveID
curves := defaultCurves
if profile != nil && len(profile.Curves) > 0 {
curves = toUTLSCurves(profile.Curves)
} else {
curves = defaultCurves
}
// Get point formats
var pointFormats []uint8
pointFormats := defaultPointFormats
if profile != nil && len(profile.PointFormats) > 0 {
pointFormats = profile.PointFormats
} else {
pointFormats = defaultPointFormats
}
// Check if GREASE is enabled
signatureAlgorithms := defaultSignatureAlgorithms
if profile != nil && len(profile.SignatureAlgorithms) > 0 {
signatureAlgorithms = make([]utls.SignatureScheme, len(profile.SignatureAlgorithms))
for i, s := range profile.SignatureAlgorithms {
signatureAlgorithms[i] = utls.SignatureScheme(s)
}
}
alpnProtocols := []string{"http/1.1"}
if profile != nil && len(profile.ALPNProtocols) > 0 {
alpnProtocols = profile.ALPNProtocols
}
supportedVersions := []uint16{utls.VersionTLS13, utls.VersionTLS12}
if profile != nil && len(profile.SupportedVersions) > 0 {
supportedVersions = profile.SupportedVersions
}
keyShareGroups := []utls.CurveID{utls.X25519}
if profile != nil && len(profile.KeyShareGroups) > 0 {
keyShareGroups = toUTLSCurves(profile.KeyShareGroups)
}
pskModes := []uint16{uint16(utls.PskModeDHE)}
if profile != nil && len(profile.PSKModes) > 0 {
pskModes = profile.PSKModes
}
enableGREASE := profile != nil && profile.EnableGREASE
extensions := make([]utls.TLSExtension, 0, 16)
if enableGREASE {
extensions = append(extensions, &utls.UtlsGREASEExtension{})
// Build key shares
keyShares := make([]utls.KeyShare, len(keyShareGroups))
for i, g := range keyShareGroups {
keyShares[i] = utls.KeyShare{Group: g}
}
// SNI extension - MUST be explicitly added for HelloCustom mode
// utls will populate the server name from Config.ServerName
extensions = append(extensions, &utls.SNIExtension{})
// Determine extension order
extOrder := defaultExtensionOrder
if profile != nil && len(profile.Extensions) > 0 {
extOrder = profile.Extensions
}
// Claude CLI extension order (captured from tshark):
// server_name(0), ec_point_formats(11), supported_groups(10), session_ticket(35),
// alpn(16), encrypt_then_mac(22), extended_master_secret(23),
// signature_algorithms(13), supported_versions(43),
// psk_key_exchange_modes(45), key_share(51)
extensions = append(extensions,
&utls.SupportedPointsExtension{SupportedPoints: pointFormats},
&utls.SupportedCurvesExtension{Curves: curves},
&utls.SessionTicketExtension{},
&utls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}},
&utls.GenericExtension{Id: 22},
&utls.ExtendedMasterSecretExtension{},
&utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: defaultSignatureAlgorithms},
&utls.SupportedVersionsExtension{Versions: []uint16{
utls.VersionTLS13,
utls.VersionTLS12,
}},
&utls.PSKKeyExchangeModesExtension{Modes: []uint8{utls.PskModeDHE}},
&utls.KeyShareExtension{KeyShares: []utls.KeyShare{
{Group: utls.X25519},
}},
)
// Build extensions list from the ordered IDs.
// Parametric extensions (curves, sigalgs, etc.) are populated with resolved profile values.
// Unknown IDs use GenericExtension (sends type ID with empty data).
extensions := make([]utls.TLSExtension, 0, len(extOrder)+2)
for _, id := range extOrder {
if isGREASEValue(id) {
extensions = append(extensions, &utls.UtlsGREASEExtension{})
continue
}
switch id {
case 0: // server_name
extensions = append(extensions, &utls.SNIExtension{})
case 5: // status_request (OCSP)
extensions = append(extensions, &utls.StatusRequestExtension{})
case 10: // supported_groups
extensions = append(extensions, &utls.SupportedCurvesExtension{Curves: curves})
case 11: // ec_point_formats
extensions = append(extensions, &utls.SupportedPointsExtension{SupportedPoints: toUint8s(pointFormats)})
case 13: // signature_algorithms
extensions = append(extensions, &utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: signatureAlgorithms})
case 16: // alpn
extensions = append(extensions, &utls.ALPNExtension{AlpnProtocols: alpnProtocols})
case 18: // signed_certificate_timestamp
extensions = append(extensions, &utls.SCTExtension{})
case 23: // extended_master_secret
extensions = append(extensions, &utls.ExtendedMasterSecretExtension{})
case 35: // session_ticket
extensions = append(extensions, &utls.SessionTicketExtension{})
case 43: // supported_versions
extensions = append(extensions, &utls.SupportedVersionsExtension{Versions: supportedVersions})
case 45: // psk_key_exchange_modes
extensions = append(extensions, &utls.PSKKeyExchangeModesExtension{Modes: toUint8s(pskModes)})
case 50: // signature_algorithms_cert
extensions = append(extensions, &utls.SignatureAlgorithmsCertExtension{SupportedSignatureAlgorithms: signatureAlgorithms})
case 51: // key_share
extensions = append(extensions, &utls.KeyShareExtension{KeyShares: keyShares})
case 0xfe0d: // encrypted_client_hello (ECH, 65037)
// Send GREASE ECH with random payload — mimics Node.js behavior when no real ECHConfig is available.
// An empty GenericExtension causes "error decoding message" from servers that validate ECH format.
extensions = append(extensions, &utls.GREASEEncryptedClientHelloExtension{})
case 0xff01: // renegotiation_info
extensions = append(extensions, &utls.RenegotiationInfoExtension{})
default:
// Unknown extension — send as GenericExtension (type ID + empty data).
// This covers encrypt_then_mac(22) and any future extensions.
extensions = append(extensions, &utls.GenericExtension{Id: id})
}
}
if enableGREASE {
// For default extension order with EnableGREASE, wrap with GREASE bookends
if enableGREASE && (profile == nil || len(profile.Extensions) == 0) {
extensions = append([]utls.TLSExtension{&utls.UtlsGREASEExtension{}}, extensions...)
extensions = append(extensions, &utls.UtlsGREASEExtension{})
}
@ -566,3 +455,12 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
TLSVersMin: utls.VersionTLS10,
}
}
// toUint8s converts []uint16 to []uint8 (for utls fields that require []uint8).
func toUint8s(vals []uint16) []uint8 {
out := make([]uint8, len(vals))
for i, v := range vals {
out[i] = uint8(v)
}
return out
}

View File

@ -0,0 +1,368 @@
//go:build integration
package tlsfingerprint
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
utls "github.com/refraction-networking/utls"
)
// CapturedFingerprint mirrors the Fingerprint struct from tls-fingerprint-web.
// Used to deserialize the JSON response from the capture server.
type CapturedFingerprint struct {
JA3Raw string `json:"ja3_raw"`
JA3Hash string `json:"ja3_hash"`
JA4 string `json:"ja4"`
HTTP2 string `json:"http2"`
CipherSuites []int `json:"cipher_suites"`
Curves []int `json:"curves"`
PointFormats []int `json:"point_formats"`
Extensions []int `json:"extensions"`
SignatureAlgorithms []int `json:"signature_algorithms"`
ALPNProtocols []string `json:"alpn_protocols"`
SupportedVersions []int `json:"supported_versions"`
KeyShareGroups []int `json:"key_share_groups"`
PSKModes []int `json:"psk_modes"`
CompressCertAlgos []int `json:"compress_cert_algos"`
EnableGREASE bool `json:"enable_grease"`
}
// TestDialerAgainstCaptureServer connects to the tls-fingerprint-web capture server
// and verifies that the dialer's TLS fingerprint matches the configured Profile.
//
// Default capture server: https://tls.sub2api.org:8090
// Override with env: TLSFINGERPRINT_CAPTURE_URL=https://localhost:8443
//
// Run: go test -v -run TestDialerAgainstCaptureServer ./internal/pkg/tlsfingerprint/...
func TestDialerAgainstCaptureServer(t *testing.T) {
captureURL := os.Getenv("TLSFINGERPRINT_CAPTURE_URL")
if captureURL == "" {
captureURL = "https://tls.sub2api.org:8090"
}
tests := []struct {
name string
profile *Profile
}{
{
name: "default_profile",
profile: &Profile{
Name: "default",
EnableGREASE: false,
// All empty → uses built-in defaults
},
},
{
name: "linux_x64_node_v22171",
profile: &Profile{
Name: "linux_x64_node_v22171",
EnableGREASE: false,
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
PointFormats: []uint16{0, 1, 2},
SignatureAlgorithms: []uint16{0x0403, 0x0503, 0x0603, 0x0807, 0x0808, 0x0809, 0x080a, 0x080b, 0x0804, 0x0805, 0x0806, 0x0401, 0x0501, 0x0601, 0x0303, 0x0301, 0x0302, 0x0402, 0x0502, 0x0602},
ALPNProtocols: []string{"http/1.1"},
SupportedVersions: []uint16{0x0304, 0x0303},
KeyShareGroups: []uint16{29},
PSKModes: []uint16{1},
Extensions: []uint16{0, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51},
},
},
{
name: "macos_arm64_node_v2430",
profile: &Profile{
Name: "MacOS_arm64_node_v2430",
EnableGREASE: false,
CipherSuites: []uint16{4865, 4866, 4867, 49195, 49199, 49196, 49200, 52393, 52392, 49161, 49171, 49162, 49172, 156, 157, 47, 53},
Curves: []uint16{29, 23, 24},
PointFormats: []uint16{0},
SignatureAlgorithms: []uint16{0x0403, 0x0804, 0x0401, 0x0503, 0x0805, 0x0501, 0x0806, 0x0601, 0x0201},
ALPNProtocols: []string{"http/1.1"},
SupportedVersions: []uint16{0x0304, 0x0303},
KeyShareGroups: []uint16{29},
PSKModes: []uint16{1},
Extensions: []uint16{0, 65037, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
captured := fetchCapturedFingerprint(t, captureURL, tc.profile)
if captured == nil {
return
}
t.Logf("JA3 Hash: %s", captured.JA3Hash)
t.Logf("JA4: %s", captured.JA4)
// Resolve effective profile values (what the dialer actually uses)
effectiveCipherSuites := tc.profile.CipherSuites
if len(effectiveCipherSuites) == 0 {
effectiveCipherSuites = defaultCipherSuites
}
effectiveCurves := tc.profile.Curves
if len(effectiveCurves) == 0 {
effectiveCurves = make([]uint16, len(defaultCurves))
for i, c := range defaultCurves {
effectiveCurves[i] = uint16(c)
}
}
effectivePointFormats := tc.profile.PointFormats
if len(effectivePointFormats) == 0 {
effectivePointFormats = defaultPointFormats
}
effectiveSigAlgs := tc.profile.SignatureAlgorithms
if len(effectiveSigAlgs) == 0 {
effectiveSigAlgs = make([]uint16, len(defaultSignatureAlgorithms))
for i, s := range defaultSignatureAlgorithms {
effectiveSigAlgs[i] = uint16(s)
}
}
effectiveALPN := tc.profile.ALPNProtocols
if len(effectiveALPN) == 0 {
effectiveALPN = []string{"http/1.1"}
}
effectiveVersions := tc.profile.SupportedVersions
if len(effectiveVersions) == 0 {
effectiveVersions = []uint16{0x0304, 0x0303}
}
effectiveKeyShare := tc.profile.KeyShareGroups
if len(effectiveKeyShare) == 0 {
effectiveKeyShare = []uint16{29} // X25519
}
effectivePSKModes := tc.profile.PSKModes
if len(effectivePSKModes) == 0 {
effectivePSKModes = []uint16{1} // psk_dhe_ke
}
// Verify each field
assertIntSliceEqual(t, "cipher_suites", uint16sToInts(effectiveCipherSuites), captured.CipherSuites)
assertIntSliceEqual(t, "curves", uint16sToInts(effectiveCurves), captured.Curves)
assertIntSliceEqual(t, "point_formats", uint16sToInts(effectivePointFormats), captured.PointFormats)
assertIntSliceEqual(t, "signature_algorithms", uint16sToInts(effectiveSigAlgs), captured.SignatureAlgorithms)
assertStringSliceEqual(t, "alpn_protocols", effectiveALPN, captured.ALPNProtocols)
assertIntSliceEqual(t, "supported_versions", uint16sToInts(effectiveVersions), captured.SupportedVersions)
assertIntSliceEqual(t, "key_share_groups", uint16sToInts(effectiveKeyShare), captured.KeyShareGroups)
assertIntSliceEqual(t, "psk_modes", uint16sToInts(effectivePSKModes), captured.PSKModes)
if captured.EnableGREASE != tc.profile.EnableGREASE {
t.Errorf("enable_grease: got %v, want %v", captured.EnableGREASE, tc.profile.EnableGREASE)
} else {
t.Logf(" enable_grease: %v OK", captured.EnableGREASE)
}
// Verify extension order
// Use profile.Extensions if set, otherwise the default order (Node.js 24.x)
expectedExtOrder := uint16sToInts(defaultExtensionOrder)
if len(tc.profile.Extensions) > 0 {
expectedExtOrder = uint16sToInts(tc.profile.Extensions)
}
// Strip GREASE values from both expected and captured for comparison
var filteredExpected, filteredActual []int
for _, e := range expectedExtOrder {
if !isGREASEValue(uint16(e)) {
filteredExpected = append(filteredExpected, e)
}
}
for _, e := range captured.Extensions {
if !isGREASEValue(uint16(e)) {
filteredActual = append(filteredActual, e)
}
}
assertIntSliceEqual(t, "extensions (order, non-GREASE)", filteredExpected, filteredActual)
// Print full captured data as JSON for debugging
capturedJSON, _ := json.MarshalIndent(captured, " ", " ")
t.Logf("Full captured fingerprint:\n %s", string(capturedJSON))
})
}
}
func fetchCapturedFingerprint(t *testing.T, captureURL string, profile *Profile) *CapturedFingerprint {
t.Helper()
dialer := NewDialer(profile, nil)
client := &http.Client{
Transport: &http.Transport{
DialTLSContext: dialer.DialTLSContext,
},
Timeout: 10 * time.Second,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", captureURL, strings.NewReader(`{"model":"test"}`))
if err != nil {
t.Fatalf("create request: %v", err)
return nil
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
return nil
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
return nil
}
var fp CapturedFingerprint
if err := json.Unmarshal(body, &fp); err != nil {
t.Logf("Response body: %s", string(body))
t.Fatalf("parse response: %v", err)
return nil
}
return &fp
}
func uint16sToInts(vals []uint16) []int {
result := make([]int, len(vals))
for i, v := range vals {
result[i] = int(v)
}
return result
}
func assertIntSliceEqual(t *testing.T, name string, expected, actual []int) {
t.Helper()
if len(expected) != len(actual) {
t.Errorf("%s: length mismatch: got %d, want %d", name, len(actual), len(expected))
if len(actual) < 20 && len(expected) < 20 {
t.Errorf(" got: %v", actual)
t.Errorf(" want: %v", expected)
}
return
}
mismatches := 0
for i := range expected {
if expected[i] != actual[i] {
if mismatches < 5 {
t.Errorf("%s[%d]: got %d (0x%04x), want %d (0x%04x)", name, i, actual[i], actual[i], expected[i], expected[i])
}
mismatches++
}
}
if mismatches == 0 {
t.Logf(" %s: %d items OK", name, len(expected))
} else if mismatches > 5 {
t.Errorf(" %s: %d/%d mismatches (showing first 5)", name, mismatches, len(expected))
}
}
func assertStringSliceEqual(t *testing.T, name string, expected, actual []string) {
t.Helper()
if len(expected) != len(actual) {
t.Errorf("%s: length mismatch: got %d (%v), want %d (%v)", name, len(actual), actual, len(expected), expected)
return
}
for i := range expected {
if expected[i] != actual[i] {
t.Errorf("%s[%d]: got %q, want %q", name, i, actual[i], expected[i])
return
}
}
t.Logf(" %s: %v OK", name, expected)
}
// TestBuildClientHelloSpecNewFields tests that new Profile fields are correctly applied.
func TestBuildClientHelloSpecNewFields(t *testing.T) {
// Test custom ALPN, versions, key shares, PSK modes
profile := &Profile{
Name: "custom_full",
EnableGREASE: false,
CipherSuites: []uint16{0x1301, 0x1302},
Curves: []uint16{29, 23},
PointFormats: []uint16{0},
SignatureAlgorithms: []uint16{0x0403, 0x0804},
ALPNProtocols: []string{"h2", "http/1.1"},
SupportedVersions: []uint16{0x0304},
KeyShareGroups: []uint16{29, 23},
PSKModes: []uint16{1},
}
spec := buildClientHelloSpecFromProfile(profile)
// Verify cipher suites
if len(spec.CipherSuites) != 2 || spec.CipherSuites[0] != 0x1301 {
t.Errorf("cipher suites: got %v", spec.CipherSuites)
}
// Check extensions for expected values
var foundALPN, foundVersions, foundKeyShare, foundPSK, foundSigAlgs bool
for _, ext := range spec.Extensions {
switch e := ext.(type) {
case *utls.ALPNExtension:
foundALPN = true
if len(e.AlpnProtocols) != 2 || e.AlpnProtocols[0] != "h2" {
t.Errorf("ALPN: got %v, want [h2, http/1.1]", e.AlpnProtocols)
}
case *utls.SupportedVersionsExtension:
foundVersions = true
if len(e.Versions) != 1 || e.Versions[0] != 0x0304 {
t.Errorf("versions: got %v, want [0x0304]", e.Versions)
}
case *utls.KeyShareExtension:
foundKeyShare = true
if len(e.KeyShares) != 2 {
t.Errorf("key shares: got %d entries, want 2", len(e.KeyShares))
}
case *utls.PSKKeyExchangeModesExtension:
foundPSK = true
if len(e.Modes) != 1 || e.Modes[0] != 1 {
t.Errorf("PSK modes: got %v, want [1]", e.Modes)
}
case *utls.SignatureAlgorithmsExtension:
foundSigAlgs = true
if len(e.SupportedSignatureAlgorithms) != 2 {
t.Errorf("sig algs: got %d, want 2", len(e.SupportedSignatureAlgorithms))
}
}
}
for name, found := range map[string]bool{
"ALPN": foundALPN, "Versions": foundVersions, "KeyShare": foundKeyShare,
"PSK": foundPSK, "SigAlgs": foundSigAlgs,
} {
if !found {
t.Errorf("extension %s not found in spec", name)
}
}
// Test nil profile uses all defaults
specDefault := buildClientHelloSpecFromProfile(nil)
for _, ext := range specDefault.Extensions {
switch e := ext.(type) {
case *utls.ALPNExtension:
if len(e.AlpnProtocols) != 1 || e.AlpnProtocols[0] != "http/1.1" {
t.Errorf("default ALPN: got %v, want [http/1.1]", e.AlpnProtocols)
}
case *utls.SupportedVersionsExtension:
if len(e.Versions) != 2 {
t.Errorf("default versions: got %v, want 2 entries", e.Versions)
}
case *utls.KeyShareExtension:
if len(e.KeyShares) != 1 {
t.Errorf("default key shares: got %d, want 1", len(e.KeyShares))
}
}
}
t.Log("TestBuildClientHelloSpecNewFields passed")
}

View File

@ -40,16 +40,15 @@ func skipIfExternalServiceUnavailable(t *testing.T, err error) {
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
// This test uses tls.peet.ws to verify the fingerprint.
// Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x)
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP)
// Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
// Expected JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
func TestJA3Fingerprint(t *testing.T) {
// Skip if network is unavailable or if running in short mode
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := &Profile{
Name: "Claude CLI Test",
Name: "Default Profile Test",
EnableGREASE: false,
}
dialer := NewDialer(profile, nil)
@ -61,7 +60,6 @@ func TestJA3Fingerprint(t *testing.T) {
Timeout: 30 * time.Second,
}
// Use tls.peet.ws fingerprint detection API
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@ -69,7 +67,7 @@ func TestJA3Fingerprint(t *testing.T) {
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0")
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/24.3.0")
resp, err := client.Do(req)
skipIfExternalServiceUnavailable(t, err)
@ -86,71 +84,23 @@ func TestJA3Fingerprint(t *testing.T) {
t.Fatalf("failed to parse fingerprint response: %v", err)
}
// Log all fingerprint information
t.Logf("JA3: %s", fpResp.TLS.JA3)
t.Logf("JA3 Hash: %s", fpResp.TLS.JA3Hash)
t.Logf("JA4: %s", fpResp.TLS.JA4)
t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint)
t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash)
// Verify JA3 hash matches expected value
expectedJA3Hash := "1a28e69016765d92e3b381168d68922c"
expectedJA3Hash := "44f88fca027f27bab4bb08d4af15f23e"
if fpResp.TLS.JA3Hash == expectedJA3Hash {
t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash)
t.Logf("✓ JA3 hash matches: %s", expectedJA3Hash)
} else {
t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash)
}
// Verify JA4 fingerprint
// JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash]
// Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP)
// The suffix _a33745022dd6_1f22a2ca17c4 should match
expectedJA4Suffix := "_a33745022dd6_1f22a2ca17c4"
if strings.HasSuffix(fpResp.TLS.JA4, expectedJA4Suffix) {
t.Logf("✓ JA4 suffix matches expected value: %s", expectedJA4Suffix)
expectedJA4CipherHash := "_5b57614c22b0_"
if strings.Contains(fpResp.TLS.JA4, expectedJA4CipherHash) {
t.Logf("✓ JA4 cipher hash matches: %s", expectedJA4CipherHash)
} else {
t.Errorf("✗ JA4 suffix mismatch: got %s, expected suffix %s", fpResp.TLS.JA4, expectedJA4Suffix)
t.Errorf("✗ JA4 cipher hash mismatch: got %s, expected containing %s", fpResp.TLS.JA4, expectedJA4CipherHash)
}
// Verify JA4 prefix (t13d5911h1 or t13i5911h1)
// d = domain (SNI present), i = IP (no SNI)
// Since we connect to tls.peet.ws (domain), we expect 'd'
expectedJA4Prefix := "t13d5911h1"
if strings.HasPrefix(fpResp.TLS.JA4, expectedJA4Prefix) {
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 59=ciphers, 11=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
} else {
// Also accept 'i' variant for IP connections
altPrefix := "t13i5911h1"
if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) {
t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix)
} else {
t.Errorf("✗ JA4 prefix mismatch: got %s, expected %s or %s", fpResp.TLS.JA4, expectedJA4Prefix, altPrefix)
}
}
// Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning)
if strings.Contains(fpResp.TLS.JA3, "4866-4867-4865") {
t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites")
} else {
t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites")
}
// Verify extension list (should be 11 extensions including SNI)
// Expected: 0-11-10-35-16-22-23-13-43-45-51
expectedExtensions := "0-11-10-35-16-22-23-13-43-45-51"
if strings.Contains(fpResp.TLS.JA3, expectedExtensions) {
t.Logf("✓ JA3 contains expected extension list: %s", expectedExtensions)
} else {
t.Logf("Warning: JA3 extension list may differ")
}
}
// TestProfileExpectation defines expected fingerprint values for a profile.
type TestProfileExpectation struct {
Profile *Profile
ExpectedJA3 string // Expected JA3 hash (empty = don't check)
ExpectedJA4 string // Expected full JA4 (empty = don't check)
JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check)
}
// TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
@ -164,30 +114,24 @@ func TestAllProfiles(t *testing.T) {
// These profiles are from config.yaml gateway.tls_fingerprint.profiles
profiles := []TestProfileExpectation{
{
// Linux x64 Node.js v22.17.1
// Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4
// Default profile (Node.js 24.x)
Profile: &Profile{
Name: "default_node_v24",
EnableGREASE: false,
},
JA4CipherHash: "5b57614c22b0",
},
{
// Linux x64 Node.js v22.17.1 (explicit profile with v22 extensions)
Profile: &Profile{
Name: "linux_x64_node_v22171",
EnableGREASE: false,
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
PointFormats: []uint8{0, 1, 2},
PointFormats: []uint16{0, 1, 2},
Extensions: []uint16{0, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51},
},
JA4CipherHash: "a33745022dd6", // stable part
},
{
// MacOS arm64 Node.js v22.18.0
// Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea
// Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406
Profile: &Profile{
Name: "macos_arm64_node_v22180",
EnableGREASE: false,
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
PointFormats: []uint8{0, 1, 2},
},
JA4CipherHash: "a33745022dd6", // stable part (same cipher suites)
JA4CipherHash: "a33745022dd6",
},
}

View File

@ -55,13 +55,13 @@ func TestDialerBasicConnection(t *testing.T) {
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
// This test uses tls.peet.ws to verify the fingerprint.
// Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x)
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP)
// Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
// Expected JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
func TestJA3Fingerprint(t *testing.T) {
skipNetworkTest(t)
profile := &Profile{
Name: "Claude CLI Test",
Name: "Default Profile Test",
EnableGREASE: false,
}
dialer := NewDialer(profile, nil)
@ -81,7 +81,7 @@ func TestJA3Fingerprint(t *testing.T) {
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0")
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/24.3.0")
resp, err := client.Do(req)
if err != nil {
@ -107,34 +107,28 @@ func TestJA3Fingerprint(t *testing.T) {
t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint)
t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash)
// Verify JA3 hash matches expected value
expectedJA3Hash := "1a28e69016765d92e3b381168d68922c"
// Verify JA3 hash matches expected value (Node.js 24.x default)
expectedJA3Hash := "44f88fca027f27bab4bb08d4af15f23e"
if fpResp.TLS.JA3Hash == expectedJA3Hash {
t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash)
} else {
t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash)
}
// Verify JA4 fingerprint
// JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash]
// Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP)
// The suffix _a33745022dd6_1f22a2ca17c4 should match
expectedJA4Suffix := "_a33745022dd6_1f22a2ca17c4"
if strings.HasSuffix(fpResp.TLS.JA4, expectedJA4Suffix) {
t.Logf("✓ JA4 suffix matches expected value: %s", expectedJA4Suffix)
// Verify JA4 cipher hash (stable middle part)
expectedJA4CipherHash := "_5b57614c22b0_"
if strings.Contains(fpResp.TLS.JA4, expectedJA4CipherHash) {
t.Logf("✓ JA4 cipher hash matches: %s", expectedJA4CipherHash)
} else {
t.Errorf("✗ JA4 suffix mismatch: got %s, expected suffix %s", fpResp.TLS.JA4, expectedJA4Suffix)
t.Errorf("✗ JA4 cipher hash mismatch: got %s, expected containing %s", fpResp.TLS.JA4, expectedJA4CipherHash)
}
// Verify JA4 prefix (t13d5911h1 or t13i5911h1)
// d = domain (SNI present), i = IP (no SNI)
// Since we connect to tls.peet.ws (domain), we expect 'd'
expectedJA4Prefix := "t13d5911h1"
// Verify JA4 prefix (t13d1714h1 or t13i1714h1)
expectedJA4Prefix := "t13d1714h1"
if strings.HasPrefix(fpResp.TLS.JA4, expectedJA4Prefix) {
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 59=ciphers, 11=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 17=ciphers, 14=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
} else {
// Also accept 'i' variant for IP connections
altPrefix := "t13i5911h1"
altPrefix := "t13i1714h1"
if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) {
t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix)
} else {
@ -142,16 +136,15 @@ func TestJA3Fingerprint(t *testing.T) {
}
}
// Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning)
if strings.Contains(fpResp.TLS.JA3, "4866-4867-4865") {
// Verify JA3 contains expected TLS 1.3 cipher suites
if strings.Contains(fpResp.TLS.JA3, "4865-4866-4867") {
t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites")
} else {
t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites")
}
// Verify extension list (should be 11 extensions including SNI)
// Expected: 0-11-10-35-16-22-23-13-43-45-51
expectedExtensions := "0-11-10-35-16-22-23-13-43-45-51"
// Verify extension list (14 extensions, Node.js 24.x order)
expectedExtensions := "0-65037-23-65281-10-11-35-16-5-13-18-51-45-43"
if strings.Contains(fpResp.TLS.JA3, expectedExtensions) {
t.Logf("✓ JA3 contains expected extension list: %s", expectedExtensions)
} else {
@ -186,8 +179,8 @@ func TestDialerWithProfile(t *testing.T) {
// Build specs and compare
// Note: We can't directly compare JA3 without making network requests
// but we can verify the specs are different
spec1 := dialer1.buildClientHelloSpec()
spec2 := dialer2.buildClientHelloSpec()
spec1 := buildClientHelloSpecFromProfile(dialer1.profile)
spec2 := buildClientHelloSpecFromProfile(dialer2.profile)
// Profile with GREASE should have more extensions
if len(spec2.Extensions) <= len(spec1.Extensions) {
@ -296,47 +289,33 @@ func mustParseURL(rawURL string) *url.URL {
return u
}
// TestProfileExpectation defines expected fingerprint values for a profile.
type TestProfileExpectation struct {
Profile *Profile
ExpectedJA3 string // Expected JA3 hash (empty = don't check)
ExpectedJA4 string // Expected full JA4 (empty = don't check)
JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check)
}
// TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
// Run with: go test -v -run TestAllProfiles ./internal/pkg/tlsfingerprint/...
func TestAllProfiles(t *testing.T) {
skipNetworkTest(t)
// Define all profiles to test with their expected fingerprints
// These profiles are from config.yaml gateway.tls_fingerprint.profiles
profiles := []TestProfileExpectation{
{
// Linux x64 Node.js v22.17.1
// Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4
// Default profile (Node.js 24.x)
// JA3 Hash: 44f88fca027f27bab4bb08d4af15f23e
// JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
Profile: &Profile{
Name: "default_node_v24",
EnableGREASE: false,
},
JA4CipherHash: "5b57614c22b0",
},
{
// Linux x64 Node.js v22.17.1 (explicit profile)
Profile: &Profile{
Name: "linux_x64_node_v22171",
EnableGREASE: false,
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
PointFormats: []uint8{0, 1, 2},
PointFormats: []uint16{0, 1, 2},
Extensions: []uint16{0, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51},
},
JA4CipherHash: "a33745022dd6", // stable part
},
{
// MacOS arm64 Node.js v22.18.0
// Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea
// Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406
Profile: &Profile{
Name: "macos_arm64_node_v22180",
EnableGREASE: false,
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
PointFormats: []uint8{0, 1, 2},
},
JA4CipherHash: "a33745022dd6", // stable part (same cipher suites)
JA4CipherHash: "a33745022dd6",
},
}

View File

@ -1,171 +0,0 @@
// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients.
package tlsfingerprint
import (
"log/slog"
"sort"
"sync"
"github.com/Wei-Shaw/sub2api/internal/config"
)
// DefaultProfileName is the name of the built-in Claude CLI profile.
const DefaultProfileName = "claude_cli_v2"
// Registry manages TLS fingerprint profiles.
// It holds a collection of profiles that can be used for TLS fingerprint simulation.
// Profiles are selected based on account ID using modulo operation.
type Registry struct {
mu sync.RWMutex
profiles map[string]*Profile
profileNames []string // Sorted list of profile names for deterministic selection
}
// NewRegistry creates a new TLS fingerprint profile registry.
// It initializes with the built-in default profile.
func NewRegistry() *Registry {
r := &Registry{
profiles: make(map[string]*Profile),
profileNames: make([]string, 0),
}
// Register the built-in default profile
r.registerBuiltinProfile()
return r
}
// NewRegistryFromConfig creates a new registry and loads profiles from config.
// If the config has custom profiles defined, they will be merged with the built-in default.
func NewRegistryFromConfig(cfg *config.TLSFingerprintConfig) *Registry {
r := NewRegistry()
if cfg == nil || !cfg.Enabled {
slog.Debug("tls_registry_disabled", "reason", "disabled or no config")
return r
}
// Load custom profiles from config
for name, profileCfg := range cfg.Profiles {
profile := &Profile{
Name: profileCfg.Name,
EnableGREASE: profileCfg.EnableGREASE,
CipherSuites: profileCfg.CipherSuites,
Curves: profileCfg.Curves,
PointFormats: profileCfg.PointFormats,
}
// If the profile has empty values, they will use defaults in dialer
r.RegisterProfile(name, profile)
slog.Debug("tls_registry_loaded_profile", "key", name, "name", profileCfg.Name)
}
slog.Debug("tls_registry_initialized", "profile_count", len(r.profileNames), "profiles", r.profileNames)
return r
}
// registerBuiltinProfile adds the default Claude CLI profile to the registry.
func (r *Registry) registerBuiltinProfile() {
defaultProfile := &Profile{
Name: "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)",
EnableGREASE: false, // Node.js does not use GREASE
// Empty slices will cause dialer to use built-in defaults
CipherSuites: nil,
Curves: nil,
PointFormats: nil,
}
r.RegisterProfile(DefaultProfileName, defaultProfile)
}
// RegisterProfile adds or updates a profile in the registry.
func (r *Registry) RegisterProfile(name string, profile *Profile) {
r.mu.Lock()
defer r.mu.Unlock()
// Check if this is a new profile
_, exists := r.profiles[name]
r.profiles[name] = profile
if !exists {
r.profileNames = append(r.profileNames, name)
// Keep names sorted for deterministic selection
sort.Strings(r.profileNames)
}
}
// GetProfile returns a profile by name.
// Returns nil if the profile does not exist.
func (r *Registry) GetProfile(name string) *Profile {
r.mu.RLock()
defer r.mu.RUnlock()
return r.profiles[name]
}
// GetDefaultProfile returns the built-in default profile.
func (r *Registry) GetDefaultProfile() *Profile {
return r.GetProfile(DefaultProfileName)
}
// GetProfileByAccountID returns a profile for the given account ID.
// The profile is selected using: profileNames[accountID % len(profiles)]
// This ensures deterministic profile assignment for each account.
func (r *Registry) GetProfileByAccountID(accountID int64) *Profile {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.profileNames) == 0 {
return nil
}
// Use modulo to select profile index
// Use absolute value to handle negative IDs (though unlikely)
idx := accountID
if idx < 0 {
idx = -idx
}
selectedIndex := int(idx % int64(len(r.profileNames)))
selectedName := r.profileNames[selectedIndex]
return r.profiles[selectedName]
}
// ProfileCount returns the number of registered profiles.
func (r *Registry) ProfileCount() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.profiles)
}
// ProfileNames returns a sorted list of all registered profile names.
func (r *Registry) ProfileNames() []string {
r.mu.RLock()
defer r.mu.RUnlock()
// Return a copy to prevent modification
names := make([]string, len(r.profileNames))
copy(names, r.profileNames)
return names
}
// Global registry instance for convenience
var globalRegistry *Registry
var globalRegistryOnce sync.Once
// GlobalRegistry returns the global TLS fingerprint registry.
// The registry is lazily initialized with the default profile.
func GlobalRegistry() *Registry {
globalRegistryOnce.Do(func() {
globalRegistry = NewRegistry()
})
return globalRegistry
}
// InitGlobalRegistry initializes the global registry with configuration.
// This should be called during application startup.
// It is safe to call multiple times; subsequent calls will update the registry.
func InitGlobalRegistry(cfg *config.TLSFingerprintConfig) *Registry {
globalRegistryOnce.Do(func() {
globalRegistry = NewRegistryFromConfig(cfg)
})
return globalRegistry
}

View File

@ -1,243 +0,0 @@
package tlsfingerprint
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
)
func TestNewRegistry(t *testing.T) {
r := NewRegistry()
// Should have exactly one profile (the default)
if r.ProfileCount() != 1 {
t.Errorf("expected 1 profile, got %d", r.ProfileCount())
}
// Should have the default profile
profile := r.GetDefaultProfile()
if profile == nil {
t.Error("expected default profile to exist")
}
// Default profile name should be in the list
names := r.ProfileNames()
if len(names) != 1 || names[0] != DefaultProfileName {
t.Errorf("expected profile names to be [%s], got %v", DefaultProfileName, names)
}
}
func TestRegisterProfile(t *testing.T) {
r := NewRegistry()
// Register a new profile
customProfile := &Profile{
Name: "Custom Profile",
EnableGREASE: true,
}
r.RegisterProfile("custom", customProfile)
// Should now have 2 profiles
if r.ProfileCount() != 2 {
t.Errorf("expected 2 profiles, got %d", r.ProfileCount())
}
// Should be able to retrieve the custom profile
retrieved := r.GetProfile("custom")
if retrieved == nil {
t.Fatal("expected custom profile to exist")
}
if retrieved.Name != "Custom Profile" {
t.Errorf("expected profile name 'Custom Profile', got '%s'", retrieved.Name)
}
if !retrieved.EnableGREASE {
t.Error("expected EnableGREASE to be true")
}
}
func TestGetProfile(t *testing.T) {
r := NewRegistry()
// Get existing profile
profile := r.GetProfile(DefaultProfileName)
if profile == nil {
t.Error("expected default profile to exist")
}
// Get non-existing profile
nonExistent := r.GetProfile("nonexistent")
if nonExistent != nil {
t.Error("expected nil for non-existent profile")
}
}
func TestGetProfileByAccountID(t *testing.T) {
r := NewRegistry()
// With only default profile, all account IDs should return the same profile
for i := int64(0); i < 10; i++ {
profile := r.GetProfileByAccountID(i)
if profile == nil {
t.Errorf("expected profile for account %d, got nil", i)
}
}
// Add more profiles
r.RegisterProfile("profile_a", &Profile{Name: "Profile A"})
r.RegisterProfile("profile_b", &Profile{Name: "Profile B"})
// Now we have 3 profiles: claude_cli_v2, profile_a, profile_b
// Names are sorted, so order is: claude_cli_v2, profile_a, profile_b
expectedOrder := []string{DefaultProfileName, "profile_a", "profile_b"}
names := r.ProfileNames()
for i, name := range expectedOrder {
if names[i] != name {
t.Errorf("expected name at index %d to be %s, got %s", i, name, names[i])
}
}
// Test modulo selection
// Account ID 0 % 3 = 0 -> claude_cli_v2
// Account ID 1 % 3 = 1 -> profile_a
// Account ID 2 % 3 = 2 -> profile_b
// Account ID 3 % 3 = 0 -> claude_cli_v2
testCases := []struct {
accountID int64
expectedName string
}{
{0, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"},
{1, "Profile A"},
{2, "Profile B"},
{3, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"},
{4, "Profile A"},
{5, "Profile B"},
{100, "Profile A"}, // 100 % 3 = 1
{-1, "Profile A"}, // |-1| % 3 = 1
{-3, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"}, // |-3| % 3 = 0
}
for _, tc := range testCases {
profile := r.GetProfileByAccountID(tc.accountID)
if profile == nil {
t.Errorf("expected profile for account %d, got nil", tc.accountID)
continue
}
if profile.Name != tc.expectedName {
t.Errorf("account %d: expected profile name '%s', got '%s'", tc.accountID, tc.expectedName, profile.Name)
}
}
}
func TestNewRegistryFromConfig(t *testing.T) {
// Test with nil config
r := NewRegistryFromConfig(nil)
if r.ProfileCount() != 1 {
t.Errorf("expected 1 profile with nil config, got %d", r.ProfileCount())
}
// Test with disabled config
disabledCfg := &config.TLSFingerprintConfig{
Enabled: false,
}
r = NewRegistryFromConfig(disabledCfg)
if r.ProfileCount() != 1 {
t.Errorf("expected 1 profile with disabled config, got %d", r.ProfileCount())
}
// Test with enabled config and custom profiles
enabledCfg := &config.TLSFingerprintConfig{
Enabled: true,
Profiles: map[string]config.TLSProfileConfig{
"custom1": {
Name: "Custom Profile 1",
EnableGREASE: true,
},
"custom2": {
Name: "Custom Profile 2",
EnableGREASE: false,
},
},
}
r = NewRegistryFromConfig(enabledCfg)
// Should have 3 profiles: default + 2 custom
if r.ProfileCount() != 3 {
t.Errorf("expected 3 profiles, got %d", r.ProfileCount())
}
// Check custom profiles exist
custom1 := r.GetProfile("custom1")
if custom1 == nil || custom1.Name != "Custom Profile 1" {
t.Error("expected custom1 profile to exist with correct name")
}
custom2 := r.GetProfile("custom2")
if custom2 == nil || custom2.Name != "Custom Profile 2" {
t.Error("expected custom2 profile to exist with correct name")
}
}
func TestProfileNames(t *testing.T) {
r := NewRegistry()
// Add profiles in non-alphabetical order
r.RegisterProfile("zebra", &Profile{Name: "Zebra"})
r.RegisterProfile("alpha", &Profile{Name: "Alpha"})
r.RegisterProfile("beta", &Profile{Name: "Beta"})
names := r.ProfileNames()
// Should be sorted alphabetically
expected := []string{"alpha", "beta", DefaultProfileName, "zebra"}
if len(names) != len(expected) {
t.Errorf("expected %d names, got %d", len(expected), len(names))
}
for i, name := range expected {
if names[i] != name {
t.Errorf("expected name at index %d to be %s, got %s", i, name, names[i])
}
}
// Test that returned slice is a copy (modifying it shouldn't affect registry)
names[0] = "modified"
originalNames := r.ProfileNames()
if originalNames[0] == "modified" {
t.Error("modifying returned slice should not affect registry")
}
}
func TestConcurrentAccess(t *testing.T) {
r := NewRegistry()
// Run concurrent reads and writes
done := make(chan bool)
// Writers
for i := 0; i < 10; i++ {
go func(id int) {
for j := 0; j < 100; j++ {
r.RegisterProfile("concurrent"+string(rune('0'+id)), &Profile{Name: "Concurrent"})
}
done <- true
}(i)
}
// Readers
for i := 0; i < 10; i++ {
go func(id int) {
for j := 0; j < 100; j++ {
_ = r.ProfileCount()
_ = r.ProfileNames()
_ = r.GetProfileByAccountID(int64(id * j))
_ = r.GetProfile(DefaultProfileName)
}
done <- true
}(i)
}
// Wait for all goroutines
for i := 0; i < 20; i++ {
<-done
}
// Test should pass without data races (run with -race flag)
}

View File

@ -8,6 +8,14 @@ type FingerprintResponse struct {
HTTP2 any `json:"http2"`
}
// TestProfileExpectation defines expected fingerprint values for a profile.
type TestProfileExpectation struct {
Profile *Profile
ExpectedJA3 string // Expected JA3 hash (empty = don't check)
ExpectedJA4 string // Expected full JA4 (empty = don't check)
JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check)
}
// TLSInfo contains TLS fingerprint details.
type TLSInfo struct {
JA3 string `json:"ja3"`

View File

@ -212,7 +212,7 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
SetContext(ctx).
SetHeader("Accept", "application/json, text/plain, */*").
SetHeader("Content-Type", "application/json").
SetHeader("User-Agent", "axios/1.8.4").
SetHeader("User-Agent", "axios/1.13.6").
SetBody(reqBody).
SetSuccessResult(&tokenResp).
Post(s.tokenURL)
@ -242,6 +242,7 @@ 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
@ -250,7 +251,7 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
SetContext(ctx).
SetHeader("Accept", "application/json, text/plain, */*").
SetHeader("Content-Type", "application/json").
SetHeader("User-Agent", "axios/1.8.4").
SetHeader("User-Agent", "axios/1.13.6").
SetBody(reqBody).
SetSuccessResult(&tokenResp).
Post(s.tokenURL)
@ -268,9 +269,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(60 * time.Second).
ImpersonateChrome().
SetTimeout(15 * time.Second).
SetCookieJar(nil) // 禁用 CookieJar
trimmed, _, err := proxyurl.Parse(proxyURL)

View File

@ -68,10 +68,9 @@ func (s *claudeUsageService) FetchUsageWithOptions(ctx context.Context, opts *se
var resp *http.Response
// 如果启用 TLS 指纹且有 HTTPUpstream使用 DoWithTLS
if opts.EnableTLSFingerprint && s.httpUpstream != nil {
// accountConcurrency 传 0 使用默认连接池配置usage 请求不需要特殊的并发设置
resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, true)
// 如果有 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)
if err != nil {
return nil, fmt.Errorf("request with TLS fingerprint failed: %w", err)
}

View File

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

View File

@ -1,6 +1,8 @@
package repository
import (
"compress/flate"
"compress/gzip"
"errors"
"fmt"
"io"
@ -13,6 +15,7 @@ import (
"sync/atomic"
"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"
@ -124,6 +127,16 @@ 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
}
@ -154,79 +167,64 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
}
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
// 根据 enableTLSFingerprint 参数决定是否使用 TLS 指纹
//
// 参数:
// - req: HTTP 请求对象
// - proxyURL: 代理地址,空字符串表示直连
// - accountID: 账户 ID用于账户级隔离和 TLS 指纹模板选择
// - accountConcurrency: 账户并发限制,用于动态调整连接池大小
// - enableTLSFingerprint: 是否启用 TLS 指纹伪装
//
// TLS 指纹说明:
// - 当 enableTLSFingerprint=true 时,使用 utls 库模拟 Claude CLI 的 TLS 指纹
// - 指纹模板根据 accountID % len(profiles) 自动选择
// - 支持直连、HTTP/HTTPS 代理、SOCKS5 代理三种场景
func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
// 如果未启用 TLS 指纹,直接使用标准请求路径
if !enableTLSFingerprint {
// 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 或空字符串
return s.Do(req, proxyURL, accountID, accountConcurrency)
}
// TLS 指纹已启用,记录调试日志
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)
if err := s.validateRequestHost(req); err != nil {
return nil, err
}
// 获取 TLS 指纹 Profile
registry := tlsfingerprint.GlobalRegistry()
profile := registry.GetProfileByAccountID(accountID)
if profile == nil {
// 如果获取不到 profile回退到普通请求
slog.Debug("tls_fingerprint_no_profile", "account_id", accountID, "fallback", "standard_request")
return s.Do(req, proxyURL, accountID, accountConcurrency)
}
slog.Debug("tls_fingerprint_using_profile", "account_id", accountID, "profile", profile.Name, "grease", profile.EnableGREASE)
// 获取或创建带 TLS 指纹的客户端
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)
// 包装响应体,在关闭时自动减少计数并更新时间戳
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)
@ -884,3 +882,36 @@ func wrapTrackedBody(body io.ReadCloser, onClose func()) io.ReadCloser {
}
return &trackedBody{ReadCloser: body, onClose: onClose}
}
// decompressResponseBody 根据 Content-Encoding 对响应体进行解压
// 支持 gzip、brbrotli、deflate解压后更新响应头以反映明文内容
func decompressResponseBody(resp *http.Response) {
if resp == nil || resp.Body == nil {
return
}
enc := strings.ToLower(resp.Header.Get("Content-Encoding"))
switch enc {
case "gzip":
gr, err := gzip.NewReader(resp.Body)
if err != nil {
return
}
resp.Body = io.NopCloser(gr)
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Length")
resp.ContentLength = -1
resp.Uncompressed = true
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
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
}
}

View File

@ -0,0 +1,100 @@
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,122 @@
package repository
import (
"context"
"encoding/json"
"log/slog"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
)
const (
tlsFPProfileCacheKey = "tls_fingerprint_profiles"
tlsFPProfilePubSubKey = "tls_fingerprint_profiles_updated"
tlsFPProfileCacheTTL = 24 * time.Hour
)
type tlsFingerprintProfileCache struct {
rdb *redis.Client
localCache []*model.TLSFingerprintProfile
localMu sync.RWMutex
}
// NewTLSFingerprintProfileCache 创建 TLS 指纹模板缓存
func NewTLSFingerprintProfileCache(rdb *redis.Client) service.TLSFingerprintProfileCache {
return &tlsFingerprintProfileCache{
rdb: rdb,
}
}
// Get 从缓存获取模板列表
func (c *tlsFingerprintProfileCache) Get(ctx context.Context) ([]*model.TLSFingerprintProfile, bool) {
c.localMu.RLock()
if c.localCache != nil {
profiles := c.localCache
c.localMu.RUnlock()
return profiles, true
}
c.localMu.RUnlock()
data, err := c.rdb.Get(ctx, tlsFPProfileCacheKey).Bytes()
if err != nil {
if err != redis.Nil {
slog.Warn("tls_fp_profile_cache_get_failed", "error", err)
}
return nil, false
}
var profiles []*model.TLSFingerprintProfile
if err := json.Unmarshal(data, &profiles); err != nil {
slog.Warn("tls_fp_profile_cache_unmarshal_failed", "error", err)
return nil, false
}
c.localMu.Lock()
c.localCache = profiles
c.localMu.Unlock()
return profiles, true
}
// Set 设置缓存
func (c *tlsFingerprintProfileCache) Set(ctx context.Context, profiles []*model.TLSFingerprintProfile) error {
data, err := json.Marshal(profiles)
if err != nil {
return err
}
if err := c.rdb.Set(ctx, tlsFPProfileCacheKey, data, tlsFPProfileCacheTTL).Err(); err != nil {
return err
}
c.localMu.Lock()
c.localCache = profiles
c.localMu.Unlock()
return nil
}
// Invalidate 使缓存失效
func (c *tlsFingerprintProfileCache) Invalidate(ctx context.Context) error {
c.localMu.Lock()
c.localCache = nil
c.localMu.Unlock()
return c.rdb.Del(ctx, tlsFPProfileCacheKey).Err()
}
// NotifyUpdate 通知其他实例刷新缓存
func (c *tlsFingerprintProfileCache) NotifyUpdate(ctx context.Context) error {
return c.rdb.Publish(ctx, tlsFPProfilePubSubKey, "refresh").Err()
}
// SubscribeUpdates 订阅缓存更新通知
func (c *tlsFingerprintProfileCache) SubscribeUpdates(ctx context.Context, handler func()) {
go func() {
sub := c.rdb.Subscribe(ctx, tlsFPProfilePubSubKey)
defer func() { _ = sub.Close() }()
ch := sub.Channel()
for {
select {
case <-ctx.Done():
slog.Debug("tls_fp_profile_cache_subscriber_stopped", "reason", "context_done")
return
case msg := <-ch:
if msg == nil {
slog.Warn("tls_fp_profile_cache_subscriber_stopped", "reason", "channel_closed")
return
}
c.localMu.Lock()
c.localCache = nil
c.localMu.Unlock()
handler()
}
}
}()
}

View File

@ -0,0 +1,213 @@
package repository
import (
"context"
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type tlsFingerprintProfileRepository struct {
client *ent.Client
}
// NewTLSFingerprintProfileRepository 创建 TLS 指纹模板仓库
func NewTLSFingerprintProfileRepository(client *ent.Client) service.TLSFingerprintProfileRepository {
return &tlsFingerprintProfileRepository{client: client}
}
// List 获取所有模板
func (r *tlsFingerprintProfileRepository) List(ctx context.Context) ([]*model.TLSFingerprintProfile, error) {
profiles, err := r.client.TLSFingerprintProfile.Query().
Order(ent.Asc(tlsfingerprintprofile.FieldName)).
All(ctx)
if err != nil {
return nil, err
}
result := make([]*model.TLSFingerprintProfile, len(profiles))
for i, p := range profiles {
result[i] = r.toModel(p)
}
return result, nil
}
// GetByID 根据 ID 获取模板
func (r *tlsFingerprintProfileRepository) GetByID(ctx context.Context, id int64) (*model.TLSFingerprintProfile, error) {
p, err := r.client.TLSFingerprintProfile.Get(ctx, id)
if err != nil {
if ent.IsNotFound(err) {
return nil, nil
}
return nil, err
}
return r.toModel(p), nil
}
// Create 创建模板
func (r *tlsFingerprintProfileRepository) Create(ctx context.Context, p *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
builder := r.client.TLSFingerprintProfile.Create().
SetName(p.Name).
SetEnableGrease(p.EnableGREASE)
if p.Description != nil {
builder.SetDescription(*p.Description)
}
if len(p.CipherSuites) > 0 {
builder.SetCipherSuites(p.CipherSuites)
}
if len(p.Curves) > 0 {
builder.SetCurves(p.Curves)
}
if len(p.PointFormats) > 0 {
builder.SetPointFormats(p.PointFormats)
}
if len(p.SignatureAlgorithms) > 0 {
builder.SetSignatureAlgorithms(p.SignatureAlgorithms)
}
if len(p.ALPNProtocols) > 0 {
builder.SetAlpnProtocols(p.ALPNProtocols)
}
if len(p.SupportedVersions) > 0 {
builder.SetSupportedVersions(p.SupportedVersions)
}
if len(p.KeyShareGroups) > 0 {
builder.SetKeyShareGroups(p.KeyShareGroups)
}
if len(p.PSKModes) > 0 {
builder.SetPskModes(p.PSKModes)
}
if len(p.Extensions) > 0 {
builder.SetExtensions(p.Extensions)
}
created, err := builder.Save(ctx)
if err != nil {
return nil, err
}
return r.toModel(created), nil
}
// Update 更新模板
func (r *tlsFingerprintProfileRepository) Update(ctx context.Context, p *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
builder := r.client.TLSFingerprintProfile.UpdateOneID(p.ID).
SetName(p.Name).
SetEnableGrease(p.EnableGREASE)
if p.Description != nil {
builder.SetDescription(*p.Description)
} else {
builder.ClearDescription()
}
if len(p.CipherSuites) > 0 {
builder.SetCipherSuites(p.CipherSuites)
} else {
builder.ClearCipherSuites()
}
if len(p.Curves) > 0 {
builder.SetCurves(p.Curves)
} else {
builder.ClearCurves()
}
if len(p.PointFormats) > 0 {
builder.SetPointFormats(p.PointFormats)
} else {
builder.ClearPointFormats()
}
if len(p.SignatureAlgorithms) > 0 {
builder.SetSignatureAlgorithms(p.SignatureAlgorithms)
} else {
builder.ClearSignatureAlgorithms()
}
if len(p.ALPNProtocols) > 0 {
builder.SetAlpnProtocols(p.ALPNProtocols)
} else {
builder.ClearAlpnProtocols()
}
if len(p.SupportedVersions) > 0 {
builder.SetSupportedVersions(p.SupportedVersions)
} else {
builder.ClearSupportedVersions()
}
if len(p.KeyShareGroups) > 0 {
builder.SetKeyShareGroups(p.KeyShareGroups)
} else {
builder.ClearKeyShareGroups()
}
if len(p.PSKModes) > 0 {
builder.SetPskModes(p.PSKModes)
} else {
builder.ClearPskModes()
}
if len(p.Extensions) > 0 {
builder.SetExtensions(p.Extensions)
} else {
builder.ClearExtensions()
}
updated, err := builder.Save(ctx)
if err != nil {
return nil, err
}
return r.toModel(updated), nil
}
// Delete 删除模板
func (r *tlsFingerprintProfileRepository) Delete(ctx context.Context, id int64) error {
return r.client.TLSFingerprintProfile.DeleteOneID(id).Exec(ctx)
}
// toModel 将 Ent 实体转换为服务模型
func (r *tlsFingerprintProfileRepository) toModel(e *ent.TLSFingerprintProfile) *model.TLSFingerprintProfile {
p := &model.TLSFingerprintProfile{
ID: e.ID,
Name: e.Name,
Description: e.Description,
EnableGREASE: e.EnableGrease,
CipherSuites: e.CipherSuites,
Curves: e.Curves,
PointFormats: e.PointFormats,
SignatureAlgorithms: e.SignatureAlgorithms,
ALPNProtocols: e.AlpnProtocols,
SupportedVersions: e.SupportedVersions,
KeyShareGroups: e.KeyShareGroups,
PSKModes: e.PskModes,
Extensions: e.Extensions,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
// 确保切片不为 nil
if p.CipherSuites == nil {
p.CipherSuites = []uint16{}
}
if p.Curves == nil {
p.Curves = []uint16{}
}
if p.PointFormats == nil {
p.PointFormats = []uint16{}
}
if p.SignatureAlgorithms == nil {
p.SignatureAlgorithms = []uint16{}
}
if p.ALPNProtocols == nil {
p.ALPNProtocols = []string{}
}
if p.SupportedVersions == nil {
p.SupportedVersions = []uint16{}
}
if p.KeyShareGroups == nil {
p.KeyShareGroups = []uint16{}
}
if p.PSKModes == nil {
p.PSKModes = []uint16{}
}
if p.Extensions == nil {
p.Extensions = []uint16{}
}
return p
}

View File

@ -73,6 +73,7 @@ var ProviderSet = wire.NewSet(
NewUserAttributeValueRepository,
NewUserGroupRateRepository,
NewErrorPassthroughRepository,
NewTLSFingerprintProfileRepository,
// Cache implementations
NewGatewayCache,
@ -96,6 +97,7 @@ var ProviderSet = wire.NewSet(
NewTotpCache,
NewRefreshTokenCache,
NewErrorPassthroughCache,
NewTLSFingerprintProfileCache,
// Encryptors
NewAESEncryptor,

View File

@ -79,11 +79,17 @@ func RegisterAdminRoutes(
// 错误透传规则管理
registerErrorPassthroughRoutes(admin, h)
// TLS 指纹模板管理
registerTLSFingerprintProfileRoutes(admin, h)
// API Key 管理
registerAdminAPIKeyRoutes(admin, h)
// 定时测试计划
registerScheduledTestRoutes(admin, h)
// 风控中心
registerRiskRoutes(admin, h)
}
}
@ -552,3 +558,26 @@ func registerErrorPassthroughRoutes(admin *gin.RouterGroup, h *handler.Handlers)
rules.DELETE("/:id", h.Admin.ErrorPassthrough.Delete)
}
}
func registerTLSFingerprintProfileRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
profiles := admin.Group("/tls-fingerprint-profiles")
{
profiles.GET("", h.Admin.TLSFingerprintProfile.List)
profiles.GET("/:id", h.Admin.TLSFingerprintProfile.GetByID)
profiles.POST("", h.Admin.TLSFingerprintProfile.Create)
profiles.PUT("/:id", h.Admin.TLSFingerprintProfile.Update)
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,22 +1147,66 @@ func (a *Account) IsAnthropicOAuthOrSetupToken() bool {
}
// IsTLSFingerprintEnabled 检查是否启用 TLS 指纹伪装
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
// 启用后将模拟 Claude Code (Node.js) 客户端的 TLS 握手特征
// 支持 Anthropic OAuth/SetupToken 和 Gemini OAuth 账号(扩展见 account_antigravity.go
// 启用后将通过 node-tls-proxy 路由流量,获得真实 Node.js TLS 握手特征
func (a *Account) IsTLSFingerprintEnabled() bool {
// 仅支持 Anthropic OAuth/SetupToken 账号
if !a.IsAnthropicOAuthOrSetupToken() {
// Anthropic OAuth/SetupToken — 原有逻辑
if a.IsAnthropicOAuthOrSetupToken() {
if a.Extra == nil {
return false
}
if v, ok := a.Extra["enable_tls_fingerprint"]; ok {
if enabled, ok := v.(bool); ok {
return enabled
}
}
return false
}
// Gemini OAuth — 扩展(实现在 account_antigravity.go
return geminiTLSFingerprintEnabled(a)
}
// GetTLSFingerprintProfileID 获取账号绑定的 TLS 指纹模板 ID
// 返回 0 表示未绑定(使用内置默认 profile
func (a *Account) GetTLSFingerprintProfileID() int64 {
if a.Extra == nil {
return false
return 0
}
if v, ok := a.Extra["enable_tls_fingerprint"]; ok {
if enabled, ok := v.(bool); ok {
return enabled
v, ok := a.Extra["tls_fingerprint_profile_id"]
if !ok {
return 0
}
switch id := v.(type) {
case float64:
return int64(id)
case int64:
return id
case int:
return int64(id)
case json.Number:
if i, err := id.Int64(); err == nil {
return i
}
}
return false
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 获取用户消息队列模式

View File

@ -0,0 +1,30 @@
package service
// ==============================================================
// antigravity — account 扩展
//
// 此文件包含 Antigravity fork 对 Account 的扩展,
// 新增了 Gemini OAuth 账号的 TLS 指纹伪装支持。
//
// 对上游文件 account.go 的改动:
// - IsTLSFingerprintEnabled() 方法改为调用本文件的 geminiTLSFingerprintEnabled()
// (仅需在与上游合并时确保钩子调用点存在)
// ==============================================================
// geminiTLSFingerprintEnabled 检查 Gemini OAuth 账号是否启用 TLS 指纹伪装
// Gemini CLI 也是 Node.js 应用,通过 node-tls-proxy 代理后
// TLS 指纹天然匹配 Gemini CLI无需单独模拟
func geminiTLSFingerprintEnabled(a *Account) bool {
if a.Platform != PlatformGemini || a.Type != AccountTypeOAuth {
return false
}
if a.Extra == nil {
return false
}
if v, ok := a.Extra["enable_tls_fingerprint"]; ok {
if enabled, ok := v.(bool); ok {
return enabled
}
}
return false
}

View File

@ -23,6 +23,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/util/soraerror"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/gin-gonic/gin"
@ -69,6 +70,7 @@ type AccountTestService struct {
antigravityGatewayService *AntigravityGatewayService
httpUpstream HTTPUpstream
cfg *config.Config
tlsFPProfileService *TLSFingerprintProfileService
soraTestGuardMu sync.Mutex
soraTestLastRun map[int64]time.Time
soraTestCooldown time.Duration
@ -83,6 +85,7 @@ func NewAccountTestService(
antigravityGatewayService *AntigravityGatewayService,
httpUpstream HTTPUpstream,
cfg *config.Config,
tlsFPProfileService *TLSFingerprintProfileService,
) *AccountTestService {
return &AccountTestService{
accountRepo: accountRepo,
@ -90,6 +93,7 @@ func NewAccountTestService(
antigravityGatewayService: antigravityGatewayService,
httpUpstream: httpUpstream,
cfg: cfg,
tlsFPProfileService: tlsFPProfileService,
soraTestLastRun: make(map[int64]time.Time),
soraTestCooldown: defaultSoraTestCooldown,
}
@ -300,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.IsTLSFingerprintEnabled())
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
}
@ -390,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, false)
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, TLSModeOff, nil)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
}
@ -520,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.IsTLSFingerprintEnabled())
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
}
@ -610,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.IsTLSFingerprintEnabled())
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
}
@ -881,9 +885,9 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
enableSoraTLSFingerprint := s.shouldEnableSoraTLSFingerprint()
soraTLSProfile := s.resolveSoraTLSProfile()
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, enableSoraTLSFingerprint)
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), soraTLSProfile)
if err != nil {
recorder.addStep("me", "failed", 0, "network_error", err.Error())
s.emitSoraProbeSummary(c, recorder)
@ -948,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, enableSoraTLSFingerprint)
subResp, subErr := s.httpUpstream.DoWithTLS(subReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), 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())})
@ -977,7 +981,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
}
// 追加 Sora2 能力探测(对齐 sora2api 的测试思路):邀请码 + 剩余额度。
s.testSora2Capabilities(c, ctx, account, authToken, proxyURL, enableSoraTLSFingerprint, recorder)
s.testSora2Capabilities(c, ctx, account, authToken, proxyURL, soraTLSProfile, recorder)
s.emitSoraProbeSummary(c, recorder)
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
@ -990,7 +994,7 @@ func (s *AccountTestService) testSora2Capabilities(
account *Account,
authToken string,
proxyURL string,
enableTLSFingerprint bool,
tlsProfile *tlsfingerprint.Profile,
recorder *soraProbeRecorder,
) {
inviteStatus, inviteHeader, inviteBody, err := s.fetchSoraTestEndpoint(
@ -999,7 +1003,7 @@ func (s *AccountTestService) testSora2Capabilities(
authToken,
soraInviteMineURL,
proxyURL,
enableTLSFingerprint,
tlsProfile,
)
if err != nil {
if recorder != nil {
@ -1016,7 +1020,7 @@ func (s *AccountTestService) testSora2Capabilities(
authToken,
soraBootstrapURL,
proxyURL,
enableTLSFingerprint,
tlsProfile,
)
if bootstrapErr == nil && bootstrapStatus == http.StatusOK {
if recorder != nil {
@ -1029,7 +1033,7 @@ func (s *AccountTestService) testSora2Capabilities(
authToken,
soraInviteMineURL,
proxyURL,
enableTLSFingerprint,
tlsProfile,
)
if err != nil {
if recorder != nil {
@ -1081,7 +1085,7 @@ func (s *AccountTestService) testSora2Capabilities(
authToken,
soraRemainingURL,
proxyURL,
enableTLSFingerprint,
tlsProfile,
)
if remainingErr != nil {
if recorder != nil {
@ -1122,7 +1126,7 @@ func (s *AccountTestService) fetchSoraTestEndpoint(
authToken string,
url string,
proxyURL string,
enableTLSFingerprint bool,
tlsProfile *tlsfingerprint.Profile,
) (int, http.Header, []byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
@ -1135,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, enableTLSFingerprint)
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), tlsProfile)
if err != nil {
return 0, nil, nil, err
}
@ -1224,11 +1228,12 @@ func parseSoraRemainingSummary(body []byte) string {
return strings.Join(parts, " | ")
}
func (s *AccountTestService) shouldEnableSoraTLSFingerprint() bool {
if s == nil || s.cfg == nil {
return true
func (s *AccountTestService) resolveSoraTLSProfile() *tlsfingerprint.Profile {
if s == nil || s.cfg == nil || !s.cfg.Sora.Client.DisableTLSFingerprint {
// Sora TLS fingerprint enabled — use built-in default profile
return &tlsfingerprint.Profile{Name: "Built-in Default (Sora)"}
}
return !s.cfg.Sora.Client.DisableTLSFingerprint
return nil // disabled
}
func isCloudflareChallengeResponse(statusCode int, headers http.Header, body []byte) bool {
@ -1464,7 +1469,8 @@ 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.GeminiCLIUserAgent)
req.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent())
req.Header.Set("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient())
return req, nil
}

View File

@ -10,6 +10,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@ -24,9 +25,9 @@ func (u *queuedHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*htt
return nil, fmt.Errorf("unexpected Do call")
}
func (u *queuedHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, enableTLSFingerprint bool) (*http.Response, error) {
func (u *queuedHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, profile *tlsfingerprint.Profile) (*http.Response, error) {
u.requests = append(u.requests, req)
u.tlsFlags = append(u.tlsFlags, enableTLSFingerprint)
u.tlsFlags = append(u.tlsFlags, profile != nil)
if len(u.responses) == 0 {
return nil, fmt.Errorf("no mocked response")
}

View File

@ -17,6 +17,7 @@ import (
openaipkg "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/singleflight"
@ -241,11 +242,12 @@ type ClaudeUsageResponse struct {
// ClaudeUsageFetchOptions 包含获取 Claude 用量数据所需的所有选项
type ClaudeUsageFetchOptions struct {
AccessToken string // OAuth access token
ProxyURL string // 代理 URL可选
AccountID int64 // 账号 ID用于 TLS 指纹选择)
EnableTLSFingerprint bool // 是否启用 TLS 指纹伪装
Fingerprint *Fingerprint // 缓存的指纹信息User-Agent 等)
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 等)
}
// ClaudeUsageFetcher fetches usage data from Anthropic OAuth API
@ -264,6 +266,7 @@ type AccountUsageService struct {
antigravityQuotaFetcher *AntigravityQuotaFetcher
cache *UsageCache
identityCache IdentityCache
tlsFPProfileService *TLSFingerprintProfileService
}
// NewAccountUsageService 创建AccountUsageService实例
@ -275,6 +278,7 @@ func NewAccountUsageService(
antigravityQuotaFetcher *AntigravityQuotaFetcher,
cache *UsageCache,
identityCache IdentityCache,
tlsFPProfileService *TLSFingerprintProfileService,
) *AccountUsageService {
return &AccountUsageService{
accountRepo: accountRepo,
@ -284,6 +288,7 @@ func NewAccountUsageService(
antigravityQuotaFetcher: antigravityQuotaFetcher,
cache: cache,
identityCache: identityCache,
tlsFPProfileService: tlsFPProfileService,
}
}
@ -1155,10 +1160,11 @@ func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *A
// 构建完整的选项
opts := &ClaudeUsageFetchOptions{
AccessToken: accessToken,
ProxyURL: proxyURL,
AccountID: account.ID,
EnableTLSFingerprint: account.IsTLSFingerprintEnabled(),
AccessToken: accessToken,
ProxyURL: proxyURL,
AccountID: account.ID,
TLSMode: account.GetTLSMode(),
TLSProfile: s.tlsFPProfileService.ResolveTLSProfile(account),
}
// 尝试获取缓存的 Fingerprint包含 User-Agent 等信息)

View File

@ -65,6 +65,12 @@ type AdminService interface {
SetAccountError(ctx context.Context, id int64, errorMsg string) error
// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号 privacy_mode未设置则尝试关闭训练数据共享并持久化。
EnsureOpenAIPrivacy(ctx context.Context, account *Account) string
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号 privacy_mode未设置则调用 setUserSettings 并持久化。
EnsureAntigravityPrivacy(ctx context.Context, account *Account) string
// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。
ForceOpenAIPrivacy(ctx context.Context, account *Account) string
// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
ForceAntigravityPrivacy(ctx context.Context, account *Account) string
SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error)
BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error)
CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error
@ -2661,3 +2667,112 @@ func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Acc
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode})
return mode
}
// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。
func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Account) string {
if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
return ""
}
if s.privacyClientFactory == nil {
return ""
}
token, _ := account.Credentials["access_token"].(string)
if token == "" {
return ""
}
var proxyURL string
if account.ProxyID != nil {
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
proxyURL = p.URL()
}
}
mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL)
if mode == "" {
return ""
}
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
logger.LegacyPrintf("service.admin", "force_update_openai_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
return mode
}
if account.Extra == nil {
account.Extra = make(map[string]any)
}
account.Extra["privacy_mode"] = mode
return mode
}
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。
// 如果 Extra["privacy_mode"] 已存在(无论成功或失败),直接跳过。
func (s *adminServiceImpl) EnsureAntigravityPrivacy(ctx context.Context, account *Account) string {
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
return ""
}
if account.Extra != nil {
if existing, ok := account.Extra["privacy_mode"].(string); ok && existing != "" {
return existing
}
}
token, _ := account.Credentials["access_token"].(string)
if token == "" {
return ""
}
projectID, _ := account.Credentials["project_id"].(string)
var proxyURL string
if account.ProxyID != nil {
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
proxyURL = p.URL()
}
}
mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
if mode == "" {
return ""
}
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
logger.LegacyPrintf("service.admin", "update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
return mode
}
applyAntigravityPrivacyMode(account, mode)
return mode
}
// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account *Account) string {
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
return ""
}
token, _ := account.Credentials["access_token"].(string)
if token == "" {
return ""
}
projectID, _ := account.Credentials["project_id"].(string)
var proxyURL string
if account.ProxyID != nil {
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
proxyURL = p.URL()
}
}
mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
if mode == "" {
return ""
}
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
logger.LegacyPrintf("service.admin", "force_update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
return mode
}
applyAntigravityPrivacyMode(account, mode)
return mode
}

View File

@ -862,6 +862,7 @@ type AntigravityGatewayService struct {
settingService *SettingService
cache GatewayCache // 用于模型级限流时清除粘性会话绑定
schedulerSnapshot *SchedulerSnapshotService
heartbeat *AntigravityHeartbeat
}
func NewAntigravityGatewayService(
@ -881,6 +882,7 @@ func NewAntigravityGatewayService(
settingService: settingService,
cache: cache,
schedulerSnapshot: schedulerSnapshot,
heartbeat: NewAntigravityHeartbeat(),
}
}
@ -1377,6 +1379,11 @@ 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)
@ -3565,11 +3572,7 @@ func mergeTextPartsToResponse(response map[string]any, textParts []string) map[s
}
func (s *AntigravityGatewayService) writeClaudeError(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)
return WriteClaudeErrorResponse(c, status, errType, message)
}
// WriteMappedClaudeError 导出版本,供 handler 层使用(如 fallback 错误处理)
@ -3655,28 +3658,7 @@ func (s *AntigravityGatewayService) writeMappedClaudeError(c *gin.Context, accou
}
func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int, message string) error {
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)
return WriteGoogleErrorResponse(c, status, message)
}
// handleClaudeStreamToNonStreaming 收集上游流式响应,转换为 Claude 非流式格式返回

View File

@ -15,6 +15,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@ -130,7 +131,7 @@ func (s *httpUpstreamStub) Do(_ *http.Request, _ string, _ int64, _ int) (*http.
return s.resp, s.err
}
func (s *httpUpstreamStub) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ bool) (*http.Response, error) {
func (s *httpUpstreamStub) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return s.resp, s.err
}
@ -171,7 +172,7 @@ func (s *queuedHTTPUpstreamStub) Do(req *http.Request, _ string, _ int64, _ int)
return resp, err
}
func (s *queuedHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ bool) (*http.Response, error) {
func (s *queuedHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return s.Do(req, proxyURL, accountID, concurrency)
}

View File

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

@ -89,7 +89,8 @@ type AntigravityTokenInfo struct {
TokenType string `json:"token_type"`
Email string `json:"email,omitempty"`
ProjectID string `json:"project_id,omitempty"`
ProjectIDMissing bool `json:"-"` // LoadCodeAssist 未返回 project_id
ProjectIDMissing bool `json:"-"`
PlanType string `json:"-"`
}
// ExchangeCode 用 authorization code 交换 token
@ -145,13 +146,17 @@ func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *Antig
result.Email = userInfo.Email
}
// 获取 project_id(部分账户类型可能没有),失败时重试
projectID, loadErr := s.loadProjectIDWithRetry(ctx, tokenResp.AccessToken, proxyURL, 3)
// 获取 project_id + plan_type(部分账户类型可能没有),失败时重试
loadResult, loadErr := s.loadProjectIDWithRetry(ctx, tokenResp.AccessToken, proxyURL, 3)
if loadErr != nil {
fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v\n", loadErr)
result.ProjectIDMissing = true
} else {
result.ProjectID = projectID
}
if loadResult != nil {
result.ProjectID = loadResult.ProjectID
if loadResult.Subscription != nil {
result.PlanType = loadResult.Subscription.PlanType
}
}
return result, nil
@ -230,13 +235,17 @@ func (s *AntigravityOAuthService) ValidateRefreshToken(ctx context.Context, refr
tokenInfo.Email = userInfo.Email
}
// 获取 project_id(容错,失败不阻塞)
projectID, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
// 获取 project_id + plan_type(容错,失败不阻塞)
loadResult, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
if loadErr != nil {
fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v\n", loadErr)
tokenInfo.ProjectIDMissing = true
} else {
tokenInfo.ProjectID = projectID
}
if loadResult != nil {
tokenInfo.ProjectID = loadResult.ProjectID
if loadResult.Subscription != nil {
tokenInfo.PlanType = loadResult.Subscription.PlanType
}
}
return tokenInfo, nil
@ -288,33 +297,42 @@ func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, accou
tokenInfo.Email = existingEmail
}
// 每次刷新都调用 LoadCodeAssist 获取 project_id,失败时重试
// 每次刷新都调用 LoadCodeAssist 获取 project_id + plan_type,失败时重试
existingProjectID := strings.TrimSpace(account.GetCredential("project_id"))
projectID, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
loadResult, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
if loadErr != nil {
// LoadCodeAssist 失败,保留原有 project_id
tokenInfo.ProjectID = existingProjectID
// 只有从未获取过 project_id 且本次也获取失败时,才标记为真正缺失
// 如果之前有 project_id本次只是临时故障不应标记为错误
if existingProjectID == "" {
tokenInfo.ProjectIDMissing = true
}
} else {
tokenInfo.ProjectID = projectID
}
if loadResult != nil {
if loadResult.ProjectID != "" {
tokenInfo.ProjectID = loadResult.ProjectID
}
if loadResult.Subscription != nil {
tokenInfo.PlanType = loadResult.Subscription.PlanType
}
}
return tokenInfo, nil
}
// loadProjectIDWithRetry 带重试机制获取 project_id
// 返回 project_id 和错误,失败时会重试指定次数
func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, accessToken, proxyURL string, maxRetries int) (string, error) {
// loadCodeAssistResult 封装 loadProjectIDWithRetry 的返回结果,
// 同时携带从 LoadCodeAssist 响应中提取的 plan_type 信息。
type loadCodeAssistResult struct {
ProjectID string
Subscription *AntigravitySubscriptionResult
}
// loadProjectIDWithRetry 带重试机制获取 project_id同时从响应中提取 plan_type。
func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, accessToken, proxyURL string, maxRetries int) (*loadCodeAssistResult, error) {
var lastErr error
var lastSubscription *AntigravitySubscriptionResult
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
// 指数退避1s, 2s, 4s
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
if backoff > 8*time.Second {
backoff = 8 * time.Second
@ -324,24 +342,34 @@ func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, ac
client, err := antigravity.NewClient(proxyURL)
if err != nil {
return "", fmt.Errorf("create antigravity client failed: %w", err)
return nil, fmt.Errorf("create antigravity client failed: %w", err)
}
loadResp, loadRaw, err := client.LoadCodeAssist(ctx, accessToken)
if loadResp != nil {
sub := NormalizeAntigravitySubscription(loadResp)
lastSubscription = &sub
}
if err == nil && loadResp != nil && loadResp.CloudAICompanionProject != "" {
return loadResp.CloudAICompanionProject, nil
return &loadCodeAssistResult{
ProjectID: loadResp.CloudAICompanionProject,
Subscription: lastSubscription,
}, nil
}
if err == nil {
if projectID, onboardErr := tryOnboardProjectID(ctx, client, accessToken, loadRaw); onboardErr == nil && projectID != "" {
return projectID, nil
return &loadCodeAssistResult{
ProjectID: projectID,
Subscription: lastSubscription,
}, nil
} else if onboardErr != nil {
lastErr = onboardErr
continue
}
}
// 记录错误
if err != nil {
lastErr = err
} else if loadResp == nil {
@ -351,7 +379,10 @@ func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, ac
}
}
return "", fmt.Errorf("获取 project_id 失败 (重试 %d 次后): %w", maxRetries, lastErr)
if lastSubscription != nil {
return &loadCodeAssistResult{Subscription: lastSubscription}, fmt.Errorf("获取 project_id 失败 (重试 %d 次后): %w", maxRetries, lastErr)
}
return nil, fmt.Errorf("获取 project_id 失败 (重试 %d 次后): %w", maxRetries, lastErr)
}
func tryOnboardProjectID(ctx context.Context, client *antigravity.Client, accessToken string, loadRaw map[string]any) (string, error) {
@ -410,7 +441,11 @@ func (s *AntigravityOAuthService) FillProjectID(ctx context.Context, account *Ac
proxyURL = proxy.URL()
}
}
return s.loadProjectIDWithRetry(ctx, accessToken, proxyURL, 3)
result, err := s.loadProjectIDWithRetry(ctx, accessToken, proxyURL, 3)
if result != nil {
return result.ProjectID, err
}
return "", err
}
// BuildAccountCredentials 构建账户凭证
@ -431,6 +466,9 @@ func (s *AntigravityOAuthService) BuildAccountCredentials(tokenInfo *Antigravity
if tokenInfo.ProjectID != "" {
creds["project_id"] = tokenInfo.ProjectID
}
if tokenInfo.PlanType != "" {
creds["plan_type"] = tokenInfo.PlanType
}
return creds
}

View File

@ -0,0 +1,81 @@
package service
import (
"context"
"log/slog"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
)
const (
AntigravityPrivacySet = "privacy_set"
AntigravityPrivacyFailed = "privacy_set_failed"
)
// setAntigravityPrivacy 调用 Antigravity API 设置隐私并验证结果。
// 流程:
// 1. setUserSettings 清空设置 → 检查返回值 {\"userSettings\":{}}
// 2. fetchUserInfo 二次验证隐私是否已生效(需要 project_id
//
// 返回 privacy_mode 值:\"privacy_set\" 成功,\"privacy_set_failed\" 失败,空串表示无法执行。
func setAntigravityPrivacy(ctx context.Context, accessToken, projectID, proxyURL string) string {
if accessToken == "" {
return ""
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
client, err := antigravity.NewClient(proxyURL)
if err != nil {
slog.Warn("antigravity_privacy_client_error", "error", err.Error())
return AntigravityPrivacyFailed
}
// 第 1 步:调用 setUserSettings检查返回值
setResp, err := client.SetUserSettings(ctx, accessToken)
if err != nil {
slog.Warn("antigravity_privacy_set_failed", "error", err.Error())
return AntigravityPrivacyFailed
}
if !setResp.IsSuccess() {
slog.Warn("antigravity_privacy_set_response_not_empty",
"user_settings", setResp.UserSettings,
)
return AntigravityPrivacyFailed
}
// 第 2 步:调用 fetchUserInfo 二次验证隐私是否已生效
if strings.TrimSpace(projectID) == "" {
slog.Warn("antigravity_privacy_missing_project_id")
return AntigravityPrivacyFailed
}
userInfo, err := client.FetchUserInfo(ctx, accessToken, projectID)
if err != nil {
slog.Warn("antigravity_privacy_verify_failed", "error", err.Error())
return AntigravityPrivacyFailed
}
if !userInfo.IsPrivate() {
slog.Warn("antigravity_privacy_verify_not_private",
"user_settings", userInfo.UserSettings,
)
return AntigravityPrivacyFailed
}
slog.Info("antigravity_privacy_set_success")
return AntigravityPrivacySet
}
func applyAntigravityPrivacyMode(account *Account, mode string) {
if account == nil || strings.TrimSpace(mode) == "" {
return
}
extra := make(map[string]any, len(account.Extra)+1)
for k, v := range account.Extra {
extra[k] = v
}
extra["privacy_mode"] = mode
account.Extra = extra
}

View File

@ -0,0 +1,41 @@
//go:build unit
package service
import "testing"
func TestApplyAntigravityPrivacyMode_SetsInMemoryExtra(t *testing.T) {
account := &Account{}
applyAntigravityPrivacyMode(account, AntigravityPrivacySet)
if account.Extra == nil {
t.Fatal("expected account.Extra to be initialized")
}
if got := account.Extra["privacy_mode"]; got != AntigravityPrivacySet {
t.Fatalf("expected privacy_mode %q, got %v", AntigravityPrivacySet, got)
}
}
func TestApplyAntigravityPrivacyMode_PreservedBySubscriptionResult(t *testing.T) {
account := &Account{
Credentials: map[string]any{
"access_token": "token",
},
Extra: map[string]any{
"existing": "value",
},
}
applyAntigravityPrivacyMode(account, AntigravityPrivacySet)
_, extra := applyAntigravitySubscriptionResult(account, AntigravitySubscriptionResult{
PlanType: "Pro",
})
if got := extra["privacy_mode"]; got != AntigravityPrivacySet {
t.Fatalf("expected subscription writeback to keep privacy_mode %q, got %v", AntigravityPrivacySet, got)
}
if got := extra["existing"]; got != "value" {
t.Fatalf("expected existing extra fields to be preserved, got %v", got)
}
}

View File

@ -12,6 +12,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/stretchr/testify/require"
)
@ -40,7 +41,7 @@ func (r *recordingOKUpstream) Do(req *http.Request, proxyURL string, accountID i
}, nil
}
func (r *recordingOKUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
func (r *recordingOKUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
return r.Do(req, proxyURL, accountID, accountConcurrency)
}
@ -61,7 +62,7 @@ func (s *stubAntigravityUpstream) Do(req *http.Request, proxyURL string, account
}, nil
}
func (s *stubAntigravityUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
func (s *stubAntigravityUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
return s.Do(req, proxyURL, accountID, accountConcurrency)
}

View File

@ -10,6 +10,7 @@ import (
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/stretchr/testify/require"
)
@ -93,7 +94,7 @@ func (m *mockSmartRetryUpstream) Do(req *http.Request, proxyURL string, accountI
}, respErr
}
func (m *mockSmartRetryUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
func (m *mockSmartRetryUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
return m.Do(req, proxyURL, accountID, accountConcurrency)
}

View File

@ -0,0 +1,62 @@
package service
import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
)
const antigravitySubscriptionAbnormal = "abnormal"
// AntigravitySubscriptionResult 表示订阅检测后的规范化结果。
type AntigravitySubscriptionResult struct {
PlanType string
SubscriptionStatus string
SubscriptionError string
}
// NormalizeAntigravitySubscription 从 LoadCodeAssistResponse 提取 plan_type + 异常状态。
// 使用 GetTier()(返回 tier ID+ TierIDToPlanType 映射。
func NormalizeAntigravitySubscription(resp *antigravity.LoadCodeAssistResponse) AntigravitySubscriptionResult {
if resp == nil {
return AntigravitySubscriptionResult{PlanType: "Free"}
}
if len(resp.IneligibleTiers) > 0 {
result := AntigravitySubscriptionResult{
PlanType: "Abnormal",
SubscriptionStatus: antigravitySubscriptionAbnormal,
}
if resp.IneligibleTiers[0] != nil {
result.SubscriptionError = strings.TrimSpace(resp.IneligibleTiers[0].ReasonMessage)
}
return result
}
tierID := resp.GetTier()
return AntigravitySubscriptionResult{
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 = 3 * time.Minute
antigravityTokenRefreshSkew = 5 * time.Minute
antigravityTokenCacheSkew = 5 * time.Minute
antigravityBackfillCooldown = 5 * time.Minute
// antigravityRequestRefreshTimeout 请求路径上 token 刷新的最大等待时间。

View File

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

View File

@ -235,6 +235,9 @@ const (
// SettingKeyBackendModeEnabled Backend 模式:禁用用户注册和自助服务,仅管理员可登录
SettingKeyBackendModeEnabled = "backend_mode_enabled"
// SettingKeyRiskSettings 风控系统配置 (JSON)
SettingKeyRiskSettings = "risk_settings"
)
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).

View File

@ -12,6 +12,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/stretchr/testify/require"
)
@ -35,7 +36,7 @@ func (u *epFixedUpstream) Do(req *http.Request, proxyURL string, accountID int64
}, nil
}
func (u *epFixedUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
func (u *epFixedUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
return u.Do(req, proxyURL, accountID, accountConcurrency)
}

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