Compare commits
60 Commits
cdbf05bf1c
...
8eb2bbcb20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eb2bbcb20 | ||
|
|
1dfd974432 | ||
|
|
cc396f59cf | ||
|
|
aa8b9cc508 | ||
|
|
6a2cf09ee0 | ||
|
|
c6fd88116b | ||
|
|
8f0dbdeaba | ||
|
|
007c09b84e | ||
|
|
73f3c068ef | ||
|
|
9a92fa4a60 | ||
|
|
576af710be | ||
|
|
b5642bd068 | ||
|
|
128f322252 | ||
|
|
17d7e57a2e | ||
|
|
50288e6b01 | ||
|
|
ab3e44e4bd | ||
|
|
61607990c8 | ||
|
|
b65275235f | ||
|
|
e298a71834 | ||
|
|
3f6fa1e3db | ||
|
|
f2c2abe628 | ||
|
|
ff5b467fbe | ||
|
|
8c10941142 | ||
|
|
f5764d8dc6 | ||
|
|
81ca4f12dd | ||
|
|
941c469ab9 | ||
|
|
8fcd819e6f | ||
|
|
9abdaed20c | ||
|
|
eb94342f78 | ||
|
|
d563eb2336 | ||
|
|
3ee6f085db | ||
|
|
7cca69a136 | ||
|
|
093a5a260e | ||
|
|
2c072c0ed6 | ||
|
|
1f39bf8a78 | ||
|
|
fdd8499ffc | ||
|
|
9398ea7af5 | ||
|
|
29dce1a59c | ||
|
|
c729ee425f | ||
|
|
c489f23810 | ||
|
|
47a544230a | ||
|
|
c13c81f09d | ||
|
|
20544a4447 | ||
|
|
b688ebeefa | ||
|
|
1854050df3 | ||
|
|
c7f4a649df | ||
|
|
ef5c8e6839 | ||
|
|
d571f300e5 | ||
|
|
ce96527dd9 | ||
|
|
f8b8b53985 | ||
|
|
b20e142249 | ||
|
|
7c6dc9dda8 | ||
|
|
5875571215 | ||
|
|
975e6b1563 | ||
|
|
f6fd7c83e3 | ||
|
|
c2965c0fb0 | ||
|
|
fdad55956e | ||
|
|
bb399e56b0 | ||
|
|
73d72651b4 | ||
|
|
ccd42c1d1a |
@ -1,102 +0,0 @@
|
||||
---
|
||||
description: 从上游 (origin/main) 同步更新,保留 Antigravity 自定义改动
|
||||
---
|
||||
|
||||
# 前置检查
|
||||
|
||||
// turbo
|
||||
1. 查看当前状态和上游差异
|
||||
```bash
|
||||
cd /Users/win/2025/aitool/MiniGravity/sub2api
|
||||
git fetch origin
|
||||
git log --oneline HEAD..origin/main
|
||||
```
|
||||
|
||||
如果上游没有新 commits,停止(无需同步)。
|
||||
|
||||
# 执行同步
|
||||
|
||||
2. 确保工作区干净
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
如果有未提交的改动,先 `git stash`。
|
||||
|
||||
// turbo
|
||||
3. 备份当前自定义 patches
|
||||
```bash
|
||||
mkdir -p /tmp/antigravity-patches
|
||||
git format-patch origin/main..HEAD -o /tmp/antigravity-patches/
|
||||
echo "已备份 $(ls /tmp/antigravity-patches/*.patch 2>/dev/null | wc -l) 个 patch 到 /tmp/antigravity-patches/"
|
||||
```
|
||||
|
||||
4. 执行 rebase(把自定义 commits 移植到最新 upstream 上)
|
||||
```bash
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
如果有冲突,根据下方"冲突解决指南"处理,然后 `git rebase --continue`。
|
||||
|
||||
// turbo
|
||||
5. 编译验证
|
||||
```bash
|
||||
cd /Users/win/2025/aitool/MiniGravity/sub2api/backend && go build ./...
|
||||
```
|
||||
|
||||
6. 推送
|
||||
```bash
|
||||
git push origin main --force-with-lease
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 冲突解决指南
|
||||
|
||||
## 高频冲突文件及处理策略
|
||||
|
||||
### `backend/internal/repository/http_upstream.go`
|
||||
**我方改动**:在 `Do()` 和 `DoWithTLS()` 中新增了 Node.js TLS 代理路由逻辑。
|
||||
**策略**:保留上游对函数签名/连接池的改动,确保我方在函数开头新增的 `isNodeTLSProxyEnabled()` 判断块被保留。
|
||||
```bash
|
||||
# 查看冲突
|
||||
git diff backend/internal/repository/http_upstream.go
|
||||
# 关键:确保以下两个块被保留(来自 ours):
|
||||
# 1. Do() 中的 Node.js proxy 路由 (~L128-137)
|
||||
# 2. DoWithTLS() 中的 Node.js proxy 路由 (~L180-187)
|
||||
# 3. isNodeTLSProxyEnabled() / shouldRouteViaNodeProxy() / doViaNodeTLSProxy() 函数
|
||||
```
|
||||
|
||||
### `backend/internal/config/config.go`
|
||||
**我方改动**:在 `GatewayConfig` struct 新增了 `NodeTLSProxy`、`InstanceSalt`、`FingerprintDefaults` 三个字段。
|
||||
**策略**:上游通常只在 struct 末尾添加新字段,我方也是添加字段,基本不冲突。
|
||||
```bash
|
||||
# 确保以下字段存在于 GatewayConfig struct 中
|
||||
grep -n "NodeTLSProxy\|InstanceSalt\|FingerprintDefaults" backend/internal/config/config.go
|
||||
```
|
||||
|
||||
### `backend/internal/service/identity_service.go`
|
||||
**我方改动**:
|
||||
- 更新了 `defaultFingerprint` 的版本号
|
||||
- 新增 `ApplyDefaultFingerprintOverrides()` 函数
|
||||
- 新增 `NewIdentityServiceWithSalt()` 函数
|
||||
- 在 `IdentityService` struct 加 `instanceSalt` 字段
|
||||
**策略**:上游通常不改 defaultFingerprint,直接 Accept Ours 这部分。
|
||||
|
||||
### `backend/internal/pkg/claude/constants.go`
|
||||
**我方改动**:更新 Claude CLI 版本常量(CLI_VERSION / SDK_VERSION)。
|
||||
**策略**:直接保留我方版本号(更新的)。
|
||||
|
||||
### `backend/cmd/server/wire_gen.go`
|
||||
**我方改动**:可能因 Wire 依赖注入改变。
|
||||
**策略**:先接受 Theirs,然后重新运行 `go generate ./cmd/server/` 重新生成。
|
||||
|
||||
---
|
||||
|
||||
# 零冲突文件(永远不会冲突)
|
||||
|
||||
以下目录是我方全新添加,upstream 没有,永远不会冲突:
|
||||
- `tools/node-tls-proxy/` — Node.js TLS 代理
|
||||
- `tools/firewall/` — iptables 防火墙规则
|
||||
- `tools/sora-curl-cffi-sidecar/` — Sora curl_cffi sidecar
|
||||
- `deploy/docker-compose.tls-proxy.yml` — TLS 代理 compose
|
||||
- `deploy/build-push-tls-proxy.sh` — 构建推送脚本
|
||||
14
Dockerfile
14
Dockerfile
@ -7,7 +7,7 @@
|
||||
# =============================================================================
|
||||
|
||||
ARG NODE_IMAGE=node:24-alpine
|
||||
ARG GOLANG_IMAGE=golang:1.26.1
|
||||
ARG GOLANG_IMAGE=golang:1.26.1-alpine
|
||||
ARG ALPINE_IMAGE=alpine:3.21
|
||||
ARG POSTGRES_IMAGE=postgres:18-alpine
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
@ -46,8 +46,8 @@ ARG GOSUMDB
|
||||
ENV GOPROXY=${GOPROXY}
|
||||
ENV GOSUMDB=${GOSUMDB}
|
||||
|
||||
# Install build dependencies (non-alpine image uses apt)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
@ -61,14 +61,14 @@ COPY backend/ ./
|
||||
# Copy frontend dist from previous stage (must be after backend copy to avoid being overwritten)
|
||||
COPY --from=frontend-builder /app/backend/internal/web/dist ./internal/web/dist
|
||||
|
||||
# Build the binary with BoringCrypto (matches real Antigravity TLS fingerprint)
|
||||
# CGO_ENABLED=1 required for BoringCrypto; static linking via -extldflags for scratch-like deployment
|
||||
# Build the binary (BuildType=release for CI builds, embed frontend)
|
||||
# Version precedence: build arg VERSION > cmd/server/VERSION
|
||||
RUN VERSION_VALUE="${VERSION}" && \
|
||||
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(tr -d '\r\n' < ./cmd/server/VERSION)"; fi && \
|
||||
DATE_VALUE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \
|
||||
CGO_ENABLED=1 GOEXPERIMENT=boringcrypto GOOS=linux go build \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
-tags embed \
|
||||
-ldflags="-s -w -linkmode external -extldflags '-static' -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \
|
||||
-ldflags="-s -w -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \
|
||||
-trimpath \
|
||||
-o /app/sub2api \
|
||||
./cmd/server
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
**AI API Gateway Platform for Subscription Quota Distribution**
|
||||
|
||||
English | [中文](README_CN.md)
|
||||
English | [中文](README_CN.md) | [日本語](README_JA.md)
|
||||
|
||||
</div>
|
||||
|
||||
@ -49,9 +49,13 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180" align="center" valign="middle"><a href="https://shop.pincc.ai/"><img src="assets/partners/logos/pincc-logo.png" alt="pincc" width="120"></a></td>
|
||||
<td width="180" align="center" valign="middle"><a href="https://shop.pincc.ai/"><img src="assets/partners/logos/pincc-logo.png" alt="pincc" width="150"></a></td>
|
||||
<td valign="middle"><b><a href="https://shop.pincc.ai/">PinCC</a></b> is the official relay service built on Sub2API, offering stable access to Claude Code, Codex, Gemini and other popular models — ready to use, no deployment or maintenance required.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=sub2api"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=sub2api">this link</a> and enter the "sub2api" promo code during first recharge to get 10% off.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Ecosystem
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
**AI API 网关平台 - 订阅配额分发管理**
|
||||
|
||||
[English](README.md) | 中文
|
||||
[English](README.md) | 中文 | [日本語](README_JA.md)
|
||||
|
||||
</div>
|
||||
|
||||
@ -48,9 +48,13 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180" align="center" valign="middle"><a href="https://shop.pincc.ai/"><img src="assets/partners/logos/pincc-logo.png" alt="pincc" width="120"></a></td>
|
||||
<td width="180" align="center" valign="middle"><a href="https://shop.pincc.ai/"><img src="assets/partners/logos/pincc-logo.png" alt="pincc" width="150"></a></td>
|
||||
<td valign="middle"><b><a href="https://shop.pincc.ai/">PinCC</a></b> 是基于 Sub2API 搭建的官方中转服务,提供 Claude Code、Codex、Gemini 等主流模型的稳定中转,开箱即用,免去自建部署与运维烦恼。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=sub2api"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=sub2api">此链接</a>注册并在充值时填写"sub2api"优惠码,首次充值可以享受9折优惠!</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 生态项目
|
||||
|
||||
589
README_JA.md
Normal file
589
README_JA.md
Normal file
@ -0,0 +1,589 @@
|
||||
# Sub2API
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://redis.io/)
|
||||
[](https://www.docker.com/)
|
||||
|
||||
<a href="https://trendshift.io/repositories/21823" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21823" alt="Wei-Shaw%2Fsub2api | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
**サブスクリプションクォータ配分のための AI API ゲートウェイプラットフォーム**
|
||||
|
||||
[English](README.md) | [中文](README_CN.md) | 日本語
|
||||
|
||||
</div>
|
||||
|
||||
> **Sub2API が公式に使用しているドメインは `sub2api.org` と `pincc.ai` のみです。Sub2API の名称を使用している他のウェブサイトは、サードパーティによるデプロイやサービスであり、本プロジェクトとは一切関係がありません。ご利用の際はご自身で確認・判断をお願いします。**
|
||||
|
||||
---
|
||||
|
||||
## デモ
|
||||
|
||||
Sub2API をオンラインでお試しください: **[https://demo.sub2api.org/](https://demo.sub2api.org/)**
|
||||
|
||||
デモ用認証情報(共有デモ環境です。セルフホスト環境では**自動作成されません**):
|
||||
|
||||
| メールアドレス | パスワード |
|
||||
|-------|----------|
|
||||
| admin@sub2api.org | admin123 |
|
||||
|
||||
## 概要
|
||||
|
||||
Sub2API は、AI 製品のサブスクリプションから API クォータを配分・管理するために設計された AI API ゲートウェイプラットフォームです。ユーザーはプラットフォームが生成した API キーを通じて上流の AI サービスにアクセスでき、プラットフォームは認証、課金、負荷分散、リクエスト転送を処理します。
|
||||
|
||||
## 機能
|
||||
|
||||
- **マルチアカウント管理** - 複数の上流アカウントタイプ(OAuth、APIキー)をサポート
|
||||
- **APIキー配布** - ユーザー向けの APIキーの生成と管理
|
||||
- **精密な課金** - トークンレベルの使用量追跡とコスト計算
|
||||
- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
|
||||
- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
|
||||
- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
|
||||
- **管理ダッシュボード** - 監視・管理のための Web インターフェース
|
||||
- **外部システム連携** - 外部システム(決済、チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
|
||||
|
||||
## セルフホストが不要な方へ
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180" align="center" valign="middle"><a href="https://shop.pincc.ai/"><img src="assets/partners/logos/pincc-logo.png" alt="pincc" width="150"></a></td>
|
||||
<td valign="middle"><b><a href="https://shop.pincc.ai/">PinCC</a></b> は Sub2API 上に構築された公式リレーサービスで、Claude Code、Codex、Gemini などの人気モデルへの安定したアクセスを提供します。デプロイやメンテナンスは不要で、すぐにご利用いただけます。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=sub2api"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=sub2api">このリンク</a>で登録し、チャージ時に「sub2api」クーポンを入力すると 10% オフになります。</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## エコシステム
|
||||
|
||||
Sub2API を拡張・統合するコミュニティプロジェクト:
|
||||
|
||||
| プロジェクト | 説明 | 機能 |
|
||||
|---------|-------------|----------|
|
||||
| [Sub2ApiPay](https://github.com/touwaeriol/sub2apipay) | セルフサービス決済システム | セルフサービスによるチャージおよびサブスクリプション購入。YiPay プロトコル、WeChat Pay、Alipay、Stripe 対応。iframe での埋め込み可能 |
|
||||
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | モバイル管理コンソール | ユーザー管理、アカウント管理、監視ダッシュボード、マルチバックエンド切り替えが可能なクロスプラットフォームアプリ(iOS/Android/Web)。Expo + React Native で構築 |
|
||||
|
||||
## 技術スタック
|
||||
|
||||
| コンポーネント | 技術 |
|
||||
|-----------|------------|
|
||||
| バックエンド | Go 1.25.7, Gin, Ent |
|
||||
| フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS |
|
||||
| データベース | PostgreSQL 15+ |
|
||||
| キャッシュ/キュー | Redis 7+ |
|
||||
|
||||
---
|
||||
|
||||
## Nginx リバースプロキシに関する注意
|
||||
|
||||
Sub2API(または CRS)を Nginx でリバースプロキシし、Codex CLI と組み合わせて使用する場合、Nginx の `http` ブロックに以下の設定を追加してください:
|
||||
|
||||
```nginx
|
||||
underscores_in_headers on;
|
||||
```
|
||||
|
||||
Nginx はデフォルトでアンダースコアを含むヘッダー(例: `session_id`)を破棄するため、マルチアカウント構成でのスティッキーセッションルーティングに支障をきたします。
|
||||
|
||||
---
|
||||
|
||||
## デプロイ
|
||||
|
||||
### 方法1: スクリプトによるインストール(推奨)
|
||||
|
||||
GitHub Releases からビルド済みバイナリをダウンロードするワンクリックインストールスクリプトです。
|
||||
|
||||
#### 前提条件
|
||||
|
||||
- Linux サーバー(amd64 または arm64)
|
||||
- PostgreSQL 15+(インストール済みかつ稼働中)
|
||||
- Redis 7+(インストール済みかつ稼働中)
|
||||
- root 権限
|
||||
|
||||
#### インストール手順
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash
|
||||
```
|
||||
|
||||
スクリプトは以下を実行します:
|
||||
1. システムアーキテクチャの検出
|
||||
2. 最新リリースのダウンロード
|
||||
3. バイナリを `/opt/sub2api` にインストール
|
||||
4. systemd サービスの作成
|
||||
5. システムユーザーと権限の設定
|
||||
|
||||
#### インストール後の作業
|
||||
|
||||
```bash
|
||||
# 1. サービスを起動
|
||||
sudo systemctl start sub2api
|
||||
|
||||
# 2. 起動時の自動起動を有効化
|
||||
sudo systemctl enable sub2api
|
||||
|
||||
# 3. ブラウザでセットアップウィザードを開く
|
||||
# http://YOUR_SERVER_IP:8080
|
||||
```
|
||||
|
||||
セットアップウィザードでは以下の設定を行います:
|
||||
- データベース設定
|
||||
- Redis 設定
|
||||
- 管理者アカウントの作成
|
||||
|
||||
#### アップグレード
|
||||
|
||||
**管理ダッシュボード**の左上にある**アップデートを確認**ボタンをクリックすることで、ダッシュボードから直接アップグレードできます。
|
||||
|
||||
Web インターフェースでは以下が可能です:
|
||||
- 新しいバージョンの自動確認
|
||||
- ワンクリックでのアップデートのダウンロードと適用
|
||||
- 必要に応じたロールバック
|
||||
|
||||
#### よく使うコマンド
|
||||
|
||||
```bash
|
||||
# ステータスを確認
|
||||
sudo systemctl status sub2api
|
||||
|
||||
# ログを表示
|
||||
sudo journalctl -u sub2api -f
|
||||
|
||||
# サービスを再起動
|
||||
sudo systemctl restart sub2api
|
||||
|
||||
# アンインストール
|
||||
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方法2: Docker Compose(推奨)
|
||||
|
||||
PostgreSQL と Redis のコンテナを含む Docker Compose でデプロイします。
|
||||
|
||||
#### 前提条件
|
||||
|
||||
- Docker 20.10+
|
||||
- Docker Compose v2+
|
||||
|
||||
#### クイックスタート(ワンクリックデプロイ)
|
||||
|
||||
自動デプロイスクリプトを使用して簡単にセットアップできます:
|
||||
|
||||
```bash
|
||||
# デプロイ用ディレクトリを作成
|
||||
mkdir -p sub2api-deploy && cd sub2api-deploy
|
||||
|
||||
# デプロイ準備スクリプトをダウンロードして実行
|
||||
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash
|
||||
|
||||
# サービスを起動
|
||||
docker compose up -d
|
||||
|
||||
# ログを表示
|
||||
docker compose logs -f sub2api
|
||||
```
|
||||
|
||||
**スクリプトの動作内容:**
|
||||
- `docker-compose.local.yml`(`docker-compose.yml` として保存)と `.env.example` をダウンロード
|
||||
- セキュアな認証情報(JWT_SECRET、TOTP_ENCRYPTION_KEY、POSTGRES_PASSWORD)を自動生成
|
||||
- 自動生成されたシークレットで `.env` ファイルを作成
|
||||
- データディレクトリを作成(バックアップ・移行が容易なローカルディレクトリを使用)
|
||||
- 生成された認証情報を参照用に表示
|
||||
|
||||
#### 手動デプロイ
|
||||
|
||||
手動でセットアップする場合:
|
||||
|
||||
```bash
|
||||
# 1. リポジトリをクローン
|
||||
git clone https://github.com/Wei-Shaw/sub2api.git
|
||||
cd sub2api/deploy
|
||||
|
||||
# 2. 環境設定ファイルをコピー
|
||||
cp .env.example .env
|
||||
|
||||
# 3. 設定を編集(セキュアなパスワードを生成)
|
||||
nano .env
|
||||
```
|
||||
|
||||
**`.env` の必須設定:**
|
||||
|
||||
```bash
|
||||
# PostgreSQL パスワード(必須)
|
||||
POSTGRES_PASSWORD=your_secure_password_here
|
||||
|
||||
# JWT シークレット(推奨 - 再起動後もユーザーのログイン状態を保持)
|
||||
JWT_SECRET=your_jwt_secret_here
|
||||
|
||||
# TOTP 暗号化キー(推奨 - 再起動後も二要素認証を維持)
|
||||
TOTP_ENCRYPTION_KEY=your_totp_key_here
|
||||
|
||||
# オプション: 管理者アカウント
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
|
||||
# オプション: カスタムポート
|
||||
SERVER_PORT=8080
|
||||
```
|
||||
|
||||
**セキュアなシークレットの生成方法:**
|
||||
```bash
|
||||
# JWT_SECRET を生成
|
||||
openssl rand -hex 32
|
||||
|
||||
# TOTP_ENCRYPTION_KEY を生成
|
||||
openssl rand -hex 32
|
||||
|
||||
# POSTGRES_PASSWORD を生成
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
```bash
|
||||
# 4. データディレクトリを作成(ローカルバージョンの場合)
|
||||
mkdir -p data postgres_data redis_data
|
||||
|
||||
# 5. すべてのサービスを起動
|
||||
# オプション A: ローカルディレクトリバージョン(推奨 - 移行が容易)
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
|
||||
# オプション B: 名前付きボリュームバージョン(シンプルなセットアップ)
|
||||
docker compose up -d
|
||||
|
||||
# 6. ステータスを確認
|
||||
docker compose -f docker-compose.local.yml ps
|
||||
|
||||
# 7. ログを表示
|
||||
docker compose -f docker-compose.local.yml logs -f sub2api
|
||||
```
|
||||
|
||||
#### デプロイバージョン
|
||||
|
||||
| バージョン | データストレージ | 移行 | 推奨用途 |
|
||||
|---------|-------------|-----------|----------|
|
||||
| **docker-compose.local.yml** | ローカルディレクトリ | ✅ 容易(ディレクトリ全体を tar) | 本番環境、頻繁なバックアップ |
|
||||
| **docker-compose.yml** | 名前付きボリューム | ⚠️ docker コマンドが必要 | シンプルなセットアップ |
|
||||
|
||||
**推奨:** データ管理が容易な `docker-compose.local.yml`(スクリプトによるデプロイ)を使用してください。
|
||||
|
||||
#### アクセス
|
||||
|
||||
ブラウザで `http://YOUR_SERVER_IP:8080` を開いてください。
|
||||
|
||||
管理者パスワードが自動生成された場合は、ログで確認できます:
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
|
||||
```
|
||||
|
||||
#### アップグレード
|
||||
|
||||
```bash
|
||||
# 最新イメージをプルしてコンテナを再作成
|
||||
docker compose -f docker-compose.local.yml pull
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
```
|
||||
|
||||
#### 簡単な移行(ローカルディレクトリバージョン)
|
||||
|
||||
`docker-compose.local.yml` を使用している場合、新しいサーバーへの移行が簡単です:
|
||||
|
||||
```bash
|
||||
# 移行元サーバーにて
|
||||
docker compose -f docker-compose.local.yml down
|
||||
cd ..
|
||||
tar czf sub2api-complete.tar.gz sub2api-deploy/
|
||||
|
||||
# 新しいサーバーに転送
|
||||
scp sub2api-complete.tar.gz user@new-server:/path/
|
||||
|
||||
# 移行先サーバーにて
|
||||
tar xzf sub2api-complete.tar.gz
|
||||
cd sub2api-deploy/
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
```
|
||||
|
||||
#### よく使うコマンド
|
||||
|
||||
```bash
|
||||
# すべてのサービスを停止
|
||||
docker compose -f docker-compose.local.yml down
|
||||
|
||||
# 再起動
|
||||
docker compose -f docker-compose.local.yml restart
|
||||
|
||||
# すべてのログを表示
|
||||
docker compose -f docker-compose.local.yml logs -f
|
||||
|
||||
# すべてのデータを削除(注意!)
|
||||
docker compose -f docker-compose.local.yml down
|
||||
rm -rf data/ postgres_data/ redis_data/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方法3: ソースからビルド
|
||||
|
||||
開発やカスタマイズのためにソースコードからビルドして実行します。
|
||||
|
||||
#### 前提条件
|
||||
|
||||
- Go 1.21+
|
||||
- Node.js 18+
|
||||
- PostgreSQL 15+
|
||||
- Redis 7+
|
||||
|
||||
#### ビルド手順
|
||||
|
||||
```bash
|
||||
# 1. リポジトリをクローン
|
||||
git clone https://github.com/Wei-Shaw/sub2api.git
|
||||
cd sub2api
|
||||
|
||||
# 2. pnpm をインストール(未インストールの場合)
|
||||
npm install -g pnpm
|
||||
|
||||
# 3. フロントエンドをビルド
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm run build
|
||||
# 出力先: ../backend/internal/web/dist/
|
||||
|
||||
# 4. フロントエンドを組み込んだバックエンドをビルド
|
||||
cd ../backend
|
||||
go build -tags embed -o sub2api ./cmd/server
|
||||
|
||||
# 5. 設定ファイルを作成
|
||||
cp ../deploy/config.example.yaml ./config.yaml
|
||||
|
||||
# 6. 設定を編集
|
||||
nano config.yaml
|
||||
```
|
||||
|
||||
> **注意:** `-tags embed` フラグはフロントエンドをバイナリに組み込みます。このフラグがない場合、バイナリはフロントエンド UI を提供しません。
|
||||
|
||||
**`config.yaml` の主要設定:**
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
mode: "release"
|
||||
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 5432
|
||||
user: "postgres"
|
||||
password: "your_password"
|
||||
dbname: "sub2api"
|
||||
|
||||
redis:
|
||||
host: "localhost"
|
||||
port: 6379
|
||||
password: ""
|
||||
|
||||
jwt:
|
||||
secret: "change-this-to-a-secure-random-string"
|
||||
expire_hour: 24
|
||||
|
||||
default:
|
||||
user_concurrency: 5
|
||||
user_balance: 0
|
||||
api_key_prefix: "sk-"
|
||||
rate_multiplier: 1.0
|
||||
```
|
||||
|
||||
### Sora ステータス(一時的に利用不可)
|
||||
|
||||
> ⚠️ Sora 関連の機能は、上流統合およびメディア配信の技術的問題により一時的に利用できません。
|
||||
> 現時点では本番環境で Sora に依存しないでください。
|
||||
> 既存の `gateway.sora_*` 設定キーは予約されていますが、これらの問題が解決されるまで有効にならない場合があります。
|
||||
|
||||
`config.yaml` では追加のセキュリティ関連オプションも利用できます:
|
||||
|
||||
- `cors.allowed_origins` - CORS 許可リスト
|
||||
- `security.url_allowlist` - 上流/価格/CRS ホストの許可リスト
|
||||
- `security.url_allowlist.enabled` - URL バリデーションの無効化(注意して使用)
|
||||
- `security.url_allowlist.allow_insecure_http` - バリデーション無効時に HTTP URL を許可
|
||||
- `security.url_allowlist.allow_private_hosts` - プライベート/ローカル IP アドレスを許可
|
||||
- `security.response_headers.enabled` - 設定可能なレスポンスヘッダーフィルタリングを有効化(無効時はデフォルトの許可リストを使用)
|
||||
- `security.csp` - Content-Security-Policy ヘッダーの制御
|
||||
- `billing.circuit_breaker` - 課金エラー時にフェイルクローズ
|
||||
- `server.trusted_proxies` - X-Forwarded-For パースの有効化
|
||||
- `turnstile.required` - リリースモードでの Turnstile 必須化
|
||||
|
||||
**⚠️ セキュリティ警告: HTTP URL 設定**
|
||||
|
||||
`security.url_allowlist.enabled=false` の場合、システムはデフォルトで最小限の URL バリデーションを行い、**HTTP URL を拒否**して HTTPS のみを許可します。HTTP URL を許可するには(開発環境や内部テスト用など)、以下を明示的に設定する必要があります:
|
||||
|
||||
```yaml
|
||||
security:
|
||||
url_allowlist:
|
||||
enabled: false # 許可リストチェックを無効化
|
||||
allow_insecure_http: true # HTTP URL を許可(⚠️ セキュリティリスクあり)
|
||||
```
|
||||
|
||||
**または環境変数で設定:**
|
||||
|
||||
```bash
|
||||
SECURITY_URL_ALLOWLIST_ENABLED=false
|
||||
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
|
||||
```
|
||||
|
||||
**HTTP を許可するリスク:**
|
||||
- API キーとデータが**平文**で送信される(傍受の危険性)
|
||||
- **中間者攻撃(MITM)**を受けやすい
|
||||
- **本番環境には不適切**
|
||||
|
||||
**HTTP を使用すべき場面:**
|
||||
- ✅ ローカルサーバーでの開発・テスト(http://localhost)
|
||||
- ✅ 信頼できるエンドポイントを持つ内部ネットワーク
|
||||
- ✅ HTTPS 取得前のアカウント接続テスト
|
||||
- ❌ 本番環境(HTTPS のみを使用)
|
||||
|
||||
**この設定なしで表示されるエラー例:**
|
||||
```
|
||||
Invalid base URL: invalid url scheme: http
|
||||
```
|
||||
|
||||
URL バリデーションまたはレスポンスヘッダーフィルタリングを無効にする場合は、ネットワーク層を強化してください:
|
||||
- 上流ドメイン/IP のエグレス許可リストを適用
|
||||
- プライベート/ループバック/リンクローカル範囲をブロック
|
||||
- TLS のみのアウトバウンドトラフィックを強制
|
||||
- プロキシで機密性の高い上流レスポンスヘッダーを除去
|
||||
|
||||
```bash
|
||||
# 6. アプリケーションを実行
|
||||
./sub2api
|
||||
```
|
||||
|
||||
#### 開発モード
|
||||
|
||||
```bash
|
||||
# バックエンド(ホットリロード付き)
|
||||
cd backend
|
||||
go run ./cmd/server
|
||||
|
||||
# フロントエンド(ホットリロード付き)
|
||||
cd frontend
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
#### コード生成
|
||||
|
||||
`backend/ent/schema` を編集した場合、Ent + Wire を再生成してください:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go generate ./ent
|
||||
go generate ./cmd/server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## シンプルモード
|
||||
|
||||
シンプルモードは、フル SaaS 機能を必要とせず、素早くアクセスしたい個人開発者や社内チーム向けに設計されています。
|
||||
|
||||
- 有効化: 環境変数 `RUN_MODE=simple` を設定
|
||||
- 違い: SaaS 関連機能を非表示にし、課金プロセスをスキップ
|
||||
- セキュリティに関する注意: 本番環境では `SIMPLE_MODE_CONFIRM=true` も設定する必要があります
|
||||
|
||||
---
|
||||
|
||||
## Antigravity サポート
|
||||
|
||||
Sub2API は [Antigravity](https://antigravity.so/) アカウントをサポートしています。認証後、Claude および Gemini モデル用の専用エンドポイントが利用可能になります。
|
||||
|
||||
### 専用エンドポイント
|
||||
|
||||
| エンドポイント | モデル |
|
||||
|----------|-------|
|
||||
| `/antigravity/v1/messages` | Claude モデル |
|
||||
| `/antigravity/v1beta/` | Gemini モデル |
|
||||
|
||||
### Claude Code の設定
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://localhost:8080/antigravity"
|
||||
export ANTHROPIC_AUTH_TOKEN="sk-xxx"
|
||||
```
|
||||
|
||||
### ハイブリッドスケジューリングモード
|
||||
|
||||
Antigravity アカウントはオプションの**ハイブリッドスケジューリング**をサポートしています。有効にすると、汎用エンドポイント `/v1/messages` および `/v1beta/` も Antigravity アカウントにリクエストをルーティングします。
|
||||
|
||||
> **⚠️ 警告**: Anthropic Claude と Antigravity Claude は**同じ会話コンテキスト内で混在させることはできません**。グループを使用して適切に分離してください。
|
||||
|
||||
### 既知の問題
|
||||
|
||||
Claude Code では、Plan Mode を自動的に終了できません。(通常、ネイティブの Claude API を使用する場合、計画が完了すると Claude Code はユーザーに計画を承認または拒否するオプションをポップアップ表示します。)
|
||||
|
||||
**回避策**: `Shift + Tab` を押して手動で Plan Mode を終了し、計画を承認または拒否するためのレスポンスを入力してください。
|
||||
|
||||
---
|
||||
|
||||
## プロジェクト構成
|
||||
|
||||
```
|
||||
sub2api/
|
||||
├── backend/ # Go バックエンドサービス
|
||||
│ ├── cmd/server/ # アプリケーションエントリ
|
||||
│ ├── internal/ # 内部モジュール
|
||||
│ │ ├── config/ # 設定
|
||||
│ │ ├── model/ # データモデル
|
||||
│ │ ├── service/ # ビジネスロジック
|
||||
│ │ ├── handler/ # HTTP ハンドラー
|
||||
│ │ └── gateway/ # API ゲートウェイコア
|
||||
│ └── resources/ # 静的リソース
|
||||
│
|
||||
├── frontend/ # Vue 3 フロントエンド
|
||||
│ └── src/
|
||||
│ ├── api/ # API 呼び出し
|
||||
│ ├── stores/ # 状態管理
|
||||
│ ├── views/ # ページコンポーネント
|
||||
│ └── components/ # 再利用可能なコンポーネント
|
||||
│
|
||||
└── deploy/ # デプロイファイル
|
||||
├── docker-compose.yml # Docker Compose 設定
|
||||
├── .env.example # Docker Compose 用環境変数
|
||||
├── config.example.yaml # バイナリデプロイ用フル設定ファイル
|
||||
└── install.sh # ワンクリックインストールスクリプト
|
||||
```
|
||||
|
||||
## 免責事項
|
||||
|
||||
> **本プロジェクトをご利用の前に、以下をよくお読みください:**
|
||||
>
|
||||
> :rotating_light: **利用規約違反のリスク**: 本プロジェクトの使用は Anthropic の利用規約に違反する可能性があります。使用前に Anthropic のユーザー契約をよくお読みください。本プロジェクトの使用に起因するすべてのリスクは、ユーザー自身が負うものとします。
|
||||
>
|
||||
> :book: **免責事項**: 本プロジェクトは技術的な学習および研究目的のみで提供されています。作者は、本プロジェクトの使用によるアカウント停止、サービス中断、その他の損失について一切の責任を負いません。
|
||||
|
||||
---
|
||||
|
||||
## スター履歴
|
||||
|
||||
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## ライセンス
|
||||
|
||||
MIT License
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**このプロジェクトが役に立ったら、ぜひスターをお願いします!**
|
||||
|
||||
</div>
|
||||
@ -1,218 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# capture_tls.sh - Capture TLS ClientHello fingerprints (JA3)
|
||||
#
|
||||
# mitmproxy terminates TLS so it can't see the real JA3 that
|
||||
# Claude CLI / Antigravity sends to Anthropic. This script
|
||||
# captures the REAL TLS fingerprint using tshark.
|
||||
#
|
||||
# Usage:
|
||||
# # Run BEFORE starting claude login / claude "hello"
|
||||
# # (don't use HTTPS_PROXY for this - direct connection)
|
||||
#
|
||||
# sudo ./capture_tls.sh # capture on default interface
|
||||
# sudo ./capture_tls.sh en0 # specify interface
|
||||
# sudo ./capture_tls.sh en0 30 # capture for 30 seconds
|
||||
#
|
||||
# Output:
|
||||
# ./captures/tls_capture_<timestamp>.txt
|
||||
# ./captures/tls_capture_<timestamp>.pcap
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IFACE="${1:-en0}"
|
||||
DURATION="${2:-60}"
|
||||
OUTDIR="./captures"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
PCAP_FILE="${OUTDIR}/tls_capture_${TIMESTAMP}.pcap"
|
||||
TXT_FILE="${OUTDIR}/tls_capture_${TIMESTAMP}.txt"
|
||||
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
# Resolve target IPs
|
||||
echo "Resolving target domains..."
|
||||
DOMAINS=(
|
||||
"api.anthropic.com"
|
||||
"platform.claude.com"
|
||||
"claude.ai"
|
||||
"cloudaicompanion.googleapis.com"
|
||||
"generativelanguage.googleapis.com"
|
||||
"oauth2.googleapis.com"
|
||||
"accounts.google.com"
|
||||
)
|
||||
|
||||
HOST_FILTER=""
|
||||
for domain in "${DOMAINS[@]}"; do
|
||||
ips=$(dig +short "$domain" 2>/dev/null | grep -E '^[0-9]+\.' | head -5)
|
||||
for ip in $ips; do
|
||||
if [ -n "$HOST_FILTER" ]; then
|
||||
HOST_FILTER="$HOST_FILTER or host $ip"
|
||||
else
|
||||
HOST_FILTER="host $ip"
|
||||
fi
|
||||
done
|
||||
echo " $domain → $ips"
|
||||
done
|
||||
|
||||
if [ -z "$HOST_FILTER" ]; then
|
||||
echo "ERROR: Could not resolve any target domains"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CAPTURE_FILTER="tcp port 443 and ($HOST_FILTER)"
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " TLS Fingerprint Capture"
|
||||
echo " Interface: $IFACE"
|
||||
echo " Duration: ${DURATION}s"
|
||||
echo " Filter: $CAPTURE_FILTER"
|
||||
echo " PCAP: $PCAP_FILE"
|
||||
echo " Report: $TXT_FILE"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo ">>> Now run 'claude login' or 'claude \"hello\"' in another terminal <<<"
|
||||
echo ">>> Press Ctrl+C to stop early <<<"
|
||||
echo ""
|
||||
|
||||
# Capture pcap in background
|
||||
tshark -i "$IFACE" -f "$CAPTURE_FILTER" -w "$PCAP_FILE" -a "duration:$DURATION" 2>/dev/null &
|
||||
TSHARK_PID=$!
|
||||
|
||||
# Wait for capture to complete or Ctrl+C
|
||||
trap "kill $TSHARK_PID 2>/dev/null; wait $TSHARK_PID 2>/dev/null" INT TERM
|
||||
wait $TSHARK_PID 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "Capture complete. Analyzing..."
|
||||
echo ""
|
||||
|
||||
# ─── Analysis ───
|
||||
|
||||
{
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " TLS ClientHello Fingerprint Report"
|
||||
echo " Captured: $(date)"
|
||||
echo " PCAP: $PCAP_FILE"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Extract JA3 fingerprints
|
||||
echo "─── JA3 Fingerprints (ClientHello) ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e frame.time \
|
||||
-e ip.dst \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.ja3 \
|
||||
-e tls.handshake.ja3_full \
|
||||
2>/dev/null | while IFS=$'\t' read -r ts dst sni ja3 ja3_full; do
|
||||
echo " Time: $ts"
|
||||
echo " Dest IP: $dst"
|
||||
echo " SNI: $sni"
|
||||
echo " JA3 Hash: $ja3"
|
||||
if [ -n "$ja3_full" ]; then
|
||||
echo " JA3 Full: $ja3_full"
|
||||
fi
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── TLS Versions ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.version \
|
||||
-e tls.handshake.extensions.supported_version \
|
||||
2>/dev/null | sort -u | while IFS=$'\t' read -r sni ver supported; do
|
||||
echo " SNI: $sni"
|
||||
echo " Record Version: $ver"
|
||||
echo " Supported Versions: $supported"
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── ALPN Protocols ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.extensions_alpn_str \
|
||||
2>/dev/null | sort -u | while IFS=$'\t' read -r sni alpn; do
|
||||
echo " SNI: $sni → ALPN: $alpn"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo ""
|
||||
echo "─── Cipher Suites (per ClientHello) ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.ciphersuite \
|
||||
2>/dev/null | head -5 | while IFS=$'\t' read -r sni ciphers; do
|
||||
echo " SNI: $sni"
|
||||
echo " Cipher Suites:"
|
||||
echo " $ciphers" | tr ',' '\n' | while read -r c; do
|
||||
echo " $c"
|
||||
done
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── Extensions (per ClientHello) ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.extension.type \
|
||||
2>/dev/null | head -5 | while IFS=$'\t' read -r sni exts; do
|
||||
echo " SNI: $sni"
|
||||
echo " Extensions: $exts"
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── Unique JA3 Summary ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.ja3 \
|
||||
2>/dev/null | sort | uniq -c | sort -rn | while read -r count sni ja3; do
|
||||
echo " ${count}x SNI: $sni JA3: $ja3"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── TCP Fingerprint (Initial Window Size, TTL) ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tcp.flags.syn == 1 && tcp.flags.ack == 0" \
|
||||
-T fields \
|
||||
-e ip.dst \
|
||||
-e ip.ttl \
|
||||
-e tcp.window_size_value \
|
||||
-e tcp.options.mss_val \
|
||||
-e tcp.options.wscale.shift \
|
||||
2>/dev/null | sort -u | while IFS=$'\t' read -r dst ttl win mss wscale; do
|
||||
echo " Dest: $dst TTL: $ttl Window: $win MSS: $mss WScale: $wscale"
|
||||
done
|
||||
|
||||
} 2>/dev/null | tee "$TXT_FILE"
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " Report saved to: $TXT_FILE"
|
||||
echo " PCAP saved to: $PCAP_FILE"
|
||||
echo ""
|
||||
echo " To re-analyze: tshark -r $PCAP_FILE -Y 'tls.handshake.type==1' ..."
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
@ -1,506 +0,0 @@
|
||||
"""
|
||||
MiniGravity Traffic Capture - mitmproxy addon
|
||||
|
||||
Captures and categorizes traffic from Claude Code and Antigravity IDE.
|
||||
Records: headers (with ordering), body, TLS info, timing.
|
||||
|
||||
Usage:
|
||||
# Claude Code (terminal)
|
||||
HTTPS_PROXY=http://127.0.0.1:8080 claude login
|
||||
HTTPS_PROXY=http://127.0.0.1:8080 claude "hello"
|
||||
|
||||
# Antigravity (VS Code) - set proxy in VS Code settings or env
|
||||
HTTPS_PROXY=http://127.0.0.1:8080 code .
|
||||
|
||||
# Start mitmproxy with this addon
|
||||
mitmproxy -s capture_traffic.py --set stream_large_bodies=10m
|
||||
# or headless:
|
||||
mitmdump -s capture_traffic.py --set stream_large_bodies=10m
|
||||
|
||||
Output:
|
||||
./captures/ - JSON files per request
|
||||
./captures/_summary.jsonl - One-line-per-request summary
|
||||
./captures/_report.txt - Human-readable report (generated on exit)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from mitmproxy import http, ctx, tls
|
||||
from mitmproxy.net.http.http1.assemble import assemble_request_head
|
||||
|
||||
|
||||
# ─── Target domains and classification ───
|
||||
|
||||
TARGET_DOMAINS = {
|
||||
# Claude / Anthropic
|
||||
"claude.ai",
|
||||
"platform.claude.com",
|
||||
"api.anthropic.com",
|
||||
# Google / Antigravity
|
||||
"accounts.google.com",
|
||||
"oauth2.googleapis.com",
|
||||
"cloudaicompanion.googleapis.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
# Telemetry
|
||||
"http-intake.logs.us5.datadoghq.com",
|
||||
"sentry.io",
|
||||
}
|
||||
|
||||
|
||||
def classify_request(flow: http.HTTPFlow) -> dict:
|
||||
"""Classify a request by source tool and purpose."""
|
||||
host = flow.request.pretty_host
|
||||
path = flow.request.path
|
||||
method = flow.request.method
|
||||
ua = flow.request.headers.get("user-agent", "")
|
||||
|
||||
# Determine source tool
|
||||
source = "unknown"
|
||||
if "claude-cli" in ua or "claude-code" in ua:
|
||||
source = "claude-cli"
|
||||
elif "node" in ua.lower() and ("stainless" in str(flow.request.headers)):
|
||||
source = "claude-cli"
|
||||
elif "axios" in ua:
|
||||
source = "claude-cli-sdk"
|
||||
elif "vscode" in ua.lower() or "visual studio" in ua.lower():
|
||||
source = "vscode-extension"
|
||||
elif "electron" in ua.lower():
|
||||
source = "desktop-app"
|
||||
elif "chrome" in ua.lower() or "safari" in ua.lower() or "mozilla" in ua.lower():
|
||||
source = "browser"
|
||||
elif "node" in ua.lower():
|
||||
source = "node-generic"
|
||||
elif "python" in ua.lower():
|
||||
source = "python-client"
|
||||
elif "go-http" in ua.lower() or "go/" in ua.lower():
|
||||
source = "go-client"
|
||||
|
||||
# Determine request purpose
|
||||
purpose = "unknown"
|
||||
|
||||
# OAuth flows
|
||||
if "/oauth/authorize" in path:
|
||||
purpose = "oauth-authorize"
|
||||
elif "/oauth/token" in path or "/v1/oauth/token" in path:
|
||||
# Distinguish exchange vs refresh
|
||||
body = _get_request_body_str(flow)
|
||||
if "refresh_token" in body:
|
||||
purpose = "oauth-token-refresh"
|
||||
else:
|
||||
purpose = "oauth-token-exchange"
|
||||
elif "/o/oauth2" in path or "/oauth2/" in path:
|
||||
purpose = "google-oauth"
|
||||
|
||||
# API calls
|
||||
elif "/v1/messages" in path:
|
||||
purpose = "api-messages"
|
||||
elif "/v1/complete" in path:
|
||||
purpose = "api-complete"
|
||||
|
||||
# Organization / setup
|
||||
elif "/api/organizations" in path:
|
||||
purpose = "org-list"
|
||||
elif "/v1/oauth/" in path and "/authorize" in path:
|
||||
purpose = "oauth-authorize-api"
|
||||
|
||||
# Telemetry
|
||||
elif "/api/event_logging" in path:
|
||||
purpose = "telemetry-otel"
|
||||
elif "datadoghq.com" in host:
|
||||
purpose = "telemetry-datadog"
|
||||
elif "sentry" in host:
|
||||
purpose = "telemetry-sentry"
|
||||
|
||||
# Google AI
|
||||
elif "cloudaicompanion" in host:
|
||||
purpose = "antigravity-api"
|
||||
elif "generativelanguage" in host:
|
||||
purpose = "gemini-api"
|
||||
|
||||
return {
|
||||
"source": source,
|
||||
"purpose": purpose,
|
||||
}
|
||||
|
||||
|
||||
def _get_request_body_str(flow: http.HTTPFlow) -> str:
|
||||
"""Safely get request body as string."""
|
||||
try:
|
||||
if flow.request.content:
|
||||
return flow.request.content.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _get_response_body_str(flow: http.HTTPFlow, max_len: int = 4096) -> str:
|
||||
"""Safely get response body as string, truncated."""
|
||||
try:
|
||||
if flow.response and flow.response.content:
|
||||
body = flow.response.content.decode("utf-8", errors="replace")
|
||||
if len(body) > max_len:
|
||||
return body[:max_len] + f"\n... [truncated, total {len(body)} bytes]"
|
||||
return body
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_json_body(body_str: str) -> any:
|
||||
"""Try to parse body as JSON, return raw string if fails."""
|
||||
if not body_str:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body_str)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return body_str
|
||||
|
||||
|
||||
def _get_tls_info(flow: http.HTTPFlow) -> dict:
|
||||
"""Extract available TLS information from the flow."""
|
||||
info = {}
|
||||
if flow.server_conn and flow.server_conn.tls_version:
|
||||
info["tls_version"] = flow.server_conn.tls_version
|
||||
if flow.server_conn and hasattr(flow.server_conn, "alpn_proto_negotiated"):
|
||||
info["alpn"] = (
|
||||
flow.server_conn.alpn_proto_negotiated.decode()
|
||||
if flow.server_conn.alpn_proto_negotiated
|
||||
else None
|
||||
)
|
||||
|
||||
# Client TLS info (what the client sent to mitmproxy)
|
||||
if flow.client_conn:
|
||||
if hasattr(flow.client_conn, "tls_version") and flow.client_conn.tls_version:
|
||||
info["client_tls_version"] = flow.client_conn.tls_version
|
||||
if (
|
||||
hasattr(flow.client_conn, "alpn_proto_negotiated")
|
||||
and flow.client_conn.alpn_proto_negotiated
|
||||
):
|
||||
info["client_alpn"] = flow.client_conn.alpn_proto_negotiated.decode()
|
||||
# SNI
|
||||
if hasattr(flow.client_conn, "sni") and flow.client_conn.sni:
|
||||
info["client_sni"] = flow.client_conn.sni
|
||||
|
||||
return info
|
||||
|
||||
|
||||
class TrafficCapture:
|
||||
def __init__(self):
|
||||
self.capture_dir = Path("./captures")
|
||||
self.capture_dir.mkdir(exist_ok=True)
|
||||
self.summary_file = self.capture_dir / "_summary.jsonl"
|
||||
self.counter = 0
|
||||
self.captures = []
|
||||
|
||||
# Write session start marker
|
||||
session_start = {
|
||||
"event": "session_start",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"note": "New capture session started",
|
||||
}
|
||||
with open(self.summary_file, "a") as f:
|
||||
f.write(json.dumps(session_start) + "\n")
|
||||
|
||||
ctx.log.info(
|
||||
f"[capture] Traffic capture started. Output: {self.capture_dir.absolute()}"
|
||||
)
|
||||
|
||||
def request(self, flow: http.HTTPFlow):
|
||||
"""Tag requests to target domains."""
|
||||
host = flow.request.pretty_host
|
||||
is_target = any(host == d or host.endswith("." + d) for d in TARGET_DOMAINS)
|
||||
flow.metadata["is_target"] = is_target
|
||||
if is_target:
|
||||
flow.metadata["capture_time_start"] = time.time()
|
||||
|
||||
def response(self, flow: http.HTTPFlow):
|
||||
"""Capture complete request/response for target domains."""
|
||||
if not flow.metadata.get("is_target"):
|
||||
return
|
||||
|
||||
self.counter += 1
|
||||
classification = classify_request(flow)
|
||||
elapsed = None
|
||||
if flow.metadata.get("capture_time_start"):
|
||||
elapsed = round(time.time() - flow.metadata["capture_time_start"], 3)
|
||||
|
||||
# Build ordered header list (order matters for fingerprinting!)
|
||||
request_headers_ordered = [
|
||||
[k, v] for k, v in flow.request.headers.fields
|
||||
]
|
||||
request_headers_ordered_decoded = []
|
||||
for k, v in request_headers_ordered:
|
||||
try:
|
||||
request_headers_ordered_decoded.append(
|
||||
[k.decode("utf-8", errors="replace"),
|
||||
v.decode("utf-8", errors="replace")]
|
||||
)
|
||||
except AttributeError:
|
||||
request_headers_ordered_decoded.append([str(k), str(v)])
|
||||
|
||||
response_headers_ordered = []
|
||||
if flow.response:
|
||||
for k, v in flow.response.headers.fields:
|
||||
try:
|
||||
response_headers_ordered.append(
|
||||
[k.decode("utf-8", errors="replace"),
|
||||
v.decode("utf-8", errors="replace")]
|
||||
)
|
||||
except AttributeError:
|
||||
response_headers_ordered.append([str(k), str(v)])
|
||||
|
||||
req_body = _get_request_body_str(flow)
|
||||
resp_body = _get_response_body_str(flow)
|
||||
|
||||
# Redact sensitive values
|
||||
req_body_parsed = _parse_json_body(req_body)
|
||||
if isinstance(req_body_parsed, dict):
|
||||
req_body_parsed = _redact_sensitive(req_body_parsed)
|
||||
|
||||
resp_body_parsed = _parse_json_body(resp_body)
|
||||
if isinstance(resp_body_parsed, dict):
|
||||
resp_body_parsed = _redact_sensitive(resp_body_parsed)
|
||||
|
||||
record = {
|
||||
"id": self.counter,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"elapsed_sec": elapsed,
|
||||
|
||||
# Classification
|
||||
"source": classification["source"],
|
||||
"purpose": classification["purpose"],
|
||||
|
||||
# Request
|
||||
"request": {
|
||||
"method": flow.request.method,
|
||||
"url": flow.request.pretty_url,
|
||||
"host": flow.request.pretty_host,
|
||||
"path": flow.request.path,
|
||||
"http_version": flow.request.http_version,
|
||||
"headers_ordered": request_headers_ordered_decoded,
|
||||
"body": req_body_parsed,
|
||||
"content_length": len(flow.request.content) if flow.request.content else 0,
|
||||
},
|
||||
|
||||
# Response
|
||||
"response": {
|
||||
"status_code": flow.response.status_code if flow.response else None,
|
||||
"http_version": flow.response.http_version if flow.response else None,
|
||||
"headers_ordered": response_headers_ordered,
|
||||
"body": resp_body_parsed,
|
||||
"content_length": (
|
||||
len(flow.response.content)
|
||||
if flow.response and flow.response.content
|
||||
else 0
|
||||
),
|
||||
},
|
||||
|
||||
# TLS
|
||||
"tls": _get_tls_info(flow),
|
||||
|
||||
# Connection
|
||||
"connection": {
|
||||
"client_address": (
|
||||
f"{flow.client_conn.peername[0]}:{flow.client_conn.peername[1]}"
|
||||
if flow.client_conn.peername
|
||||
else None
|
||||
),
|
||||
"server_address": (
|
||||
f"{flow.server_conn.peername[0]}:{flow.server_conn.peername[1]}"
|
||||
if flow.server_conn and flow.server_conn.peername
|
||||
else None
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
self.captures.append(record)
|
||||
|
||||
# Save individual capture file
|
||||
filename = (
|
||||
f"{self.counter:04d}_{classification['source']}"
|
||||
f"_{classification['purpose']}"
|
||||
f"_{flow.request.pretty_host}.json"
|
||||
)
|
||||
filepath = self.capture_dir / filename
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(record, f, indent=2, ensure_ascii=False, default=str)
|
||||
|
||||
# Append to summary
|
||||
summary_line = {
|
||||
"id": self.counter,
|
||||
"ts": datetime.now(timezone.utc).strftime("%H:%M:%S"),
|
||||
"source": classification["source"],
|
||||
"purpose": classification["purpose"],
|
||||
"method": flow.request.method,
|
||||
"url": flow.request.pretty_url[:120],
|
||||
"status": flow.response.status_code if flow.response else None,
|
||||
"ua": flow.request.headers.get("user-agent", "")[:80],
|
||||
"elapsed": elapsed,
|
||||
}
|
||||
with open(self.summary_file, "a") as f:
|
||||
f.write(json.dumps(summary_line) + "\n")
|
||||
|
||||
# Console output
|
||||
status = flow.response.status_code if flow.response else "???"
|
||||
ctx.log.info(
|
||||
f"[capture #{self.counter}] "
|
||||
f"[{classification['source']}] "
|
||||
f"[{classification['purpose']}] "
|
||||
f"{flow.request.method} {flow.request.pretty_url[:80]} "
|
||||
f"→ {status} "
|
||||
f"({elapsed}s)"
|
||||
)
|
||||
|
||||
# Highlight important findings
|
||||
ua = flow.request.headers.get("user-agent", "")
|
||||
if classification["purpose"] in (
|
||||
"oauth-token-exchange",
|
||||
"oauth-token-refresh",
|
||||
):
|
||||
ctx.log.warn(
|
||||
f"[capture] TOKEN EXCHANGE/REFRESH detected!\n"
|
||||
f" UA: {ua}\n"
|
||||
f" Headers: {[h[0] for h in request_headers_ordered_decoded]}"
|
||||
)
|
||||
|
||||
def done(self):
|
||||
"""Generate report on exit."""
|
||||
if not self.captures:
|
||||
ctx.log.info("[capture] No captures recorded.")
|
||||
return
|
||||
|
||||
report_path = self.capture_dir / "_report.txt"
|
||||
with open(report_path, "w") as f:
|
||||
f.write("=" * 80 + "\n")
|
||||
f.write(" MiniGravity Traffic Capture Report\n")
|
||||
f.write(f" Generated: {datetime.now().isoformat()}\n")
|
||||
f.write(f" Total requests captured: {len(self.captures)}\n")
|
||||
f.write("=" * 80 + "\n\n")
|
||||
|
||||
# Group by source
|
||||
by_source = {}
|
||||
for cap in self.captures:
|
||||
src = cap["source"]
|
||||
if src not in by_source:
|
||||
by_source[src] = []
|
||||
by_source[src].append(cap)
|
||||
|
||||
for source, caps in sorted(by_source.items()):
|
||||
f.write(f"\n{'─' * 60}\n")
|
||||
f.write(f" Source: {source} ({len(caps)} requests)\n")
|
||||
f.write(f"{'─' * 60}\n\n")
|
||||
|
||||
# Group by purpose within source
|
||||
by_purpose = {}
|
||||
for cap in caps:
|
||||
p = cap["purpose"]
|
||||
if p not in by_purpose:
|
||||
by_purpose[p] = []
|
||||
by_purpose[p].append(cap)
|
||||
|
||||
for purpose, pcaps in sorted(by_purpose.items()):
|
||||
f.write(f" [{purpose}] ({len(pcaps)} requests)\n\n")
|
||||
|
||||
for cap in pcaps:
|
||||
req = cap["request"]
|
||||
f.write(f" #{cap['id']} {req['method']} {req['url'][:100]}\n")
|
||||
f.write(f" HTTP Version: {req['http_version']}\n")
|
||||
|
||||
f.write(" Request Headers (ordered):\n")
|
||||
for hdr in req["headers_ordered"]:
|
||||
val = hdr[1]
|
||||
# Truncate long values
|
||||
if len(val) > 100:
|
||||
val = val[:100] + "..."
|
||||
f.write(f" {hdr[0]}: {val}\n")
|
||||
|
||||
if req["body"]:
|
||||
body_str = json.dumps(
|
||||
req["body"], indent=6, ensure_ascii=False, default=str
|
||||
)
|
||||
if len(body_str) > 500:
|
||||
body_str = body_str[:500] + "\n ..."
|
||||
f.write(f" Request Body:\n {body_str}\n")
|
||||
|
||||
resp = cap["response"]
|
||||
f.write(f" Response: {resp['status_code']}\n")
|
||||
|
||||
if cap["tls"]:
|
||||
f.write(f" TLS: {json.dumps(cap['tls'])}\n")
|
||||
|
||||
f.write("\n")
|
||||
|
||||
# Comparison section
|
||||
f.write(f"\n{'=' * 80}\n")
|
||||
f.write(" FINGERPRINT COMPARISON\n")
|
||||
f.write(f"{'=' * 80}\n\n")
|
||||
|
||||
# Collect unique UA per source+purpose
|
||||
ua_map = {}
|
||||
for cap in self.captures:
|
||||
key = f"{cap['source']}:{cap['purpose']}"
|
||||
ua = dict(cap["request"]["headers_ordered"]).get("user-agent", "N/A")
|
||||
if key not in ua_map:
|
||||
ua_map[key] = set()
|
||||
ua_map[key].add(ua)
|
||||
|
||||
f.write(" User-Agent by source:purpose\n")
|
||||
for key, uas in sorted(ua_map.items()):
|
||||
for ua in uas:
|
||||
f.write(f" {key:40s} → {ua}\n")
|
||||
|
||||
# Collect header sets per source+purpose
|
||||
f.write("\n Header names by source:purpose\n")
|
||||
header_map = {}
|
||||
for cap in self.captures:
|
||||
key = f"{cap['source']}:{cap['purpose']}"
|
||||
hdrs = tuple(h[0].lower() for h in cap["request"]["headers_ordered"])
|
||||
if key not in header_map:
|
||||
header_map[key] = set()
|
||||
header_map[key].add(hdrs)
|
||||
|
||||
for key, hdr_sets in sorted(header_map.items()):
|
||||
for hdrs in hdr_sets:
|
||||
f.write(f" {key}:\n")
|
||||
for h in hdrs:
|
||||
f.write(f" - {h}\n")
|
||||
f.write("\n")
|
||||
|
||||
ctx.log.info(
|
||||
f"[capture] Report written to {report_path.absolute()}\n"
|
||||
f"[capture] {len(self.captures)} requests captured in {self.capture_dir.absolute()}"
|
||||
)
|
||||
|
||||
|
||||
def _redact_sensitive(d: dict) -> dict:
|
||||
"""Redact sensitive values in a dict, preserving structure."""
|
||||
sensitive_keys = {
|
||||
"access_token", "refresh_token", "code", "code_verifier",
|
||||
"session_key", "sessionKey", "password", "secret",
|
||||
"authorization", "cookie",
|
||||
}
|
||||
result = {}
|
||||
for k, v in d.items():
|
||||
if k.lower() in {s.lower() for s in sensitive_keys}:
|
||||
if isinstance(v, str) and len(v) > 8:
|
||||
result[k] = v[:4] + "****" + v[-4:]
|
||||
else:
|
||||
result[k] = "****"
|
||||
elif isinstance(v, dict):
|
||||
result[k] = _redact_sensitive(v)
|
||||
elif isinstance(v, list):
|
||||
result[k] = [
|
||||
_redact_sensitive(item) if isinstance(item, dict) else item
|
||||
for item in v
|
||||
]
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
|
||||
addons = [TrafficCapture()]
|
||||
@ -1,129 +0,0 @@
|
||||
{
|
||||
"id": 1,
|
||||
"timestamp": "2026-03-26T16:28:57.647791+00:00",
|
||||
"elapsed_sec": 0.322,
|
||||
"source": "claude-cli-sdk",
|
||||
"purpose": "unknown",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest",
|
||||
"host": "downloads.claude.ai",
|
||||
"path": "/claude-code-releases/plugins/claude-plugins-official/latest",
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Accept",
|
||||
"application/json, text/plain, */*"
|
||||
],
|
||||
[
|
||||
"Accept-Encoding",
|
||||
"gzip, compress, deflate, br"
|
||||
],
|
||||
[
|
||||
"User-Agent",
|
||||
"axios/1.13.6"
|
||||
],
|
||||
[
|
||||
"Host",
|
||||
"downloads.claude.ai"
|
||||
]
|
||||
],
|
||||
"body": null,
|
||||
"content_length": 0
|
||||
},
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"x-guploader-uploadid",
|
||||
"AMNfjG29CnIrYUAyZBJSnylKbYWnv3VH6x45qXwHunjwYiMbCueqWoZ3CouUPbV2VjfNtKXGrIpIQNI"
|
||||
],
|
||||
[
|
||||
"x-goog-generation",
|
||||
"1774486030779283"
|
||||
],
|
||||
[
|
||||
"x-goog-metageneration",
|
||||
"1"
|
||||
],
|
||||
[
|
||||
"x-goog-stored-content-encoding",
|
||||
"identity"
|
||||
],
|
||||
[
|
||||
"x-goog-stored-content-length",
|
||||
"40"
|
||||
],
|
||||
[
|
||||
"x-goog-hash",
|
||||
"crc32c=/q0yrA=="
|
||||
],
|
||||
[
|
||||
"x-goog-hash",
|
||||
"md5=tRgumXLHnEzHzEWYd8YEyg=="
|
||||
],
|
||||
[
|
||||
"x-goog-storage-class",
|
||||
"STANDARD"
|
||||
],
|
||||
[
|
||||
"accept-ranges",
|
||||
"bytes"
|
||||
],
|
||||
[
|
||||
"Content-Length",
|
||||
"40"
|
||||
],
|
||||
[
|
||||
"server",
|
||||
"UploadServer"
|
||||
],
|
||||
[
|
||||
"via",
|
||||
"1.1 google"
|
||||
],
|
||||
[
|
||||
"Date",
|
||||
"Thu, 26 Mar 2026 16:28:57 GMT"
|
||||
],
|
||||
[
|
||||
"Age",
|
||||
"0"
|
||||
],
|
||||
[
|
||||
"Last-Modified",
|
||||
"Thu, 26 Mar 2026 16:17:10 GMT"
|
||||
],
|
||||
[
|
||||
"ETag",
|
||||
"\"b5182e9972c79c4cc7cc459877c604ca\""
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"text/plain"
|
||||
],
|
||||
[
|
||||
"Cache-Control",
|
||||
"public,no-cache,max-age=300"
|
||||
],
|
||||
[
|
||||
"Alt-Svc",
|
||||
"h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000"
|
||||
]
|
||||
],
|
||||
"body": "b10b583de281385442474e836644534b938b2678",
|
||||
"content_length": 40
|
||||
},
|
||||
"tls": {
|
||||
"tls_version": "TLSv1.3",
|
||||
"alpn": "http/1.1",
|
||||
"client_tls_version": "TLSv1.3",
|
||||
"client_alpn": "http/1.1",
|
||||
"client_sni": "downloads.claude.ai"
|
||||
},
|
||||
"connection": {
|
||||
"client_address": "127.0.0.1:55671",
|
||||
"server_address": "198.18.0.44:443"
|
||||
}
|
||||
}
|
||||
@ -1,125 +0,0 @@
|
||||
{
|
||||
"id": 2,
|
||||
"timestamp": "2026-03-26T16:28:57.668166+00:00",
|
||||
"elapsed_sec": 0.481,
|
||||
"source": "claude-cli-sdk",
|
||||
"purpose": "unknown",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial",
|
||||
"host": "api.anthropic.com",
|
||||
"path": "/mcp-registry/v0/servers?version=latest&visibility=commercial",
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Accept",
|
||||
"application/json, text/plain, */*"
|
||||
],
|
||||
[
|
||||
"Accept-Encoding",
|
||||
"gzip, compress, deflate, br"
|
||||
],
|
||||
[
|
||||
"User-Agent",
|
||||
"axios/1.13.6"
|
||||
],
|
||||
[
|
||||
"Host",
|
||||
"api.anthropic.com"
|
||||
]
|
||||
],
|
||||
"body": null,
|
||||
"content_length": 0
|
||||
},
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Date",
|
||||
"Thu, 26 Mar 2026 16:28:57 GMT"
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"application/json"
|
||||
],
|
||||
[
|
||||
"Transfer-Encoding",
|
||||
"chunked"
|
||||
],
|
||||
[
|
||||
"Connection",
|
||||
"keep-alive"
|
||||
],
|
||||
[
|
||||
"x-request-id",
|
||||
"a26ee618-f205-4a23-87b1-6225e17b92ef"
|
||||
],
|
||||
[
|
||||
"access-control-allow-origin",
|
||||
"*"
|
||||
],
|
||||
[
|
||||
"access-control-allow-methods",
|
||||
"GET, OPTIONS"
|
||||
],
|
||||
[
|
||||
"access-control-allow-headers",
|
||||
"*"
|
||||
],
|
||||
[
|
||||
"x-envoy-upstream-service-time",
|
||||
"9"
|
||||
],
|
||||
[
|
||||
"Content-Encoding",
|
||||
"gzip"
|
||||
],
|
||||
[
|
||||
"vary",
|
||||
"Accept-Encoding"
|
||||
],
|
||||
[
|
||||
"Server",
|
||||
"cloudflare"
|
||||
],
|
||||
[
|
||||
"server-timing",
|
||||
"x-originResponse;dur=11"
|
||||
],
|
||||
[
|
||||
"cf-cache-status",
|
||||
"DYNAMIC"
|
||||
],
|
||||
[
|
||||
"set-cookie",
|
||||
"_cfuvid=XtplK6T__J5GJ7ZHZ75.1K.blAKEiURZzIRFOxbjm0U-1774542537.272683-1.0.1.1-Folg8_rQ2RrBi0Img0NdFQUYWxTawBjeo7zj11dFizU; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
[
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
[
|
||||
"X-Robots-Tag",
|
||||
"none"
|
||||
],
|
||||
[
|
||||
"CF-RAY",
|
||||
"9e278809ffffe371-NRT"
|
||||
]
|
||||
],
|
||||
"body": "{\n \"servers\": [\n {\n \"server\": {\n \"name\": \"com.canva.mcp/canva\",\n \"version\": \"1.0.0\",\n \"description\": \"Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your AI workflow—an AI-powered design agent that helps you create polished visuals faster, with less friction.\",\n \"title\": \"Canva\",\n \"remotes\": [\n {\n \"type\": \"streamable-http\",\n \"url\": \"https://mcp.canva.com/mcp\"\n }\n ]\n },\n \"_meta\": {\n \"com.anthropic.api/mcp-registry\": {\n \"uuid\": \"eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"type\": \"remote\",\n \"toolNames\": [\n \"search-designs\",\n \"get-design\",\n \"get-design-pages\",\n \"get-design-content\",\n \"search\",\n \"fetch\",\n \"import-design-from-url\",\n \"get-design-import-from-url-status\",\n \"export-design\",\n \"get-export-formats\",\n \"get-design-export-status\",\n \"create-folder\",\n \"move-item-to-folder\",\n \"list-folder-items\",\n \"add-comment-thread-to-design\",\n \"generate-design\",\n \"get-design-generation-job\"\n ],\n \"promptNames\": [],\n \"isAuthless\": false,\n \"displayName\": \"Canva\",\n \"oneLiner\": \"Search, create, autofill, and export Canva designs\",\n \"iconUrl\": \"https://mcp.canva.com/mcp\",\n \"documentation\": \"https://www.canva.dev/docs/connect/canva-mcp-server-setup/\",\n \"support\": \"https://www.canva.com/en_au/help/\",\n \"privacyPolicy\": \"https://www.canva.com/policies/privacy-policy/\",\n \"url\": \"https://mcp.canva.com/mcp\",\n \"author\": {\n \"name\": \"Canva\",\n \"url\": \"https://canva.com\"\n },\n \"slug\": \"canva\",\n \"directoryUrl\": \"http://claude.ai/directory/eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"claudeCodeCopyText\": \"claude mcp add --transport http canva https://mcp.canva.com/mcp\",\n \"permissions\": \"Read and write\",\n \"useCases\": [\n \"design\"\n ],\n \"worksWith\": [\n \"claude\",\n \"claude-api\",\n \"claude-code\"\n ],\n \"publishedOn\": \"Fri Jan 16 2026 19:33:48 GMT+0000 (Coordinated Universal Time)\",\n \"createdOn\": \"Thu Aug 28 2025 14:02:54 GMT+0000 (Coordinated Universal Time)\",\n \"updatedOn\": \"Fri Jan 16 2026 19:33:28 GMT+0000 (Coordinated Universal Time)\",\n \"logo\": \"canva\",\n \"backgroundPattern\": \"Line 1\",\n \"heroVideoId\": \"wXC2u36w2Rc\",\n \"heroVideoPreviewLink\": \"https://cdn.sanity.io/files/4zrzovbb/website/4925fcd732bab964631e2678e413dfa2a549a2a9.mp4\",\n \"serverLabel\": \"mcp.canva.com\",\n \"itemId\": \"68b0618e3f55cc591b6b22a2\",\n \"collectionId\": \"68b05e2de975b4de7dd02d9d\",\n \"localeId\": \"68a44d4040f98a4adf2207b5\",\n \"htmlContent\": \"<p id=\\\"\\\">Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your workflow—helping you create polished visuals faster, with less friction.</p><p id=\\\"\\\">You can use the Canva connector to: <br><br>Browse, Search & 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"
|
||||
}
|
||||
}
|
||||
@ -1,125 +0,0 @@
|
||||
{
|
||||
"id": 3,
|
||||
"timestamp": "2026-03-26T16:30:00.064058+00:00",
|
||||
"elapsed_sec": 0.731,
|
||||
"source": "claude-cli-sdk",
|
||||
"purpose": "unknown",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial",
|
||||
"host": "api.anthropic.com",
|
||||
"path": "/mcp-registry/v0/servers?version=latest&visibility=commercial",
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Accept",
|
||||
"application/json, text/plain, */*"
|
||||
],
|
||||
[
|
||||
"Accept-Encoding",
|
||||
"gzip, compress, deflate, br"
|
||||
],
|
||||
[
|
||||
"User-Agent",
|
||||
"axios/1.13.6"
|
||||
],
|
||||
[
|
||||
"Host",
|
||||
"api.anthropic.com"
|
||||
]
|
||||
],
|
||||
"body": null,
|
||||
"content_length": 0
|
||||
},
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Date",
|
||||
"Thu, 26 Mar 2026 16:29:59 GMT"
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"application/json"
|
||||
],
|
||||
[
|
||||
"Transfer-Encoding",
|
||||
"chunked"
|
||||
],
|
||||
[
|
||||
"Connection",
|
||||
"keep-alive"
|
||||
],
|
||||
[
|
||||
"x-request-id",
|
||||
"ddcd43e3-8799-43b9-9d49-8dcc6a0b90dd"
|
||||
],
|
||||
[
|
||||
"access-control-allow-origin",
|
||||
"*"
|
||||
],
|
||||
[
|
||||
"access-control-allow-methods",
|
||||
"GET, OPTIONS"
|
||||
],
|
||||
[
|
||||
"access-control-allow-headers",
|
||||
"*"
|
||||
],
|
||||
[
|
||||
"x-envoy-upstream-service-time",
|
||||
"8"
|
||||
],
|
||||
[
|
||||
"Content-Encoding",
|
||||
"gzip"
|
||||
],
|
||||
[
|
||||
"vary",
|
||||
"Accept-Encoding"
|
||||
],
|
||||
[
|
||||
"Server",
|
||||
"cloudflare"
|
||||
],
|
||||
[
|
||||
"server-timing",
|
||||
"x-originResponse;dur=10"
|
||||
],
|
||||
[
|
||||
"cf-cache-status",
|
||||
"DYNAMIC"
|
||||
],
|
||||
[
|
||||
"set-cookie",
|
||||
"_cfuvid=DUvPIDhglzXAjPSEJhKi0nemis9e5knKw1jmUxq8LnE-1774542599.5569572-1.0.1.1-5oD..eF758shBNx1g_VrkNhd2HcST2hu4QKN5ciERz4; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
[
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
[
|
||||
"X-Robots-Tag",
|
||||
"none"
|
||||
],
|
||||
[
|
||||
"CF-RAY",
|
||||
"9e27898f3c1eefbb-NRT"
|
||||
]
|
||||
],
|
||||
"body": "{\n \"servers\": [\n {\n \"server\": {\n \"name\": \"com.canva.mcp/canva\",\n \"version\": \"1.0.0\",\n \"description\": \"Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your AI workflow—an AI-powered design agent that helps you create polished visuals faster, with less friction.\",\n \"title\": \"Canva\",\n \"remotes\": [\n {\n \"type\": \"streamable-http\",\n \"url\": \"https://mcp.canva.com/mcp\"\n }\n ]\n },\n \"_meta\": {\n \"com.anthropic.api/mcp-registry\": {\n \"uuid\": \"eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"type\": \"remote\",\n \"toolNames\": [\n \"search-designs\",\n \"get-design\",\n \"get-design-pages\",\n \"get-design-content\",\n \"search\",\n \"fetch\",\n \"import-design-from-url\",\n \"get-design-import-from-url-status\",\n \"export-design\",\n \"get-export-formats\",\n \"get-design-export-status\",\n \"create-folder\",\n \"move-item-to-folder\",\n \"list-folder-items\",\n \"add-comment-thread-to-design\",\n \"generate-design\",\n \"get-design-generation-job\"\n ],\n \"promptNames\": [],\n \"isAuthless\": false,\n \"displayName\": \"Canva\",\n \"oneLiner\": \"Search, create, autofill, and export Canva designs\",\n \"iconUrl\": \"https://mcp.canva.com/mcp\",\n \"documentation\": \"https://www.canva.dev/docs/connect/canva-mcp-server-setup/\",\n \"support\": \"https://www.canva.com/en_au/help/\",\n \"privacyPolicy\": \"https://www.canva.com/policies/privacy-policy/\",\n \"url\": \"https://mcp.canva.com/mcp\",\n \"author\": {\n \"name\": \"Canva\",\n \"url\": \"https://canva.com\"\n },\n \"slug\": \"canva\",\n \"directoryUrl\": \"http://claude.ai/directory/eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"claudeCodeCopyText\": \"claude mcp add --transport http canva https://mcp.canva.com/mcp\",\n \"permissions\": \"Read and write\",\n \"useCases\": [\n \"design\"\n ],\n \"worksWith\": [\n \"claude\",\n \"claude-api\",\n \"claude-code\"\n ],\n \"publishedOn\": \"Fri Jan 16 2026 19:33:48 GMT+0000 (Coordinated Universal Time)\",\n \"createdOn\": \"Thu Aug 28 2025 14:02:54 GMT+0000 (Coordinated Universal Time)\",\n \"updatedOn\": \"Fri Jan 16 2026 19:33:28 GMT+0000 (Coordinated Universal Time)\",\n \"logo\": \"canva\",\n \"backgroundPattern\": \"Line 1\",\n \"heroVideoId\": \"wXC2u36w2Rc\",\n \"heroVideoPreviewLink\": \"https://cdn.sanity.io/files/4zrzovbb/website/4925fcd732bab964631e2678e413dfa2a549a2a9.mp4\",\n \"serverLabel\": \"mcp.canva.com\",\n \"itemId\": \"68b0618e3f55cc591b6b22a2\",\n \"collectionId\": \"68b05e2de975b4de7dd02d9d\",\n \"localeId\": \"68a44d4040f98a4adf2207b5\",\n \"htmlContent\": \"<p id=\\\"\\\">Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your workflow—helping you create polished visuals faster, with less friction.</p><p id=\\\"\\\">You can use the Canva connector to: <br><br>Browse, Search & 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"
|
||||
}
|
||||
}
|
||||
@ -1,129 +0,0 @@
|
||||
{
|
||||
"id": 4,
|
||||
"timestamp": "2026-03-26T16:30:00.153789+00:00",
|
||||
"elapsed_sec": 0.833,
|
||||
"source": "claude-cli-sdk",
|
||||
"purpose": "unknown",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest",
|
||||
"host": "downloads.claude.ai",
|
||||
"path": "/claude-code-releases/plugins/claude-plugins-official/latest",
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Accept",
|
||||
"application/json, text/plain, */*"
|
||||
],
|
||||
[
|
||||
"Accept-Encoding",
|
||||
"gzip, compress, deflate, br"
|
||||
],
|
||||
[
|
||||
"User-Agent",
|
||||
"axios/1.13.6"
|
||||
],
|
||||
[
|
||||
"Host",
|
||||
"downloads.claude.ai"
|
||||
]
|
||||
],
|
||||
"body": null,
|
||||
"content_length": 0
|
||||
},
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"x-guploader-uploadid",
|
||||
"AMNfjG37C4G0lUKtWOt8YyD-JuE6Y6MUhtxm8P77pzlb0lJzsdb6sG8xLwNpaolt4FWHSJVblZLmjWM"
|
||||
],
|
||||
[
|
||||
"x-goog-generation",
|
||||
"1774486030779283"
|
||||
],
|
||||
[
|
||||
"x-goog-metageneration",
|
||||
"1"
|
||||
],
|
||||
[
|
||||
"x-goog-stored-content-encoding",
|
||||
"identity"
|
||||
],
|
||||
[
|
||||
"x-goog-stored-content-length",
|
||||
"40"
|
||||
],
|
||||
[
|
||||
"x-goog-hash",
|
||||
"crc32c=/q0yrA=="
|
||||
],
|
||||
[
|
||||
"x-goog-hash",
|
||||
"md5=tRgumXLHnEzHzEWYd8YEyg=="
|
||||
],
|
||||
[
|
||||
"x-goog-storage-class",
|
||||
"STANDARD"
|
||||
],
|
||||
[
|
||||
"accept-ranges",
|
||||
"bytes"
|
||||
],
|
||||
[
|
||||
"Content-Length",
|
||||
"40"
|
||||
],
|
||||
[
|
||||
"server",
|
||||
"UploadServer"
|
||||
],
|
||||
[
|
||||
"via",
|
||||
"1.1 google"
|
||||
],
|
||||
[
|
||||
"Date",
|
||||
"Thu, 26 Mar 2026 16:29:59 GMT"
|
||||
],
|
||||
[
|
||||
"Age",
|
||||
"0"
|
||||
],
|
||||
[
|
||||
"Last-Modified",
|
||||
"Thu, 26 Mar 2026 16:17:10 GMT"
|
||||
],
|
||||
[
|
||||
"ETag",
|
||||
"\"b5182e9972c79c4cc7cc459877c604ca\""
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"text/plain"
|
||||
],
|
||||
[
|
||||
"Cache-Control",
|
||||
"public,no-cache,max-age=300"
|
||||
],
|
||||
[
|
||||
"Alt-Svc",
|
||||
"h3=\":443\"; ma=2592000"
|
||||
]
|
||||
],
|
||||
"body": "b10b583de281385442474e836644534b938b2678",
|
||||
"content_length": 40
|
||||
},
|
||||
"tls": {
|
||||
"tls_version": "TLSv1.3",
|
||||
"alpn": "http/1.1",
|
||||
"client_tls_version": "TLSv1.3",
|
||||
"client_alpn": "http/1.1",
|
||||
"client_sni": "downloads.claude.ai"
|
||||
},
|
||||
"connection": {
|
||||
"client_address": "127.0.0.1:56003",
|
||||
"server_address": "198.18.0.44:443"
|
||||
}
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
================================================================================
|
||||
MiniGravity Traffic Capture Report
|
||||
Generated: 2026-03-27T00:50:12.040880
|
||||
Total requests captured: 4
|
||||
================================================================================
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
Source: claude-cli-sdk (4 requests)
|
||||
────────────────────────────────────────────────────────────
|
||||
|
||||
[unknown] (4 requests)
|
||||
|
||||
#1 GET https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest
|
||||
HTTP Version: HTTP/1.1
|
||||
Request Headers (ordered):
|
||||
Accept: application/json, text/plain, */*
|
||||
Accept-Encoding: gzip, compress, deflate, br
|
||||
User-Agent: axios/1.13.6
|
||||
Host: downloads.claude.ai
|
||||
Response: 200
|
||||
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "downloads.claude.ai"}
|
||||
|
||||
#2 GET https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial
|
||||
HTTP Version: HTTP/1.1
|
||||
Request Headers (ordered):
|
||||
Accept: application/json, text/plain, */*
|
||||
Accept-Encoding: gzip, compress, deflate, br
|
||||
User-Agent: axios/1.13.6
|
||||
Host: api.anthropic.com
|
||||
Response: 200
|
||||
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "api.anthropic.com"}
|
||||
|
||||
#3 GET https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial
|
||||
HTTP Version: HTTP/1.1
|
||||
Request Headers (ordered):
|
||||
Accept: application/json, text/plain, */*
|
||||
Accept-Encoding: gzip, compress, deflate, br
|
||||
User-Agent: axios/1.13.6
|
||||
Host: api.anthropic.com
|
||||
Response: 200
|
||||
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "api.anthropic.com"}
|
||||
|
||||
#4 GET https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest
|
||||
HTTP Version: HTTP/1.1
|
||||
Request Headers (ordered):
|
||||
Accept: application/json, text/plain, */*
|
||||
Accept-Encoding: gzip, compress, deflate, br
|
||||
User-Agent: axios/1.13.6
|
||||
Host: downloads.claude.ai
|
||||
Response: 200
|
||||
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "downloads.claude.ai"}
|
||||
|
||||
|
||||
================================================================================
|
||||
FINGERPRINT COMPARISON
|
||||
================================================================================
|
||||
|
||||
User-Agent by source:purpose
|
||||
claude-cli-sdk:unknown → N/A
|
||||
|
||||
Header names by source:purpose
|
||||
claude-cli-sdk:unknown:
|
||||
- accept
|
||||
- accept-encoding
|
||||
- user-agent
|
||||
- host
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
{"event": "session_start", "timestamp": "2026-03-26T16:28:39.558811+00:00", "note": "New capture session started"}
|
||||
{"id": 1, "ts": "16:28:57", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.322}
|
||||
{"id": 2, "ts": "16:28:57", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.481}
|
||||
{"id": 3, "ts": "16:30:00", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.731}
|
||||
{"id": 4, "ts": "16:30:00", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.833}
|
||||
@ -1,240 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract JA3 fingerprint from pcap file (no tshark needed).
|
||||
Parses TLS ClientHello directly from raw packets.
|
||||
"""
|
||||
import struct
|
||||
import hashlib
|
||||
import sys
|
||||
|
||||
def parse_pcap(filepath):
|
||||
"""Parse pcap file and extract TLS ClientHello JA3 fingerprints."""
|
||||
results = []
|
||||
with open(filepath, 'rb') as f:
|
||||
# Read pcap global header
|
||||
magic = struct.unpack('<I', f.read(4))[0]
|
||||
if magic == 0xa1b2c3d4:
|
||||
endian = '<'
|
||||
elif magic == 0xd4c3b2a1:
|
||||
endian = '>'
|
||||
else:
|
||||
print(f"Not a pcap file (magic: {hex(magic)})")
|
||||
return results
|
||||
|
||||
f.read(20) # rest of global header
|
||||
|
||||
packet_num = 0
|
||||
while True:
|
||||
# Read packet header
|
||||
pkt_hdr = f.read(16)
|
||||
if len(pkt_hdr) < 16:
|
||||
break
|
||||
ts_sec, ts_usec, incl_len, orig_len = struct.unpack(f'{endian}IIII', pkt_hdr)
|
||||
pkt_data = f.read(incl_len)
|
||||
if len(pkt_data) < incl_len:
|
||||
break
|
||||
packet_num += 1
|
||||
|
||||
# Parse Ethernet header (14 bytes)
|
||||
if len(pkt_data) < 14:
|
||||
continue
|
||||
eth_type = struct.unpack('!H', pkt_data[12:14])[0]
|
||||
if eth_type != 0x0800: # IPv4
|
||||
continue
|
||||
|
||||
# Parse IP header
|
||||
ip_start = 14
|
||||
if len(pkt_data) < ip_start + 20:
|
||||
continue
|
||||
ip_ver_ihl = pkt_data[ip_start]
|
||||
ip_ihl = (ip_ver_ihl & 0x0F) * 4
|
||||
ip_proto = pkt_data[ip_start + 9]
|
||||
dst_ip = '.'.join(str(b) for b in pkt_data[ip_start+16:ip_start+20])
|
||||
|
||||
if ip_proto != 6: # TCP
|
||||
continue
|
||||
|
||||
# Parse TCP header
|
||||
tcp_start = ip_start + ip_ihl
|
||||
if len(pkt_data) < tcp_start + 20:
|
||||
continue
|
||||
dst_port = struct.unpack('!H', pkt_data[tcp_start+2:tcp_start+4])[0]
|
||||
tcp_data_offset = ((pkt_data[tcp_start + 12] >> 4) & 0xF) * 4
|
||||
|
||||
# TLS record starts after TCP header
|
||||
tls_start = tcp_start + tcp_data_offset
|
||||
if len(pkt_data) < tls_start + 6:
|
||||
continue
|
||||
|
||||
# Check for TLS Handshake (content type 22)
|
||||
if pkt_data[tls_start] != 22:
|
||||
continue
|
||||
|
||||
tls_version = struct.unpack('!H', pkt_data[tls_start+1:tls_start+3])[0]
|
||||
tls_length = struct.unpack('!H', pkt_data[tls_start+3:tls_start+5])[0]
|
||||
|
||||
# Check for ClientHello (handshake type 1)
|
||||
hs_start = tls_start + 5
|
||||
if len(pkt_data) < hs_start + 4:
|
||||
continue
|
||||
if pkt_data[hs_start] != 1: # ClientHello
|
||||
continue
|
||||
|
||||
# Parse ClientHello
|
||||
try:
|
||||
ja3 = extract_ja3(pkt_data, hs_start, dst_ip, dst_port)
|
||||
if ja3:
|
||||
results.append(ja3)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
def extract_ja3(data, hs_start, dst_ip, dst_port):
|
||||
"""Extract JA3 components from ClientHello."""
|
||||
# Handshake header: type(1) + length(3)
|
||||
pos = hs_start + 4
|
||||
|
||||
# ClientHello: version(2) + random(32)
|
||||
if len(data) < pos + 34:
|
||||
return None
|
||||
ch_version = struct.unpack('!H', data[pos:pos+2])[0]
|
||||
pos += 34 # skip version + random
|
||||
|
||||
# Session ID
|
||||
if len(data) < pos + 1:
|
||||
return None
|
||||
session_id_len = data[pos]
|
||||
pos += 1 + session_id_len
|
||||
|
||||
# Cipher Suites
|
||||
if len(data) < pos + 2:
|
||||
return None
|
||||
cs_len = struct.unpack('!H', data[pos:pos+2])[0]
|
||||
pos += 2
|
||||
if len(data) < pos + cs_len:
|
||||
return None
|
||||
|
||||
cipher_suites = []
|
||||
for i in range(0, cs_len, 2):
|
||||
cs = struct.unpack('!H', data[pos+i:pos+i+2])[0]
|
||||
# Skip GREASE values
|
||||
if (cs & 0x0f0f) == 0x0a0a:
|
||||
continue
|
||||
cipher_suites.append(str(cs))
|
||||
pos += cs_len
|
||||
|
||||
# Compression methods
|
||||
if len(data) < pos + 1:
|
||||
return None
|
||||
comp_len = data[pos]
|
||||
pos += 1 + comp_len
|
||||
|
||||
# Extensions
|
||||
extensions = []
|
||||
elliptic_curves = []
|
||||
ec_point_formats = []
|
||||
supported_versions = []
|
||||
sni = ""
|
||||
|
||||
if len(data) > pos + 2:
|
||||
ext_total_len = struct.unpack('!H', data[pos:pos+2])[0]
|
||||
pos += 2
|
||||
ext_end = pos + ext_total_len
|
||||
|
||||
while pos + 4 <= ext_end and pos + 4 <= len(data):
|
||||
ext_type = struct.unpack('!H', data[pos:pos+2])[0]
|
||||
ext_len = struct.unpack('!H', data[pos+2:pos+4])[0]
|
||||
ext_data_start = pos + 4
|
||||
|
||||
# Skip GREASE
|
||||
if (ext_type & 0x0f0f) == 0x0a0a:
|
||||
pos = ext_data_start + ext_len
|
||||
continue
|
||||
|
||||
extensions.append(str(ext_type))
|
||||
|
||||
# SNI (type 0)
|
||||
if ext_type == 0 and ext_len > 5:
|
||||
try:
|
||||
name_len = struct.unpack('!H', data[ext_data_start+3:ext_data_start+5])[0]
|
||||
sni = data[ext_data_start+5:ext_data_start+5+name_len].decode('ascii', errors='replace')
|
||||
except:
|
||||
pass
|
||||
|
||||
# Supported Groups / Elliptic Curves (type 10)
|
||||
if ext_type == 10 and ext_len >= 2:
|
||||
curves_len = struct.unpack('!H', data[ext_data_start:ext_data_start+2])[0]
|
||||
for i in range(0, curves_len, 2):
|
||||
if ext_data_start + 2 + i + 2 <= len(data):
|
||||
curve = struct.unpack('!H', data[ext_data_start+2+i:ext_data_start+2+i+2])[0]
|
||||
if (curve & 0x0f0f) != 0x0a0a:
|
||||
elliptic_curves.append(str(curve))
|
||||
|
||||
# EC Point Formats (type 11)
|
||||
if ext_type == 11 and ext_len >= 1:
|
||||
fmt_len = data[ext_data_start]
|
||||
for i in range(fmt_len):
|
||||
if ext_data_start + 1 + i < len(data):
|
||||
ec_point_formats.append(str(data[ext_data_start+1+i]))
|
||||
|
||||
# Supported Versions (type 43)
|
||||
if ext_type == 43 and ext_len >= 1:
|
||||
sv_len = data[ext_data_start]
|
||||
for i in range(0, sv_len, 2):
|
||||
if ext_data_start + 1 + i + 2 <= len(data):
|
||||
ver = struct.unpack('!H', data[ext_data_start+1+i:ext_data_start+1+i+2])[0]
|
||||
if (ver & 0x0f0f) != 0x0a0a:
|
||||
supported_versions.append(hex(ver))
|
||||
|
||||
pos = ext_data_start + ext_len
|
||||
|
||||
# Build JA3 string: TLSVersion,Ciphers,Extensions,EllipticCurves,ECPointFormats
|
||||
ja3_str = ','.join([
|
||||
str(ch_version),
|
||||
'-'.join(cipher_suites),
|
||||
'-'.join(extensions),
|
||||
'-'.join(elliptic_curves),
|
||||
'-'.join(ec_point_formats),
|
||||
])
|
||||
ja3_hash = hashlib.md5(ja3_str.encode()).hexdigest()
|
||||
|
||||
return {
|
||||
'dst_ip': dst_ip,
|
||||
'dst_port': dst_port,
|
||||
'sni': sni,
|
||||
'ja3_hash': ja3_hash,
|
||||
'ja3_string': ja3_str,
|
||||
'tls_version': hex(ch_version),
|
||||
'cipher_count': len(cipher_suites),
|
||||
'extension_count': len(extensions),
|
||||
'supported_versions': supported_versions,
|
||||
'ciphers': cipher_suites[:10], # first 10 for display
|
||||
}
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 ja3_extract.py <pcap_file>")
|
||||
sys.exit(1)
|
||||
|
||||
results = parse_pcap(sys.argv[1])
|
||||
if not results:
|
||||
print("No TLS ClientHello found in pcap")
|
||||
sys.exit(0)
|
||||
|
||||
# Deduplicate by JA3 hash + SNI
|
||||
seen = set()
|
||||
for r in results:
|
||||
key = f"{r['ja3_hash']}:{r['sni']}"
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
print(f"SNI: {r['sni']}")
|
||||
print(f"Dest: {r['dst_ip']}:{r['dst_port']}")
|
||||
print(f"JA3 Hash: {r['ja3_hash']}")
|
||||
print(f"TLS Ver: {r['tls_version']}")
|
||||
print(f"Ciphers: {r['cipher_count']} suites (first 10: {r['ciphers']})")
|
||||
print(f"Extensions: {r['extension_count']}")
|
||||
print(f"Sup. Vers: {r['supported_versions']}")
|
||||
print(f"JA3 Full: {r['ja3_string'][:200]}...")
|
||||
print()
|
||||
@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# run.sh - One-command capture for Claude Code / Antigravity
|
||||
#
|
||||
# Usage:
|
||||
# ./run.sh # Start both mitmproxy + tshark
|
||||
# ./run.sh mitm # mitmproxy only (HTTP layer)
|
||||
# ./run.sh tls # tshark only (TLS layer)
|
||||
# ./run.sh tls en0 # tshark on specific interface
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
MODE="${1:-both}"
|
||||
IFACE="${2:-en0}"
|
||||
|
||||
# Check dependencies
|
||||
check_dep() {
|
||||
if ! command -v "$1" &>/dev/null; then
|
||||
echo "ERROR: $1 not found. Install with: $2"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
mkdir -p ./captures
|
||||
|
||||
case "$MODE" in
|
||||
mitm|mitmproxy)
|
||||
check_dep mitmproxy "brew install mitmproxy"
|
||||
echo ""
|
||||
echo "Starting mitmproxy on :8080"
|
||||
echo ""
|
||||
echo "To capture Claude Code traffic:"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude login"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude 'hello'"
|
||||
echo ""
|
||||
echo "To capture VS Code / Antigravity traffic:"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 code ."
|
||||
echo ""
|
||||
mitmdump -s capture_traffic.py \
|
||||
--set stream_large_bodies=10m \
|
||||
--set console_eventlog_verbosity=warn \
|
||||
-p 8080
|
||||
;;
|
||||
|
||||
tls|tshark)
|
||||
check_dep tshark "brew install wireshark"
|
||||
echo "Starting TLS capture (requires sudo)..."
|
||||
sudo bash ./capture_tls.sh "$IFACE" 120
|
||||
;;
|
||||
|
||||
both)
|
||||
check_dep mitmproxy "brew install mitmproxy"
|
||||
check_dep tshark "brew install wireshark"
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════"
|
||||
echo " MiniGravity Traffic Capture"
|
||||
echo "═══════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Starting two capture layers:"
|
||||
echo " 1. mitmproxy (:8080) → HTTP headers/body"
|
||||
echo " 2. tshark → TLS fingerprints"
|
||||
echo ""
|
||||
echo " Step 1: In another terminal, run:"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude login"
|
||||
echo ""
|
||||
echo " Step 2: After login, run:"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude 'hello'"
|
||||
echo ""
|
||||
echo " Step 3: Press Ctrl+C here when done"
|
||||
echo "═══════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Start tshark in background (needs sudo)
|
||||
echo "[*] Starting tshark (may ask for sudo password)..."
|
||||
sudo bash ./capture_tls.sh "$IFACE" 300 &
|
||||
TSHARK_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
# Start mitmproxy in foreground
|
||||
echo "[*] Starting mitmproxy..."
|
||||
mitmdump -s capture_traffic.py \
|
||||
--set stream_large_bodies=10m \
|
||||
--set console_eventlog_verbosity=warn \
|
||||
-p 8080
|
||||
|
||||
# Cleanup tshark on exit
|
||||
sudo kill "$TSHARK_PID" 2>/dev/null || true
|
||||
wait "$TSHARK_PID" 2>/dev/null || true
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: $0 [mitm|tls|both] [interface]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@ -1,204 +0,0 @@
|
||||
#!/bin/bash
|
||||
# sub2api Antigravity — 指纹防泄露 + macOS 特征伪装规则
|
||||
#
|
||||
# 功能:
|
||||
# 1. QUIC/UDP 阻断 — 强制走 TCP/TLS
|
||||
# 2. 出站 TCP 443 限制 — 只有 nodeproxy 用户能直连
|
||||
# 3. IPv6 阻断 — 消除 IPv6 泄露
|
||||
# 4. TCP TTL 伪装 — 改为 64,匹配 macOS/Linux(对抗 OS 识别)
|
||||
# 5. TCP 时间戳重写 — 禁用内核时间戳,防止通过 TCP TS 推算 uptime/系统时间
|
||||
# 6. 系统时区设置 — 设为 America/Los_Angeles(加州时区,匹配目标用户群)
|
||||
#
|
||||
# 用法:
|
||||
# sudo bash setup-firewall.sh [apply|remove|status|timezone]
|
||||
#
|
||||
# 前置条件:
|
||||
# - Node.js proxy 以专用用户 "nodeproxy" 运行
|
||||
# - 创建用户: sudo useradd -r -s /usr/sbin/nologin nodeproxy
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NODE_PROXY_USER="${MG_NODE_PROXY_USER:-nodeproxy}"
|
||||
CHAIN_NAME="MG_FINGERPRINT"
|
||||
TARGET_TZ="America/New_York"
|
||||
|
||||
log() { echo "[$(date '+%H:%M:%S')] $*"; }
|
||||
|
||||
# ─── 时区设置 ────────────────────────────────────────────────────────
|
||||
set_timezone() {
|
||||
log "Setting system timezone to $TARGET_TZ ..."
|
||||
if command -v timedatectl &>/dev/null; then
|
||||
timedatectl set-timezone "$TARGET_TZ"
|
||||
log " timedatectl: timezone set to $(timedatectl show -p Timezone --value)"
|
||||
elif [ -f "/usr/share/zoneinfo/$TARGET_TZ" ]; then
|
||||
ln -sf "/usr/share/zoneinfo/$TARGET_TZ" /etc/localtime
|
||||
echo "$TARGET_TZ" > /etc/timezone
|
||||
log " /etc/localtime -> $TARGET_TZ"
|
||||
else
|
||||
log " WARNING: Cannot set timezone — timedatectl not found and zoneinfo missing"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── TCP 时间戳禁用 ──────────────────────────────────────────────────
|
||||
# Linux TCP 时间戳会随系统 uptime 线性增长,对方可通过测量 TS 差值
|
||||
# 推算服务器启动时间,识破"全天候在线的服务器"特征。
|
||||
# 禁用后 TCP TS 选项不再发送,无法通过 TS 推断 uptime。
|
||||
disable_tcp_timestamps() {
|
||||
log "Disabling TCP timestamps (anti-uptime fingerprinting)..."
|
||||
sysctl -w net.ipv4.tcp_timestamps=0 > /dev/null
|
||||
# 持久化(防止重启后恢复)
|
||||
if ! grep -q "net.ipv4.tcp_timestamps" /etc/sysctl.conf 2>/dev/null; then
|
||||
echo "net.ipv4.tcp_timestamps=0" >> /etc/sysctl.conf
|
||||
log " Written to /etc/sysctl.conf"
|
||||
else
|
||||
sed -i 's/net.ipv4.tcp_timestamps=.*/net.ipv4.tcp_timestamps=0/' /etc/sysctl.conf
|
||||
log " Updated in /etc/sysctl.conf"
|
||||
fi
|
||||
log " TCP timestamps: DISABLED"
|
||||
}
|
||||
|
||||
enable_tcp_timestamps() {
|
||||
sysctl -w net.ipv4.tcp_timestamps=1 > /dev/null
|
||||
sed -i 's/net.ipv4.tcp_timestamps=.*/net.ipv4.tcp_timestamps=1/' /etc/sysctl.conf 2>/dev/null || true
|
||||
log " TCP timestamps: ENABLED (restored)"
|
||||
}
|
||||
|
||||
# ─── iptables 规则 ───────────────────────────────────────────────────
|
||||
apply_rules() {
|
||||
log "Applying fingerprint firewall rules..."
|
||||
|
||||
# 验证用户存在
|
||||
if ! id "$NODE_PROXY_USER" &>/dev/null; then
|
||||
log "ERROR: User '$NODE_PROXY_USER' does not exist."
|
||||
log "Create it: sudo useradd -r -s /usr/sbin/nologin $NODE_PROXY_USER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建自定义链(幂等)
|
||||
iptables -N "$CHAIN_NAME" 2>/dev/null || iptables -F "$CHAIN_NAME"
|
||||
|
||||
# === Rule 1: QUIC 阻断 ===
|
||||
iptables -A "$CHAIN_NAME" -p udp --dport 443 -j DROP \
|
||||
-m comment --comment "MG: block QUIC/HTTP3 UDP 443"
|
||||
iptables -A "$CHAIN_NAME" -p udp --dport 4433 -j DROP \
|
||||
-m comment --comment "MG: block QUIC alt UDP 4433"
|
||||
|
||||
# === Rule 2: 允许 nodeproxy 出站 TCP 443 ===
|
||||
iptables -A "$CHAIN_NAME" -p tcp --dport 443 \
|
||||
-m owner --uid-owner "$NODE_PROXY_USER" -j ACCEPT \
|
||||
-m comment --comment "MG: allow nodeproxy TCP 443"
|
||||
|
||||
# === Rule 3: 阻止其他进程直连 TCP 443 ===
|
||||
iptables -A "$CHAIN_NAME" -p tcp --dport 443 -j REJECT --reject-with tcp-reset \
|
||||
-m comment --comment "MG: block non-proxy TCP 443"
|
||||
|
||||
# 挂载到 OUTPUT(幂等)
|
||||
if ! iptables -C OUTPUT -j "$CHAIN_NAME" 2>/dev/null; then
|
||||
iptables -A OUTPUT -j "$CHAIN_NAME"
|
||||
fi
|
||||
|
||||
# === Rule 4: IPv6 全面阻断 ===
|
||||
ip6tables -N "${CHAIN_NAME}_V6" 2>/dev/null || ip6tables -F "${CHAIN_NAME}_V6"
|
||||
ip6tables -A "${CHAIN_NAME}_V6" -o lo -j ACCEPT \
|
||||
-m comment --comment "MG: allow IPv6 loopback"
|
||||
ip6tables -A "${CHAIN_NAME}_V6" -j DROP \
|
||||
-m comment --comment "MG: block all IPv6 outbound"
|
||||
if ! ip6tables -C OUTPUT -j "${CHAIN_NAME}_V6" 2>/dev/null; then
|
||||
ip6tables -A OUTPUT -j "${CHAIN_NAME}_V6"
|
||||
fi
|
||||
|
||||
# === Rule 5: TCP TTL 伪装 (macOS TTL = 64) ===
|
||||
# macOS 和 Linux 默认 TTL 都是 64,但数据中心 Linux 有些发行版是 128。
|
||||
# 强制设为 64 确保一致,并防止"服务器离对方 0 跳"露馅。
|
||||
iptables -t mangle -N "${CHAIN_NAME}_TTL" 2>/dev/null || iptables -t mangle -F "${CHAIN_NAME}_TTL"
|
||||
iptables -t mangle -A "${CHAIN_NAME}_TTL" -p tcp --dport 443 \
|
||||
-j TTL --ttl-set 64 \
|
||||
-m comment --comment "MG: spoof TTL=64 (macOS)"
|
||||
if ! iptables -t mangle -C OUTPUT -j "${CHAIN_NAME}_TTL" 2>/dev/null; then
|
||||
iptables -t mangle -A OUTPUT -j "${CHAIN_NAME}_TTL"
|
||||
fi
|
||||
|
||||
log "Firewall rules applied successfully."
|
||||
log " - UDP 443/4433: BLOCKED (QUIC)"
|
||||
log " - TCP 443: ONLY '$NODE_PROXY_USER' allowed"
|
||||
log " - IPv6 outbound: BLOCKED"
|
||||
log " - TCP TTL: FORCED to 64 (macOS spoof)"
|
||||
|
||||
# === TCP Window Size 伪装 (macOS 特征) ===
|
||||
# macOS 初始 TCP 接收窗口约 65535(Linux 服务器默认 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
|
||||
@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# save-patches.sh — 将 Antigravity 自定义改动导出为 patch 文件
|
||||
# 用法: ./tools/scripts/save-patches.sh [输出目录]
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
OUTPUT_DIR="${1:-$REPO_ROOT/tools/patches}"
|
||||
UPSTREAM="origin/main"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# 检查是否有新的 upstream commits
|
||||
DIVERGED=$(git log --oneline "$UPSTREAM"..HEAD 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$DIVERGED" -eq 0 ]; then
|
||||
echo "[save-patches] 没有领先 upstream 的 commits,无需保存。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# 导出 patches
|
||||
git format-patch "$UPSTREAM"..HEAD --output-directory "$OUTPUT_DIR" --no-stat
|
||||
|
||||
COUNT=$(ls "$OUTPUT_DIR"/*.patch 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo "[save-patches] ✅ 已导出 $COUNT 个 patch 到 $OUTPUT_DIR/"
|
||||
echo ""
|
||||
echo "恢复方法(在全新 upstream checkout 上):"
|
||||
echo " git am $OUTPUT_DIR/*.patch"
|
||||
echo " # 或逐一应用:"
|
||||
echo " for p in $OUTPUT_DIR/*.patch; do git am \"\$p\" || git am --skip; done"
|
||||
@ -1,91 +0,0 @@
|
||||
#!/bin/bash
|
||||
# =============================================================
|
||||
# 节点 1:上海服务器
|
||||
# 部署:sub2api + node-tls-proxy + postgres + redis
|
||||
# =============================================================
|
||||
# 用法:bash setup-node1-shanghai.sh
|
||||
# 前置:已安装 Docker,已克隆仓库到当前目录
|
||||
|
||||
set -euo pipefail
|
||||
GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m'
|
||||
ok() { echo -e "${GREEN}✅ $*${NC}"; }
|
||||
info() { echo -e "${YELLOW}ℹ $*${NC}"; }
|
||||
|
||||
echo "================================================"
|
||||
echo " 节点1:上海服务器 部署"
|
||||
echo "================================================"
|
||||
|
||||
# ── 1. 检查 Docker ─────────────────────────────────
|
||||
if ! command -v docker &>/dev/null; then
|
||||
info "安装 Docker..."
|
||||
curl -fsSL https://get.docker.com | bash
|
||||
systemctl enable docker && systemctl start docker
|
||||
fi
|
||||
ok "Docker 已就绪"
|
||||
|
||||
# ── 2. 进入 deploy 目录 ─────────────────────────────
|
||||
# 兼容从仓库根目录执行(/root/sub2api/)或脚本原始位置(antigravity/maintenance/)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [ -d "$SCRIPT_DIR/deploy" ]; then
|
||||
DEPLOY_DIR="$SCRIPT_DIR/deploy"
|
||||
elif [ -d "$(dirname "$SCRIPT_DIR")/deploy" ]; then
|
||||
DEPLOY_DIR="$(dirname "$SCRIPT_DIR")/deploy"
|
||||
elif [ -d "$(dirname "$(dirname "$SCRIPT_DIR")")/deploy" ]; then
|
||||
DEPLOY_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")/deploy"
|
||||
elif [ -d "$(pwd)/deploy" ]; then
|
||||
DEPLOY_DIR="$(pwd)/deploy"
|
||||
else
|
||||
echo "❌ 找不到 deploy/ 目录,请在仓库根目录执行脚本"
|
||||
exit 1
|
||||
fi
|
||||
cd "$DEPLOY_DIR"
|
||||
ok "工作目录: $DEPLOY_DIR"
|
||||
|
||||
# ── 3. 生成 .env(如不存在)──────────────────────────
|
||||
if [ ! -f .env ]; then
|
||||
cat > .env << EOF
|
||||
# ========== 必填 ==========
|
||||
POSTGRES_PASSWORD=$(openssl rand -hex 16)
|
||||
ADMIN_EMAIL=admin@sub2api.local
|
||||
ADMIN_PASSWORD=$(openssl rand -hex 8)
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
TOTP_ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
|
||||
# ========== 时区(上海)==========
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# ========== Gemini OAuth(如有)==========
|
||||
GEMINI_CLI_OAUTH_CLIENT_SECRET=
|
||||
ANTIGRAVITY_OAUTH_CLIENT_SECRET=
|
||||
EOF
|
||||
ok ".env 已生成(node-tls-proxy 在本机,无需额外配置)"
|
||||
fi
|
||||
|
||||
# ── 4. 启动服务 ─────────────────────────────────────
|
||||
info "启动 sub2api + node-tls-proxy..."
|
||||
docker compose -f docker-compose.yml \
|
||||
-f docker-compose.tls-proxy.yml \
|
||||
pull
|
||||
docker compose -f docker-compose.yml \
|
||||
-f docker-compose.tls-proxy.yml \
|
||||
up -d
|
||||
|
||||
ok "服务启动完成"
|
||||
|
||||
# ── 5. 验证 ────────────────────────────────────────
|
||||
sleep 10
|
||||
echo ""
|
||||
echo "【验证】"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
echo ""
|
||||
if curl -sf http://127.0.0.1:8080/health >/dev/null 2>&1; then
|
||||
ok "sub2api 健康检查通过(端口 8080)"
|
||||
else
|
||||
echo "⏳ sub2api 还在启动,等 30 秒后手动检查..."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo " 节点1 部署完成"
|
||||
echo " 管理面板: http://$(curl -sf ipinfo.io/ip 2>/dev/null || echo '<服务器IP>'):8080"
|
||||
echo "================================================"
|
||||
@ -1,97 +0,0 @@
|
||||
#!/bin/bash
|
||||
# =============================================================
|
||||
# 节点 2:海外 CN 中转机
|
||||
# 部署:GOST 双向中转
|
||||
# 接收上海: relay+tls :3456 → 转发到美国落地 :8443
|
||||
# =============================================================
|
||||
# 用法:bash setup-node2-cn-relay.sh
|
||||
|
||||
set -euo pipefail
|
||||
GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m'
|
||||
ok() { echo -e "${GREEN}✅ $*${NC}"; }
|
||||
info() { echo -e "${YELLOW}ℹ $*${NC}"; }
|
||||
fail() { echo -e "${RED}❌ $*${NC}"; }
|
||||
|
||||
# ── 配置(修改这里)──────────────────────────────────
|
||||
US_LANDING_IP="${US_LANDING_IP:-}" # 美国落地机 IP
|
||||
GOST_USER="${GOST_USER:-gostuser}"
|
||||
GOST_PASS="${GOST_PASS:-$(openssl rand -hex 8)}"
|
||||
LISTEN_PORT_FROM_SH="${LISTEN_PORT_FROM_SH:-3456}" # 接收上海的端口
|
||||
LISTEN_PORT_TO_US="${LISTEN_PORT_TO_US:-8443}" # 美国落地机监听端口
|
||||
|
||||
echo "================================================"
|
||||
echo " 节点2:海外CN中转机 部署"
|
||||
echo "================================================"
|
||||
|
||||
# 检查必填
|
||||
if [ -z "$US_LANDING_IP" ]; then
|
||||
read -rp "请输入美国落地机 IP: " US_LANDING_IP
|
||||
fi
|
||||
|
||||
# ── 1. 安装 GOST ────────────────────────────────────
|
||||
if ! command -v gost &>/dev/null; then
|
||||
info "安装 GOST..."
|
||||
ARCH=$(uname -m)
|
||||
[ "$ARCH" = "x86_64" ] && GARCH="amd64" || GARCH="arm64"
|
||||
LATEST=$(curl -sf https://api.github.com/repos/go-gost/gost/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
|
||||
VER="${LATEST#v}"
|
||||
wget -qO /tmp/gost.tar.gz \
|
||||
"https://github.com/go-gost/gost/releases/download/${LATEST}/gost_${VER}_linux_${GARCH}.tar.gz"
|
||||
tar xzf /tmp/gost.tar.gz -C /tmp/
|
||||
mv /tmp/gost /usr/local/bin/gost
|
||||
chmod +x /usr/local/bin/gost
|
||||
fi
|
||||
ok "GOST $(gost -V 2>/dev/null | head -1 || echo '已安装')"
|
||||
|
||||
# ── 2. 创建 Systemd 服务 ────────────────────────────
|
||||
# 中转机职责:
|
||||
# - 接收上海 sub2api 发来的 relay+tls 连接(:3456)
|
||||
# - 将流量通过 relay+tls 转发到美国落地机(:8443)
|
||||
cat > /etc/systemd/system/gost-sub2api-relay.service << EOF
|
||||
[Unit]
|
||||
Description=GOST sub2api CN Relay - 接收上海转发到美国落地
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nobody
|
||||
ExecStart=/usr/local/bin/gost \\
|
||||
-L "http://${GOST_USER}:${GOST_PASS}@:${LISTEN_PORT_FROM_SH}" \\
|
||||
-F "relay+tls://${GOST_USER}:${GOST_PASS}@${US_LANDING_IP}:${LISTEN_PORT_TO_US}"
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable gost-sub2api-relay
|
||||
systemctl restart gost-sub2api-relay
|
||||
sleep 2
|
||||
ok "GOST 中转服务已启动(服务名: gost-sub2api-relay,不影响现有 gost-relay)"
|
||||
|
||||
# ── 3. 防火墙开放端口 ───────────────────────────────
|
||||
if command -v ufw &>/dev/null; then
|
||||
ufw allow "${LISTEN_PORT_FROM_SH}/tcp" comment "GOST from Shanghai" 2>/dev/null || true
|
||||
ufw allow ssh 2>/dev/null || true
|
||||
ok "ufw 端口已开放"
|
||||
fi
|
||||
|
||||
# ── 4. 输出上海配置 ─────────────────────────────────
|
||||
MY_IP=$(curl -sf ipinfo.io/ip 2>/dev/null || echo '<本机IP>')
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo " 节点2 部署完成"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "【上海服务器 .env 填写以下值】"
|
||||
echo " GATEWAY_NODE_TLS_PROXY_LISTEN_HOST=${MY_IP}"
|
||||
echo " GATEWAY_NODE_TLS_PROXY_LISTEN_PORT=${LISTEN_PORT_FROM_SH}"
|
||||
echo ""
|
||||
echo "【GOST 认证信息(勿泄露)】"
|
||||
echo " 用户名: ${GOST_USER}"
|
||||
echo " 密码: ${GOST_PASS}"
|
||||
echo ""
|
||||
systemctl status gost-sub2api-relay --no-pager -l | tail -5
|
||||
@ -1,144 +0,0 @@
|
||||
#!/bin/bash
|
||||
# =============================================================
|
||||
# 节点 3:美国落地机(Debian 12,洛杉矶)
|
||||
# 部署:GOST 出口 + TCP 指纹伪装
|
||||
# 接收 CN中转 relay+tls :8443 → 直连 Anthropic/Google
|
||||
# =============================================================
|
||||
# 用法:sudo bash setup-node3-us-landing.sh
|
||||
|
||||
set -euo pipefail
|
||||
GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m'
|
||||
ok() { echo -e "${GREEN}✅ $*${NC}"; }
|
||||
info() { echo -e "${YELLOW}ℹ $*${NC}"; }
|
||||
fail() { echo -e "${RED}❌ $*${NC}"; }
|
||||
|
||||
GOST_USER="${GOST_USER:-gostuser}"
|
||||
GOST_PASS="${GOST_PASS:-}" # 与 CN中转机相同,启动时填写
|
||||
LISTEN_PORT="${LISTEN_PORT:-8443}"
|
||||
|
||||
echo "================================================"
|
||||
echo " 节点3:美国落地机 部署(Debian 12 / LA)"
|
||||
echo "================================================"
|
||||
|
||||
[ "$(id -u)" != "0" ] && { fail "请用 sudo 执行"; exit 1; }
|
||||
|
||||
# ── 1. 系统更新 ─────────────────────────────────────
|
||||
info "更新系统包..."
|
||||
apt-get update -qq && apt-get upgrade -y -qq
|
||||
ok "系统已更新"
|
||||
|
||||
# ── 2. TCP 指纹伪装(macOS 特征)──────────────────────
|
||||
info "应用 TCP 指纹伪装..."
|
||||
|
||||
# 实时生效
|
||||
sysctl -w net.ipv4.ip_default_ttl=64 # TTL=64(macOS 标准)
|
||||
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" # 接收窗口65535(macOS默认)
|
||||
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 信息失败)"
|
||||
@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# sync-upstream.sh — 从 upstream (origin/main) 同步更新,保留自定义改动
|
||||
# 用法: ./tools/scripts/sync-upstream.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
UPSTREAM="origin/main"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo "========================================"
|
||||
echo " Antigravity Fork — Upstream Sync Tool"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Step 1: 检查工作区
|
||||
if ! git diff --quiet || ! git diff --staged --quiet; then
|
||||
echo "❌ 工作区有未提交的改动,请先 git stash 或 git commit"
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Fetch
|
||||
echo "[1/4] Fetching upstream..."
|
||||
git fetch origin
|
||||
|
||||
# Step 3: 检查是否有新 commits
|
||||
NEW=$(git log --oneline HEAD.."$UPSTREAM" 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$NEW" -eq 0 ]; then
|
||||
echo "✅ 已是最新,无需同步。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "上游有 $NEW 个新 commits:"
|
||||
git log --oneline HEAD.."$UPSTREAM"
|
||||
echo ""
|
||||
|
||||
# Step 4: 备份当前 patches
|
||||
PATCH_DIR="/tmp/antigravity-patches-$(date +%Y%m%d-%H%M%S)"
|
||||
echo "[2/4] 备份自定义 patches 到 $PATCH_DIR ..."
|
||||
mkdir -p "$PATCH_DIR"
|
||||
git format-patch "$UPSTREAM"..HEAD -o "$PATCH_DIR/" --no-stat
|
||||
BACKED=$(ls "$PATCH_DIR"/*.patch 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo " 已备份 $BACKED 个 patch"
|
||||
|
||||
# Step 5: Rebase
|
||||
echo ""
|
||||
echo "[3/4] 执行 rebase (git rebase $UPSTREAM)..."
|
||||
echo " 如果出现冲突,请参考 .agents/workflows/sync-upstream.md 中的冲突解决指南"
|
||||
echo ""
|
||||
if ! git rebase "$UPSTREAM"; then
|
||||
echo ""
|
||||
echo "❌ Rebase 出现冲突!"
|
||||
echo ""
|
||||
echo "请按以下步骤处理:"
|
||||
echo " 1. 查看冲突文件: git diff --name-only --diff-filter=U"
|
||||
echo " 2. 解决冲突(参考 .agents/workflows/sync-upstream.md)"
|
||||
echo " 3. git add <解决的文件>"
|
||||
echo " 4. git rebase --continue"
|
||||
echo ""
|
||||
echo "备份的 patches 在: $PATCH_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 6: 编译验证
|
||||
echo "[4/4] 编译验证..."
|
||||
if ! (cd "$REPO_ROOT/backend" && go build ./... 2>&1); then
|
||||
echo ""
|
||||
echo "❌ 编译失败!rebase 后有破坏性改动需要修复。"
|
||||
echo "备份的 patches 在: $PATCH_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ 同步完成!"
|
||||
echo ""
|
||||
echo "自定义改动(我方 commits)已成功移植到最新 upstream 上。"
|
||||
echo "请运行以下命令推送:"
|
||||
echo " git push origin main --force-with-lease"
|
||||
echo ""
|
||||
echo "备份路径(可删除): $PATCH_DIR"
|
||||
@ -1,184 +0,0 @@
|
||||
#!/bin/bash
|
||||
# test-linux.sh — Linux 服务器全量指纹验证脚本
|
||||
# 用途:验证所有 TCP/OS 层伪装 + Node.js proxy 状态
|
||||
# 运行方式:sudo bash test-linux.sh
|
||||
#
|
||||
# 注意:sysctl 和 iptables 检查需要 sudo
|
||||
|
||||
set -euo pipefail
|
||||
GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' NC='\033[0m'
|
||||
ok() { echo -e "${GREEN}✅ $*${NC}"; }
|
||||
fail() { echo -e "${RED}❌ $*${NC}"; }
|
||||
info() { echo -e "${YELLOW}ℹ $*${NC}"; }
|
||||
|
||||
PROXY_PORT="${PROXY_PORT:-3456}"
|
||||
|
||||
echo "══════════════════════════════════════════════"
|
||||
echo " Linux 服务器指纹伪装验证"
|
||||
echo "══════════════════════════════════════════════"
|
||||
|
||||
# ── 1. 时区 ────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "【1】系统时区"
|
||||
TZ_NOW=$(timedatectl show -p Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || date +%Z)
|
||||
echo " 当前时区: $TZ_NOW"
|
||||
echo " 当前时间: $(date)"
|
||||
if [[ "$TZ_NOW" == "America/New_York" ]]; then
|
||||
ok "时区正确(America/New_York)"
|
||||
else
|
||||
fail "时区错误(应为 America/New_York,当前: $TZ_NOW)"
|
||||
info "修复: sudo timedatectl set-timezone America/New_York"
|
||||
fi
|
||||
|
||||
# ── 2. TCP 时间戳 ──────────────────────────────────────────
|
||||
echo ""
|
||||
echo "【2】TCP 时间戳(防 uptime 推算)"
|
||||
TS=$(sysctl -n net.ipv4.tcp_timestamps 2>/dev/null || echo "unknown")
|
||||
echo " net.ipv4.tcp_timestamps = $TS"
|
||||
if [[ "$TS" == "0" ]]; then
|
||||
ok "TCP 时间戳已禁用"
|
||||
else
|
||||
fail "TCP 时间戳未禁用(当前: $TS,应为 0)"
|
||||
info "修复: sudo sysctl -w net.ipv4.tcp_timestamps=0"
|
||||
fi
|
||||
|
||||
# ── 3. TTL ─────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "【3】出站 TTL(macOS 特征)"
|
||||
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=64(macOS/Linux 标准值)"
|
||||
else
|
||||
fail "TTL 不为 64(当前: $TTL)"
|
||||
fi
|
||||
|
||||
# ── 4. TCP Window Size ─────────────────────────────────────
|
||||
echo ""
|
||||
echo "【4】TCP Window Size(macOS 特征)"
|
||||
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 包含 65535(macOS 特征)"
|
||||
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_VER),JA4 指纹可能不匹配"
|
||||
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 "══════════════════════════════════════════════"
|
||||
@ -1,117 +0,0 @@
|
||||
#!/bin/bash
|
||||
# test-mac.sh — 本机(Mac)指纹验证脚本
|
||||
# 用途:验证 Node.js TLS 指纹、H2 连通性
|
||||
# 运行方式:bash test-mac.sh
|
||||
|
||||
set -euo pipefail
|
||||
GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' NC='\033[0m'
|
||||
ok() { echo -e "${GREEN}✅ $*${NC}"; }
|
||||
fail() { echo -e "${RED}❌ $*${NC}"; }
|
||||
info() { echo -e "${YELLOW}ℹ $*${NC}"; }
|
||||
|
||||
echo "══════════════════════════════════════"
|
||||
echo " MAC 本地指纹验证"
|
||||
echo "══════════════════════════════════════"
|
||||
|
||||
# ── 1. Node.js 版本 ────────────────────────────────────────
|
||||
echo ""
|
||||
echo "【1】Node.js 版本"
|
||||
NODE_VER=$(node --version 2>/dev/null || echo "未安装")
|
||||
echo " 本机 Node: $NODE_VER"
|
||||
if [[ "$NODE_VER" == v22* ]]; then
|
||||
ok "Node.js v22.x — 与 Claude CLI 内置版本匹配"
|
||||
else
|
||||
fail "Node.js 版本不是 v22.x(当前: $NODE_VER),JA4 指纹可能不一致"
|
||||
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 "══════════════════════════════════════"
|
||||
@ -1,114 +0,0 @@
|
||||
#!/bin/bash
|
||||
# update-cli-version.sh — 自动追踪并更新 Claude CLI 版本号
|
||||
#
|
||||
# 原理:
|
||||
# 从 npm registry 拉取 @anthropic-ai/claude-code 最新版本,
|
||||
# 更新 proxy.js 和 docker-compose 中的 CLI_VERSION 环境变量。
|
||||
# 建议通过 cron 每天运行一次。
|
||||
#
|
||||
# 用法:
|
||||
# bash update-cli-version.sh # 检查并更新
|
||||
# bash update-cli-version.sh --check # 仅检查,不写入
|
||||
# bash update-cli-version.sh --force VER # 强制设定版本
|
||||
#
|
||||
# cron 示例(每天 3 点,时区 America/New_York):
|
||||
# 0 3 * * * /bin/bash /path/to/update-cli-version.sh >> /var/log/cli-version.log 2>&1
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROXY_JS="$(dirname "$0")/../node-tls-proxy/proxy.js"
|
||||
LOG_FILE="/tmp/cli-version-update.log"
|
||||
DRY_RUN=false
|
||||
FORCE_VERSION=""
|
||||
|
||||
# 解析参数
|
||||
case "${1:-}" in
|
||||
--check) DRY_RUN=true ;;
|
||||
--force) FORCE_VERSION="${2:-}" ;;
|
||||
esac
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S') ET] $*" | tee -a "$LOG_FILE"; }
|
||||
|
||||
# ── 当前版本 ──────────────────────────────────────────────────
|
||||
current_version() {
|
||||
grep -oP "CLI_VERSION = process\.env\.CLI_VERSION \|\| '\K[0-9]+\.[0-9]+\.[0-9]+" "$PROXY_JS" 2>/dev/null || echo "unknown"
|
||||
}
|
||||
|
||||
# ── 从 npm 拉取最新版本 ───────────────────────────────────────
|
||||
fetch_latest_version() {
|
||||
# 尝试 npm registry (JSON API)
|
||||
local ver
|
||||
ver=$(curl -sf --max-time 10 \
|
||||
"https://registry.npmjs.org/@anthropic-ai/claude-code/latest" \
|
||||
| grep -oP '"version"\s*:\s*"\K[0-9]+\.[0-9]+\.[0-9]+' \
|
||||
| head -1) || true
|
||||
|
||||
if [ -z "$ver" ]; then
|
||||
# 备用:npm view(需要 npm 可用)
|
||||
ver=$(npm view @anthropic-ai/claude-code version 2>/dev/null) || true
|
||||
fi
|
||||
|
||||
echo "${ver:-}"
|
||||
}
|
||||
|
||||
# ── 版本比较:$1 > $2 时返回 0 ──────────────────────────────
|
||||
version_gt() {
|
||||
local a="$1" b="$2"
|
||||
[ "$a" = "$b" ] && return 1
|
||||
local sorted
|
||||
sorted=$(printf '%s\n%s\n' "$a" "$b" | sort -V | head -1)
|
||||
[ "$sorted" = "$b" ]
|
||||
}
|
||||
|
||||
# ── 更新 proxy.js 中的版本号 ─────────────────────────────────
|
||||
update_proxy_js() {
|
||||
local new_ver="$1"
|
||||
if [ ! -f "$PROXY_JS" ]; then
|
||||
log "ERROR: proxy.js not found at $PROXY_JS"
|
||||
return 1
|
||||
fi
|
||||
sed -i "s|CLI_VERSION = process\.env\.CLI_VERSION || '[0-9.]*'|CLI_VERSION = process.env.CLI_VERSION || '${new_ver}'|" "$PROXY_JS"
|
||||
log " proxy.js: CLI_VERSION updated to $new_ver"
|
||||
}
|
||||
|
||||
# ── 主流程 ────────────────────────────────────────────────────
|
||||
main() {
|
||||
local current latest
|
||||
|
||||
current=$(current_version)
|
||||
log "Current CLI_VERSION: $current"
|
||||
|
||||
if [ -n "$FORCE_VERSION" ]; then
|
||||
latest="$FORCE_VERSION"
|
||||
log "Force mode: target version = $latest"
|
||||
else
|
||||
log "Fetching latest version from npm..."
|
||||
latest=$(fetch_latest_version)
|
||||
if [ -z "$latest" ]; then
|
||||
log "ERROR: Failed to fetch version from npm. Keeping current."
|
||||
exit 1
|
||||
fi
|
||||
log "Latest CLI_VERSION on npm: $latest"
|
||||
fi
|
||||
|
||||
if [ "$current" = "$latest" ]; then
|
||||
log "Already up to date ($current). No changes needed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! version_gt "$latest" "$current" && [ -z "$FORCE_VERSION" ]; then
|
||||
log "npm version ($latest) is not newer than current ($current). Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if $DRY_RUN; then
|
||||
log "DRY RUN: would update $current -> $latest (use without --check to apply)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "Updating $current -> $latest ..."
|
||||
update_proxy_js "$latest"
|
||||
log "Done. Restart node-tls-proxy to apply: docker compose restart node-tls-proxy"
|
||||
}
|
||||
|
||||
main
|
||||
@ -1,24 +0,0 @@
|
||||
FROM node:24.13.0-slim
|
||||
|
||||
LABEL maintainer="Wei-Shaw <github.com/Wei-Shaw>"
|
||||
LABEL description="Node.js TLS Forward Proxy - native JA3/JA4 fingerprint matching"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY proxy.js package.json ./
|
||||
|
||||
# 零依赖,不需要 npm install
|
||||
|
||||
ENV PROXY_PORT=3456
|
||||
ENV PROXY_HOST=0.0.0.0
|
||||
ENV UPSTREAM_HOST=api.anthropic.com
|
||||
|
||||
EXPOSE 3456
|
||||
|
||||
# 健康检查:用 Node.js 内置 http 模块,不依赖 curl
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=5s \
|
||||
CMD node -e "const http=require('http');const r=http.get('http://127.0.0.1:'+(process.env.PROXY_PORT||3456)+'/__health',s=>{process.exit(s.statusCode===200?0:1)});r.on('error',()=>process.exit(1));r.setTimeout(3000,()=>{r.destroy();process.exit(1)})"
|
||||
|
||||
USER node
|
||||
CMD ["node", "proxy.js"]
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "node-tls-proxy",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Node.js TLS forward proxy for native JA3/JA4 fingerprint matching",
|
||||
"main": "proxy.js",
|
||||
"scripts": {
|
||||
"start": "node proxy.js",
|
||||
"health": "curl -s http://127.0.0.1:${PROXY_PORT:-3456}/__health | jq ."
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
@ -1,737 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const http2 = require('http2');
|
||||
const net = require('net');
|
||||
const crypto = require('crypto');
|
||||
// os 模块不引用 — 避免暴露真实主机信息
|
||||
|
||||
// ─── 配置 ───────────────────────────────────────────────
|
||||
const UPSTREAM_HOST = process.env.UPSTREAM_HOST || 'api.anthropic.com';
|
||||
const LISTEN_PORT = parseInt(process.env.PROXY_PORT || '3456', 10);
|
||||
const LISTEN_HOST = process.env.PROXY_HOST || '127.0.0.1';
|
||||
const UPSTREAM_PROXY = process.env.UPSTREAM_PROXY || '';
|
||||
const CONNECT_TIMEOUT = parseInt(process.env.CONNECT_TIMEOUT || '30000', 10);
|
||||
const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '600000', 10);
|
||||
const TELEMETRY_ENABLED = process.env.TELEMETRY_ENABLED !== 'false'; // 默认开启
|
||||
const DD_API_KEY = process.env.DD_API_KEY || 'pubbbf48e6d78dae54bceaa4acf463299bf';
|
||||
const CLI_VERSION = process.env.CLI_VERSION || '2.1.84';
|
||||
const BUILD_TIME = process.env.BUILD_TIME || '2026-03-25T23:49:18Z';
|
||||
// 伪装的 Node 版本(CLI 2.1.84 打包的 Bun 报告的 Node 兼容版本)
|
||||
const FAKE_NODE_VERSION = process.env.FAKE_NODE_VERSION || 'v24.3.0';
|
||||
|
||||
const log = (level, msg, extra = {}) => {
|
||||
const entry = { time: new Date().toISOString(), level, msg, ...extra };
|
||||
process.stderr.write(JSON.stringify(entry) + '\n');
|
||||
};
|
||||
|
||||
const HEALTH_PATH = '/__health';
|
||||
const h2Hosts = new Set();
|
||||
const h2Sessions = new Map();
|
||||
|
||||
// ─── 虚拟主机身份生成 ─────────────────────────────────────
|
||||
// 每个账号基于 seed 生成全局唯一的主机身份,看起来像一台真实的个人开发机
|
||||
// 匹配 CLI 的 OTEL detectResources: hostDetector + processDetector + serviceInstanceIdDetector
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. 同一账号(seed)永远产出同一台"机器"的特征
|
||||
// 2. 不同账号的特征互不相同(无共享池、无碰撞)
|
||||
// 3. 每个字段都像人手动设置的,不是程序生成的
|
||||
|
||||
// ─── macOS 主机身份词表 ──────────────────────────────────────────
|
||||
// macOS 用户 hostname 习惯: "alex-MBP", "sam-MacBook-Pro" 等
|
||||
const MBP_NAMES = ['alex','sam','chris','max','lee','kai','jamie','taylor','morgan','casey',
|
||||
'drew','avery','riley','blake','jordan','ryan','parker','quinn','reese','cameron'];
|
||||
const MBP_SUFFIX = ['-MBP','-MacBook','-MacBook-Pro','-MacBook-Air',"s-MBP","s-MacBook","s-MacBook-Pro"];
|
||||
|
||||
function generateHostIdentity(seed) {
|
||||
const h = (s) => crypto.createHash('sha256').update(seed + ':' + s).digest();
|
||||
|
||||
// ── hostname: macOS 风格 ──
|
||||
const hb = h('hostname');
|
||||
const name = MBP_NAMES[hb.readUInt8(0) % MBP_NAMES.length];
|
||||
const sfx = MBP_SUFFIX[hb.readUInt8(1) % MBP_SUFFIX.length];
|
||||
const hostname = `${name}${sfx}`;
|
||||
|
||||
// ── username: 取自 hostname 名字(真实 Mac 行为) ──
|
||||
const username = name;
|
||||
|
||||
// ── terminal: macOS 常见终端分布 ──
|
||||
const termRoll = h('terminal').readUInt8(0) % 100;
|
||||
const terminal = termRoll < 75 ? 'xterm-256color' :
|
||||
termRoll < 88 ? 'screen-256color' :
|
||||
termRoll < 96 ? 'alacritty' : 'kitty';
|
||||
|
||||
// ── shell: macOS 默认 zsh(Catalina+);部分用 bash/fish ──
|
||||
const shellRoll = h('shell').readUInt8(0) % 100;
|
||||
const shell = shellRoll < 65 ? '/bin/zsh' :
|
||||
shellRoll < 82 ? '/usr/local/bin/zsh' :
|
||||
shellRoll < 93 ? '/bin/bash' : '/opt/homebrew/bin/fish';
|
||||
|
||||
// ── host.id: macOS IOPlatformUUID 格式(大写 UUID) ──
|
||||
const mid = h('machine-id');
|
||||
const machineId = [
|
||||
mid.slice(0,4).toString('hex').toUpperCase(),
|
||||
mid.slice(4,6).toString('hex').toUpperCase(),
|
||||
mid.slice(6,8).toString('hex').toUpperCase(),
|
||||
mid.slice(8,10).toString('hex').toUpperCase(),
|
||||
mid.slice(10,16).toString('hex').toUpperCase(),
|
||||
].join('-');
|
||||
|
||||
// ── PID: macOS GUI 应用 PID 通常较小 ──
|
||||
const pid = 500 + Math.floor(Math.random() * 8000);
|
||||
|
||||
// ── macOS 版本: 13(Ventura)/14(Sonoma)/15(Sequoia) ──
|
||||
const kb = h('kernel');
|
||||
const macosMajor = 13 + (kb.readUInt8(0) % 3);
|
||||
const macosMinor = kb.readUInt8(1) % 8;
|
||||
const macosPatch = kb.readUInt8(2) % 5;
|
||||
// Darwin 内核: macOS 13=22.x, 14=23.x, 15=24.x
|
||||
const darwinMajor = 22 + (macosMajor - 13);
|
||||
const darwinMinor = kb.readUInt8(3) % 7;
|
||||
const darwinPatch = kb.readUInt8(4) % 5;
|
||||
const osVersion = `${macosMajor}.${macosMinor}.${macosPatch}`;
|
||||
|
||||
// ── arch: Apple Silicon arm64 占 70%,Intel x64 占 30% ──
|
||||
const arch = h('arch').readUInt8(0) % 100 < 70 ? 'arm64' : 'x64';
|
||||
|
||||
// ── 可执行文件路径: macOS 常见安装位置 ──
|
||||
const pathRoll = h('execpath').readUInt8(0) % 100;
|
||||
const executablePath = pathRoll < 50 ? `/Users/${username}/.claude/local/claude` :
|
||||
pathRoll < 80 ? '/usr/local/bin/claude' :
|
||||
pathRoll < 95 ? `/Users/${username}/.local/bin/claude` :
|
||||
'/opt/homebrew/bin/claude';
|
||||
|
||||
return {
|
||||
hostname, username, terminal, shell, machineId, pid, arch,
|
||||
osType: 'Darwin',
|
||||
osVersion,
|
||||
kernelRelease: `${darwinMajor}.${darwinMinor}.${darwinPatch}`,
|
||||
serviceInstanceId: crypto.randomUUID(),
|
||||
executablePath,
|
||||
executableName: 'claude',
|
||||
command: 'claude',
|
||||
commandArgs: [],
|
||||
runtimeName: 'nodejs',
|
||||
runtimeVersion: FAKE_NODE_VERSION.replace('v', ''),
|
||||
ripgrepVersion: (() => {
|
||||
const rv = h('ripgrep');
|
||||
return ['14.1.1','14.1.0','14.0.2','13.0.0','13.0.1','14.0.1','14.0.0'][rv.readUInt8(0) % 7];
|
||||
})(),
|
||||
ripgrepPath: (() => {
|
||||
const rp = h('rgpath');
|
||||
return [
|
||||
'/opt/homebrew/bin/rg',
|
||||
'/usr/local/bin/rg',
|
||||
`/Users/${username}/.cargo/bin/rg`,
|
||||
'/usr/local/opt/ripgrep/bin/rg',
|
||||
][rp.readUInt8(0) % 4];
|
||||
})(),
|
||||
mcpServerCount: 1 + (h('mcp').readUInt8(0) % 5),
|
||||
mcpFailCount: h('mcp').readUInt8(1) % 3,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 遥测模拟 ────────────────────────────────────────────
|
||||
|
||||
// 每个 device_id 的会话状态
|
||||
const sessionStates = new Map();
|
||||
|
||||
function getOrCreateSession(deviceId) {
|
||||
if (sessionStates.has(deviceId)) return sessionStates.get(deviceId);
|
||||
const hostId = generateHostIdentity(deviceId);
|
||||
const state = {
|
||||
sessionId: crypto.randomUUID(),
|
||||
deviceId,
|
||||
hostId,
|
||||
startTime: Date.now(),
|
||||
requestCount: 0,
|
||||
// 追踪 ripgrep 是否已上报
|
||||
ripgrepReported: false,
|
||||
};
|
||||
sessionStates.set(deviceId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function generateDeviceId(accountSeed) {
|
||||
return crypto.createHash('sha256').update(`device:${accountSeed}`).digest('hex');
|
||||
}
|
||||
|
||||
// ─── OTEL Resource Attributes (匹配 CLI 的 detectResources) ───
|
||||
|
||||
function buildEnvBlock(hostId) {
|
||||
const platformStr = 'darwin';
|
||||
return {
|
||||
platform: platformStr,
|
||||
node_version: FAKE_NODE_VERSION,
|
||||
terminal: hostId.terminal,
|
||||
package_managers: 'npm,pnpm',
|
||||
runtimes: 'deno,node',
|
||||
is_running_with_bun: true,
|
||||
is_ci: false,
|
||||
is_claubbit: false,
|
||||
is_github_action: false,
|
||||
is_claude_code_action: false,
|
||||
is_claude_ai_auth: false,
|
||||
version: CLI_VERSION,
|
||||
arch: hostId.arch,
|
||||
is_claude_code_remote: false,
|
||||
deployment_environment: `unknown-${platformStr}`,
|
||||
is_conductor: false,
|
||||
version_base: CLI_VERSION,
|
||||
build_time: BUILD_TIME,
|
||||
is_local_agent_mode: false,
|
||||
vcs: 'git',
|
||||
platform_raw: platformStr,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProcessMetrics(uptime) {
|
||||
// 模拟真实 CLI 的内存曲线:RSS 随 uptime 缓慢增长
|
||||
const baseRss = 180_000_000 + Math.min(uptime * 50_000, 200_000_000);
|
||||
const rss = Math.floor(baseRss + Math.random() * 80_000_000);
|
||||
const heapTotal = Math.floor(rss * 0.6 + Math.random() * 10_000_000);
|
||||
const heapUsed = Math.floor(heapTotal * 0.5 + Math.random() * heapTotal * 0.3);
|
||||
return Buffer.from(JSON.stringify({
|
||||
uptime,
|
||||
rss,
|
||||
heapTotal,
|
||||
heapUsed,
|
||||
external: 14_000_000 + Math.floor(Math.random() * 2_000_000),
|
||||
arrayBuffers: Math.floor(Math.random() * 200_000),
|
||||
constrainedMemory: 51539607552,
|
||||
cpuUsage: {
|
||||
user: Math.floor(uptime * 10_000 + Math.random() * 300_000),
|
||||
system: Math.floor(uptime * 2_000 + Math.random() * 80_000),
|
||||
},
|
||||
cpuPercent: Math.random() * 200,
|
||||
})).toString('base64');
|
||||
}
|
||||
|
||||
function buildEvent(eventName, session, model, betas, extraData, timestampOverride) {
|
||||
const uptime = (Date.now() - session.startTime) / 1000;
|
||||
const processMetrics = buildProcessMetrics(uptime);
|
||||
// 缓存最近一次的 process metrics,供 DataDog 日志复用(保持两边一致)
|
||||
session._lastProcessMetrics = { uptime, raw: processMetrics };
|
||||
const eventData = {
|
||||
event_name: eventName,
|
||||
client_timestamp: timestampOverride || new Date().toISOString(),
|
||||
model: model || 'claude-sonnet-4-6',
|
||||
session_id: session.sessionId,
|
||||
user_type: 'external',
|
||||
betas: betas || 'claude-code-20250219,interleaved-thinking-2025-05-14',
|
||||
env: buildEnvBlock(session.hostId),
|
||||
entrypoint: 'cli',
|
||||
is_interactive: true,
|
||||
client_type: 'cli',
|
||||
process: processMetrics,
|
||||
event_id: crypto.randomUUID(),
|
||||
device_id: session.deviceId,
|
||||
// 注意:不加 resource 字段 — event_logging/batch 是自定义端点,
|
||||
// OTEL resource attributes 由 CLI 通过单独的 OTLP exporter 发送,不在这里
|
||||
};
|
||||
// 合并额外字段(用于特定事件的附加数据)
|
||||
if (extraData) Object.assign(eventData, extraData);
|
||||
return {
|
||||
event_type: 'ClaudeCodeInternalEvent',
|
||||
event_data: eventData,
|
||||
};
|
||||
}
|
||||
|
||||
// 发送遥测到 api.anthropic.com/api/event_logging/batch
|
||||
function sendTelemetryEvents(events, session) {
|
||||
if (!TELEMETRY_ENABLED || events.length === 0) return;
|
||||
|
||||
const body = JSON.stringify({ events });
|
||||
const headers = {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': `claude-code/${CLI_VERSION}`,
|
||||
'x-service-name': 'claude-code',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
};
|
||||
// 注意:真实 CLI 2.1.84 的 event_logging/batch 不发 traceparent
|
||||
// traceparent 仅在 OTLP exporter(单独通道)中使用,不在这个端点
|
||||
|
||||
const opts = {
|
||||
hostname: 'api.anthropic.com',
|
||||
port: 443,
|
||||
path: '/api/event_logging/batch',
|
||||
method: 'POST',
|
||||
headers,
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
const req = https.request(opts, (res) => {
|
||||
res.resume(); // drain
|
||||
log('debug', 'telemetry_sent', { status: res.statusCode, events: events.length });
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
log('debug', 'telemetry_error', { error: err.message });
|
||||
});
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.end(body);
|
||||
}
|
||||
|
||||
// 发送 DataDog 日志
|
||||
function sendDatadogLog(eventName, session, model) {
|
||||
if (!TELEMETRY_ENABLED) return;
|
||||
|
||||
const hostId = session.hostId;
|
||||
const uptime = (Date.now() - session.startTime) / 1000;
|
||||
|
||||
// 复用 Anthropic 事件侧缓存的 process metrics(保持两边数值一致)
|
||||
// 如果没有缓存(首次调用),现场生成
|
||||
let pm;
|
||||
if (session._lastProcessMetrics && Math.abs(session._lastProcessMetrics.uptime - uptime) < 2) {
|
||||
pm = JSON.parse(Buffer.from(session._lastProcessMetrics.raw, 'base64').toString());
|
||||
} else {
|
||||
const baseRss = 180_000_000 + Math.min(uptime * 50_000, 200_000_000);
|
||||
const rss = Math.floor(baseRss + Math.random() * 80_000_000);
|
||||
const heapTotal = Math.floor(rss * 0.6 + Math.random() * 10_000_000);
|
||||
const heapUsed = Math.floor(heapTotal * 0.5 + Math.random() * heapTotal * 0.3);
|
||||
pm = {
|
||||
uptime,
|
||||
rss,
|
||||
heapTotal,
|
||||
heapUsed,
|
||||
external: 14_000_000 + Math.floor(Math.random() * 2_000_000),
|
||||
arrayBuffers: Math.floor(Math.random() * 10_000),
|
||||
constrainedMemory: 0,
|
||||
cpuUsage: {
|
||||
user: Math.floor(uptime * 10_000 + Math.random() * 300_000),
|
||||
system: Math.floor(uptime * 2_000 + Math.random() * 80_000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const entry = {
|
||||
ddsource: 'nodejs',
|
||||
ddtags: `event:${eventName},arch:${hostId.arch},client_type:cli,model:${model || 'claude-sonnet-4-6'},platform:darwin,user_type:external,version:${CLI_VERSION},version_base:${CLI_VERSION}`,
|
||||
message: eventName,
|
||||
service: 'claude-code',
|
||||
hostname: hostId.hostname,
|
||||
env: 'external',
|
||||
model: model || 'claude-sonnet-4-6',
|
||||
session_id: session.sessionId,
|
||||
user_type: 'external',
|
||||
entrypoint: 'cli',
|
||||
is_interactive: 'true',
|
||||
client_type: 'cli',
|
||||
process_metrics: pm,
|
||||
platform: 'darwin',
|
||||
platform_raw: 'darwin',
|
||||
arch: hostId.arch,
|
||||
node_version: FAKE_NODE_VERSION,
|
||||
version: CLI_VERSION,
|
||||
version_base: CLI_VERSION,
|
||||
build_time: BUILD_TIME,
|
||||
deployment_environment: 'unknown-darwin',
|
||||
vcs: 'git',
|
||||
};
|
||||
|
||||
const body = JSON.stringify([entry]);
|
||||
const opts = {
|
||||
hostname: 'http-intake.logs.us5.datadoghq.com',
|
||||
port: 443,
|
||||
path: '/api/v2/logs',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'axios/1.13.6',
|
||||
'dd-api-key': DD_API_KEY,
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
const req = https.request(opts, (res) => { res.resume(); });
|
||||
req.on('error', () => {});
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.end(body);
|
||||
}
|
||||
|
||||
// 请求前发遥测(模拟 CLI 启动 + 初始化事件)
|
||||
function emitPreRequestTelemetry(reqHeaders, body) {
|
||||
const accountSeed = reqHeaders['x-forwarded-host'] || 'default';
|
||||
const deviceId = generateDeviceId(accountSeed + ':' + (reqHeaders['authorization'] || '').slice(-16));
|
||||
const session = getOrCreateSession(deviceId);
|
||||
session.requestCount++;
|
||||
|
||||
// 从请求体解析真实 model
|
||||
let model = 'claude-sonnet-4-6';
|
||||
try {
|
||||
const parsed = JSON.parse(body.toString());
|
||||
if (parsed.model) model = parsed.model;
|
||||
} catch (_) {}
|
||||
|
||||
const betas = reqHeaders['anthropic-beta'] || 'claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24';
|
||||
|
||||
// 首次请求:发完整启动事件序列(匹配真实 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-300ms),20% 慢速思考(400-1200ms)
|
||||
// 使用 -log(rand) 指数衰减使延迟尾部更接近真实键盘输入节奏
|
||||
const jitterMs = (() => {
|
||||
if (Math.random() < 0.80) {
|
||||
return Math.floor(80 + (-Math.log(Math.random()) * 90)); // 快:~80-300ms
|
||||
}
|
||||
return Math.floor(400 + Math.random() * 800); // 慢:400-1200ms
|
||||
})();
|
||||
await new Promise(r => setTimeout(r, jitterMs));
|
||||
|
||||
// ── H2 优先策略 ──────────────────────────────────────────────────
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
|
||||
// 定期清理过期 session(1 小时无活动)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, state] of sessionStates) {
|
||||
if (now - state.startTime > 3600_000) sessionStates.delete(id);
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
let stopping = false;
|
||||
function shutdown(sig) {
|
||||
if (stopping) return; stopping = true;
|
||||
for (const s of h2Sessions.values()) try { s.close(); } catch (_) {}
|
||||
h2Sessions.clear();
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(1), 5000);
|
||||
}
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('uncaughtException', (e) => log('error', 'uncaught', { error: e.message }));
|
||||
process.on('unhandledRejection', (r) => log('error', 'rejection', { error: String(r) }));
|
||||
BIN
assets/partners/logos/packycode.png
Normal file
BIN
assets/partners/logos/packycode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
@ -4,7 +4,7 @@ VERSION ?= $(shell tr -d '\r\n' < ./cmd/server/VERSION)
|
||||
LDFLAGS ?= -s -w -X main.Version=$(VERSION)
|
||||
|
||||
build:
|
||||
CGO_ENABLED=1 GOEXPERIMENT=boringcrypto go build -ldflags="$(LDFLAGS)" -trimpath -o bin/server ./cmd/server
|
||||
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -trimpath -o bin/server ./cmd/server
|
||||
|
||||
generate:
|
||||
go generate ./ent
|
||||
|
||||
@ -1 +1 @@
|
||||
0.1.105
|
||||
0.1.106
|
||||
|
||||
@ -142,7 +142,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
|
||||
schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig)
|
||||
antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oauthRefreshAPI, tempUnschedCache)
|
||||
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService)
|
||||
internal500CounterCache := repository.NewInternal500CounterCache(redisClient)
|
||||
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache)
|
||||
tlsFingerprintProfileRepository := repository.NewTLSFingerprintProfileRepository(client)
|
||||
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
|
||||
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
|
||||
@ -153,8 +154,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
rpmCache := repository.NewRPMCache(redisClient)
|
||||
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
|
||||
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
|
||||
riskRepository := service.NewRiskRepository(db, settingRepository, redisClient)
|
||||
riskService := service.NewRiskService(riskRepository, settingRepository, redisClient)
|
||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
|
||||
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
||||
dataManagementService := service.NewDataManagementService()
|
||||
@ -181,9 +180,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
||||
claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oauthRefreshAPI)
|
||||
digestSessionStore := service.NewDigestSessionStore()
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, riskService)
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService)
|
||||
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oauthRefreshAPI)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, riskService)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider)
|
||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
|
||||
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
|
||||
opsService := service.NewOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink)
|
||||
@ -219,8 +218,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db)
|
||||
scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository)
|
||||
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
|
||||
riskHandler := admin.NewRiskHandler(riskService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, riskHandler)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
|
||||
@ -456,11 +456,6 @@ type GatewayConfig struct {
|
||||
// TLSFingerprint: TLS指纹伪装配置
|
||||
TLSFingerprint TLSFingerprintConfig `mapstructure:"tls_fingerprint"`
|
||||
|
||||
// NodeTLSProxy: Node.js TLS 代理配置
|
||||
// 启用后,上游请求通过本地 Node.js 进程发起 TLS 连接,
|
||||
// 实现天然 JA3/JA4 指纹匹配(无需 uTLS 模拟)
|
||||
NodeTLSProxy NodeTLSProxyConfig `mapstructure:"node_tls_proxy"`
|
||||
|
||||
// InstanceSalt: 实例级隔离盐值
|
||||
// 用于 user_id 重写和 session hash 的种子混淆,
|
||||
// 不同 sub2api 实例设置不同的 salt,确保相同输入产生不同输出。
|
||||
@ -702,28 +697,6 @@ type TLSProfileConfig struct {
|
||||
Extensions []uint16 `mapstructure:"extensions"`
|
||||
}
|
||||
|
||||
// NodeTLSProxyConfig Node.js TLS 代理配置
|
||||
// 启用后,上游 HTTPS 请求不再由 Go 直接发起,而是通过本地 Node.js 进程中转。
|
||||
// Node.js 使用原生 OpenSSL TLS 栈,其 JA3/JA4 指纹天然匹配 Claude CLI。
|
||||
type NodeTLSProxyConfig struct {
|
||||
// Enabled: 是否启用 Node.js TLS 代理(默认关闭,保持向后兼容)
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
// ListenPort: Node.js 代理监听端口(默认 3456)
|
||||
ListenPort int `mapstructure:"listen_port"`
|
||||
// ListenHost: Node.js 代理监听地址(默认 127.0.0.1)
|
||||
ListenHost string `mapstructure:"listen_host"`
|
||||
// HealthPath: 健康检查路径(默认 /__health)
|
||||
HealthPath string `mapstructure:"health_path"`
|
||||
// UpstreamHost: 上游目标主机(默认 api.anthropic.com)
|
||||
// 通常不需要修改,除非需要指向不同的 API 端点
|
||||
UpstreamHost string `mapstructure:"upstream_host"`
|
||||
// ProxyHosts: 需要走 Node.js 代理的上游主机白名单
|
||||
// 只有目标主机在此列表中的 HTTPS 请求才走 Node.js 代理
|
||||
// 不在列表中的请求走原有路径(uTLS 或直连)
|
||||
// 为空时默认只代理 api.anthropic.com
|
||||
ProxyHosts []string `mapstructure:"proxy_hosts"`
|
||||
}
|
||||
|
||||
// FingerprintDefaultsConfig 指纹默认值配置
|
||||
// 允许每个 sub2api 实例设置不同的默认指纹值,与其他实例区分。
|
||||
// 所有字段为空时使用代码内置默认值。
|
||||
@ -1337,8 +1310,8 @@ func setDefaults() {
|
||||
viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10)
|
||||
|
||||
// Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移)
|
||||
viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/c7947e9871687e664180bc971d4837f1fc2784a9/model_prices_and_context_window.json")
|
||||
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/c7947e9871687e664180bc971d4837f1fc2784a9/model_prices_and_context_window.sha256")
|
||||
viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.json")
|
||||
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256")
|
||||
viper.SetDefault("pricing.data_dir", "./data")
|
||||
viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json")
|
||||
viper.SetDefault("pricing.update_interval_hours", 24)
|
||||
@ -1519,13 +1492,6 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.user_message_queue.cleanup_interval_seconds", 60)
|
||||
|
||||
viper.SetDefault("gateway.tls_fingerprint.enabled", true)
|
||||
|
||||
// Node.js TLS Proxy 默认值
|
||||
viper.SetDefault("gateway.node_tls_proxy.enabled", false)
|
||||
viper.SetDefault("gateway.node_tls_proxy.listen_port", 3456)
|
||||
viper.SetDefault("gateway.node_tls_proxy.listen_host", "127.0.0.1")
|
||||
viper.SetDefault("gateway.node_tls_proxy.health_path", "/__health")
|
||||
viper.SetDefault("gateway.node_tls_proxy.upstream_host", "api.anthropic.com")
|
||||
viper.SetDefault("concurrency.ping_interval", 10)
|
||||
|
||||
// Sora 直连配置
|
||||
|
||||
@ -267,6 +267,9 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// 收集需要异步设置隐私的 Antigravity OAuth 账号
|
||||
var privacyAccounts []*service.Account
|
||||
|
||||
for i := range dataPayload.Accounts {
|
||||
item := dataPayload.Accounts[i]
|
||||
if err := validateDataAccount(item); err != nil {
|
||||
@ -314,7 +317,8 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
|
||||
SkipDefaultGroupBind: skipDefaultGroupBind,
|
||||
}
|
||||
|
||||
if _, err := h.adminService.CreateAccount(ctx, accountInput); err != nil {
|
||||
created, err := h.adminService.CreateAccount(ctx, accountInput)
|
||||
if err != nil {
|
||||
result.AccountFailed++
|
||||
result.Errors = append(result.Errors, DataImportError{
|
||||
Kind: "account",
|
||||
@ -323,9 +327,30 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
|
||||
})
|
||||
continue
|
||||
}
|
||||
// 收集 Antigravity OAuth 账号,稍后异步设置隐私
|
||||
if created.Platform == service.PlatformAntigravity && created.Type == service.AccountTypeOAuth {
|
||||
privacyAccounts = append(privacyAccounts, created)
|
||||
}
|
||||
result.AccountCreated++
|
||||
}
|
||||
|
||||
// 异步设置 Antigravity 隐私,避免大量导入时阻塞请求
|
||||
if len(privacyAccounts) > 0 {
|
||||
adminSvc := h.adminService
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("import_antigravity_privacy_panic", "recover", r)
|
||||
}
|
||||
}()
|
||||
bgCtx := context.Background()
|
||||
for _, acc := range privacyAccounts {
|
||||
adminSvc.ForceAntigravityPrivacy(bgCtx, acc)
|
||||
}
|
||||
slog.Info("import_antigravity_privacy_done", "count", len(privacyAccounts))
|
||||
}()
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type RiskHandler struct {
|
||||
service *service.RiskService
|
||||
}
|
||||
|
||||
func NewRiskHandler(svc *service.RiskService) *RiskHandler {
|
||||
return &RiskHandler{service: svc}
|
||||
}
|
||||
|
||||
func (h *RiskHandler) GetSummary(c *gin.Context) {
|
||||
summary, err := h.service.GetSummary(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, summary)
|
||||
}
|
||||
|
||||
func (h *RiskHandler) ListAccounts(c *gin.Context) {
|
||||
filter := service.RiskAccountFilter{
|
||||
Level: c.Query("risk_level"),
|
||||
Platform: c.Query("platform"),
|
||||
}
|
||||
if p := c.Query("page"); p != "" {
|
||||
if v, err := strconv.Atoi(p); err == nil {
|
||||
filter.Page = v
|
||||
}
|
||||
}
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil {
|
||||
filter.PageSize = v
|
||||
}
|
||||
}
|
||||
|
||||
list, err := h.service.ListAccounts(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, list)
|
||||
}
|
||||
|
||||
func (h *RiskHandler) GetAccountDetail(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.ErrorFrom(c, service.ErrRiskAccountNotFound)
|
||||
return
|
||||
}
|
||||
detail, err := h.service.GetAccountDetail(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, detail)
|
||||
}
|
||||
|
||||
type overrideRiskLevelRequest struct {
|
||||
Level string `json:"level" binding:"required"`
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *RiskHandler) OverrideRiskLevel(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.ErrorFrom(c, service.ErrRiskAccountNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req overrideRiskLevelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.OverrideRiskLevel(c.Request.Context(), id, req.Level, req.Reason); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
func (h *RiskHandler) GetSettings(c *gin.Context) {
|
||||
settings, err := h.service.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, settings)
|
||||
}
|
||||
|
||||
func (h *RiskHandler) UpdateSettings(c *gin.Context) {
|
||||
var req service.RiskSettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
updated, err := h.service.UpdateSettings(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, updated)
|
||||
}
|
||||
@ -129,6 +129,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion,
|
||||
AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling,
|
||||
BackendModeEnabled: settings.BackendModeEnabled,
|
||||
EnableFingerprintUnification: settings.EnableFingerprintUnification,
|
||||
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
|
||||
})
|
||||
}
|
||||
|
||||
@ -209,6 +211,10 @@ type UpdateSettingsRequest struct {
|
||||
|
||||
// Backend Mode
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
|
||||
// Gateway forwarding behavior
|
||||
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
|
||||
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
|
||||
}
|
||||
|
||||
// UpdateSettings 更新系统设置
|
||||
@ -601,6 +607,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.OpsMetricsIntervalSeconds
|
||||
}(),
|
||||
EnableFingerprintUnification: func() bool {
|
||||
if req.EnableFingerprintUnification != nil {
|
||||
return *req.EnableFingerprintUnification
|
||||
}
|
||||
return previousSettings.EnableFingerprintUnification
|
||||
}(),
|
||||
EnableMetadataPassthrough: func() bool {
|
||||
if req.EnableMetadataPassthrough != nil {
|
||||
return *req.EnableMetadataPassthrough
|
||||
}
|
||||
return previousSettings.EnableMetadataPassthrough
|
||||
}(),
|
||||
}
|
||||
|
||||
if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil {
|
||||
@ -679,6 +697,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion,
|
||||
AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling,
|
||||
BackendModeEnabled: updatedSettings.BackendModeEnabled,
|
||||
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
|
||||
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
|
||||
})
|
||||
}
|
||||
|
||||
@ -851,6 +871,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.CustomMenuItems != after.CustomMenuItems {
|
||||
changed = append(changed, "custom_menu_items")
|
||||
}
|
||||
if before.EnableFingerprintUnification != after.EnableFingerprintUnification {
|
||||
changed = append(changed, "enable_fingerprint_unification")
|
||||
}
|
||||
if before.EnableMetadataPassthrough != after.EnableMetadataPassthrough {
|
||||
changed = append(changed, "enable_metadata_passthrough")
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
@ -1568,18 +1594,26 @@ func (h *SettingHandler) GetRectifierSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
patterns := settings.APIKeySignaturePatterns
|
||||
if patterns == nil {
|
||||
patterns = []string{}
|
||||
}
|
||||
response.Success(c, dto.RectifierSettings{
|
||||
Enabled: settings.Enabled,
|
||||
ThinkingSignatureEnabled: settings.ThinkingSignatureEnabled,
|
||||
ThinkingBudgetEnabled: settings.ThinkingBudgetEnabled,
|
||||
APIKeySignatureEnabled: settings.APIKeySignatureEnabled,
|
||||
APIKeySignaturePatterns: patterns,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRectifierSettingsRequest 更新整流器配置请求
|
||||
type UpdateRectifierSettingsRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ThinkingSignatureEnabled bool `json:"thinking_signature_enabled"`
|
||||
ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ThinkingSignatureEnabled bool `json:"thinking_signature_enabled"`
|
||||
ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"`
|
||||
APIKeySignatureEnabled bool `json:"apikey_signature_enabled"`
|
||||
APIKeySignaturePatterns []string `json:"apikey_signature_patterns"`
|
||||
}
|
||||
|
||||
// UpdateRectifierSettings 更新请求整流器配置
|
||||
@ -1591,10 +1625,32 @@ func (h *SettingHandler) UpdateRectifierSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 校验并清理自定义匹配关键词
|
||||
const maxPatterns = 50
|
||||
const maxPatternLen = 500
|
||||
if len(req.APIKeySignaturePatterns) > maxPatterns {
|
||||
response.BadRequest(c, "Too many signature patterns (max 50)")
|
||||
return
|
||||
}
|
||||
var cleanedPatterns []string
|
||||
for _, p := range req.APIKeySignaturePatterns {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if len(p) > maxPatternLen {
|
||||
response.BadRequest(c, "Signature pattern too long (max 500 characters)")
|
||||
return
|
||||
}
|
||||
cleanedPatterns = append(cleanedPatterns, p)
|
||||
}
|
||||
|
||||
settings := &service.RectifierSettings{
|
||||
Enabled: req.Enabled,
|
||||
ThinkingSignatureEnabled: req.ThinkingSignatureEnabled,
|
||||
ThinkingBudgetEnabled: req.ThinkingBudgetEnabled,
|
||||
APIKeySignatureEnabled: req.APIKeySignatureEnabled,
|
||||
APIKeySignaturePatterns: cleanedPatterns,
|
||||
}
|
||||
|
||||
if err := h.settingService.SetRectifierSettings(c.Request.Context(), settings); err != nil {
|
||||
@ -1609,10 +1665,16 @@ func (h *SettingHandler) UpdateRectifierSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
updatedPatterns := updatedSettings.APIKeySignaturePatterns
|
||||
if updatedPatterns == nil {
|
||||
updatedPatterns = []string{}
|
||||
}
|
||||
response.Success(c, dto.RectifierSettings{
|
||||
Enabled: updatedSettings.Enabled,
|
||||
ThinkingSignatureEnabled: updatedSettings.ThinkingSignatureEnabled,
|
||||
ThinkingBudgetEnabled: updatedSettings.ThinkingBudgetEnabled,
|
||||
APIKeySignatureEnabled: updatedSettings.APIKeySignatureEnabled,
|
||||
APIKeySignaturePatterns: updatedPatterns,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -268,6 +268,14 @@ func AccountFromServiceShallow(a *service.Account) *Account {
|
||||
target := a.GetCacheTTLOverrideTarget()
|
||||
out.CacheTTLOverrideTarget = &target
|
||||
}
|
||||
// 自定义 Base URL 中继转发
|
||||
if a.IsCustomBaseURLEnabled() {
|
||||
enabled := true
|
||||
out.CustomBaseURLEnabled = &enabled
|
||||
if customURL := a.GetCustomBaseURL(); customURL != "" {
|
||||
out.CustomBaseURL = &customURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提取账号配额限制(apikey / bedrock 类型有效)
|
||||
|
||||
@ -94,6 +94,10 @@ type SystemSettings struct {
|
||||
|
||||
// Backend Mode
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
|
||||
// Gateway forwarding behavior
|
||||
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
|
||||
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
|
||||
}
|
||||
|
||||
type DefaultSubscriptionSetting struct {
|
||||
@ -184,9 +188,11 @@ type StreamTimeoutSettings struct {
|
||||
|
||||
// RectifierSettings 请求整流器配置 DTO
|
||||
type RectifierSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ThinkingSignatureEnabled bool `json:"thinking_signature_enabled"`
|
||||
ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ThinkingSignatureEnabled bool `json:"thinking_signature_enabled"`
|
||||
ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"`
|
||||
APIKeySignatureEnabled bool `json:"apikey_signature_enabled"`
|
||||
APIKeySignaturePatterns []string `json:"apikey_signature_patterns"`
|
||||
}
|
||||
|
||||
// BetaPolicyRule Beta 策略规则 DTO
|
||||
|
||||
@ -198,6 +198,10 @@ type Account struct {
|
||||
CacheTTLOverrideEnabled *bool `json:"cache_ttl_override_enabled,omitempty"`
|
||||
CacheTTLOverrideTarget *string `json:"cache_ttl_override_target,omitempty"`
|
||||
|
||||
// 自定义 Base URL 中继转发(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
CustomBaseURLEnabled *bool `json:"custom_base_url_enabled,omitempty"`
|
||||
CustomBaseURL *string `json:"custom_base_url,omitempty"`
|
||||
|
||||
// API Key 账号配额限制
|
||||
QuotaLimit *float64 `json:"quota_limit,omitempty"`
|
||||
QuotaUsed *float64 `json:"quota_used,omitempty"`
|
||||
|
||||
@ -422,11 +422,24 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
wroteFallback := h.ensureForwardErrorResponse(c, streamStarted)
|
||||
reqLog.Error("gateway.forward_failed",
|
||||
forwardFailedFields := []zap.Field{
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.String("account_name", account.Name),
|
||||
zap.String("account_platform", account.Platform),
|
||||
zap.Bool("fallback_error_response_written", wroteFallback),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
if account.Proxy != nil {
|
||||
forwardFailedFields = append(forwardFailedFields,
|
||||
zap.Int64("proxy_id", account.Proxy.ID),
|
||||
zap.String("proxy_name", account.Proxy.Name),
|
||||
zap.String("proxy_host", account.Proxy.Host),
|
||||
zap.Int("proxy_port", account.Proxy.Port),
|
||||
)
|
||||
} else if account.ProxyID != nil {
|
||||
forwardFailedFields = append(forwardFailedFields, zap.Int64p("proxy_id", account.ProxyID))
|
||||
}
|
||||
reqLog.Error("gateway.forward_failed", forwardFailedFields...)
|
||||
return
|
||||
}
|
||||
|
||||
@ -741,11 +754,24 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
wroteFallback := h.ensureForwardErrorResponse(c, streamStarted)
|
||||
reqLog.Error("gateway.forward_failed",
|
||||
forwardFailedFields := []zap.Field{
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.String("account_name", account.Name),
|
||||
zap.String("account_platform", account.Platform),
|
||||
zap.Bool("fallback_error_response_written", wroteFallback),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
if account.Proxy != nil {
|
||||
forwardFailedFields = append(forwardFailedFields,
|
||||
zap.Int64("proxy_id", account.Proxy.ID),
|
||||
zap.String("proxy_name", account.Proxy.Name),
|
||||
zap.String("proxy_host", account.Proxy.Host),
|
||||
zap.Int("proxy_port", account.Proxy.Port),
|
||||
)
|
||||
} else if account.ProxyID != nil {
|
||||
forwardFailedFields = append(forwardFailedFields, zap.Int64p("proxy_id", account.ProxyID))
|
||||
}
|
||||
reqLog.Error("gateway.forward_failed", forwardFailedFields...)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -30,7 +30,6 @@ type AdminHandlers struct {
|
||||
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
|
||||
APIKey *admin.AdminAPIKeyHandler
|
||||
ScheduledTest *admin.ScheduledTestHandler
|
||||
Risk *admin.RiskHandler
|
||||
}
|
||||
|
||||
// Handlers contains all HTTP handlers
|
||||
|
||||
@ -541,6 +541,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
reqModel := modelResult.String()
|
||||
routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel)
|
||||
reqStream := gjson.GetBytes(body, "stream").Bool()
|
||||
|
||||
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
|
||||
@ -606,7 +607,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
apiKey.GroupID,
|
||||
"", // no previous_response_id
|
||||
sessionHash,
|
||||
reqModel,
|
||||
routingModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportAny,
|
||||
)
|
||||
@ -621,7 +622,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
if apiKey.Group != nil {
|
||||
defaultModel = apiKey.Group.DefaultMappedModel
|
||||
}
|
||||
if defaultModel != "" && defaultModel != reqModel {
|
||||
if defaultModel != "" && defaultModel != routingModel {
|
||||
reqLog.Info("openai_messages.fallback_to_default_model",
|
||||
zap.String("default_mapped_model", defaultModel),
|
||||
)
|
||||
|
||||
@ -33,7 +33,6 @@ func ProvideAdminHandlers(
|
||||
tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler,
|
||||
apiKeyHandler *admin.AdminAPIKeyHandler,
|
||||
scheduledTestHandler *admin.ScheduledTestHandler,
|
||||
riskHandler *admin.RiskHandler,
|
||||
) *AdminHandlers {
|
||||
return &AdminHandlers{
|
||||
Dashboard: dashboardHandler,
|
||||
@ -60,7 +59,6 @@ func ProvideAdminHandlers(
|
||||
TLSFingerprintProfile: tlsFingerprintProfileHandler,
|
||||
APIKey: apiKeyHandler,
|
||||
ScheduledTest: scheduledTestHandler,
|
||||
Risk: riskHandler,
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,7 +150,6 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewTLSFingerprintProfileHandler,
|
||||
admin.NewAdminAPIKeyHandler,
|
||||
admin.NewScheduledTestHandler,
|
||||
admin.NewRiskHandler,
|
||||
|
||||
// AdminHandlers and Handlers constructors
|
||||
ProvideAdminHandlers,
|
||||
|
||||
@ -29,21 +29,6 @@ func (e *ForbiddenError) Error() string {
|
||||
return fmt.Sprintf("fetchAvailableModels 失败 (HTTP %d): %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// GetGoogAPIClient 返回 x-goog-api-client 头的值(导出供心跳等外部使用)
|
||||
// 格式与真实 Antigravity 的 Go SDK 一致: gl-go/{goVersion} gax-go/v2 grpc-go/1.81.0-dev
|
||||
// 注意: 不使用 runtime.Version() — 服务器编译的 Go 版本 ≠ 真实 Antigravity 的 Go 版本
|
||||
func GetGoogAPIClient() string {
|
||||
return "gl-go/go1.27 gax-go/v2 grpc-go/1.81.0-dev"
|
||||
}
|
||||
|
||||
// setAntigravityHeaders 设置与真实 Antigravity IDE 一致的 HTTP 请求头
|
||||
func setAntigravityHeaders(req *http.Request, accessToken string) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("User-Agent", GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient())
|
||||
}
|
||||
|
||||
// NewAPIRequestWithURL 使用指定的 base URL 创建 Antigravity API 请求(v1internal 端点)
|
||||
func NewAPIRequestWithURL(ctx context.Context, baseURL, action, accessToken string, body []byte) (*http.Request, error) {
|
||||
// 构建 URL,流式请求添加 ?alt=sse 参数
|
||||
@ -58,8 +43,10 @@ func NewAPIRequestWithURL(ctx context.Context, baseURL, action, accessToken stri
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 设置与真实 Antigravity IDE 一致的请求头
|
||||
setAntigravityHeaders(req, accessToken)
|
||||
// 基础 Headers(与 Antigravity-Manager 保持一致,只设置这 3 个)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("User-Agent", GetUserAgent())
|
||||
|
||||
return req, nil
|
||||
}
|
||||
@ -289,16 +276,6 @@ func NewClient(proxyURL string) (*Client, error) {
|
||||
return nil, fmt.Errorf("configure proxy: %w", err)
|
||||
}
|
||||
client.Transport = transport
|
||||
} else {
|
||||
// 无显式代理时,使用支持 HTTPS_PROXY 环境变量的 Transport
|
||||
// 用于 OAuth token 交换等需要访问外部服务的场景
|
||||
client.Transport = &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: proxyDialTimeout,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: proxyTLSHandshakeTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
return &Client{
|
||||
@ -306,11 +283,6 @@ func NewClient(proxyURL string) (*Client, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DoRaw 执行原始 HTTP 请求(供心跳等内部使用)
|
||||
func (c *Client) DoRaw(req *http.Request) (*http.Response, error) {
|
||||
return c.httpClient.Do(req)
|
||||
}
|
||||
|
||||
// IsConnectionError 判断是否为连接错误(网络超时、DNS 失败、连接拒绝)
|
||||
func IsConnectionError(err error) bool {
|
||||
if err == nil {
|
||||
@ -490,7 +462,6 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@ -570,7 +541,6 @@ func (c *Client) OnboardUser(ctx context.Context, accessToken, tierID string) (s
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@ -705,7 +675,6 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@ -757,6 +726,8 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
|
||||
// ── Privacy API ──────────────────────────────────────────────────────
|
||||
|
||||
// privacyBaseURL 隐私设置 API 仅使用 daily 端点(与 Antigravity 客户端行为一致)
|
||||
const privacyBaseURL = antigravityDailyBaseURL
|
||||
|
||||
@ -795,15 +766,18 @@ func (r *SetUserSettingsResponse) IsSuccess() bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
// userSettings 为 nil 或空 map 均视为成功
|
||||
if len(r.UserSettings) == 0 {
|
||||
return true
|
||||
}
|
||||
// 如果包含 telemetryEnabled 字段,说明未成功清除
|
||||
_, hasTelemetry := r.UserSettings["telemetryEnabled"]
|
||||
return !hasTelemetry
|
||||
}
|
||||
|
||||
// SetUserSettings 调用 setUserSettings API 设置用户隐私,返回解析后的响应
|
||||
func (c *Client) SetUserSettings(ctx context.Context, accessToken string) (*SetUserSettingsResponse, error) {
|
||||
// 发送空 user_settings 以清除隐私设置
|
||||
payload := SetUserSettingsRequest{UserSettings: map[string]any{}}
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
|
||||
@ -23,16 +23,13 @@ const (
|
||||
UserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
|
||||
// Antigravity OAuth 客户端凭证
|
||||
// 注意:真实 Antigravity 主 Client ID 是 884354919052-...,但需要对应的 client_secret
|
||||
// 当前使用的 1071006060591-... 同样存在于真实二进制中(可能是备用登录模式)
|
||||
// 如需切换,必须同时更新 client_secret(通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET)
|
||||
ClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||
|
||||
// AntigravityOAuthClientSecretEnv 是 Antigravity OAuth client_secret 的环境变量名。
|
||||
AntigravityOAuthClientSecretEnv = "ANTIGRAVITY_OAUTH_CLIENT_SECRET"
|
||||
|
||||
// redirect_uri — 真实 Antigravity IDE 使用 localhost 动态端口 + /oauth-callback 路径
|
||||
RedirectURI = "http://localhost:8085/oauth-callback"
|
||||
// 固定的 redirect_uri(用户需手动复制 code)
|
||||
RedirectURI = "http://localhost:8085/callback"
|
||||
|
||||
// OAuth scopes
|
||||
Scopes = "https://www.googleapis.com/auth/cloud-platform " +
|
||||
@ -47,22 +44,13 @@ const (
|
||||
// URL 可用性 TTL(不可用 URL 的恢复时间)
|
||||
URLAvailabilityTTL = 5 * time.Minute
|
||||
|
||||
// Antigravity API 端点(真实 Antigravity 日志确认使用 daily-cloudcode-pa 无 sandbox 后缀)
|
||||
// Antigravity API 端点
|
||||
antigravityProdBaseURL = "https://cloudcode-pa.googleapis.com"
|
||||
antigravityDailyBaseURL = "https://daily-cloudcode-pa.googleapis.com"
|
||||
antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||
)
|
||||
|
||||
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认匹配真实 extension 最新版本
|
||||
// Gemini 3.1 Pro 等新模型需要较新的版本号才允许访问(上游会检查版本返回 "not available on this version")
|
||||
var defaultUserAgentVersion = "1.21.6"
|
||||
|
||||
// defaultPlatformOS 和 defaultPlatformArch 模拟真实客户端的操作系统和架构
|
||||
// 真实 Antigravity IDE 运行在用户桌面(macOS/Windows),不是 Linux 服务器
|
||||
// 可通过环境变量 ANTIGRAVITY_PLATFORM_OS / ANTIGRAVITY_PLATFORM_ARCH 覆盖
|
||||
var (
|
||||
defaultPlatformOS = "darwin"
|
||||
defaultPlatformArch = "arm64"
|
||||
)
|
||||
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.20.5
|
||||
var defaultUserAgentVersion = "1.20.5"
|
||||
|
||||
// defaultClientSecret 可通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 配置
|
||||
var defaultClientSecret = "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"
|
||||
@ -76,19 +64,11 @@ func init() {
|
||||
if secret := os.Getenv(AntigravityOAuthClientSecretEnv); secret != "" {
|
||||
defaultClientSecret = secret
|
||||
}
|
||||
// 从环境变量读取模拟平台(真实 Antigravity 运行在桌面 OS,不是 Linux 服务器)
|
||||
if p := os.Getenv("ANTIGRAVITY_PLATFORM_OS"); p != "" {
|
||||
defaultPlatformOS = p
|
||||
}
|
||||
if a := os.Getenv("ANTIGRAVITY_PLATFORM_ARCH"); a != "" {
|
||||
defaultPlatformArch = a
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserAgent 返回当前配置的 User-Agent(匹配真实 Antigravity 格式: antigravity/{version} {os}/{arch})
|
||||
// 注意: 不使用 runtime.GOOS/GOARCH — 服务器是 Linux,但真实用户在 macOS/Windows 桌面
|
||||
// GetUserAgent 返回当前配置的 User-Agent
|
||||
func GetUserAgent() string {
|
||||
return fmt.Sprintf("antigravity/%s %s/%s", defaultUserAgentVersion, defaultPlatformOS, defaultPlatformArch)
|
||||
return fmt.Sprintf("antigravity/%s windows/amd64", defaultUserAgentVersion)
|
||||
}
|
||||
|
||||
func getClientSecret() (string, error) {
|
||||
|
||||
@ -262,41 +262,8 @@ func hasMCPTools(tools []ClaudeTool) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// claudeCodeSignatures Claude Code / Anthropic 特征字符串,命中任意一个即视为需要过滤的 CLI 默认 prompt
|
||||
var claudeCodeSignatures = []string{
|
||||
"You are Claude Code, Anthropic's official CLI",
|
||||
"You are Claude Code,",
|
||||
"Anthropic's official CLI",
|
||||
"x-anthropic-billing-header",
|
||||
"cc_entrypoint=cli",
|
||||
}
|
||||
|
||||
// filterClaudeCodePrompt 过滤 Claude Code 默认 system prompt,防止 Anthropic 特征暴露给上游
|
||||
// 策略:检测到特征字符串后,尝试提取用户自定义指令部分("Instructions from:" 之后),否则返回空
|
||||
func filterClaudeCodePrompt(text string) (string, bool) {
|
||||
matched := false
|
||||
for _, sig := range claudeCodeSignatures {
|
||||
if strings.Contains(text, sig) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return text, false
|
||||
}
|
||||
// 尝试保留用户自定义指令
|
||||
if idx := strings.Index(text, "Instructions from:"); idx >= 0 {
|
||||
return text[idx:], true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// filterOpenCodePrompt 过滤 OpenCode / Claude Code 默认提示词,只保留用户自定义指令
|
||||
// filterOpenCodePrompt 过滤 OpenCode 默认提示词,只保留用户自定义指令
|
||||
func filterOpenCodePrompt(text string) string {
|
||||
// 优先检测 Claude Code 特征
|
||||
if filtered, matched := filterClaudeCodePrompt(text); matched {
|
||||
return filtered
|
||||
}
|
||||
if !strings.Contains(text, "You are an interactive CLI tool") {
|
||||
return text
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package antigravity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@ -352,7 +353,7 @@ func TestBuildGenerationConfig_ThinkingDynamicBudget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformClaudeToGeminiWithOptions_FiltersBillingHeaderSystemBlock(t *testing.T) {
|
||||
func TestTransformClaudeToGeminiWithOptions_PreservesBillingHeaderSystemBlock(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
system json.RawMessage
|
||||
@ -387,11 +388,15 @@ func TestTransformClaudeToGeminiWithOptions_FiltersBillingHeaderSystemBlock(t *t
|
||||
require.NoError(t, json.Unmarshal(body, &req))
|
||||
require.NotNil(t, req.Request.SystemInstruction)
|
||||
|
||||
// Claude Code / Anthropic 特征字符串不应透传给上游
|
||||
found := false
|
||||
for _, part := range req.Request.SystemInstruction.Parts {
|
||||
require.NotContains(t, part.Text, "x-anthropic-billing-header",
|
||||
"Claude Code 特征字符串不应透传给 Antigravity 上游")
|
||||
if strings.Contains(part.Text, "x-anthropic-billing-header keep") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.True(t, found, "转换后的 systemInstruction 应保留 x-anthropic-billing-header 内容")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
// Package geminicli provides helpers for interacting with Gemini CLI tools.
|
||||
package geminicli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
import "time"
|
||||
|
||||
const (
|
||||
AIStudioBaseURL = "https://generativelanguage.googleapis.com"
|
||||
@ -50,70 +46,6 @@ const (
|
||||
|
||||
SessionTTL = 30 * time.Minute
|
||||
|
||||
// GeminiCLIUserAgent 静态回退值(不含 model)
|
||||
// 优先使用 GetGeminiCLIUserAgent(model) 获取完整格式
|
||||
GeminiCLIUserAgent = "GeminiCLI/0.33.1"
|
||||
|
||||
// FakeNodeVersion 模拟真实 Gemini CLI 的 Node.js 版本
|
||||
// 用于 x-goog-api-client 和 token exchange User-Agent
|
||||
FakeNodeVersion = "24.13.1"
|
||||
|
||||
// GoogleAuthLibraryUA 模拟 google-auth-library 的 User-Agent
|
||||
// 真实 Gemini CLI token exchange 由 google-auth-library 发起
|
||||
GoogleAuthLibraryUA = "google-api-nodejs-client"
|
||||
|
||||
// FakePlatformOS 和 FakePlatformArch 模拟真实客户端的操作系统和架构
|
||||
// 真实 Gemini CLI 运行在用户桌面,不是 Linux 服务器
|
||||
// Node.js process.platform: darwin, linux, win32
|
||||
// Node.js process.arch: arm64, x64 (注意: Node.js 用 x64,不是 amd64)
|
||||
FakePlatformOS = "darwin"
|
||||
FakePlatformArch = "arm64"
|
||||
// GeminiCLIUserAgent mimics Gemini CLI to maximize compatibility with internal endpoints.
|
||||
GeminiCLIUserAgent = "GeminiCLI/0.1.5 (Windows; AMD64)"
|
||||
)
|
||||
|
||||
// defaultGeminiCLIVersion 可通过环境变量 GEMINI_CLI_VERSION 覆盖
|
||||
var defaultGeminiCLIVersion = "0.33.1"
|
||||
|
||||
// defaultFakePlatformOS/Arch 可通过环境变量覆盖
|
||||
var (
|
||||
defaultFakePlatformOS = FakePlatformOS
|
||||
defaultFakePlatformArch = FakePlatformArch
|
||||
)
|
||||
|
||||
func init() {
|
||||
if v := os.Getenv("GEMINI_CLI_VERSION"); v != "" {
|
||||
defaultGeminiCLIVersion = v
|
||||
}
|
||||
if p := os.Getenv("GEMINI_CLI_PLATFORM_OS"); p != "" {
|
||||
defaultFakePlatformOS = p
|
||||
}
|
||||
if a := os.Getenv("GEMINI_CLI_PLATFORM_ARCH"); a != "" {
|
||||
defaultFakePlatformArch = a
|
||||
}
|
||||
}
|
||||
|
||||
// GetGeminiCLIUserAgent 返回匹配真实 Gemini CLI 格式的 User-Agent
|
||||
// 真实格式: GeminiCLI/{version}/{model} ({platform}; {arch})
|
||||
// 示例: GeminiCLI/0.33.1/gemini-2.5-pro (darwin; arm64)
|
||||
// 注意: 不使用 runtime.GOOS/GOARCH — 服务器是 Linux,但要模拟桌面客户端
|
||||
// 注意: Node.js 用 x64 不是 amd64,arm64 两者一致
|
||||
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
|
||||
}
|
||||
|
||||
@ -24,20 +24,18 @@ const (
|
||||
RedirectURI = "https://platform.claude.com/oauth/code/callback"
|
||||
|
||||
// Scopes - Browser URL (includes org:create_api_key for user authorization)
|
||||
ScopeOAuth = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers"
|
||||
ScopeOAuth = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
|
||||
// Scopes - Internal API call (org:create_api_key not supported in API)
|
||||
ScopeAPI = "user:profile user:inference user:sessions:claude_code user:mcp_servers"
|
||||
ScopeAPI = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
|
||||
// Scopes - Setup token (inference only)
|
||||
ScopeInference = "user:inference"
|
||||
|
||||
// Code Verifier character set (RFC 7636 compliant)
|
||||
codeVerifierCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
|
||||
// Session TTL
|
||||
SessionTTL = 30 * time.Minute
|
||||
)
|
||||
|
||||
// OAuthSession stores OAuth flow state
|
||||
|
||||
type OAuthSession struct {
|
||||
State string `json:"state"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
@ -147,30 +145,14 @@ func GenerateSessionID() (string, error) {
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// GenerateCodeVerifier generates a PKCE code verifier using character set method
|
||||
// GenerateCodeVerifier generates a PKCE code verifier (RFC 7636).
|
||||
// Uses 32 random bytes → base64url-no-pad, producing a 43-char verifier.
|
||||
func GenerateCodeVerifier() (string, error) {
|
||||
const targetLen = 32
|
||||
charsetLen := len(codeVerifierCharset)
|
||||
limit := 256 - (256 % charsetLen)
|
||||
|
||||
result := make([]byte, 0, targetLen)
|
||||
randBuf := make([]byte, targetLen*2)
|
||||
|
||||
for len(result) < targetLen {
|
||||
if _, err := rand.Read(randBuf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, b := range randBuf {
|
||||
if int(b) < limit {
|
||||
result = append(result, codeVerifierCharset[int(b)%charsetLen])
|
||||
if len(result) >= targetLen {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
bytes, err := GenerateRandomBytes(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base64URLEncode(result), nil
|
||||
return base64URLEncode(bytes), nil
|
||||
}
|
||||
|
||||
// GenerateCodeChallenge generates a PKCE code challenge using S256 method
|
||||
|
||||
@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
@ -257,9 +258,12 @@ func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) erro
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) Delete(ctx context.Context, id int64) error {
|
||||
// 存在唯一键约束 生成tombstone key 用来释放原key,长度远小于 128,满足 schema 限制
|
||||
tombstoneKey := fmt.Sprintf("__deleted__%d__%d", id, time.Now().UnixNano())
|
||||
// 显式软删除:避免依赖 Hook 行为,确保 deleted_at 一定被设置。
|
||||
affected, err := r.client.APIKey.Update().
|
||||
Where(apikey.IDEQ(id), apikey.DeletedAtIsNil()).
|
||||
SetKey(tombstoneKey).
|
||||
SetDeletedAt(time.Now()).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
|
||||
@ -151,6 +151,31 @@ func (s *APIKeyRepoSuite) TestDelete() {
|
||||
s.Require().Error(err, "expected error after delete")
|
||||
}
|
||||
|
||||
func (s *APIKeyRepoSuite) TestCreate_AfterSoftDelete_AllowsSameKey() {
|
||||
user := s.mustCreateUser("recreate-after-soft-delete@test.com")
|
||||
const reusedKey = "sk-reuse-after-soft-delete"
|
||||
|
||||
first := &service.APIKey{
|
||||
UserID: user.ID,
|
||||
Key: reusedKey,
|
||||
Name: "First Key",
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
s.Require().NoError(s.repo.Create(s.ctx, first), "create first key")
|
||||
|
||||
s.Require().NoError(s.repo.Delete(s.ctx, first.ID), "soft delete first key")
|
||||
|
||||
second := &service.APIKey{
|
||||
UserID: user.ID,
|
||||
Key: reusedKey,
|
||||
Name: "Second Key",
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
s.Require().NoError(s.repo.Create(s.ctx, second), "create second key with same key")
|
||||
s.Require().NotZero(second.ID)
|
||||
s.Require().NotEqual(first.ID, second.ID, "recreated key should be a new row")
|
||||
}
|
||||
|
||||
// --- ListByUserID / CountByUserID ---
|
||||
|
||||
func (s *APIKeyRepoSuite) TestListByUserID() {
|
||||
|
||||
@ -242,7 +242,6 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshToken,
|
||||
"client_id": oauth.ClientID,
|
||||
"scope": oauth.ScopeAPI,
|
||||
}
|
||||
|
||||
var tokenResp oauth.TokenResponse
|
||||
@ -269,9 +268,9 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
|
||||
|
||||
func createReqClient(proxyURL string) (*req.Client, error) {
|
||||
// 禁用 CookieJar,确保每次授权都是干净的会话
|
||||
// 不使用 ImpersonateChrome() — 真实 Claude CLI 用 axios (Bun fetch),TLS 指纹应为 Node.js/Bun
|
||||
client := req.C().
|
||||
SetTimeout(15 * time.Second).
|
||||
SetTimeout(60 * time.Second).
|
||||
ImpersonateChrome().
|
||||
SetCookieJar(nil) // 禁用 CookieJar
|
||||
|
||||
trimmed, _, err := proxyurl.Parse(proxyURL)
|
||||
|
||||
@ -68,9 +68,9 @@ func (s *claudeUsageService) FetchUsageWithOptions(ctx context.Context, opts *se
|
||||
|
||||
var resp *http.Response
|
||||
|
||||
// 如果有 TLS Mode(非 off)且有 HTTPUpstream,使用 DoWithTLS
|
||||
if opts.TLSMode != service.TLSModeOff && s.httpUpstream != nil {
|
||||
resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, opts.TLSMode, opts.TLSProfile)
|
||||
// 如果有 TLS Profile 且有 HTTPUpstream,使用 DoWithTLS
|
||||
if opts.TLSProfile != nil && s.httpUpstream != nil {
|
||||
resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, opts.TLSProfile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request with TLS fingerprint failed: %w", err)
|
||||
}
|
||||
|
||||
@ -63,8 +63,6 @@ func (c *geminiOAuthClient) ExchangeCode(ctx context.Context, oauthType, code, c
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetFormDataFromValues(formData).
|
||||
SetHeader("User-Agent", geminicli.GetGeminiCLITokenExchangeUA()).
|
||||
SetHeader("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient()).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(c.tokenURL)
|
||||
if err != nil {
|
||||
@ -108,8 +106,6 @@ func (c *geminiOAuthClient) RefreshToken(ctx context.Context, oauthType, refresh
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetFormDataFromValues(formData).
|
||||
SetHeader("User-Agent", geminicli.GetGeminiCLITokenExchangeUA()).
|
||||
SetHeader("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient()).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(c.tokenURL)
|
||||
if err != nil {
|
||||
|
||||
@ -34,8 +34,7 @@ func (c *geminiCliCodeAssistClient) LoadCodeAssist(ctx context.Context, accessTo
|
||||
SetContext(ctx).
|
||||
SetHeader("Authorization", "Bearer "+accessToken).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("User-Agent", geminicli.GetGeminiCLIUserAgent()).
|
||||
SetHeader("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient()).
|
||||
SetHeader("User-Agent", geminicli.GeminiCLIUserAgent).
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&out).
|
||||
Post(c.baseURL + "/v1internal:loadCodeAssist")
|
||||
@ -79,8 +78,7 @@ func (c *geminiCliCodeAssistClient) OnboardUser(ctx context.Context, accessToken
|
||||
SetContext(ctx).
|
||||
SetHeader("Authorization", "Bearer "+accessToken).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("User-Agent", geminicli.GetGeminiCLIUserAgent()).
|
||||
SetHeader("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient()).
|
||||
SetHeader("User-Agent", geminicli.GeminiCLIUserAgent).
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&out).
|
||||
Post(c.baseURL + "/v1internal:onboardUser")
|
||||
@ -118,7 +116,7 @@ func createGeminiCliReqClient(proxyURL string) (*req.Client, error) {
|
||||
func defaultLoadCodeAssistRequest() *geminicli.LoadCodeAssistRequest {
|
||||
return &geminicli.LoadCodeAssistRequest{
|
||||
Metadata: geminicli.LoadCodeAssistMetadata{
|
||||
IDEType: "IDE_UNSPECIFIED",
|
||||
IDEType: "ANTIGRAVITY",
|
||||
Platform: "PLATFORM_UNSPECIFIED",
|
||||
PluginType: "GEMINI",
|
||||
},
|
||||
@ -129,7 +127,7 @@ func defaultOnboardUserRequest() *geminicli.OnboardUserRequest {
|
||||
return &geminicli.OnboardUserRequest{
|
||||
TierID: "LEGACY",
|
||||
Metadata: geminicli.LoadCodeAssistMetadata{
|
||||
IDEType: "IDE_UNSPECIFIED",
|
||||
IDEType: "ANTIGRAVITY",
|
||||
Platform: "PLATFORM_UNSPECIFIED",
|
||||
PluginType: "GEMINI",
|
||||
},
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
|
||||
@ -127,16 +128,6 @@ func NewHTTPUpstream(cfg *config.Config) service.HTTPUpstream {
|
||||
// - 调用方必须关闭 resp.Body,否则会导致 inFlight 计数泄漏
|
||||
// - inFlight > 0 的客户端不会被淘汰,确保活跃请求不被中断
|
||||
func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
||||
// Node.js TLS 代理:仅 Anthropic API
|
||||
// Antigravity (googleapis) 使用 Go 原生 TLS(更接近真实 BoringCrypto 指纹)
|
||||
// proxyURL 通过 X-Upstream-Proxy header 传递给 node-tls-proxy 动态选择出口
|
||||
if s.isNodeTLSProxyEnabled() && req != nil && req.URL != nil && req.URL.Scheme == "https" {
|
||||
host := req.URL.Hostname()
|
||||
if host == "api.anthropic.com" {
|
||||
return s.doViaNodeTLSProxy(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.validateRequestHost(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -156,6 +147,9 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 如果上游返回了压缩内容,解压后再交给业务层
|
||||
decompressResponseBody(resp)
|
||||
|
||||
// 包装响应体,在关闭时自动减少计数并更新时间戳
|
||||
// 这确保了流式响应(如 SSE)在完全读取前不会被淘汰
|
||||
resp.Body = wrapTrackedBody(resp.Body, func() {
|
||||
@ -168,63 +162,51 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
|
||||
|
||||
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
|
||||
//
|
||||
// mode 决定指纹策略:
|
||||
// - TLSModeOff / "": 不启用,行为与 Do 相同
|
||||
// - TLSModeNode: 走本地 Node.js TLS 代理(需 gateway.node_tls_proxy.enabled=true)
|
||||
// - TLSModeUTLS: 用 profile 模拟 TLS ClientHello(profile 为 nil 时降级为 Off)
|
||||
func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, mode service.TLSMode, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
switch mode {
|
||||
case service.TLSModeNode:
|
||||
if s.isNodeTLSProxyEnabled() && req != nil && req.URL != nil && s.shouldRouteViaNodeProxy(req) {
|
||||
return s.doViaNodeTLSProxy(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
|
||||
case service.TLSModeUTLS:
|
||||
if profile == nil {
|
||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
targetHost := ""
|
||||
if req != nil && req.URL != nil {
|
||||
targetHost = req.URL.Host
|
||||
}
|
||||
proxyInfo := "direct"
|
||||
if proxyURL != "" {
|
||||
proxyInfo = proxyURL
|
||||
}
|
||||
slog.Debug("tls_fingerprint_utls", "account_id", accountID, "target", targetHost, "proxy", proxyInfo, "profile", profile.Name)
|
||||
|
||||
if err := s.validateRequestHost(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, err := s.acquireClientWithTLS(proxyURL, accountID, accountConcurrency, profile)
|
||||
if err != nil {
|
||||
slog.Debug("tls_fingerprint_acquire_client_failed", "account_id", accountID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
resp, err := entry.client.Do(req)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&entry.inFlight, -1)
|
||||
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
|
||||
slog.Debug("tls_fingerprint_request_failed", "account_id", accountID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
slog.Debug("tls_fingerprint_request_success", "account_id", accountID, "status", resp.StatusCode)
|
||||
decompressResponseBody(resp)
|
||||
resp.Body = wrapTrackedBody(resp.Body, func() {
|
||||
atomic.AddInt64(&entry.inFlight, -1)
|
||||
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
|
||||
})
|
||||
return resp, nil
|
||||
|
||||
default: // TLSModeOff 或空字符串
|
||||
// profile 为 nil 时不启用 TLS 指纹,行为与 Do 方法相同。
|
||||
// profile 非 nil 时使用指定的 Profile 进行 TLS 指纹伪装。
|
||||
func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
if profile == nil {
|
||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
targetHost := ""
|
||||
if req != nil && req.URL != nil {
|
||||
targetHost = req.URL.Host
|
||||
}
|
||||
proxyInfo := "direct"
|
||||
if proxyURL != "" {
|
||||
proxyInfo = proxyURL
|
||||
}
|
||||
slog.Debug("tls_fingerprint_enabled", "account_id", accountID, "target", targetHost, "proxy", proxyInfo, "profile", profile.Name)
|
||||
|
||||
if err := s.validateRequestHost(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry, err := s.acquireClientWithTLS(proxyURL, accountID, accountConcurrency, profile)
|
||||
if err != nil {
|
||||
slog.Debug("tls_fingerprint_acquire_client_failed", "account_id", accountID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := entry.client.Do(req)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&entry.inFlight, -1)
|
||||
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
|
||||
slog.Debug("tls_fingerprint_request_failed", "account_id", accountID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decompressResponseBody(resp)
|
||||
|
||||
resp.Body = wrapTrackedBody(resp.Body, func() {
|
||||
atomic.AddInt64(&entry.inFlight, -1)
|
||||
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
|
||||
})
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// acquireClientWithTLS 获取或创建带 TLS 指纹的客户端
|
||||
func (s *httpUpstreamService) acquireClientWithTLS(proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*upstreamClientEntry, error) {
|
||||
return s.getClientEntryWithTLS(proxyURL, accountID, accountConcurrency, profile, true, true)
|
||||
@ -883,35 +865,55 @@ func wrapTrackedBody(body io.ReadCloser, onClose func()) io.ReadCloser {
|
||||
return &trackedBody{ReadCloser: body, onClose: onClose}
|
||||
}
|
||||
|
||||
// decompressResponseBody 根据 Content-Encoding 对响应体进行解压
|
||||
// 支持 gzip、br(brotli)、deflate;解压后更新响应头以反映明文内容
|
||||
// decompressResponseBody 根据 Content-Encoding 解压响应体。
|
||||
// 当请求显式设置了 accept-encoding 时,Go 的 Transport 不会自动解压,需要手动处理。
|
||||
// 解压成功后会删除 Content-Encoding 和 Content-Length header(长度已不准确)。
|
||||
func decompressResponseBody(resp *http.Response) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return
|
||||
}
|
||||
enc := strings.ToLower(resp.Header.Get("Content-Encoding"))
|
||||
switch enc {
|
||||
ce := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Encoding")))
|
||||
if ce == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
switch ce {
|
||||
case "gzip":
|
||||
gr, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
return // 解压失败,保持原样
|
||||
}
|
||||
resp.Body = io.NopCloser(gr)
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Content-Length")
|
||||
resp.ContentLength = -1
|
||||
resp.Uncompressed = true
|
||||
reader = gr
|
||||
case "br":
|
||||
resp.Body = io.NopCloser(brotli.NewReader(resp.Body))
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Content-Length")
|
||||
resp.ContentLength = -1
|
||||
resp.Uncompressed = true
|
||||
reader = brotli.NewReader(resp.Body)
|
||||
case "deflate":
|
||||
resp.Body = io.NopCloser(flate.NewReader(resp.Body))
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Content-Length")
|
||||
resp.ContentLength = -1
|
||||
resp.Uncompressed = true
|
||||
reader = flate.NewReader(resp.Body)
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
originalBody := resp.Body
|
||||
resp.Body = &decompressedBody{reader: reader, closer: originalBody}
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Content-Length") // 解压后长度不确定
|
||||
resp.ContentLength = -1
|
||||
}
|
||||
|
||||
// decompressedBody 组合解压 reader 和原始 body 的 close。
|
||||
type decompressedBody struct {
|
||||
reader io.Reader
|
||||
closer io.Closer
|
||||
}
|
||||
|
||||
func (d *decompressedBody) Read(p []byte) (int, error) {
|
||||
return d.reader.Read(p)
|
||||
}
|
||||
|
||||
func (d *decompressedBody) Close() error {
|
||||
// 如果 reader 本身也是 Closer(如 gzip.Reader),先关闭它
|
||||
if rc, ok := d.reader.(io.Closer); ok {
|
||||
_ = rc.Close()
|
||||
}
|
||||
return d.closer.Close()
|
||||
}
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
package repository
|
||||
|
||||
// ==============================================================
|
||||
// antigravity — Node.js TLS 代理扩展
|
||||
//
|
||||
// 此文件包含 Antigravity fork 新增的 Node.js TLS 代理功能,
|
||||
// 与 upstream 代码完全隔离,便于 upstream 更新时的合并维护。
|
||||
//
|
||||
// 上游文件 http_upstream.go 中的钩子调用点:
|
||||
// Do() L128-137 — 直接路由到 doViaNodeTLSProxy
|
||||
// DoWithTLS() L188-193 — 优先走 Node.js 代理
|
||||
// ==============================================================
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// isNodeTLSProxyEnabled 检查 Node.js TLS 代理是否启用
|
||||
func (s *httpUpstreamService) isNodeTLSProxyEnabled() bool {
|
||||
if s.cfg == nil {
|
||||
return false
|
||||
}
|
||||
return s.cfg.Gateway.NodeTLSProxy.Enabled
|
||||
}
|
||||
|
||||
// shouldRouteViaNodeProxy 判断请求是否应该走 Node.js TLS 代理
|
||||
// 仅拦截目标主机在 proxy_hosts 白名单中的 HTTPS 请求,
|
||||
// 白名单为空时默认只代理 api.anthropic.com。
|
||||
func (s *httpUpstreamService) shouldRouteViaNodeProxy(req *http.Request) bool {
|
||||
if req == nil || req.URL == nil || req.URL.Scheme != "https" {
|
||||
return false
|
||||
}
|
||||
reqHost := req.URL.Hostname()
|
||||
if reqHost == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
hosts := s.cfg.Gateway.NodeTLSProxy.ProxyHosts
|
||||
if len(hosts) == 0 {
|
||||
// 默认只代理 Anthropic
|
||||
return reqHost == "api.anthropic.com"
|
||||
}
|
||||
for _, h := range hosts {
|
||||
if reqHost == h {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// doViaNodeTLSProxy 通过 Node.js TLS 代理发送请求
|
||||
// 将 HTTPS 请求改为 HTTP 明文发送到本地 Node.js 代理,
|
||||
// 由 Node.js 进程使用原生 TLS 栈完成到上游的 HTTPS 连接。
|
||||
// 原始目标主机通过 X-Forwarded-Host 传递给 Node.js 代理,
|
||||
// 代理据此动态连接到正确的上游主机。
|
||||
func (s *httpUpstreamService) doViaNodeTLSProxy(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
||||
proxyCfg := s.cfg.Gateway.NodeTLSProxy
|
||||
listenHost := proxyCfg.ListenHost
|
||||
if listenHost == "" {
|
||||
listenHost = "127.0.0.1"
|
||||
}
|
||||
listenPort := proxyCfg.ListenPort
|
||||
if listenPort == 0 {
|
||||
listenPort = 3456
|
||||
}
|
||||
|
||||
// 克隆请求,避免修改原始 req(重试时需要原始 URL)
|
||||
proxyReq := req.Clone(req.Context())
|
||||
// 安全复制 Body:优先用 GetBody 工厂方法
|
||||
if req.GetBody != nil {
|
||||
proxyReq.Body, _ = req.GetBody()
|
||||
} else {
|
||||
proxyReq.Body = req.Body
|
||||
}
|
||||
|
||||
// 保存原始目标主机,通过自定义头传给 Node.js 代理
|
||||
originalHost := req.URL.Host
|
||||
proxyReq.Header.Set("X-Forwarded-Host", originalHost)
|
||||
|
||||
// 如果账号绑定了代理(落地机 GOST),通过 header 传递给 node-tls-proxy
|
||||
// node-tls-proxy 会用此代理作为上游出口,实现动态路由
|
||||
if proxyURL != "" {
|
||||
proxyReq.Header.Set("X-Upstream-Proxy", proxyURL)
|
||||
}
|
||||
|
||||
// 重写请求 URL:https://api.anthropic.com/v1/... → http://127.0.0.1:3456/v1/...
|
||||
proxyReq.URL.Scheme = "http"
|
||||
proxyReq.URL.Host = fmt.Sprintf("%s:%d", listenHost, listenPort)
|
||||
|
||||
slog.Debug("node_tls_proxy_rewrite",
|
||||
"account_id", accountID,
|
||||
"original_host", originalHost,
|
||||
"rewritten_to", proxyReq.URL.Host,
|
||||
)
|
||||
|
||||
// 通过标准 HTTP 客户端发送(不需要 TLS,代理是本地 HTTP)
|
||||
return s.Do(proxyReq, "", accountID, accountConcurrency)
|
||||
}
|
||||
55
backend/internal/repository/internal500_counter_cache.go
Normal file
55
backend/internal/repository/internal500_counter_cache.go
Normal file
@ -0,0 +1,55 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
internal500CounterPrefix = "internal500_count:account:"
|
||||
internal500CounterTTLSeconds = 86400 // 24 小时兜底
|
||||
)
|
||||
|
||||
// internal500CounterIncrScript 使用 Lua 脚本原子性地增加计数并返回当前值
|
||||
// 如果 key 不存在,则创建并设置过期时间
|
||||
var internal500CounterIncrScript = redis.NewScript(`
|
||||
local key = KEYS[1]
|
||||
local ttl = tonumber(ARGV[1])
|
||||
|
||||
local count = redis.call('INCR', key)
|
||||
if count == 1 then
|
||||
redis.call('EXPIRE', key, ttl)
|
||||
end
|
||||
|
||||
return count
|
||||
`)
|
||||
|
||||
type internal500CounterCache struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
// NewInternal500CounterCache 创建 INTERNAL 500 连续失败计数器缓存实例
|
||||
func NewInternal500CounterCache(rdb *redis.Client) service.Internal500CounterCache {
|
||||
return &internal500CounterCache{rdb: rdb}
|
||||
}
|
||||
|
||||
// IncrementInternal500Count 原子递增计数并返回当前值
|
||||
func (c *internal500CounterCache) IncrementInternal500Count(ctx context.Context, accountID int64) (int64, error) {
|
||||
key := fmt.Sprintf("%s%d", internal500CounterPrefix, accountID)
|
||||
|
||||
result, err := internal500CounterIncrScript.Run(ctx, c.rdb, []string{key}, internal500CounterTTLSeconds).Int64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("increment internal500 count: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResetInternal500Count 清零计数器(成功响应时调用)
|
||||
func (c *internal500CounterCache) ResetInternal500Count(ctx context.Context, accountID int64) error {
|
||||
key := fmt.Sprintf("%s%d", internal500CounterPrefix, accountID)
|
||||
return c.rdb.Del(ctx, key).Err()
|
||||
}
|
||||
@ -81,6 +81,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewAPIKeyCache,
|
||||
NewTempUnschedCache,
|
||||
NewTimeoutCounterCache,
|
||||
NewInternal500CounterCache,
|
||||
ProvideConcurrencyCache,
|
||||
ProvideSessionLimitCache,
|
||||
NewRPMCache,
|
||||
|
||||
@ -540,6 +540,8 @@ func TestAPIContracts(t *testing.T) {
|
||||
"max_claude_code_version": "",
|
||||
"allow_ungrouped_key_scheduling": false,
|
||||
"backend_mode_enabled": false,
|
||||
"enable_fingerprint_unification": true,
|
||||
"enable_metadata_passthrough": false,
|
||||
"custom_menu_items": [],
|
||||
"custom_endpoints": []
|
||||
}
|
||||
|
||||
@ -87,9 +87,6 @@ func RegisterAdminRoutes(
|
||||
|
||||
// 定时测试计划
|
||||
registerScheduledTestRoutes(admin, h)
|
||||
|
||||
// 风控中心
|
||||
registerRiskRoutes(admin, h)
|
||||
}
|
||||
}
|
||||
|
||||
@ -263,6 +260,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
accounts.POST("/:id/test", h.Admin.Account.Test)
|
||||
accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState)
|
||||
accounts.POST("/:id/refresh", h.Admin.Account.Refresh)
|
||||
accounts.POST("/:id/set-privacy", h.Admin.Account.SetPrivacy)
|
||||
accounts.POST("/:id/refresh-tier", h.Admin.Account.RefreshTier)
|
||||
accounts.GET("/:id/stats", h.Admin.Account.GetStats)
|
||||
accounts.POST("/:id/clear-error", h.Admin.Account.ClearError)
|
||||
@ -569,15 +567,3 @@ func registerTLSFingerprintProfileRoutes(admin *gin.RouterGroup, h *handler.Hand
|
||||
profiles.DELETE("/:id", h.Admin.TLSFingerprintProfile.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
func registerRiskRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
risk := admin.Group("/risk")
|
||||
{
|
||||
risk.GET("/summary", h.Admin.Risk.GetSummary)
|
||||
risk.GET("/accounts", h.Admin.Risk.ListAccounts)
|
||||
risk.GET("/accounts/:id", h.Admin.Risk.GetAccountDetail)
|
||||
risk.PUT("/accounts/:id/override", h.Admin.Risk.OverrideRiskLevel)
|
||||
risk.GET("/settings", h.Admin.Risk.GetSettings)
|
||||
risk.PUT("/settings", h.Admin.Risk.UpdateSettings)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1147,23 +1147,22 @@ func (a *Account) IsAnthropicOAuthOrSetupToken() bool {
|
||||
}
|
||||
|
||||
// IsTLSFingerprintEnabled 检查是否启用 TLS 指纹伪装
|
||||
// 支持 Anthropic OAuth/SetupToken 和 Gemini OAuth 账号(扩展见 account_antigravity.go)
|
||||
// 启用后将通过 node-tls-proxy 路由流量,获得真实 Node.js TLS 握手特征
|
||||
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
|
||||
// 启用后将模拟 Claude Code (Node.js) 客户端的 TLS 握手特征
|
||||
func (a *Account) IsTLSFingerprintEnabled() bool {
|
||||
// Anthropic OAuth/SetupToken — 原有逻辑
|
||||
if a.IsAnthropicOAuthOrSetupToken() {
|
||||
if a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := a.Extra["enable_tls_fingerprint"]; ok {
|
||||
if enabled, ok := v.(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
// 仅支持 Anthropic OAuth/SetupToken 账号
|
||||
if !a.IsAnthropicOAuthOrSetupToken() {
|
||||
return false
|
||||
}
|
||||
// Gemini OAuth — 扩展(实现在 account_antigravity.go)
|
||||
return geminiTLSFingerprintEnabled(a)
|
||||
if a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := a.Extra["enable_tls_fingerprint"]; ok {
|
||||
if enabled, ok := v.(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetTLSFingerprintProfileID 获取账号绑定的 TLS 指纹模板 ID
|
||||
@ -1191,24 +1190,6 @@ func (a *Account) GetTLSFingerprintProfileID() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetTLSMode 获取账号配置的 TLS 指纹模式
|
||||
// 返回值: TLSModeNode / TLSModeUTLS / TLSModeOff
|
||||
// 存储在 Extra["tls_mode"],未设置时:支持指纹的账号默认 node,其余 off
|
||||
func (a *Account) GetTLSMode() TLSMode {
|
||||
if a.Extra != nil {
|
||||
if m, ok := a.Extra["tls_mode"].(string); ok && m != "" {
|
||||
switch TLSMode(m) {
|
||||
case TLSModeNode, TLSModeUTLS, TLSModeOff:
|
||||
return TLSMode(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.IsTLSFingerprintEnabled() {
|
||||
return TLSModeNode
|
||||
}
|
||||
return TLSModeOff
|
||||
}
|
||||
|
||||
// GetUserMsgQueueMode 获取用户消息队列模式
|
||||
// "serialize" = 串行队列, "throttle" = 软性限速, "" = 未设置(使用全局配置)
|
||||
func (a *Account) GetUserMsgQueueMode() string {
|
||||
@ -1248,6 +1229,28 @@ func (a *Account) IsSessionIDMaskingEnabled() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsCustomBaseURLEnabled 检查是否启用自定义 base URL 中继转发
|
||||
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
|
||||
func (a *Account) IsCustomBaseURLEnabled() bool {
|
||||
if !a.IsAnthropicOAuthOrSetupToken() {
|
||||
return false
|
||||
}
|
||||
if a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := a.Extra["custom_base_url_enabled"]; ok {
|
||||
if enabled, ok := v.(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetCustomBaseURL 返回自定义中继服务的 base URL
|
||||
func (a *Account) GetCustomBaseURL() string {
|
||||
return a.GetExtraString("custom_base_url")
|
||||
}
|
||||
|
||||
// IsCacheTTLOverrideEnabled 检查是否启用缓存 TTL 强制替换
|
||||
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
|
||||
// 启用后将所有 cache creation tokens 归入指定的 TTL 类型(5m 或 1h)
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
package service
|
||||
|
||||
// ==============================================================
|
||||
// antigravity — account 扩展
|
||||
//
|
||||
// 此文件包含 Antigravity fork 对 Account 的扩展,
|
||||
// 新增了 Gemini OAuth 账号的 TLS 指纹伪装支持。
|
||||
//
|
||||
// 对上游文件 account.go 的改动:
|
||||
// - IsTLSFingerprintEnabled() 方法改为调用本文件的 geminiTLSFingerprintEnabled()
|
||||
// (仅需在与上游合并时确保钩子调用点存在)
|
||||
// ==============================================================
|
||||
|
||||
// geminiTLSFingerprintEnabled 检查 Gemini OAuth 账号是否启用 TLS 指纹伪装
|
||||
// Gemini CLI 也是 Node.js 应用,通过 node-tls-proxy 代理后
|
||||
// TLS 指纹天然匹配 Gemini CLI,无需单独模拟
|
||||
func geminiTLSFingerprintEnabled(a *Account) bool {
|
||||
if a.Platform != PlatformGemini || a.Type != AccountTypeOAuth {
|
||||
return false
|
||||
}
|
||||
if a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := a.Extra["enable_tls_fingerprint"]; ok {
|
||||
if enabled, ok := v.(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -304,7 +304,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@ -394,7 +394,7 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, TLSModeOff, nil)
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, nil)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@ -524,7 +524,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@ -614,7 +614,7 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@ -887,7 +887,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
|
||||
}
|
||||
soraTLSProfile := s.resolveSoraTLSProfile()
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), soraTLSProfile)
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, soraTLSProfile)
|
||||
if err != nil {
|
||||
recorder.addStep("me", "failed", 0, "network_error", err.Error())
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
@ -952,7 +952,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
|
||||
subReq.Header.Set("Origin", "https://sora.chatgpt.com")
|
||||
subReq.Header.Set("Referer", "https://sora.chatgpt.com/")
|
||||
|
||||
subResp, subErr := s.httpUpstream.DoWithTLS(subReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), soraTLSProfile)
|
||||
subResp, subErr := s.httpUpstream.DoWithTLS(subReq, proxyURL, account.ID, account.Concurrency, soraTLSProfile)
|
||||
if subErr != nil {
|
||||
recorder.addStep("subscription", "failed", 0, "network_error", subErr.Error())
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Subscription check skipped: %s", subErr.Error())})
|
||||
@ -1139,7 +1139,7 @@ func (s *AccountTestService) fetchSoraTestEndpoint(
|
||||
req.Header.Set("Origin", "https://sora.chatgpt.com")
|
||||
req.Header.Set("Referer", "https://sora.chatgpt.com/")
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), tlsProfile)
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
@ -1469,8 +1469,7 @@ func (s *AccountTestService) buildCodeAssistRequest(ctx context.Context, accessT
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient())
|
||||
req.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
@ -245,7 +245,6 @@ type ClaudeUsageFetchOptions struct {
|
||||
AccessToken string // OAuth access token
|
||||
ProxyURL string // 代理 URL(可选)
|
||||
AccountID int64 // 账号 ID(用于连接池隔离)
|
||||
TLSMode TLSMode // TLS 模式(off/node/utls)
|
||||
TLSProfile *tlsfingerprint.Profile // TLS 指纹 Profile(nil 表示不启用)
|
||||
Fingerprint *Fingerprint // 缓存的指纹信息(User-Agent 等)
|
||||
}
|
||||
@ -1163,7 +1162,6 @@ func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *A
|
||||
AccessToken: accessToken,
|
||||
ProxyURL: proxyURL,
|
||||
AccountID: account.ID,
|
||||
TLSMode: account.GetTLSMode(),
|
||||
TLSProfile: s.tlsFPProfileService.ResolveTLSProfile(account),
|
||||
}
|
||||
|
||||
|
||||
@ -1866,6 +1866,18 @@ func (s *adminServiceImpl) ClearAccountError(ctx context.Context, id int64) (*Ac
|
||||
if err := s.accountRepo.ClearError(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.accountRepo.ClearRateLimit(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.accountRepo.ClearAntigravityQuotaScopes(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.accountRepo.ClearModelRateLimits(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.accountRepo.ClearTempUnschedulable(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.accountRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
@ -2641,10 +2653,8 @@ func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Acc
|
||||
if s.privacyClientFactory == nil {
|
||||
return ""
|
||||
}
|
||||
if account.Extra != nil {
|
||||
if _, ok := account.Extra["privacy_mode"]; ok {
|
||||
return ""
|
||||
}
|
||||
if shouldSkipOpenAIPrivacyEnsure(account.Extra) {
|
||||
return ""
|
||||
}
|
||||
|
||||
token, _ := account.Credentials["access_token"].(string)
|
||||
@ -2707,10 +2717,13 @@ func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Acco
|
||||
|
||||
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。
|
||||
// 如果 Extra["privacy_mode"] 已存在(无论成功或失败),直接跳过。
|
||||
// 仅对从未设置过隐私的账号执行 setUserSettings + fetchUserInfo 流程。
|
||||
// 用户可通过前端 ForceAntigravityPrivacy(SetPrivacy 按钮)强制重新设置。
|
||||
func (s *adminServiceImpl) EnsureAntigravityPrivacy(ctx context.Context, account *Account) string {
|
||||
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
|
||||
return ""
|
||||
}
|
||||
// 已设置过则跳过(无论成功或失败),用户可通过 Force 手动重试
|
||||
if account.Extra != nil {
|
||||
if existing, ok := account.Extra["privacy_mode"].(string); ok && existing != "" {
|
||||
return existing
|
||||
|
||||
86
backend/internal/service/admin_service_clear_error_test.go
Normal file
86
backend/internal/service/admin_service_clear_error_test.go
Normal file
@ -0,0 +1,86 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type accountRepoStubForClearAccountError struct {
|
||||
mockAccountRepoForGemini
|
||||
account *Account
|
||||
clearErrorCalls int
|
||||
clearRateLimitCalls int
|
||||
clearAntigravityCalls int
|
||||
clearModelRateLimitCalls int
|
||||
clearTempUnschedCalls int
|
||||
}
|
||||
|
||||
func (r *accountRepoStubForClearAccountError) GetByID(ctx context.Context, id int64) (*Account, error) {
|
||||
return r.account, nil
|
||||
}
|
||||
|
||||
func (r *accountRepoStubForClearAccountError) ClearError(ctx context.Context, id int64) error {
|
||||
r.clearErrorCalls++
|
||||
r.account.Status = StatusActive
|
||||
r.account.ErrorMessage = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *accountRepoStubForClearAccountError) ClearRateLimit(ctx context.Context, id int64) error {
|
||||
r.clearRateLimitCalls++
|
||||
r.account.RateLimitedAt = nil
|
||||
r.account.RateLimitResetAt = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *accountRepoStubForClearAccountError) ClearAntigravityQuotaScopes(ctx context.Context, id int64) error {
|
||||
r.clearAntigravityCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *accountRepoStubForClearAccountError) ClearModelRateLimits(ctx context.Context, id int64) error {
|
||||
r.clearModelRateLimitCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *accountRepoStubForClearAccountError) ClearTempUnschedulable(ctx context.Context, id int64) error {
|
||||
r.clearTempUnschedCalls++
|
||||
r.account.TempUnschedulableUntil = nil
|
||||
r.account.TempUnschedulableReason = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAdminService_ClearAccountError_AlsoClearsRecoverableRuntimeState(t *testing.T) {
|
||||
until := time.Now().Add(10 * time.Minute)
|
||||
resetAt := time.Now().Add(5 * time.Minute)
|
||||
repo := &accountRepoStubForClearAccountError{
|
||||
account: &Account{
|
||||
ID: 31,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusError,
|
||||
ErrorMessage: "refresh failed",
|
||||
RateLimitResetAt: &resetAt,
|
||||
TempUnschedulableUntil: &until,
|
||||
TempUnschedulableReason: "missing refresh token",
|
||||
},
|
||||
}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
|
||||
updated, err := svc.ClearAccountError(context.Background(), 31)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updated)
|
||||
require.Equal(t, 1, repo.clearErrorCalls)
|
||||
require.Equal(t, 1, repo.clearRateLimitCalls)
|
||||
require.Equal(t, 1, repo.clearAntigravityCalls)
|
||||
require.Equal(t, 1, repo.clearModelRateLimitCalls)
|
||||
require.Equal(t, 1, repo.clearTempUnschedCalls)
|
||||
require.Nil(t, updated.RateLimitResetAt)
|
||||
require.Nil(t, updated.TempUnschedulableUntil)
|
||||
require.Empty(t, updated.TempUnschedulableReason)
|
||||
}
|
||||
@ -614,6 +614,7 @@ func (s *AntigravityGatewayService) antigravityRetryLoop(p antigravityRetryLoopP
|
||||
urlFallbackLoop:
|
||||
for urlIdx, baseURL := range availableURLs {
|
||||
usedBaseURL = baseURL
|
||||
allAttemptsInternal500 := true // 追踪本轮所有 attempt 是否全部命中 INTERNAL 500
|
||||
for attempt := 1; attempt <= antigravityMaxRetries; attempt++ {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
@ -766,10 +767,19 @@ urlFallbackLoop:
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled_during_backoff", p.prefix)
|
||||
return nil, p.ctx.Err()
|
||||
}
|
||||
// 追踪 INTERNAL 500:非匹配的 attempt 清除标记
|
||||
if !isAntigravityInternalServerError(resp.StatusCode, respBody) {
|
||||
allAttemptsInternal500 = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// INTERNAL 500 渐进惩罚:3 次重试全部命中特定 500 时递增计数器并惩罚
|
||||
if allAttemptsInternal500 && isAntigravityInternalServerError(resp.StatusCode, respBody) {
|
||||
s.handleInternal500RetryExhausted(p.ctx, p.prefix, p.account)
|
||||
}
|
||||
|
||||
// 其他 4xx 错误或重试用尽,直接返回
|
||||
resp = &http.Response{
|
||||
StatusCode: resp.StatusCode,
|
||||
@ -788,6 +798,11 @@ urlFallbackLoop:
|
||||
antigravity.DefaultURLAvailability.MarkSuccess(usedBaseURL)
|
||||
}
|
||||
|
||||
// 成功响应时清零 INTERNAL 500 连续失败计数器(覆盖所有成功路径,含 smart retry)
|
||||
if resp != nil && resp.StatusCode < 400 {
|
||||
s.resetInternal500Counter(p.ctx, p.prefix, p.account.ID)
|
||||
}
|
||||
|
||||
return &antigravityRetryLoopResult{resp: resp}, nil
|
||||
}
|
||||
|
||||
@ -862,7 +877,7 @@ type AntigravityGatewayService struct {
|
||||
settingService *SettingService
|
||||
cache GatewayCache // 用于模型级限流时清除粘性会话绑定
|
||||
schedulerSnapshot *SchedulerSnapshotService
|
||||
heartbeat *AntigravityHeartbeat
|
||||
internal500Cache Internal500CounterCache // INTERNAL 500 渐进惩罚计数器
|
||||
}
|
||||
|
||||
func NewAntigravityGatewayService(
|
||||
@ -873,6 +888,7 @@ func NewAntigravityGatewayService(
|
||||
rateLimitService *RateLimitService,
|
||||
httpUpstream HTTPUpstream,
|
||||
settingService *SettingService,
|
||||
internal500Cache Internal500CounterCache,
|
||||
) *AntigravityGatewayService {
|
||||
return &AntigravityGatewayService{
|
||||
accountRepo: accountRepo,
|
||||
@ -882,7 +898,7 @@ func NewAntigravityGatewayService(
|
||||
settingService: settingService,
|
||||
cache: cache,
|
||||
schedulerSnapshot: schedulerSnapshot,
|
||||
heartbeat: NewAntigravityHeartbeat(),
|
||||
internal500Cache: internal500Cache,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1379,11 +1395,6 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
// 注册心跳(首次 API 调用时自动注册,后续更新 token)
|
||||
if s.heartbeat != nil && projectID != "" {
|
||||
s.heartbeat.Register(account.ID, accessToken, projectID, proxyURL)
|
||||
}
|
||||
|
||||
// 获取转换选项
|
||||
// Antigravity 上游要求必须包含身份提示词,否则会返回 429
|
||||
transformOpts := s.getClaudeTransformOptions(ctx)
|
||||
@ -3572,7 +3583,11 @@ func mergeTextPartsToResponse(response map[string]any, textParts []string) map[s
|
||||
}
|
||||
|
||||
func (s *AntigravityGatewayService) writeClaudeError(c *gin.Context, status int, errType, message string) error {
|
||||
return WriteClaudeErrorResponse(c, status, errType, message)
|
||||
c.JSON(status, gin.H{
|
||||
"type": "error",
|
||||
"error": gin.H{"type": errType, "message": message},
|
||||
})
|
||||
return fmt.Errorf("%s", message)
|
||||
}
|
||||
|
||||
// WriteMappedClaudeError 导出版本,供 handler 层使用(如 fallback 错误处理)
|
||||
@ -3658,7 +3673,28 @@ func (s *AntigravityGatewayService) writeMappedClaudeError(c *gin.Context, accou
|
||||
}
|
||||
|
||||
func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int, message string) error {
|
||||
return WriteGoogleErrorResponse(c, status, message)
|
||||
statusStr := "UNKNOWN"
|
||||
switch status {
|
||||
case 400:
|
||||
statusStr = "INVALID_ARGUMENT"
|
||||
case 404:
|
||||
statusStr = "NOT_FOUND"
|
||||
case 429:
|
||||
statusStr = "RESOURCE_EXHAUSTED"
|
||||
case 500:
|
||||
statusStr = "INTERNAL"
|
||||
case 502, 503:
|
||||
statusStr = "UNAVAILABLE"
|
||||
}
|
||||
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"code": status,
|
||||
"message": message,
|
||||
"status": statusStr,
|
||||
},
|
||||
})
|
||||
return fmt.Errorf("%s", message)
|
||||
}
|
||||
|
||||
// handleClaudeStreamToNonStreaming 收集上游流式响应,转换为 Claude 非流式格式返回
|
||||
|
||||
@ -1,204 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
)
|
||||
|
||||
// AntigravityHeartbeat 模拟真实 Antigravity IDE 的心跳行为
|
||||
// 真实 IDE 每 5 分钟发送 loadCodeAssist + fetchAvailableModels
|
||||
type AntigravityHeartbeat struct {
|
||||
mu sync.Mutex
|
||||
sessions map[int64]*heartbeatSession // accountID -> session
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
type heartbeatSession struct {
|
||||
accountID int64
|
||||
accessToken string
|
||||
projectID string
|
||||
proxyURL string
|
||||
lastBeat time.Time
|
||||
}
|
||||
|
||||
// NewAntigravityHeartbeat 创建心跳管理器
|
||||
func NewAntigravityHeartbeat() *AntigravityHeartbeat {
|
||||
hb := &AntigravityHeartbeat{
|
||||
sessions: make(map[int64]*heartbeatSession),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go hb.loop()
|
||||
return hb
|
||||
}
|
||||
|
||||
// Register 注册账号心跳(首次 API 调用时调用)
|
||||
func (h *AntigravityHeartbeat) Register(accountID int64, accessToken, projectID, proxyURL string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if _, exists := h.sessions[accountID]; exists {
|
||||
// 更新 token(可能已刷新)
|
||||
h.sessions[accountID].accessToken = accessToken
|
||||
return
|
||||
}
|
||||
|
||||
h.sessions[accountID] = &heartbeatSession{
|
||||
accountID: accountID,
|
||||
accessToken: accessToken,
|
||||
projectID: projectID,
|
||||
proxyURL: proxyURL,
|
||||
lastBeat: time.Now(),
|
||||
}
|
||||
log.Printf("[antigravity-heartbeat] registered account %d (project: %s)", accountID, projectID)
|
||||
}
|
||||
|
||||
// UpdateToken 更新账号的 access token(token 刷新后调用)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
97
backend/internal/service/antigravity_internal500_penalty.go
Normal file
97
backend/internal/service/antigravity_internal500_penalty.go
Normal file
@ -0,0 +1,97 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// INTERNAL 500 渐进惩罚:连续多轮全部返回特定 500 错误时的惩罚时长
|
||||
const (
|
||||
internal500PenaltyTier1Duration = 30 * time.Minute // 第 1 轮:临时不可调度 30 分钟
|
||||
internal500PenaltyTier2Duration = 2 * time.Hour // 第 2 轮:临时不可调度 2 小时
|
||||
internal500PenaltyTier3Threshold = 3 // 第 3+ 轮:永久禁用
|
||||
)
|
||||
|
||||
// isAntigravityInternalServerError 检测特定的 INTERNAL 500 错误
|
||||
// 必须同时匹配 error.code==500, error.message=="Internal error encountered.", error.status=="INTERNAL"
|
||||
func isAntigravityInternalServerError(statusCode int, body []byte) bool {
|
||||
if statusCode != http.StatusInternalServerError {
|
||||
return false
|
||||
}
|
||||
return gjson.GetBytes(body, "error.code").Int() == 500 &&
|
||||
gjson.GetBytes(body, "error.message").String() == "Internal error encountered." &&
|
||||
gjson.GetBytes(body, "error.status").String() == "INTERNAL"
|
||||
}
|
||||
|
||||
// applyInternal500Penalty 根据连续 INTERNAL 500 轮次数应用渐进惩罚
|
||||
// count=1: temp_unschedulable 10 分钟
|
||||
// count=2: temp_unschedulable 10 小时
|
||||
// count>=3: SetError 永久禁用
|
||||
func (s *AntigravityGatewayService) applyInternal500Penalty(
|
||||
ctx context.Context, prefix string, account *Account, count int64,
|
||||
) {
|
||||
switch {
|
||||
case count >= int64(internal500PenaltyTier3Threshold):
|
||||
reason := fmt.Sprintf("INTERNAL 500 consecutive failures: %d rounds", count)
|
||||
if err := s.accountRepo.SetError(ctx, account.ID, reason); err != nil {
|
||||
slog.Error("internal500_set_error_failed", "account_id", account.ID, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Warn("internal500_account_disabled",
|
||||
"account_id", account.ID, "account_name", account.Name, "consecutive_count", count)
|
||||
case count == 2:
|
||||
until := time.Now().Add(internal500PenaltyTier2Duration)
|
||||
reason := fmt.Sprintf("INTERNAL 500 x%d (temp unsched %v)", count, internal500PenaltyTier2Duration)
|
||||
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil {
|
||||
slog.Error("internal500_temp_unsched_failed", "account_id", account.ID, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Warn("internal500_temp_unschedulable",
|
||||
"account_id", account.ID, "account_name", account.Name,
|
||||
"duration", internal500PenaltyTier2Duration, "consecutive_count", count)
|
||||
case count == 1:
|
||||
until := time.Now().Add(internal500PenaltyTier1Duration)
|
||||
reason := fmt.Sprintf("INTERNAL 500 x%d (temp unsched %v)", count, internal500PenaltyTier1Duration)
|
||||
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil {
|
||||
slog.Error("internal500_temp_unsched_failed", "account_id", account.ID, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("internal500_temp_unschedulable",
|
||||
"account_id", account.ID, "account_name", account.Name,
|
||||
"duration", internal500PenaltyTier1Duration, "consecutive_count", count)
|
||||
}
|
||||
}
|
||||
|
||||
// handleInternal500RetryExhausted 处理 INTERNAL 500 重试耗尽:递增计数器并应用惩罚
|
||||
func (s *AntigravityGatewayService) handleInternal500RetryExhausted(
|
||||
ctx context.Context, prefix string, account *Account,
|
||||
) {
|
||||
if s.internal500Cache == nil {
|
||||
return
|
||||
}
|
||||
count, err := s.internal500Cache.IncrementInternal500Count(ctx, account.ID)
|
||||
if err != nil {
|
||||
slog.Error("internal500_counter_increment_failed",
|
||||
"prefix", prefix, "account_id", account.ID, "error", err)
|
||||
return
|
||||
}
|
||||
s.applyInternal500Penalty(ctx, prefix, account, count)
|
||||
}
|
||||
|
||||
// resetInternal500Counter 成功响应时清零 INTERNAL 500 计数器
|
||||
func (s *AntigravityGatewayService) resetInternal500Counter(
|
||||
ctx context.Context, prefix string, accountID int64,
|
||||
) {
|
||||
if s.internal500Cache == nil {
|
||||
return
|
||||
}
|
||||
if err := s.internal500Cache.ResetInternal500Count(ctx, accountID); err != nil {
|
||||
slog.Error("internal500_counter_reset_failed",
|
||||
"prefix", prefix, "account_id", accountID, "error", err)
|
||||
}
|
||||
}
|
||||
321
backend/internal/service/antigravity_internal500_penalty_test.go
Normal file
321
backend/internal/service/antigravity_internal500_penalty_test.go
Normal file
@ -0,0 +1,321 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- mock: Internal500CounterCache ---
|
||||
|
||||
type mockInternal500Cache struct {
|
||||
incrementCount int64
|
||||
incrementErr error
|
||||
resetErr error
|
||||
|
||||
incrementCalls []int64 // 记录 IncrementInternal500Count 被调用时的 accountID
|
||||
resetCalls []int64 // 记录 ResetInternal500Count 被调用时的 accountID
|
||||
}
|
||||
|
||||
func (m *mockInternal500Cache) IncrementInternal500Count(_ context.Context, accountID int64) (int64, error) {
|
||||
m.incrementCalls = append(m.incrementCalls, accountID)
|
||||
return m.incrementCount, m.incrementErr
|
||||
}
|
||||
|
||||
func (m *mockInternal500Cache) ResetInternal500Count(_ context.Context, accountID int64) error {
|
||||
m.resetCalls = append(m.resetCalls, accountID)
|
||||
return m.resetErr
|
||||
}
|
||||
|
||||
// --- mock: 专用于 internal500 惩罚测试的 AccountRepository ---
|
||||
|
||||
type internal500AccountRepoStub struct {
|
||||
AccountRepository // 嵌入接口,未实现的方法会 panic(不应被调用)
|
||||
|
||||
tempUnschedCalls []tempUnschedCall
|
||||
setErrorCalls []setErrorCall
|
||||
}
|
||||
|
||||
type tempUnschedCall struct {
|
||||
accountID int64
|
||||
until time.Time
|
||||
reason string
|
||||
}
|
||||
|
||||
type setErrorCall struct {
|
||||
accountID int64
|
||||
reason string
|
||||
}
|
||||
|
||||
func (r *internal500AccountRepoStub) SetTempUnschedulable(_ context.Context, id int64, until time.Time, reason string) error {
|
||||
r.tempUnschedCalls = append(r.tempUnschedCalls, tempUnschedCall{accountID: id, until: until, reason: reason})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *internal500AccountRepoStub) SetError(_ context.Context, id int64, errorMsg string) error {
|
||||
r.setErrorCalls = append(r.setErrorCalls, setErrorCall{accountID: id, reason: errorMsg})
|
||||
return nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TestIsAntigravityInternalServerError
|
||||
// =============================================================================
|
||||
|
||||
func TestIsAntigravityInternalServerError(t *testing.T) {
|
||||
t.Run("匹配完整的 INTERNAL 500 body", func(t *testing.T) {
|
||||
body := []byte(`{"error":{"code":500,"message":"Internal error encountered.","status":"INTERNAL"}}`)
|
||||
require.True(t, isAntigravityInternalServerError(500, body))
|
||||
})
|
||||
|
||||
t.Run("statusCode 不是 500", func(t *testing.T) {
|
||||
body := []byte(`{"error":{"code":500,"message":"Internal error encountered.","status":"INTERNAL"}}`)
|
||||
require.False(t, isAntigravityInternalServerError(429, body))
|
||||
require.False(t, isAntigravityInternalServerError(503, body))
|
||||
require.False(t, isAntigravityInternalServerError(200, body))
|
||||
})
|
||||
|
||||
t.Run("body 中 message 不匹配", func(t *testing.T) {
|
||||
body := []byte(`{"error":{"code":500,"message":"Some other error","status":"INTERNAL"}}`)
|
||||
require.False(t, isAntigravityInternalServerError(500, body))
|
||||
})
|
||||
|
||||
t.Run("body 中 status 不匹配", func(t *testing.T) {
|
||||
body := []byte(`{"error":{"code":500,"message":"Internal error encountered.","status":"UNAVAILABLE"}}`)
|
||||
require.False(t, isAntigravityInternalServerError(500, body))
|
||||
})
|
||||
|
||||
t.Run("body 中 code 不匹配", func(t *testing.T) {
|
||||
body := []byte(`{"error":{"code":503,"message":"Internal error encountered.","status":"INTERNAL"}}`)
|
||||
require.False(t, isAntigravityInternalServerError(500, body))
|
||||
})
|
||||
|
||||
t.Run("空 body", func(t *testing.T) {
|
||||
require.False(t, isAntigravityInternalServerError(500, []byte{}))
|
||||
require.False(t, isAntigravityInternalServerError(500, nil))
|
||||
})
|
||||
|
||||
t.Run("其他 500 错误格式(纯文本)", func(t *testing.T) {
|
||||
body := []byte(`Internal Server Error`)
|
||||
require.False(t, isAntigravityInternalServerError(500, body))
|
||||
})
|
||||
|
||||
t.Run("其他 500 错误格式(不同 JSON 结构)", func(t *testing.T) {
|
||||
body := []byte(`{"message":"Internal Server Error","statusCode":500}`)
|
||||
require.False(t, isAntigravityInternalServerError(500, body))
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TestApplyInternal500Penalty
|
||||
// =============================================================================
|
||||
|
||||
func TestApplyInternal500Penalty(t *testing.T) {
|
||||
t.Run("count=1 → SetTempUnschedulable 10 分钟", func(t *testing.T) {
|
||||
repo := &internal500AccountRepoStub{}
|
||||
svc := &AntigravityGatewayService{accountRepo: repo}
|
||||
account := &Account{ID: 1, Name: "acc-1"}
|
||||
|
||||
before := time.Now()
|
||||
svc.applyInternal500Penalty(context.Background(), "[test]", account, 1)
|
||||
after := time.Now()
|
||||
|
||||
require.Len(t, repo.tempUnschedCalls, 1)
|
||||
require.Empty(t, repo.setErrorCalls)
|
||||
|
||||
call := repo.tempUnschedCalls[0]
|
||||
require.Equal(t, int64(1), call.accountID)
|
||||
require.Contains(t, call.reason, "INTERNAL 500")
|
||||
// until 应在 [before+10m, after+10m] 范围内
|
||||
require.True(t, call.until.After(before.Add(internal500PenaltyTier1Duration).Add(-time.Second)))
|
||||
require.True(t, call.until.Before(after.Add(internal500PenaltyTier1Duration).Add(time.Second)))
|
||||
})
|
||||
|
||||
t.Run("count=2 → SetTempUnschedulable 10 小时", func(t *testing.T) {
|
||||
repo := &internal500AccountRepoStub{}
|
||||
svc := &AntigravityGatewayService{accountRepo: repo}
|
||||
account := &Account{ID: 2, Name: "acc-2"}
|
||||
|
||||
before := time.Now()
|
||||
svc.applyInternal500Penalty(context.Background(), "[test]", account, 2)
|
||||
after := time.Now()
|
||||
|
||||
require.Len(t, repo.tempUnschedCalls, 1)
|
||||
require.Empty(t, repo.setErrorCalls)
|
||||
|
||||
call := repo.tempUnschedCalls[0]
|
||||
require.Equal(t, int64(2), call.accountID)
|
||||
require.Contains(t, call.reason, "INTERNAL 500")
|
||||
require.True(t, call.until.After(before.Add(internal500PenaltyTier2Duration).Add(-time.Second)))
|
||||
require.True(t, call.until.Before(after.Add(internal500PenaltyTier2Duration).Add(time.Second)))
|
||||
})
|
||||
|
||||
t.Run("count=3 → SetError 永久禁用", func(t *testing.T) {
|
||||
repo := &internal500AccountRepoStub{}
|
||||
svc := &AntigravityGatewayService{accountRepo: repo}
|
||||
account := &Account{ID: 3, Name: "acc-3"}
|
||||
|
||||
svc.applyInternal500Penalty(context.Background(), "[test]", account, 3)
|
||||
|
||||
require.Empty(t, repo.tempUnschedCalls)
|
||||
require.Len(t, repo.setErrorCalls, 1)
|
||||
|
||||
call := repo.setErrorCalls[0]
|
||||
require.Equal(t, int64(3), call.accountID)
|
||||
require.Contains(t, call.reason, "INTERNAL 500 consecutive failures: 3")
|
||||
})
|
||||
|
||||
t.Run("count=5 → SetError 永久禁用(>=3 都走永久禁用)", func(t *testing.T) {
|
||||
repo := &internal500AccountRepoStub{}
|
||||
svc := &AntigravityGatewayService{accountRepo: repo}
|
||||
account := &Account{ID: 5, Name: "acc-5"}
|
||||
|
||||
svc.applyInternal500Penalty(context.Background(), "[test]", account, 5)
|
||||
|
||||
require.Empty(t, repo.tempUnschedCalls)
|
||||
require.Len(t, repo.setErrorCalls, 1)
|
||||
|
||||
call := repo.setErrorCalls[0]
|
||||
require.Equal(t, int64(5), call.accountID)
|
||||
require.Contains(t, call.reason, "INTERNAL 500 consecutive failures: 5")
|
||||
})
|
||||
|
||||
t.Run("count=0 → 不调用任何方法", func(t *testing.T) {
|
||||
repo := &internal500AccountRepoStub{}
|
||||
svc := &AntigravityGatewayService{accountRepo: repo}
|
||||
account := &Account{ID: 10, Name: "acc-10"}
|
||||
|
||||
svc.applyInternal500Penalty(context.Background(), "[test]", account, 0)
|
||||
|
||||
require.Empty(t, repo.tempUnschedCalls)
|
||||
require.Empty(t, repo.setErrorCalls)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TestHandleInternal500RetryExhausted
|
||||
// =============================================================================
|
||||
|
||||
func TestHandleInternal500RetryExhausted(t *testing.T) {
|
||||
t.Run("internal500Cache 为 nil → 不 panic,不调用任何方法", func(t *testing.T) {
|
||||
repo := &internal500AccountRepoStub{}
|
||||
svc := &AntigravityGatewayService{
|
||||
accountRepo: repo,
|
||||
internal500Cache: nil,
|
||||
}
|
||||
account := &Account{ID: 1, Name: "acc-1"}
|
||||
|
||||
// 不应 panic
|
||||
require.NotPanics(t, func() {
|
||||
svc.handleInternal500RetryExhausted(context.Background(), "[test]", account)
|
||||
})
|
||||
require.Empty(t, repo.tempUnschedCalls)
|
||||
require.Empty(t, repo.setErrorCalls)
|
||||
})
|
||||
|
||||
t.Run("IncrementInternal500Count 返回 error → 不调用惩罚方法", func(t *testing.T) {
|
||||
repo := &internal500AccountRepoStub{}
|
||||
cache := &mockInternal500Cache{
|
||||
incrementErr: errors.New("redis connection error"),
|
||||
}
|
||||
svc := &AntigravityGatewayService{
|
||||
accountRepo: repo,
|
||||
internal500Cache: cache,
|
||||
}
|
||||
account := &Account{ID: 2, Name: "acc-2"}
|
||||
|
||||
svc.handleInternal500RetryExhausted(context.Background(), "[test]", account)
|
||||
|
||||
require.Len(t, cache.incrementCalls, 1)
|
||||
require.Equal(t, int64(2), cache.incrementCalls[0])
|
||||
require.Empty(t, repo.tempUnschedCalls)
|
||||
require.Empty(t, repo.setErrorCalls)
|
||||
})
|
||||
|
||||
t.Run("IncrementInternal500Count 返回 count=1 → 触发 tier1 惩罚", func(t *testing.T) {
|
||||
repo := &internal500AccountRepoStub{}
|
||||
cache := &mockInternal500Cache{
|
||||
incrementCount: 1,
|
||||
}
|
||||
svc := &AntigravityGatewayService{
|
||||
accountRepo: repo,
|
||||
internal500Cache: cache,
|
||||
}
|
||||
account := &Account{ID: 3, Name: "acc-3"}
|
||||
|
||||
svc.handleInternal500RetryExhausted(context.Background(), "[test]", account)
|
||||
|
||||
require.Len(t, cache.incrementCalls, 1)
|
||||
require.Equal(t, int64(3), cache.incrementCalls[0])
|
||||
// tier1: SetTempUnschedulable
|
||||
require.Len(t, repo.tempUnschedCalls, 1)
|
||||
require.Equal(t, int64(3), repo.tempUnschedCalls[0].accountID)
|
||||
require.Empty(t, repo.setErrorCalls)
|
||||
})
|
||||
|
||||
t.Run("IncrementInternal500Count 返回 count=3 → 触发 tier3 永久禁用", func(t *testing.T) {
|
||||
repo := &internal500AccountRepoStub{}
|
||||
cache := &mockInternal500Cache{
|
||||
incrementCount: 3,
|
||||
}
|
||||
svc := &AntigravityGatewayService{
|
||||
accountRepo: repo,
|
||||
internal500Cache: cache,
|
||||
}
|
||||
account := &Account{ID: 4, Name: "acc-4"}
|
||||
|
||||
svc.handleInternal500RetryExhausted(context.Background(), "[test]", account)
|
||||
|
||||
require.Len(t, cache.incrementCalls, 1)
|
||||
require.Empty(t, repo.tempUnschedCalls)
|
||||
require.Len(t, repo.setErrorCalls, 1)
|
||||
require.Equal(t, int64(4), repo.setErrorCalls[0].accountID)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TestResetInternal500Counter
|
||||
// =============================================================================
|
||||
|
||||
func TestResetInternal500Counter(t *testing.T) {
|
||||
t.Run("internal500Cache 为 nil → 不 panic", func(t *testing.T) {
|
||||
svc := &AntigravityGatewayService{
|
||||
internal500Cache: nil,
|
||||
}
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
svc.resetInternal500Counter(context.Background(), "[test]", 1)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ResetInternal500Count 返回 error → 不 panic(仅日志)", func(t *testing.T) {
|
||||
cache := &mockInternal500Cache{
|
||||
resetErr: errors.New("redis timeout"),
|
||||
}
|
||||
svc := &AntigravityGatewayService{
|
||||
internal500Cache: cache,
|
||||
}
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
svc.resetInternal500Counter(context.Background(), "[test]", 42)
|
||||
})
|
||||
require.Len(t, cache.resetCalls, 1)
|
||||
require.Equal(t, int64(42), cache.resetCalls[0])
|
||||
})
|
||||
|
||||
t.Run("正常调用 → 调用 ResetInternal500Count", func(t *testing.T) {
|
||||
cache := &mockInternal500Cache{}
|
||||
svc := &AntigravityGatewayService{
|
||||
internal500Cache: cache,
|
||||
}
|
||||
|
||||
svc.resetInternal500Counter(context.Background(), "[test]", 99)
|
||||
|
||||
require.Len(t, cache.resetCalls, 1)
|
||||
require.Equal(t, int64(99), cache.resetCalls[0])
|
||||
})
|
||||
}
|
||||
@ -322,7 +322,7 @@ func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, accou
|
||||
// loadCodeAssistResult 封装 loadProjectIDWithRetry 的返回结果,
|
||||
// 同时携带从 LoadCodeAssist 响应中提取的 plan_type 信息。
|
||||
type loadCodeAssistResult struct {
|
||||
ProjectID string
|
||||
ProjectID string
|
||||
Subscription *AntigravitySubscriptionResult
|
||||
}
|
||||
|
||||
|
||||
@ -16,10 +16,10 @@ const (
|
||||
|
||||
// setAntigravityPrivacy 调用 Antigravity API 设置隐私并验证结果。
|
||||
// 流程:
|
||||
// 1. setUserSettings 清空设置 → 检查返回值 {\"userSettings\":{}}
|
||||
// 1. setUserSettings 清空设置 → 检查返回值 {"userSettings":{}}
|
||||
// 2. fetchUserInfo 二次验证隐私是否已生效(需要 project_id)
|
||||
//
|
||||
// 返回 privacy_mode 值:\"privacy_set\" 成功,\"privacy_set_failed\" 失败,空串表示无法执行。
|
||||
// 返回 privacy_mode 值:"privacy_set" 成功,"privacy_set_failed" 失败,空串表示无法执行。
|
||||
func setAntigravityPrivacy(ctx context.Context, accessToken, projectID, proxyURL string) string {
|
||||
if accessToken == "" {
|
||||
return ""
|
||||
|
||||
@ -2,7 +2,33 @@
|
||||
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func applyAntigravitySubscriptionResult(account *Account, result AntigravitySubscriptionResult) (map[string]any, map[string]any) {
|
||||
credentials := make(map[string]any)
|
||||
for k, v := range account.Credentials {
|
||||
credentials[k] = v
|
||||
}
|
||||
credentials["plan_type"] = result.PlanType
|
||||
|
||||
extra := make(map[string]any)
|
||||
for k, v := range account.Extra {
|
||||
extra[k] = v
|
||||
}
|
||||
if result.SubscriptionStatus != "" {
|
||||
extra["subscription_status"] = result.SubscriptionStatus
|
||||
} else {
|
||||
delete(extra, "subscription_status")
|
||||
}
|
||||
if result.SubscriptionError != "" {
|
||||
extra["subscription_error"] = result.SubscriptionError
|
||||
} else {
|
||||
delete(extra, "subscription_error")
|
||||
}
|
||||
return credentials, extra
|
||||
}
|
||||
|
||||
func TestApplyAntigravityPrivacyMode_SetsInMemoryExtra(t *testing.T) {
|
||||
account := &Account{}
|
||||
|
||||
@ -36,27 +36,3 @@ func NormalizeAntigravitySubscription(resp *antigravity.LoadCodeAssistResponse)
|
||||
PlanType: antigravity.TierIDToPlanType(tierID),
|
||||
}
|
||||
}
|
||||
|
||||
func applyAntigravitySubscriptionResult(account *Account, result AntigravitySubscriptionResult) (map[string]any, map[string]any) {
|
||||
credentials := make(map[string]any)
|
||||
for k, v := range account.Credentials {
|
||||
credentials[k] = v
|
||||
}
|
||||
credentials["plan_type"] = result.PlanType
|
||||
|
||||
extra := make(map[string]any)
|
||||
for k, v := range account.Extra {
|
||||
extra[k] = v
|
||||
}
|
||||
if result.SubscriptionStatus != "" {
|
||||
extra["subscription_status"] = result.SubscriptionStatus
|
||||
} else {
|
||||
delete(extra, "subscription_status")
|
||||
}
|
||||
if result.SubscriptionError != "" {
|
||||
extra["subscription_error"] = result.SubscriptionError
|
||||
} else {
|
||||
delete(extra, "subscription_error")
|
||||
}
|
||||
return credentials, extra
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
antigravityTokenRefreshSkew = 5 * time.Minute
|
||||
antigravityTokenRefreshSkew = 3 * time.Minute
|
||||
antigravityTokenCacheSkew = 5 * time.Minute
|
||||
antigravityBackfillCooldown = 5 * time.Minute
|
||||
// antigravityRequestRefreshTimeout 请求路径上 token 刷新的最大等待时间。
|
||||
|
||||
@ -36,8 +36,7 @@ func (r *AntigravityTokenRefresher) CanRefresh(account *Account) bool {
|
||||
}
|
||||
|
||||
// NeedsRefresh 检查账户是否需要刷新
|
||||
// Deprecated: Antigravity 已改为请求路径按需刷新,不再注册后台定时刷新器。
|
||||
// 此方法仅保留以满足 TokenRefresher 接口,不会被 TokenRefreshService 调用。
|
||||
// Antigravity 使用固定的15分钟刷新窗口,忽略全局配置
|
||||
func (r *AntigravityTokenRefresher) NeedsRefresh(account *Account, _ time.Duration) bool {
|
||||
if !r.CanRefresh(account) {
|
||||
return false
|
||||
|
||||
@ -236,8 +236,11 @@ const (
|
||||
// SettingKeyBackendModeEnabled Backend 模式:禁用用户注册和自助服务,仅管理员可登录
|
||||
SettingKeyBackendModeEnabled = "backend_mode_enabled"
|
||||
|
||||
// SettingKeyRiskSettings 风控系统配置 (JSON)
|
||||
SettingKeyRiskSettings = "risk_settings"
|
||||
// Gateway Forwarding Behavior
|
||||
// SettingKeyEnableFingerprintUnification 是否统一 OAuth 账号的 X-Stainless-* 指纹头(默认 true)
|
||||
SettingKeyEnableFingerprintUnification = "enable_fingerprint_unification"
|
||||
// SettingKeyEnableMetadataPassthrough 是否透传客户端原始 metadata.user_id(默认 false)
|
||||
SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough"
|
||||
)
|
||||
|
||||
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
|
||||
|
||||
@ -176,13 +176,13 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamPreservesBodyAnd
|
||||
|
||||
require.Equal(t, "claude-3-haiku-20240307", gjson.GetBytes(upstream.lastBody, "model").String(), "透传模式应应用账号级模型映射")
|
||||
|
||||
require.Equal(t, "upstream-anthropic-key", upstream.lastReq.Header.Get("x-api-key"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("authorization"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("x-goog-api-key"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("cookie"))
|
||||
require.Equal(t, "2023-06-01", upstream.lastReq.Header.Get("anthropic-version"))
|
||||
require.Equal(t, "interleaved-thinking-2025-05-14", upstream.lastReq.Header.Get("anthropic-beta"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("x-stainless-lang"), "API Key 透传不应注入 OAuth 指纹头")
|
||||
require.Equal(t, "upstream-anthropic-key", getHeaderRaw(upstream.lastReq.Header, "x-api-key"))
|
||||
require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "authorization"))
|
||||
require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-goog-api-key"))
|
||||
require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "cookie"))
|
||||
require.Equal(t, "2023-06-01", getHeaderRaw(upstream.lastReq.Header, "anthropic-version"))
|
||||
require.Equal(t, "interleaved-thinking-2025-05-14", getHeaderRaw(upstream.lastReq.Header, "anthropic-beta"))
|
||||
require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-stainless-lang"), "API Key 透传不应注入 OAuth 指纹头")
|
||||
|
||||
require.Contains(t, rec.Body.String(), `"cached_tokens":7`)
|
||||
require.NotContains(t, rec.Body.String(), `"cache_read_input_tokens":7`, "透传输出不应被网关改写")
|
||||
@ -258,9 +258,9 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardCountTokensPreservesBo
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "claude-3-opus-20240229", gjson.GetBytes(upstream.lastBody, "model").String(), "count_tokens 透传模式应应用账号级模型映射")
|
||||
require.Equal(t, "upstream-anthropic-key", upstream.lastReq.Header.Get("x-api-key"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("authorization"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("cookie"))
|
||||
require.Equal(t, "upstream-anthropic-key", getHeaderRaw(upstream.lastReq.Header, "x-api-key"))
|
||||
require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "authorization"))
|
||||
require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "cookie"))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.JSONEq(t, upstreamRespBody, rec.Body.String())
|
||||
require.Empty(t, rec.Header().Get("Set-Cookie"))
|
||||
@ -685,8 +685,8 @@ func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *t
|
||||
|
||||
req, err := svc.buildUpstreamRequest(context.Background(), c, account, []byte(`{"model":"claude-3-7-sonnet-20250219"}`), "oauth-token", "oauth", "claude-3-7-sonnet-20250219", true, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Bearer oauth-token", req.Header.Get("authorization"))
|
||||
require.Contains(t, req.Header.Get("anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta")
|
||||
require.Equal(t, "Bearer oauth-token", getHeaderRaw(req.Header, "authorization"))
|
||||
require.Contains(t, getHeaderRaw(req.Header, "anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta")
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(t *testing.T) {
|
||||
@ -756,8 +756,8 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, upstream.lastReq)
|
||||
require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("authorization"))
|
||||
require.Contains(t, upstream.lastReq.Header.Get("anthropic-beta"), claude.BetaOAuth)
|
||||
require.Equal(t, "Bearer oauth-token", getHeaderRaw(upstream.lastReq.Header, "authorization"))
|
||||
require.Contains(t, getHeaderRaw(upstream.lastReq.Header, "anthropic-beta"), claude.BetaOAuth)
|
||||
|
||||
system := gjson.GetBytes(upstream.lastBody, "system")
|
||||
require.True(t, system.Exists())
|
||||
|
||||
@ -2,31 +2,28 @@ package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDebugGatewayBodyLoggingEnabled(t *testing.T) {
|
||||
t.Run("default disabled", func(t *testing.T) {
|
||||
t.Setenv(debugGatewayBodyEnv, "")
|
||||
if debugGatewayBodyLoggingEnabled() {
|
||||
t.Fatalf("expected debug gateway body logging to be disabled by default")
|
||||
func TestParseDebugEnvBool(t *testing.T) {
|
||||
t.Run("empty is false", func(t *testing.T) {
|
||||
if parseDebugEnvBool("") {
|
||||
t.Fatalf("expected false for empty string")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enabled with true-like values", func(t *testing.T) {
|
||||
t.Run("true-like values", func(t *testing.T) {
|
||||
for _, value := range []string{"1", "true", "TRUE", "yes", "on"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Setenv(debugGatewayBodyEnv, value)
|
||||
if !debugGatewayBodyLoggingEnabled() {
|
||||
t.Fatalf("expected debug gateway body logging to be enabled for %q", value)
|
||||
if !parseDebugEnvBool(value) {
|
||||
t.Fatalf("expected true for %q", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disabled with other values", func(t *testing.T) {
|
||||
t.Run("false-like values", func(t *testing.T) {
|
||||
for _, value := range []string{"0", "false", "off", "debug"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Setenv(debugGatewayBodyEnv, value)
|
||||
if debugGatewayBodyLoggingEnabled() {
|
||||
t.Fatalf("expected debug gateway body logging to be disabled for %q", value)
|
||||
if parseDebugEnvBool(value) {
|
||||
t.Fatalf("expected false for %q", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// WriteClaudeErrorResponse 写入 Claude 格式的错误响应(共享实现)
|
||||
// 用于 AntigravityGatewayService 和 GeminiMessagesCompatService
|
||||
func WriteClaudeErrorResponse(c *gin.Context, status int, errType, message string) error {
|
||||
c.JSON(status, gin.H{
|
||||
"type": "error",
|
||||
"error": gin.H{"type": errType, "message": message},
|
||||
})
|
||||
return fmt.Errorf("%s", message)
|
||||
}
|
||||
|
||||
// WriteGoogleErrorResponse 写入 Google 格式的错误响应(共享实现)
|
||||
// 使用 googleapi.HTTPStatusToGoogleStatus 统一映射 HTTP 状态码
|
||||
func WriteGoogleErrorResponse(c *gin.Context, status int, message string) error {
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"code": status,
|
||||
"message": message,
|
||||
"status": googleapi.HTTPStatusToGoogleStatus(status),
|
||||
},
|
||||
})
|
||||
return fmt.Errorf("%s", message)
|
||||
}
|
||||
@ -120,7 +120,7 @@ func (s *GatewayService) ForwardAsChatCompletions(
|
||||
}
|
||||
|
||||
// 11. Send request
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
@ -117,7 +117,7 @@ func (s *GatewayService) ForwardAsResponses(
|
||||
}
|
||||
|
||||
// 11. Send request
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
@ -12,7 +12,9 @@ import (
|
||||
"log/slog"
|
||||
mathrand "math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@ -366,6 +368,9 @@ var allowedHeaders = map[string]bool{
|
||||
"sec-fetch-mode": true,
|
||||
"user-agent": true,
|
||||
"content-type": true,
|
||||
"accept-encoding": true,
|
||||
"x-claude-code-session-id": true,
|
||||
"x-client-request-id": true,
|
||||
}
|
||||
|
||||
// GatewayCache 定义网关服务的缓存操作接口。
|
||||
@ -565,7 +570,6 @@ type GatewayService struct {
|
||||
debugClaudeMimic atomic.Bool
|
||||
debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
riskService *RiskService
|
||||
}
|
||||
|
||||
// NewGatewayService creates a new GatewayService
|
||||
@ -593,7 +597,6 @@ func NewGatewayService(
|
||||
digestStore *DigestSessionStore,
|
||||
settingService *SettingService,
|
||||
tlsFPProfileService *TLSFingerprintProfileService,
|
||||
riskService *RiskService,
|
||||
) *GatewayService {
|
||||
userGroupRateTTL := resolveUserGroupRateCacheTTL(cfg)
|
||||
modelsListTTL := resolveModelsListCacheTTL(cfg)
|
||||
@ -626,7 +629,6 @@ func NewGatewayService(
|
||||
modelsListCacheTTL: modelsListTTL,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
tlsFPProfileService: tlsFPProfileService,
|
||||
riskService: riskService,
|
||||
}
|
||||
svc.userGroupRateResolver = newUserGroupRateResolver(
|
||||
userGroupRateRepo,
|
||||
@ -637,6 +639,9 @@ func NewGatewayService(
|
||||
)
|
||||
svc.debugModelRouting.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_MODEL_ROUTING")))
|
||||
svc.debugClaudeMimic.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_CLAUDE_MIMIC")))
|
||||
if path := strings.TrimSpace(os.Getenv(debugGatewayBodyEnv)); path != "" {
|
||||
svc.initDebugGatewayBodyFile(path)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
@ -4075,8 +4080,15 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
reqStream := parsed.Stream
|
||||
originalModel := reqModel
|
||||
|
||||
// === DEBUG: 打印客户端原始请求 body ===
|
||||
debugLogRequestBody("CLIENT_ORIGINAL", body)
|
||||
// === DEBUG: 打印客户端原始请求(headers + body 摘要)===
|
||||
if c != nil {
|
||||
s.debugLogGatewaySnapshot("CLIENT_ORIGINAL", c.Request.Header, body, map[string]string{
|
||||
"account": fmt.Sprintf("%d(%s)", account.ID, account.Name),
|
||||
"account_type": string(account.Type),
|
||||
"model": reqModel,
|
||||
"stream": strconv.FormatBool(reqStream),
|
||||
})
|
||||
}
|
||||
|
||||
isClaudeCode := isClaudeCodeRequest(ctx, c, parsed)
|
||||
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
|
||||
@ -4093,9 +4105,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
if s.identityService != nil {
|
||||
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
|
||||
if err == nil && fp != nil {
|
||||
if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" {
|
||||
normalizeOpts.injectMetadata = true
|
||||
normalizeOpts.metadataUserID = metadataUserID
|
||||
// metadata 透传开启时跳过 metadata 注入
|
||||
_, mimicMPT := s.settingService.GetGatewayForwardingSettings(ctx)
|
||||
if !mimicMPT {
|
||||
if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" {
|
||||
normalizeOpts.injectMetadata = true
|
||||
normalizeOpts.metadataUserID = metadataUserID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4137,14 +4153,15 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 获取代理URL
|
||||
// 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递)
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 TLS 模式和指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析)
|
||||
tlsMode := account.GetTLSMode()
|
||||
// 解析 TLS 指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析)
|
||||
tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account)
|
||||
|
||||
// 调试日志:记录即将转发的账号信息
|
||||
@ -4169,7 +4186,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsMode, tlsProfile)
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
@ -4202,7 +4219,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
if readErr == nil {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if s.isThinkingBlockSignatureError(respBody) && s.settingService.IsSignatureRectifierEnabled(ctx) {
|
||||
if s.shouldRectifySignatureError(ctx, account, respBody) {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
@ -4247,7 +4264,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
retryReq, buildErr := s.buildUpstreamRequest(retryCtx, c, account, filteredBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
||||
releaseRetryCtx()
|
||||
if buildErr == nil {
|
||||
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, tlsMode, tlsProfile)
|
||||
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if retryErr == nil {
|
||||
if retryResp.StatusCode < 400 {
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: thinking block retry succeeded (blocks downgraded)", account.ID)
|
||||
@ -4257,7 +4274,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
|
||||
retryRespBody, retryReadErr := io.ReadAll(io.LimitReader(retryResp.Body, 2<<20))
|
||||
_ = retryResp.Body.Close()
|
||||
if retryReadErr == nil && retryResp.StatusCode == 400 && s.isThinkingBlockSignatureError(retryRespBody) {
|
||||
if retryReadErr == nil && retryResp.StatusCode == 400 && s.isSignatureErrorPattern(ctx, account, retryRespBody) {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
@ -4282,7 +4299,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
retryReq2, buildErr2 := s.buildUpstreamRequest(retryCtx2, c, account, filteredBody2, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
||||
releaseRetryCtx2()
|
||||
if buildErr2 == nil {
|
||||
retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsMode, tlsProfile)
|
||||
retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if retryErr2 == nil {
|
||||
resp = retryResp2
|
||||
break
|
||||
@ -4353,7 +4370,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
budgetRetryReq, buildErr := s.buildUpstreamRequest(budgetRetryCtx, c, account, rectifiedBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
||||
releaseBudgetRetryCtx()
|
||||
if buildErr == nil {
|
||||
budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsMode, tlsProfile)
|
||||
budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if retryErr == nil {
|
||||
resp = budgetRetryResp
|
||||
break
|
||||
@ -4659,7 +4676,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
@ -4871,8 +4888,9 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||||
if !allowedHeaders[lowerKey] {
|
||||
continue
|
||||
}
|
||||
wireKey := resolveWireCasing(key)
|
||||
for _, v := range values {
|
||||
req.Header.Add(key, v)
|
||||
addHeaderRaw(req.Header, wireKey, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4882,13 +4900,13 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||||
req.Header.Del("x-api-key")
|
||||
req.Header.Del("x-goog-api-key")
|
||||
req.Header.Del("cookie")
|
||||
req.Header.Set("x-api-key", token)
|
||||
setHeaderRaw(req.Header, "x-api-key", token)
|
||||
|
||||
if req.Header.Get("content-type") == "" {
|
||||
req.Header.Set("content-type", "application/json")
|
||||
if getHeaderRaw(req.Header, "content-type") == "" {
|
||||
setHeaderRaw(req.Header, "content-type", "application/json")
|
||||
}
|
||||
if req.Header.Get("anthropic-version") == "" {
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
if getHeaderRaw(req.Header, "anthropic-version") == "" {
|
||||
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
|
||||
}
|
||||
|
||||
return req, nil
|
||||
@ -5377,7 +5395,7 @@ func (s *GatewayService) executeBedrockUpstream(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, TLSModeOff, nil)
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, nil)
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
@ -5615,6 +5633,16 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
||||
}
|
||||
targetURL = validatedURL + "/v1/messages?beta=true"
|
||||
}
|
||||
} else if account.IsCustomBaseURLEnabled() {
|
||||
customURL := account.GetCustomBaseURL()
|
||||
if customURL == "" {
|
||||
return nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID)
|
||||
}
|
||||
validatedURL, err := s.validateUpstreamBaseURL(customURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages", account)
|
||||
}
|
||||
|
||||
clientHeaders := http.Header{}
|
||||
@ -5622,8 +5650,12 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
||||
clientHeaders = c.Request.Header
|
||||
}
|
||||
|
||||
// OAuth账号:应用统一指纹
|
||||
// OAuth账号:应用统一指纹和metadata重写(受设置开关控制)
|
||||
var fingerprint *Fingerprint
|
||||
enableFP, enableMPT := true, false
|
||||
if s.settingService != nil {
|
||||
enableFP, enableMPT = s.settingService.GetGatewayForwardingSettings(ctx)
|
||||
}
|
||||
if account.IsOAuth() && s.identityService != nil {
|
||||
// 1. 获取或创建指纹(包含随机生成的ClientID)
|
||||
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
|
||||
@ -5631,40 +5663,43 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
||||
logger.LegacyPrintf("service.gateway", "Warning: failed to get fingerprint for account %d: %v", account.ID, err)
|
||||
// 失败时降级为透传原始headers
|
||||
} else {
|
||||
fingerprint = fp
|
||||
if enableFP {
|
||||
fingerprint = fp
|
||||
}
|
||||
|
||||
// 2. 重写metadata.user_id(需要指纹中的ClientID和账号的account_uuid)
|
||||
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
|
||||
accountUUID := account.GetExtraString("account_uuid")
|
||||
if accountUUID != "" && fp.ClientID != "" {
|
||||
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
|
||||
body = newBody
|
||||
// 当 metadata 透传开启时跳过重写
|
||||
if !enableMPT {
|
||||
accountUUID := account.GetExtraString("account_uuid")
|
||||
if accountUUID != "" && fp.ClientID != "" {
|
||||
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
|
||||
body = newBody
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === DEBUG: 打印转发给上游的 body(metadata 已重写) ===
|
||||
debugLogRequestBody("UPSTREAM_FORWARD", body)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 设置认证头
|
||||
// 设置认证头(保持原始大小写)
|
||||
if tokenType == "oauth" {
|
||||
req.Header.Set("authorization", "Bearer "+token)
|
||||
setHeaderRaw(req.Header, "authorization", "Bearer "+token)
|
||||
} else {
|
||||
req.Header.Set("x-api-key", token)
|
||||
setHeaderRaw(req.Header, "x-api-key", token)
|
||||
}
|
||||
|
||||
// 白名单透传headers
|
||||
// 白名单透传headers(恢复真实 wire casing)
|
||||
for key, values := range clientHeaders {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if allowedHeaders[lowerKey] {
|
||||
wireKey := resolveWireCasing(key)
|
||||
for _, v := range values {
|
||||
req.Header.Add(key, v)
|
||||
addHeaderRaw(req.Header, wireKey, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5674,15 +5709,15 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
||||
s.identityService.ApplyFingerprint(req, fingerprint)
|
||||
}
|
||||
|
||||
// 确保必要的headers存在
|
||||
if req.Header.Get("content-type") == "" {
|
||||
req.Header.Set("content-type", "application/json")
|
||||
// 确保必要的headers存在(保持原始大小写)
|
||||
if getHeaderRaw(req.Header, "content-type") == "" {
|
||||
setHeaderRaw(req.Header, "content-type", "application/json")
|
||||
}
|
||||
if req.Header.Get("anthropic-version") == "" {
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
if getHeaderRaw(req.Header, "anthropic-version") == "" {
|
||||
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
|
||||
}
|
||||
if tokenType == "oauth" {
|
||||
applyClaudeOAuthHeaderDefaults(req, reqStream)
|
||||
applyClaudeOAuthHeaderDefaults(req)
|
||||
}
|
||||
|
||||
// Build effective drop set: merge static defaults with dynamic beta policy filter rules
|
||||
@ -5698,31 +5733,50 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
||||
// - 保留 incoming beta 的同时,确保 OAuth 所需 beta 存在
|
||||
applyClaudeCodeMimicHeaders(req, reqStream)
|
||||
|
||||
incomingBeta := req.Header.Get("anthropic-beta")
|
||||
incomingBeta := getHeaderRaw(req.Header, "anthropic-beta")
|
||||
// Match real Claude CLI traffic (per mitmproxy reports):
|
||||
// messages requests typically use only oauth + interleaved-thinking.
|
||||
// Also drop claude-code beta if a downstream client added it.
|
||||
requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking}
|
||||
req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropWithClaudeCodeSet))
|
||||
setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropWithClaudeCodeSet))
|
||||
} else {
|
||||
// Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta
|
||||
clientBetaHeader := req.Header.Get("anthropic-beta")
|
||||
req.Header.Set("anthropic-beta", stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBetaHeader), effectiveDropSet))
|
||||
clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")
|
||||
setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBetaHeader), effectiveDropSet))
|
||||
}
|
||||
} else {
|
||||
// API-key accounts: apply beta policy filter to strip controlled tokens
|
||||
if existingBeta := req.Header.Get("anthropic-beta"); existingBeta != "" {
|
||||
req.Header.Set("anthropic-beta", stripBetaTokensWithSet(existingBeta, effectiveDropSet))
|
||||
if existingBeta := getHeaderRaw(req.Header, "anthropic-beta"); existingBeta != "" {
|
||||
setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(existingBeta, effectiveDropSet))
|
||||
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
|
||||
// API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭)
|
||||
if requestNeedsBetaFeatures(body) {
|
||||
if beta := defaultAPIKeyBetaHeader(body); beta != "" {
|
||||
req.Header.Set("anthropic-beta", beta)
|
||||
setHeaderRaw(req.Header, "anthropic-beta", beta)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
|
||||
if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
|
||||
if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
|
||||
if parsed := ParseMetadataUserID(uid); parsed != nil {
|
||||
setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 ===
|
||||
s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{
|
||||
"url": req.URL.String(),
|
||||
"token_type": tokenType,
|
||||
"mimic_claude_code": strconv.FormatBool(mimicClaudeCode),
|
||||
"fingerprint_applied": strconv.FormatBool(fingerprint != nil),
|
||||
"enable_fp": strconv.FormatBool(enableFP),
|
||||
"enable_mpt": strconv.FormatBool(enableMPT),
|
||||
})
|
||||
|
||||
// Always capture a compact fingerprint line for later error diagnostics.
|
||||
// We only print it when needed (or when the explicit debug flag is enabled).
|
||||
if c != nil && tokenType == "oauth" {
|
||||
@ -5802,24 +5856,21 @@ func defaultAPIKeyBetaHeader(body []byte) string {
|
||||
return claude.APIKeyBetaHeader
|
||||
}
|
||||
|
||||
func applyClaudeOAuthHeaderDefaults(req *http.Request, isStream bool) {
|
||||
func applyClaudeOAuthHeaderDefaults(req *http.Request) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
if req.Header.Get("accept") == "" {
|
||||
req.Header.Set("accept", "application/json")
|
||||
if getHeaderRaw(req.Header, "Accept") == "" {
|
||||
setHeaderRaw(req.Header, "Accept", "application/json")
|
||||
}
|
||||
for key, value := range claude.DefaultHeaders {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if req.Header.Get(key) == "" {
|
||||
req.Header.Set(key, value)
|
||||
if getHeaderRaw(req.Header, key) == "" {
|
||||
setHeaderRaw(req.Header, resolveWireCasing(key), value)
|
||||
}
|
||||
}
|
||||
if isStream && req.Header.Get("x-stainless-helper-method") == "" {
|
||||
req.Header.Set("x-stainless-helper-method", "stream")
|
||||
}
|
||||
}
|
||||
|
||||
func mergeAnthropicBeta(required []string, incoming string) string {
|
||||
@ -6114,18 +6165,19 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
|
||||
return
|
||||
}
|
||||
// Start with the standard defaults (fill missing).
|
||||
applyClaudeOAuthHeaderDefaults(req, isStream)
|
||||
applyClaudeOAuthHeaderDefaults(req)
|
||||
// Then force key headers to match Claude Code fingerprint regardless of what the client sent.
|
||||
// 使用 resolveWireCasing 确保 key 与真实 wire format 一致(如 "x-app" 而非 "X-App")
|
||||
for key, value := range claude.DefaultHeaders {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
req.Header.Set(key, value)
|
||||
setHeaderRaw(req.Header, resolveWireCasing(key), value)
|
||||
}
|
||||
// Real Claude CLI uses Accept: application/json (even for streaming).
|
||||
req.Header.Set("accept", "application/json")
|
||||
setHeaderRaw(req.Header, "Accept", "application/json")
|
||||
if isStream {
|
||||
req.Header.Set("x-stainless-helper-method", "stream")
|
||||
setHeaderRaw(req.Header, "x-stainless-helper-method", "stream")
|
||||
}
|
||||
}
|
||||
|
||||
@ -6143,6 +6195,59 @@ func truncateForLog(b []byte, maxBytes int) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// shouldRectifySignatureError 统一判断是否应触发签名整流(strip thinking blocks 并重试)。
|
||||
// 根据账号类型检查对应的开关和匹配模式。
|
||||
func (s *GatewayService) shouldRectifySignatureError(ctx context.Context, account *Account, respBody []byte) bool {
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
// API Key 账号:独立开关,一次读取配置
|
||||
settings, err := s.settingService.GetRectifierSettings(ctx)
|
||||
if err != nil || !settings.Enabled || !settings.APIKeySignatureEnabled {
|
||||
return false
|
||||
}
|
||||
// 先检查内置模式(同 OAuth),再检查自定义关键词
|
||||
if s.isThinkingBlockSignatureError(respBody) {
|
||||
return true
|
||||
}
|
||||
return matchSignaturePatterns(respBody, settings.APIKeySignaturePatterns)
|
||||
}
|
||||
// OAuth/SetupToken/Upstream/Bedrock 等:保持原有行为(内置模式 + 原开关)
|
||||
return s.isThinkingBlockSignatureError(respBody) && s.settingService.IsSignatureRectifierEnabled(ctx)
|
||||
}
|
||||
|
||||
// isSignatureErrorPattern 仅做模式匹配,不检查开关。
|
||||
// 用于已进入重试流程后的二阶段检测(此时开关已在首次调用时验证过)。
|
||||
func (s *GatewayService) isSignatureErrorPattern(ctx context.Context, account *Account, respBody []byte) bool {
|
||||
if s.isThinkingBlockSignatureError(respBody) {
|
||||
return true
|
||||
}
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
settings, err := s.settingService.GetRectifierSettings(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return matchSignaturePatterns(respBody, settings.APIKeySignaturePatterns)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchSignaturePatterns 检查响应体是否匹配自定义关键词列表(不区分大小写)。
|
||||
func matchSignaturePatterns(respBody []byte, patterns []string) bool {
|
||||
if len(patterns) == 0 {
|
||||
return false
|
||||
}
|
||||
bodyLower := strings.ToLower(string(respBody))
|
||||
for _, p := range patterns {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(bodyLower, strings.ToLower(p)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isThinkingBlockSignatureError 检测是否是thinking block相关错误
|
||||
// 这类错误可以通过过滤thinking blocks并重试来解决
|
||||
func (s *GatewayService) isThinkingBlockSignatureError(respBody []byte) bool {
|
||||
@ -7686,7 +7791,6 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
||||
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
|
||||
logger.LegacyPrintf("service.gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
|
||||
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
|
||||
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
||||
return nil
|
||||
}
|
||||
@ -7710,7 +7814,6 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
||||
return billingErr
|
||||
}
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
|
||||
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -7871,7 +7974,6 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
|
||||
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
|
||||
logger.LegacyPrintf("service.gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
|
||||
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
|
||||
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
||||
return nil
|
||||
}
|
||||
@ -7895,7 +7997,6 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
|
||||
return billingErr
|
||||
}
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
|
||||
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -7986,14 +8087,16 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取代理URL
|
||||
// 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递)
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed")
|
||||
@ -8015,13 +8118,13 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
||||
}
|
||||
|
||||
// 检测 thinking block 签名错误(400)并重试一次(过滤 thinking blocks)
|
||||
if resp.StatusCode == 400 && s.isThinkingBlockSignatureError(respBody) && s.settingService.IsSignatureRectifierEnabled(ctx) {
|
||||
if resp.StatusCode == 400 && s.shouldRectifySignatureError(ctx, account, respBody) {
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: detected thinking block signature error on count_tokens, retrying with filtered thinking blocks", account.ID)
|
||||
|
||||
filteredBody := FilterThinkingBlocksForRetry(body)
|
||||
retryReq, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel, shouldMimicClaudeCode)
|
||||
if buildErr == nil {
|
||||
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if retryErr == nil {
|
||||
resp = retryResp
|
||||
respBody, err = readUpstreamResponseBodyLimited(resp.Body, maxReadBytes)
|
||||
@ -8110,7 +8213,7 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
@ -8232,8 +8335,9 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
|
||||
if !allowedHeaders[lowerKey] {
|
||||
continue
|
||||
}
|
||||
wireKey := resolveWireCasing(key)
|
||||
for _, v := range values {
|
||||
req.Header.Add(key, v)
|
||||
addHeaderRaw(req.Header, wireKey, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8267,6 +8371,16 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
||||
}
|
||||
targetURL = validatedURL + "/v1/messages/count_tokens?beta=true"
|
||||
}
|
||||
} else if account.IsCustomBaseURLEnabled() {
|
||||
customURL := account.GetCustomBaseURL()
|
||||
if customURL == "" {
|
||||
return nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID)
|
||||
}
|
||||
validatedURL, err := s.validateUpstreamBaseURL(customURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages/count_tokens", account)
|
||||
}
|
||||
|
||||
clientHeaders := http.Header{}
|
||||
@ -8274,15 +8388,23 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
||||
clientHeaders = c.Request.Header
|
||||
}
|
||||
|
||||
// OAuth 账号:应用统一指纹和重写 userID
|
||||
// OAuth 账号:应用统一指纹和重写 userID(受设置开关控制)
|
||||
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
|
||||
ctEnableFP, ctEnableMPT := true, false
|
||||
if s.settingService != nil {
|
||||
ctEnableFP, ctEnableMPT = s.settingService.GetGatewayForwardingSettings(ctx)
|
||||
}
|
||||
var ctFingerprint *Fingerprint
|
||||
if account.IsOAuth() && s.identityService != nil {
|
||||
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
|
||||
if err == nil {
|
||||
accountUUID := account.GetExtraString("account_uuid")
|
||||
if accountUUID != "" && fp.ClientID != "" {
|
||||
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
|
||||
body = newBody
|
||||
ctFingerprint = fp
|
||||
if !ctEnableMPT {
|
||||
accountUUID := account.GetExtraString("account_uuid")
|
||||
if accountUUID != "" && fp.ClientID != "" {
|
||||
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
|
||||
body = newBody
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8293,40 +8415,38 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 设置认证头
|
||||
// 设置认证头(保持原始大小写)
|
||||
if tokenType == "oauth" {
|
||||
req.Header.Set("authorization", "Bearer "+token)
|
||||
setHeaderRaw(req.Header, "authorization", "Bearer "+token)
|
||||
} else {
|
||||
req.Header.Set("x-api-key", token)
|
||||
setHeaderRaw(req.Header, "x-api-key", token)
|
||||
}
|
||||
|
||||
// 白名单透传 headers
|
||||
// 白名单透传 headers(恢复真实 wire casing)
|
||||
for key, values := range clientHeaders {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if allowedHeaders[lowerKey] {
|
||||
wireKey := resolveWireCasing(key)
|
||||
for _, v := range values {
|
||||
req.Header.Add(key, v)
|
||||
addHeaderRaw(req.Header, wireKey, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth 账号:应用指纹到请求头
|
||||
if account.IsOAuth() && s.identityService != nil {
|
||||
fp, _ := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
|
||||
if fp != nil {
|
||||
s.identityService.ApplyFingerprint(req, fp)
|
||||
}
|
||||
// OAuth 账号:应用指纹到请求头(受设置开关控制)
|
||||
if ctEnableFP && ctFingerprint != nil {
|
||||
s.identityService.ApplyFingerprint(req, ctFingerprint)
|
||||
}
|
||||
|
||||
// 确保必要的 headers 存在
|
||||
if req.Header.Get("content-type") == "" {
|
||||
req.Header.Set("content-type", "application/json")
|
||||
// 确保必要的 headers 存在(保持原始大小写)
|
||||
if getHeaderRaw(req.Header, "content-type") == "" {
|
||||
setHeaderRaw(req.Header, "content-type", "application/json")
|
||||
}
|
||||
if req.Header.Get("anthropic-version") == "" {
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
if getHeaderRaw(req.Header, "anthropic-version") == "" {
|
||||
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
|
||||
}
|
||||
if tokenType == "oauth" {
|
||||
applyClaudeOAuthHeaderDefaults(req, false)
|
||||
applyClaudeOAuthHeaderDefaults(req)
|
||||
}
|
||||
|
||||
// Build effective drop set for count_tokens: merge static defaults with dynamic beta policy filter rules
|
||||
@ -8337,35 +8457,44 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
||||
if mimicClaudeCode {
|
||||
applyClaudeCodeMimicHeaders(req, false)
|
||||
|
||||
incomingBeta := req.Header.Get("anthropic-beta")
|
||||
incomingBeta := getHeaderRaw(req.Header, "anthropic-beta")
|
||||
requiredBetas := []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking, claude.BetaTokenCounting}
|
||||
req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, ctEffectiveDropSet))
|
||||
setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, ctEffectiveDropSet))
|
||||
} else {
|
||||
clientBetaHeader := req.Header.Get("anthropic-beta")
|
||||
clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")
|
||||
if clientBetaHeader == "" {
|
||||
req.Header.Set("anthropic-beta", claude.CountTokensBetaHeader)
|
||||
setHeaderRaw(req.Header, "anthropic-beta", claude.CountTokensBetaHeader)
|
||||
} else {
|
||||
beta := s.getBetaHeader(modelID, clientBetaHeader)
|
||||
if !strings.Contains(beta, claude.BetaTokenCounting) {
|
||||
beta = beta + "," + claude.BetaTokenCounting
|
||||
}
|
||||
req.Header.Set("anthropic-beta", stripBetaTokensWithSet(beta, ctEffectiveDropSet))
|
||||
setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(beta, ctEffectiveDropSet))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// API-key accounts: apply beta policy filter to strip controlled tokens
|
||||
if existingBeta := req.Header.Get("anthropic-beta"); existingBeta != "" {
|
||||
req.Header.Set("anthropic-beta", stripBetaTokensWithSet(existingBeta, ctEffectiveDropSet))
|
||||
if existingBeta := getHeaderRaw(req.Header, "anthropic-beta"); existingBeta != "" {
|
||||
setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(existingBeta, ctEffectiveDropSet))
|
||||
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
|
||||
// API-key:与 messages 同步的按需 beta 注入(默认关闭)
|
||||
if requestNeedsBetaFeatures(body) {
|
||||
if beta := defaultAPIKeyBetaHeader(body); beta != "" {
|
||||
req.Header.Set("anthropic-beta", beta)
|
||||
setHeaderRaw(req.Header, "anthropic-beta", beta)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
|
||||
if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
|
||||
if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
|
||||
if parsed := ParseMetadataUserID(uid); parsed != nil {
|
||||
setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c != nil && tokenType == "oauth" {
|
||||
c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
|
||||
}
|
||||
@ -8387,6 +8516,19 @@ func (s *GatewayService) countTokensError(c *gin.Context, status int, errType, m
|
||||
})
|
||||
}
|
||||
|
||||
// buildCustomRelayURL 构建自定义中继转发 URL
|
||||
// 在 path 后附加 beta=true 和可选的 proxy 查询参数
|
||||
func (s *GatewayService) buildCustomRelayURL(baseURL, path string, account *Account) string {
|
||||
u := strings.TrimRight(baseURL, "/") + path + "?beta=true"
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL := account.Proxy.URL()
|
||||
if proxyURL != "" {
|
||||
u += "&proxy=" + url.QueryEscape(proxyURL)
|
||||
}
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) {
|
||||
if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
|
||||
normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
|
||||
@ -8531,42 +8673,94 @@ func reconcileCachedTokens(usage map[string]any) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func debugGatewayBodyLoggingEnabled() bool {
|
||||
raw := strings.TrimSpace(os.Getenv(debugGatewayBodyEnv))
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
const debugGatewayBodyDefaultFilename = "gateway_debug.log"
|
||||
|
||||
switch strings.ToLower(raw) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// debugLogRequestBody 打印请求 body 用于调试 metadata.user_id 重写。
|
||||
// 默认关闭,仅在设置环境变量时启用:
|
||||
// initDebugGatewayBodyFile 初始化网关调试日志文件。
|
||||
//
|
||||
// SUB2API_DEBUG_GATEWAY_BODY=1
|
||||
func debugLogRequestBody(tag string, body []byte) {
|
||||
if !debugGatewayBodyLoggingEnabled() {
|
||||
// - "1"/"true" 等布尔值 → 当前目录下 gateway_debug.log
|
||||
// - 已有目录路径 → 该目录下 gateway_debug.log
|
||||
// - 其他 → 视为完整文件路径
|
||||
func (s *GatewayService) initDebugGatewayBodyFile(path string) {
|
||||
if parseDebugEnvBool(path) {
|
||||
path = debugGatewayBodyDefaultFilename
|
||||
}
|
||||
|
||||
// 如果 path 指向一个已存在的目录,自动追加默认文件名
|
||||
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
||||
path = filepath.Join(path, debugGatewayBodyDefaultFilename)
|
||||
}
|
||||
|
||||
// 确保父目录存在
|
||||
if dir := filepath.Dir(path); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
slog.Error("failed to create gateway debug log directory", "dir", dir, "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
slog.Error("failed to open gateway debug log file", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
logger.LegacyPrintf("service.gateway", "[DEBUG_%s] body is empty", tag)
|
||||
return
|
||||
}
|
||||
|
||||
// 提取 metadata 字段完整打印
|
||||
metadataResult := gjson.GetBytes(body, "metadata")
|
||||
if metadataResult.Exists() {
|
||||
logger.LegacyPrintf("service.gateway", "[DEBUG_%s] metadata = %s", tag, metadataResult.Raw)
|
||||
} else {
|
||||
logger.LegacyPrintf("service.gateway", "[DEBUG_%s] metadata field not found", tag)
|
||||
}
|
||||
|
||||
// 全量打印 body
|
||||
logger.LegacyPrintf("service.gateway", "[DEBUG_%s] body (%d bytes) = %s", tag, len(body), string(body))
|
||||
s.debugGatewayBodyFile.Store(f)
|
||||
slog.Info("gateway debug logging enabled", "path", path)
|
||||
}
|
||||
|
||||
// debugLogGatewaySnapshot 将网关请求的完整快照(headers + body)写入独立的调试日志文件,
|
||||
// 用于对比客户端原始请求和上游转发请求。
|
||||
//
|
||||
// 启用方式(环境变量):
|
||||
//
|
||||
// SUB2API_DEBUG_GATEWAY_BODY=1 # 写入 gateway_debug.log
|
||||
// SUB2API_DEBUG_GATEWAY_BODY=/tmp/gateway_debug.log # 写入指定路径
|
||||
//
|
||||
// tag: "CLIENT_ORIGINAL" 或 "UPSTREAM_FORWARD"
|
||||
func (s *GatewayService) debugLogGatewaySnapshot(tag string, headers http.Header, body []byte, extra map[string]string) {
|
||||
f := s.debugGatewayBodyFile.Load()
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
ts := time.Now().Format("2006-01-02 15:04:05.000")
|
||||
fmt.Fprintf(&buf, "\n========== [%s] %s ==========\n", ts, tag)
|
||||
|
||||
// 1. context
|
||||
if len(extra) > 0 {
|
||||
fmt.Fprint(&buf, "--- context ---\n")
|
||||
extraKeys := make([]string, 0, len(extra))
|
||||
for k := range extra {
|
||||
extraKeys = append(extraKeys, k)
|
||||
}
|
||||
sort.Strings(extraKeys)
|
||||
for _, k := range extraKeys {
|
||||
fmt.Fprintf(&buf, " %s: %s\n", k, extra[k])
|
||||
}
|
||||
}
|
||||
|
||||
// 2. headers(按真实 Claude CLI wire 顺序排列,便于与抓包对比;auth 脱敏)
|
||||
fmt.Fprint(&buf, "--- headers ---\n")
|
||||
for _, k := range sortHeadersByWireOrder(headers) {
|
||||
for _, v := range headers[k] {
|
||||
fmt.Fprintf(&buf, " %s: %s\n", k, safeHeaderValueForLog(k, v))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. body(完整输出,格式化 JSON 便于 diff)
|
||||
fmt.Fprint(&buf, "--- body ---\n")
|
||||
if len(body) == 0 {
|
||||
fmt.Fprint(&buf, " (empty)\n")
|
||||
} else {
|
||||
var pretty bytes.Buffer
|
||||
if json.Indent(&pretty, body, " ", " ") == nil {
|
||||
fmt.Fprintf(&buf, " %s\n", pretty.Bytes())
|
||||
} else {
|
||||
// JSON 格式化失败时原样输出
|
||||
fmt.Fprintf(&buf, " %s\n", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入文件(调试用,并发写入可能交错但不影响可读性)
|
||||
_, _ = f.WriteString(buf.String())
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
@ -669,8 +670,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
||||
}
|
||||
upstreamReq.Header.Set("Content-Type", "application/json")
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent(mappedModel))
|
||||
upstreamReq.Header.Set("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient())
|
||||
upstreamReq.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent)
|
||||
return upstreamReq, "x-request-id", nil
|
||||
} else {
|
||||
// Mode 2: AI Studio API with OAuth (like API key mode, but using Bearer token)
|
||||
@ -691,8 +691,6 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
||||
}
|
||||
upstreamReq.Header.Set("Content-Type", "application/json")
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent(mappedModel))
|
||||
upstreamReq.Header.Set("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient())
|
||||
return upstreamReq, "x-request-id", nil
|
||||
}
|
||||
}
|
||||
@ -724,7 +722,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
||||
c.Set(OpsUpstreamRequestBodyKey, string(body))
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), nil)
|
||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
@ -1173,8 +1171,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
||||
}
|
||||
upstreamReq.Header.Set("Content-Type", "application/json")
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent(mappedModel))
|
||||
upstreamReq.Header.Set("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient())
|
||||
upstreamReq.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent)
|
||||
return upstreamReq, "x-request-id", nil
|
||||
} else {
|
||||
// Mode 2: AI Studio API with OAuth (like API key mode, but using Bearer token)
|
||||
@ -1195,8 +1192,6 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
||||
}
|
||||
upstreamReq.Header.Set("Content-Type", "application/json")
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent(mappedModel))
|
||||
upstreamReq.Header.Set("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient())
|
||||
return upstreamReq, "x-request-id", nil
|
||||
}
|
||||
}
|
||||
@ -1227,7 +1222,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
||||
c.Set(OpsUpstreamRequestBodyKey, string(body))
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), nil)
|
||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
@ -2153,11 +2148,22 @@ func randomHex(nBytes int) string {
|
||||
}
|
||||
|
||||
func (s *GeminiMessagesCompatService) writeClaudeError(c *gin.Context, status int, errType, message string) error {
|
||||
return WriteClaudeErrorResponse(c, status, errType, message)
|
||||
c.JSON(status, gin.H{
|
||||
"type": "error",
|
||||
"error": gin.H{"type": errType, "message": message},
|
||||
})
|
||||
return fmt.Errorf("%s", message)
|
||||
}
|
||||
|
||||
func (s *GeminiMessagesCompatService) writeGoogleError(c *gin.Context, status int, message string) error {
|
||||
return WriteGoogleErrorResponse(c, status, message)
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"code": status,
|
||||
"message": message,
|
||||
"status": googleapi.HTTPStatusToGoogleStatus(status),
|
||||
},
|
||||
})
|
||||
return fmt.Errorf("%s", message)
|
||||
}
|
||||
|
||||
func unwrapIfNeeded(isOAuth bool, raw []byte) []byte {
|
||||
@ -2583,7 +2589,7 @@ func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, ac
|
||||
return nil, fmt.Errorf("unsupported account type: %s", account.Type)
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), nil)
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -1037,8 +1037,7 @@ func fetchProjectIDFromResourceManager(ctx context.Context, accessToken, proxyUR
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", geminicli.GetGeminiCLIGoogAPIClient())
|
||||
req.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent)
|
||||
|
||||
client, err := httpclient.GetClient(httpclient.Options{
|
||||
ProxyURL: strings.TrimSpace(proxyURL),
|
||||
|
||||
165
backend/internal/service/header_util.go
Normal file
165
backend/internal/service/header_util.go
Normal file
@ -0,0 +1,165 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// headerWireCasing 定义每个白名单 header 在真实 Claude CLI 抓包中的准确大小写。
|
||||
// Go 的 HTTP server 解析请求时会将所有 header key 转为 Canonical 形式(如 x-app → X-App),
|
||||
// 此 map 用于在转发时恢复到真实的 wire format。
|
||||
//
|
||||
// 来源:对真实 Claude CLI (claude-cli/2.1.81) 到 api.anthropic.com 的 HTTPS 流量抓包。
|
||||
var headerWireCasing = map[string]string{
|
||||
// Title case
|
||||
"accept": "Accept",
|
||||
"user-agent": "User-Agent",
|
||||
|
||||
// X-Stainless-* 保持 SDK 原始大小写
|
||||
"x-stainless-retry-count": "X-Stainless-Retry-Count",
|
||||
"x-stainless-timeout": "X-Stainless-Timeout",
|
||||
"x-stainless-lang": "X-Stainless-Lang",
|
||||
"x-stainless-package-version": "X-Stainless-Package-Version",
|
||||
"x-stainless-os": "X-Stainless-OS",
|
||||
"x-stainless-arch": "X-Stainless-Arch",
|
||||
"x-stainless-runtime": "X-Stainless-Runtime",
|
||||
"x-stainless-runtime-version": "X-Stainless-Runtime-Version",
|
||||
"x-stainless-helper-method": "x-stainless-helper-method",
|
||||
|
||||
// Anthropic SDK 自身设置的 header,全小写
|
||||
"anthropic-dangerous-direct-browser-access": "anthropic-dangerous-direct-browser-access",
|
||||
"anthropic-version": "anthropic-version",
|
||||
"anthropic-beta": "anthropic-beta",
|
||||
"x-app": "x-app",
|
||||
"content-type": "content-type",
|
||||
"accept-language": "accept-language",
|
||||
"sec-fetch-mode": "sec-fetch-mode",
|
||||
"accept-encoding": "accept-encoding",
|
||||
"authorization": "authorization",
|
||||
|
||||
// Claude Code 2.1.87+ 新增 header
|
||||
"x-claude-code-session-id": "X-Claude-Code-Session-Id",
|
||||
"x-client-request-id": "x-client-request-id",
|
||||
"content-length": "content-length",
|
||||
}
|
||||
|
||||
// headerWireOrder 定义真实 Claude CLI 发送 header 的顺序(基于抓包)。
|
||||
// 用于 debug log 按此顺序输出,便于与抓包结果直接对比。
|
||||
var headerWireOrder = []string{
|
||||
"Accept",
|
||||
"X-Stainless-Retry-Count",
|
||||
"X-Stainless-Timeout",
|
||||
"X-Stainless-Lang",
|
||||
"X-Stainless-Package-Version",
|
||||
"X-Stainless-OS",
|
||||
"X-Stainless-Arch",
|
||||
"X-Stainless-Runtime",
|
||||
"X-Stainless-Runtime-Version",
|
||||
"anthropic-dangerous-direct-browser-access",
|
||||
"anthropic-version",
|
||||
"authorization",
|
||||
"x-app",
|
||||
"User-Agent",
|
||||
"X-Claude-Code-Session-Id",
|
||||
"content-type",
|
||||
"anthropic-beta",
|
||||
"x-client-request-id",
|
||||
"accept-language",
|
||||
"sec-fetch-mode",
|
||||
"accept-encoding",
|
||||
"content-length",
|
||||
"x-stainless-helper-method",
|
||||
}
|
||||
|
||||
// headerWireOrderSet 用于快速判断某个 key 是否在 headerWireOrder 中(按 lowercase 匹配)。
|
||||
var headerWireOrderSet map[string]struct{}
|
||||
|
||||
func init() {
|
||||
headerWireOrderSet = make(map[string]struct{}, len(headerWireOrder))
|
||||
for _, k := range headerWireOrder {
|
||||
headerWireOrderSet[strings.ToLower(k)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveWireCasing 将 Go canonical key(如 X-Stainless-Os)映射为真实 wire casing(如 X-Stainless-OS)。
|
||||
// 如果 map 中没有对应条目,返回原始 key 不变。
|
||||
func resolveWireCasing(key string) string {
|
||||
if wk, ok := headerWireCasing[strings.ToLower(key)]; ok {
|
||||
return wk
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// setHeaderRaw sets a header bypassing Go's canonical-case normalization.
|
||||
// The key is stored exactly as provided, preserving original casing.
|
||||
//
|
||||
// It first removes any existing value under the canonical key, the wire casing key,
|
||||
// and the exact raw key, preventing duplicates from any source.
|
||||
func setHeaderRaw(h http.Header, key, value string) {
|
||||
h.Del(key) // remove canonical form (e.g. "Anthropic-Beta")
|
||||
if wk := resolveWireCasing(key); wk != key {
|
||||
delete(h, wk) // remove wire casing form if different
|
||||
}
|
||||
delete(h, key) // remove exact raw key if it differs from canonical
|
||||
h[key] = []string{value}
|
||||
}
|
||||
|
||||
// addHeaderRaw appends a header value bypassing Go's canonical-case normalization.
|
||||
func addHeaderRaw(h http.Header, key, value string) {
|
||||
h[key] = append(h[key], value)
|
||||
}
|
||||
|
||||
// getHeaderRaw reads a header value, trying multiple key forms to handle the mismatch
|
||||
// between Go canonical keys, wire casing keys, and raw keys:
|
||||
// 1. exact key as provided
|
||||
// 2. wire casing form (from headerWireCasing)
|
||||
// 3. Go canonical form (via http.Header.Get)
|
||||
func getHeaderRaw(h http.Header, key string) string {
|
||||
// 1. exact key
|
||||
if vals := h[key]; len(vals) > 0 {
|
||||
return vals[0]
|
||||
}
|
||||
// 2. wire casing (e.g. looking up "Anthropic-Dangerous-Direct-Browser-Access" finds "anthropic-dangerous-direct-browser-access")
|
||||
if wk := resolveWireCasing(key); wk != key {
|
||||
if vals := h[wk]; len(vals) > 0 {
|
||||
return vals[0]
|
||||
}
|
||||
}
|
||||
// 3. canonical fallback
|
||||
return h.Get(key)
|
||||
}
|
||||
|
||||
// sortHeadersByWireOrder 按照真实 Claude CLI 的 header 顺序返回排序后的 key 列表。
|
||||
// 在 headerWireOrder 中定义的 key 按其顺序排列,未定义的 key 追加到末尾。
|
||||
func sortHeadersByWireOrder(h http.Header) []string {
|
||||
// 构建 lowercase -> actual map key 的映射
|
||||
present := make(map[string]string, len(h))
|
||||
for k := range h {
|
||||
present[strings.ToLower(k)] = k
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(h))
|
||||
seen := make(map[string]struct{}, len(h))
|
||||
|
||||
// 先按 wire order 输出
|
||||
for _, wk := range headerWireOrder {
|
||||
lk := strings.ToLower(wk)
|
||||
if actual, ok := present[lk]; ok {
|
||||
if _, dup := seen[lk]; !dup {
|
||||
result = append(result, actual)
|
||||
seen[lk] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 再追加不在 wire order 中的 header
|
||||
for k := range h {
|
||||
lk := strings.ToLower(k)
|
||||
if _, ok := seen[lk]; !ok {
|
||||
result = append(result, k)
|
||||
seen[lk] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@ -6,18 +6,6 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
)
|
||||
|
||||
// TLSMode 定义账号级别的 TLS 指纹模式
|
||||
type TLSMode string
|
||||
|
||||
const (
|
||||
// TLSModeOff 不启用 TLS 指纹,直接使用标准 Go HTTP 客户端
|
||||
TLSModeOff TLSMode = "off"
|
||||
// TLSModeNode 通过本地 Node.js TLS 代理发请求,天然匹配 Claude CLI 指纹
|
||||
TLSModeNode TLSMode = "node"
|
||||
// TLSModeUTLS 使用 uTLS 库模拟指定 Profile 的 TLS ClientHello
|
||||
TLSModeUTLS TLSMode = "utls"
|
||||
)
|
||||
|
||||
// HTTPUpstream 上游 HTTP 请求接口
|
||||
// 用于向上游 API(Claude、OpenAI、Gemini 等)发送请求
|
||||
type HTTPUpstream interface {
|
||||
@ -26,11 +14,11 @@ type HTTPUpstream interface {
|
||||
|
||||
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
|
||||
//
|
||||
// mode 参数决定指纹策略:
|
||||
// - TLSModeOff / "": 不启用,行为与 Do 相同
|
||||
// - TLSModeNode: 走本地 Node.js TLS 代理(需 gateway.node_tls_proxy.enabled=true)
|
||||
// - TLSModeUTLS: 用 profile 模拟 TLS ClientHello(profile 为 nil 时降级为 Off)
|
||||
// profile 参数:
|
||||
// - nil: 不启用 TLS 指纹,行为与 Do 方法相同
|
||||
// - non-nil: 使用指定的 Profile 进行 TLS 指纹伪装
|
||||
//
|
||||
// profile 仅在 mode=TLSModeUTLS 时生效,来自数据库或内置默认值。
|
||||
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, mode TLSMode, profile *tlsfingerprint.Profile) (*http.Response, error)
|
||||
// Profile 由调用方通过 TLSFingerprintProfileService 解析后传入,
|
||||
// 支持按账号绑定的数据库 profile 或内置默认 profile。
|
||||
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error)
|
||||
}
|
||||
|
||||
@ -35,8 +35,6 @@ var defaultFingerprint = Fingerprint{
|
||||
StainlessRuntimeVersion: "v24.3.0",
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Fingerprint represents account fingerprint data
|
||||
type Fingerprint struct {
|
||||
ClientID string
|
||||
@ -74,8 +72,6 @@ func NewIdentityService(cache IdentityCache) *IdentityService {
|
||||
return &IdentityService{cache: cache}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// GetOrCreateFingerprint 获取或创建账号的指纹
|
||||
// 如果缓存存在,检测user-agent版本,新版本则更新
|
||||
// 如果缓存不存在,生成随机ClientID并从请求头创建指纹,然后缓存
|
||||
@ -179,6 +175,7 @@ func getHeaderOrDefault(headers http.Header, key, defaultValue string) string {
|
||||
}
|
||||
|
||||
// ApplyFingerprint 将指纹应用到请求头(覆盖原有的x-stainless-*头)
|
||||
// 使用 setHeaderRaw 保持原始大小写(如 X-Stainless-OS 而非 X-Stainless-Os)
|
||||
func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) {
|
||||
if fp == nil {
|
||||
return
|
||||
@ -186,27 +183,27 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) {
|
||||
|
||||
// 设置user-agent
|
||||
if fp.UserAgent != "" {
|
||||
req.Header.Set("user-agent", fp.UserAgent)
|
||||
setHeaderRaw(req.Header, "User-Agent", fp.UserAgent)
|
||||
}
|
||||
|
||||
// 设置x-stainless-*头
|
||||
// 设置x-stainless-*头(保持与 claude.DefaultHeaders 一致的大小写)
|
||||
if fp.StainlessLang != "" {
|
||||
req.Header.Set("X-Stainless-Lang", fp.StainlessLang)
|
||||
setHeaderRaw(req.Header, "X-Stainless-Lang", fp.StainlessLang)
|
||||
}
|
||||
if fp.StainlessPackageVersion != "" {
|
||||
req.Header.Set("X-Stainless-Package-Version", fp.StainlessPackageVersion)
|
||||
setHeaderRaw(req.Header, "X-Stainless-Package-Version", fp.StainlessPackageVersion)
|
||||
}
|
||||
if fp.StainlessOS != "" {
|
||||
req.Header.Set("X-Stainless-OS", fp.StainlessOS)
|
||||
setHeaderRaw(req.Header, "X-Stainless-OS", fp.StainlessOS)
|
||||
}
|
||||
if fp.StainlessArch != "" {
|
||||
req.Header.Set("X-Stainless-Arch", fp.StainlessArch)
|
||||
setHeaderRaw(req.Header, "X-Stainless-Arch", fp.StainlessArch)
|
||||
}
|
||||
if fp.StainlessRuntime != "" {
|
||||
req.Header.Set("X-Stainless-Runtime", fp.StainlessRuntime)
|
||||
setHeaderRaw(req.Header, "X-Stainless-Runtime", fp.StainlessRuntime)
|
||||
}
|
||||
if fp.StainlessRuntimeVersion != "" {
|
||||
req.Header.Set("X-Stainless-Runtime-Version", fp.StainlessRuntimeVersion)
|
||||
setHeaderRaw(req.Header, "X-Stainless-Runtime-Version", fp.StainlessRuntimeVersion)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,9 +7,8 @@ package service
|
||||
// 新增了实例级隔离盐值和指纹默认值覆盖功能。
|
||||
//
|
||||
// 对上游文件 identity_service.go 的最小化改动:
|
||||
// - defaultFingerprint 版本号更新(L29/L31):claude-cli 2.1.81 / sdk 0.80.0
|
||||
// - IdentityService struct 新增 instanceSalt 字段(L86)
|
||||
// [以上两处改动仍在原文件中,因为是对已有定义的修改,无法完全抽离]
|
||||
// - defaultFingerprint 版本号更新
|
||||
// - IdentityService struct 新增 instanceSalt 字段
|
||||
// ==============================================================
|
||||
|
||||
// ApplyDefaultFingerprintOverrides 用配置覆盖 identity_service 的默认指纹
|
||||
|
||||
11
backend/internal/service/internal500_counter.go
Normal file
11
backend/internal/service/internal500_counter.go
Normal file
@ -0,0 +1,11 @@
|
||||
package service
|
||||
|
||||
import "context"
|
||||
|
||||
// Internal500CounterCache 追踪 Antigravity 账号连续 INTERNAL 500 失败轮数
|
||||
type Internal500CounterCache interface {
|
||||
// IncrementInternal500Count 原子递增计数并返回当前值
|
||||
IncrementInternal500Count(ctx context.Context, accountID int64) (int64, error)
|
||||
// ResetInternal500Count 清零计数器(成功响应时调用)
|
||||
ResetInternal500Count(ctx context.Context, accountID int64) error
|
||||
}
|
||||
103
backend/internal/service/openai_compat_model.go
Normal file
103
backend/internal/service/openai_compat_model.go
Normal file
@ -0,0 +1,103 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||
)
|
||||
|
||||
func NormalizeOpenAICompatRequestedModel(model string) string {
|
||||
trimmed := strings.TrimSpace(model)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
normalized, _, ok := splitOpenAICompatReasoningModel(trimmed)
|
||||
if !ok || normalized == "" {
|
||||
return trimmed
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func applyOpenAICompatModelNormalization(req *apicompat.AnthropicRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
|
||||
originalModel := strings.TrimSpace(req.Model)
|
||||
if originalModel == "" {
|
||||
return
|
||||
}
|
||||
|
||||
normalizedModel, derivedEffort, hasReasoningSuffix := splitOpenAICompatReasoningModel(originalModel)
|
||||
if hasReasoningSuffix && normalizedModel != "" {
|
||||
req.Model = normalizedModel
|
||||
}
|
||||
|
||||
if req.OutputConfig != nil && strings.TrimSpace(req.OutputConfig.Effort) != "" {
|
||||
return
|
||||
}
|
||||
|
||||
claudeEffort := openAIReasoningEffortToClaudeOutputEffort(derivedEffort)
|
||||
if claudeEffort == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if req.OutputConfig == nil {
|
||||
req.OutputConfig = &apicompat.AnthropicOutputConfig{}
|
||||
}
|
||||
req.OutputConfig.Effort = claudeEffort
|
||||
}
|
||||
|
||||
func splitOpenAICompatReasoningModel(model string) (normalizedModel string, reasoningEffort string, ok bool) {
|
||||
trimmed := strings.TrimSpace(model)
|
||||
if trimmed == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
modelID := trimmed
|
||||
if strings.Contains(modelID, "/") {
|
||||
parts := strings.Split(modelID, "/")
|
||||
modelID = parts[len(parts)-1]
|
||||
}
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
if !strings.HasPrefix(strings.ToLower(modelID), "gpt-") {
|
||||
return trimmed, "", false
|
||||
}
|
||||
|
||||
parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool {
|
||||
switch r {
|
||||
case '-', '_', ' ':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
if len(parts) == 0 {
|
||||
return trimmed, "", false
|
||||
}
|
||||
|
||||
last := strings.NewReplacer("-", "", "_", "", " ", "").Replace(parts[len(parts)-1])
|
||||
switch last {
|
||||
case "none", "minimal":
|
||||
case "low", "medium", "high":
|
||||
reasoningEffort = last
|
||||
case "xhigh", "extrahigh":
|
||||
reasoningEffort = "xhigh"
|
||||
default:
|
||||
return trimmed, "", false
|
||||
}
|
||||
|
||||
return normalizeCodexModel(modelID), reasoningEffort, true
|
||||
}
|
||||
|
||||
func openAIReasoningEffortToClaudeOutputEffort(effort string) string {
|
||||
switch strings.TrimSpace(effort) {
|
||||
case "low", "medium", "high":
|
||||
return effort
|
||||
case "xhigh":
|
||||
return "max"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
129
backend/internal/service/openai_compat_model_test.go
Normal file
129
backend/internal/service/openai_compat_model_test.go
Normal file
@ -0,0 +1,129 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestNormalizeOpenAICompatRequestedModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "gpt reasoning alias strips xhigh", input: "gpt-5.4-xhigh", want: "gpt-5.4"},
|
||||
{name: "gpt reasoning alias strips none", input: "gpt-5.4-none", want: "gpt-5.4"},
|
||||
{name: "codex max model stays intact", input: "gpt-5.1-codex-max", want: "gpt-5.1-codex-max"},
|
||||
{name: "non openai model unchanged", input: "claude-opus-4-6", want: "claude-opus-4-6"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, NormalizeOpenAICompatRequestedModel(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOpenAICompatModelNormalization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("derives xhigh from model suffix when output config missing", func(t *testing.T) {
|
||||
req := &apicompat.AnthropicRequest{Model: "gpt-5.4-xhigh"}
|
||||
|
||||
applyOpenAICompatModelNormalization(req)
|
||||
|
||||
require.Equal(t, "gpt-5.4", req.Model)
|
||||
require.NotNil(t, req.OutputConfig)
|
||||
require.Equal(t, "max", req.OutputConfig.Effort)
|
||||
})
|
||||
|
||||
t.Run("explicit output config wins over model suffix", func(t *testing.T) {
|
||||
req := &apicompat.AnthropicRequest{
|
||||
Model: "gpt-5.4-xhigh",
|
||||
OutputConfig: &apicompat.AnthropicOutputConfig{Effort: "low"},
|
||||
}
|
||||
|
||||
applyOpenAICompatModelNormalization(req)
|
||||
|
||||
require.Equal(t, "gpt-5.4", req.Model)
|
||||
require.NotNil(t, req.OutputConfig)
|
||||
require.Equal(t, "low", req.OutputConfig.Effort)
|
||||
})
|
||||
|
||||
t.Run("non openai model is untouched", func(t *testing.T) {
|
||||
req := &apicompat.AnthropicRequest{Model: "claude-opus-4-6"}
|
||||
|
||||
applyOpenAICompatModelNormalization(req)
|
||||
|
||||
require.Equal(t, "claude-opus-4-6", req.Model)
|
||||
require.Nil(t, req.OutputConfig)
|
||||
})
|
||||
}
|
||||
|
||||
func TestForwardAsAnthropic_NormalizesRoutingAndEffortForGpt54XHigh(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
body := []byte(`{"model":"gpt-5.4-xhigh","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstreamBody := strings.Join([]string{
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
}, "\n")
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_compat"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||
}}
|
||||
|
||||
svc := &OpenAIGatewayService{httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-acc",
|
||||
"model_mapping": map[string]any{
|
||||
"gpt-5.4": "gpt-5.4",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "gpt-5.4-xhigh", result.Model)
|
||||
require.Equal(t, "gpt-5.4", result.UpstreamModel)
|
||||
require.Equal(t, "gpt-5.4", result.BillingModel)
|
||||
require.NotNil(t, result.ReasoningEffort)
|
||||
require.Equal(t, "xhigh", *result.ReasoningEffort)
|
||||
|
||||
require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
require.Equal(t, "xhigh", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "gpt-5.4-xhigh", gjson.GetBytes(rec.Body.Bytes(), "model").String())
|
||||
require.Equal(t, "ok", gjson.GetBytes(rec.Body.Bytes(), "content.0.text").String())
|
||||
t.Logf("upstream body: %s", string(upstream.lastBody))
|
||||
t.Logf("response body: %s", rec.Body.String())
|
||||
}
|
||||
@ -40,6 +40,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
return nil, fmt.Errorf("parse anthropic request: %w", err)
|
||||
}
|
||||
originalModel := anthropicReq.Model
|
||||
applyOpenAICompatModelNormalization(&anthropicReq)
|
||||
clientStream := anthropicReq.Stream // client's original stream preference
|
||||
|
||||
// 2. Convert Anthropic → Responses
|
||||
@ -59,7 +60,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
}
|
||||
|
||||
// 3. Model mapping
|
||||
mappedModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel)
|
||||
mappedModel := resolveOpenAIForwardModel(account, anthropicReq.Model, defaultMappedModel)
|
||||
responsesReq.Model = mappedModel
|
||||
|
||||
logger.L().Debug("openai messages: model mapping applied",
|
||||
|
||||
@ -895,14 +895,16 @@ func TestOpenAIGatewayServiceRecordUsage_UsesRequestedModelAndUpstreamModelMetad
|
||||
require.Equal(t, 1, userRepo.deductCalls)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceRecordUsage_BillsMappedRequestsUsingUpstreamModelFallback(t *testing.T) {
|
||||
func TestOpenAIGatewayServiceRecordUsage_BillsMappedRequestsUsingRequestedModel(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
subRepo := &openAIRecordUsageSubRepoStub{}
|
||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
|
||||
usage := OpenAIUsage{InputTokens: 20, OutputTokens: 10}
|
||||
|
||||
expectedCost, err := svc.billingService.CalculateCost("gpt-5.1-codex", UsageTokens{
|
||||
// Billing should use the requested model ("gpt-5.1"), not the upstream mapped model ("gpt-5.1-codex").
|
||||
// This ensures pricing is always based on the model the user requested.
|
||||
expectedCost, err := svc.billingService.CalculateCost("gpt-5.1", UsageTokens{
|
||||
InputTokens: 20,
|
||||
OutputTokens: 10,
|
||||
}, 1.1)
|
||||
|
||||
@ -337,7 +337,6 @@ type OpenAIGatewayService struct {
|
||||
openaiWSRetryMetrics openAIWSRetryMetrics
|
||||
responseHeaderFilter *responseheaders.CompiledHeaderFilter
|
||||
codexSnapshotThrottle *accountWriteThrottle
|
||||
riskService *RiskService
|
||||
}
|
||||
|
||||
// NewOpenAIGatewayService creates a new OpenAIGatewayService
|
||||
@ -358,7 +357,6 @@ func NewOpenAIGatewayService(
|
||||
httpUpstream HTTPUpstream,
|
||||
deferredService *DeferredService,
|
||||
openAITokenProvider *OpenAITokenProvider,
|
||||
riskService *RiskService,
|
||||
) *OpenAIGatewayService {
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: accountRepo,
|
||||
@ -388,7 +386,6 @@ func NewOpenAIGatewayService(
|
||||
openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
codexSnapshotThrottle: newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval),
|
||||
riskService: riskService,
|
||||
}
|
||||
svc.logOpenAIWSModeBootstrap()
|
||||
return svc
|
||||
@ -4156,9 +4153,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
}
|
||||
|
||||
billingModel := forwardResultBillingModel(result.Model, result.UpstreamModel)
|
||||
if result.BillingModel != "" {
|
||||
billingModel = strings.TrimSpace(result.BillingModel)
|
||||
}
|
||||
serviceTier := ""
|
||||
if result.ServiceTier != nil {
|
||||
serviceTier = strings.TrimSpace(*result.ServiceTier)
|
||||
@ -4230,7 +4224,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway")
|
||||
logger.LegacyPrintf("service.openai_gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
|
||||
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
|
||||
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
||||
return nil
|
||||
}
|
||||
@ -4254,7 +4247,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
return billingErr
|
||||
}
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway")
|
||||
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -502,6 +502,25 @@ func (s *OpenAIOAuthService) RefreshAccountToken(ctx context.Context, account *A
|
||||
|
||||
refreshToken := account.GetCredential("refresh_token")
|
||||
if refreshToken == "" {
|
||||
accessToken := account.GetCredential("access_token")
|
||||
if accessToken != "" {
|
||||
tokenInfo := &OpenAITokenInfo{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: "",
|
||||
IDToken: account.GetCredential("id_token"),
|
||||
ClientID: account.GetCredential("client_id"),
|
||||
Email: account.GetCredential("email"),
|
||||
ChatGPTAccountID: account.GetCredential("chatgpt_account_id"),
|
||||
ChatGPTUserID: account.GetCredential("chatgpt_user_id"),
|
||||
OrganizationID: account.GetCredential("organization_id"),
|
||||
PlanType: account.GetCredential("plan_type"),
|
||||
}
|
||||
if expiresAt := account.GetCredentialAsTime("expires_at"); expiresAt != nil {
|
||||
tokenInfo.ExpiresAt = expiresAt.Unix()
|
||||
tokenInfo.ExpiresIn = int64(time.Until(*expiresAt).Seconds())
|
||||
}
|
||||
return tokenInfo, nil
|
||||
}
|
||||
return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_NO_REFRESH_TOKEN", "no refresh token available")
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type openaiOAuthClientRefreshStub struct {
|
||||
refreshCalls int32
|
||||
}
|
||||
|
||||
func (s *openaiOAuthClientRefreshStub) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*openai.TokenResponse, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (s *openaiOAuthClientRefreshStub) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*openai.TokenResponse, error) {
|
||||
atomic.AddInt32(&s.refreshCalls, 1)
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (s *openaiOAuthClientRefreshStub) RefreshTokenWithClientID(ctx context.Context, refreshToken, proxyURL string, clientID string) (*openai.TokenResponse, error) {
|
||||
atomic.AddInt32(&s.refreshCalls, 1)
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func TestOpenAIOAuthService_RefreshAccountToken_NoRefreshTokenUsesExistingAccessToken(t *testing.T) {
|
||||
client := &openaiOAuthClientRefreshStub{}
|
||||
svc := NewOpenAIOAuthService(nil, client)
|
||||
|
||||
expiresAt := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
|
||||
account := &Account{
|
||||
ID: 77,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "existing-access-token",
|
||||
"expires_at": expiresAt,
|
||||
"client_id": "client-id-1",
|
||||
},
|
||||
}
|
||||
|
||||
info, err := svc.RefreshAccountToken(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, info)
|
||||
require.Equal(t, "existing-access-token", info.AccessToken)
|
||||
require.Equal(t, "client-id-1", info.ClientID)
|
||||
require.Zero(t, atomic.LoadInt32(&client.refreshCalls), "existing access token should be reused without calling refresh")
|
||||
}
|
||||
89
backend/internal/service/openai_privacy_retry_test.go
Normal file
89
backend/internal/service/openai_privacy_retry_test.go
Normal file
@ -0,0 +1,89 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAdminService_EnsureOpenAIPrivacy_RetriesNonSuccessModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, mode := range []string{PrivacyModeFailed, PrivacyModeCFBlocked} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
privacyCalls := 0
|
||||
svc := &adminServiceImpl{
|
||||
accountRepo: &mockAccountRepoForGemini{},
|
||||
privacyClientFactory: func(proxyURL string) (*req.Client, error) {
|
||||
privacyCalls++
|
||||
return nil, errors.New("factory failed")
|
||||
},
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
ID: 101,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token-1",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"privacy_mode": mode,
|
||||
},
|
||||
}
|
||||
|
||||
got := svc.EnsureOpenAIPrivacy(context.Background(), account)
|
||||
|
||||
require.Equal(t, PrivacyModeFailed, got)
|
||||
require.Equal(t, 1, privacyCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenRefreshService_ensureOpenAIPrivacy_RetriesNonSuccessModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &config.Config{
|
||||
TokenRefresh: config.TokenRefreshConfig{
|
||||
MaxRetries: 1,
|
||||
RetryBackoffSeconds: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, mode := range []string{PrivacyModeFailed, PrivacyModeCFBlocked} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := NewTokenRefreshService(&tokenRefreshAccountRepo{}, nil, nil, nil, nil, nil, nil, cfg, nil)
|
||||
privacyCalls := 0
|
||||
service.SetPrivacyDeps(func(proxyURL string) (*req.Client, error) {
|
||||
privacyCalls++
|
||||
return nil, errors.New("factory failed")
|
||||
}, nil)
|
||||
|
||||
account := &Account{
|
||||
ID: 202,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token-2",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"privacy_mode": mode,
|
||||
},
|
||||
}
|
||||
|
||||
service.ensureOpenAIPrivacy(context.Background(), account)
|
||||
|
||||
require.Equal(t, 1, privacyCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,19 @@ const (
|
||||
PrivacyModeCFBlocked = "training_set_cf_blocked"
|
||||
)
|
||||
|
||||
func shouldSkipOpenAIPrivacyEnsure(extra map[string]any) bool {
|
||||
if extra == nil {
|
||||
return false
|
||||
}
|
||||
raw, ok := extra["privacy_mode"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
mode, _ := raw.(string)
|
||||
mode = strings.TrimSpace(mode)
|
||||
return mode != PrivacyModeFailed && mode != PrivacyModeCFBlocked
|
||||
}
|
||||
|
||||
// disableOpenAITraining calls ChatGPT settings API to turn off "Improve the model for everyone".
|
||||
// Returns privacy_mode value: "training_off" on success, "cf_blocked" / "failed" on failure.
|
||||
func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL string) string {
|
||||
|
||||
@ -166,7 +166,7 @@ type opsCleanupDeletedCounts struct {
|
||||
|
||||
func (c opsCleanupDeletedCounts) String() string {
|
||||
return fmt.Sprintf(
|
||||
"err_logs=%d retry_attempts=%d alert_events=%d sys_logs=%d log_audits=%d sys_metrics=%d hourly_preagg=%d daily_preagg=%d",
|
||||
"error_logs=%d retry_attempts=%d alert_events=%d system_logs=%d log_audits=%d system_metrics=%d hourly_preagg=%d daily_preagg=%d",
|
||||
c.errorLogs,
|
||||
c.retryAttempts,
|
||||
c.alertEvents,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user