Merge branch 'Wei-Shaw:main' into main
This commit is contained in:
commit
b6d46fd52f
@ -12,7 +12,7 @@
|
||||
|
||||
**AI API Gateway Platform for Subscription Quota Distribution**
|
||||
|
||||
English | [中文](README_CN.md)
|
||||
English | [中文](README_CN.md) | [日本語](README_JA.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
**AI API 网关平台 - 订阅配额分发管理**
|
||||
|
||||
[English](README.md) | 中文
|
||||
[English](README.md) | 中文 | [日本語](README_JA.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
585
README_JA.md
Normal file
585
README_JA.md
Normal file
@ -0,0 +1,585 @@
|
||||
# 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="120"></a></td>
|
||||
<td valign="middle"><b><a href="https://shop.pincc.ai/">PinCC</a></b> は Sub2API 上に構築された公式リレーサービスで、Claude Code、Codex、Gemini などの人気モデルへの安定したアクセスを提供します。デプロイやメンテナンスは不要で、すぐにご利用いただけます。</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 +1 @@
|
||||
0.1.104
|
||||
0.1.105
|
||||
|
||||
@ -132,14 +132,17 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
|
||||
usageCache := service.NewUsageCache()
|
||||
identityCache := repository.NewIdentityCache(redisClient)
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache)
|
||||
geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oauthRefreshAPI)
|
||||
gatewayCache := repository.NewGatewayCache(redisClient)
|
||||
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
|
||||
schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig)
|
||||
antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oauthRefreshAPI, tempUnschedCache)
|
||||
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig)
|
||||
tlsFingerprintProfileRepository := repository.NewTLSFingerprintProfileRepository(client)
|
||||
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
|
||||
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig)
|
||||
rpmCache := repository.NewRPMCache(redisClient)
|
||||
@ -171,7 +174,7 @@ 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)
|
||||
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)
|
||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
|
||||
@ -203,12 +206,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
errorPassthroughCache := repository.NewErrorPassthroughCache(redisClient)
|
||||
errorPassthroughService := service.NewErrorPassthroughService(errorPassthroughRepository, errorPassthroughCache)
|
||||
errorPassthroughHandler := admin.NewErrorPassthroughHandler(errorPassthroughService)
|
||||
tlsFingerprintProfileHandler := admin.NewTLSFingerprintProfileHandler(tlsFingerprintProfileService)
|
||||
adminAPIKeyHandler := admin.NewAdminAPIKeyHandler(adminService)
|
||||
scheduledTestPlanRepository := repository.NewScheduledTestPlanRepository(db)
|
||||
scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db)
|
||||
scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository)
|
||||
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, adminAPIKeyHandler, scheduledTestHandler)
|
||||
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)
|
||||
|
||||
@ -29,6 +29,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
|
||||
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
|
||||
"github.com/Wei-Shaw/sub2api/ent/setting"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagelog"
|
||||
"github.com/Wei-Shaw/sub2api/ent/user"
|
||||
@ -73,6 +74,8 @@ type Client struct {
|
||||
SecuritySecret *SecuritySecretClient
|
||||
// Setting is the client for interacting with the Setting builders.
|
||||
Setting *SettingClient
|
||||
// TLSFingerprintProfile is the client for interacting with the TLSFingerprintProfile builders.
|
||||
TLSFingerprintProfile *TLSFingerprintProfileClient
|
||||
// UsageCleanupTask is the client for interacting with the UsageCleanupTask builders.
|
||||
UsageCleanupTask *UsageCleanupTaskClient
|
||||
// UsageLog is the client for interacting with the UsageLog builders.
|
||||
@ -112,6 +115,7 @@ func (c *Client) init() {
|
||||
c.RedeemCode = NewRedeemCodeClient(c.config)
|
||||
c.SecuritySecret = NewSecuritySecretClient(c.config)
|
||||
c.Setting = NewSettingClient(c.config)
|
||||
c.TLSFingerprintProfile = NewTLSFingerprintProfileClient(c.config)
|
||||
c.UsageCleanupTask = NewUsageCleanupTaskClient(c.config)
|
||||
c.UsageLog = NewUsageLogClient(c.config)
|
||||
c.User = NewUserClient(c.config)
|
||||
@ -225,6 +229,7 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) {
|
||||
RedeemCode: NewRedeemCodeClient(cfg),
|
||||
SecuritySecret: NewSecuritySecretClient(cfg),
|
||||
Setting: NewSettingClient(cfg),
|
||||
TLSFingerprintProfile: NewTLSFingerprintProfileClient(cfg),
|
||||
UsageCleanupTask: NewUsageCleanupTaskClient(cfg),
|
||||
UsageLog: NewUsageLogClient(cfg),
|
||||
User: NewUserClient(cfg),
|
||||
@ -265,6 +270,7 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error)
|
||||
RedeemCode: NewRedeemCodeClient(cfg),
|
||||
SecuritySecret: NewSecuritySecretClient(cfg),
|
||||
Setting: NewSettingClient(cfg),
|
||||
TLSFingerprintProfile: NewTLSFingerprintProfileClient(cfg),
|
||||
UsageCleanupTask: NewUsageCleanupTaskClient(cfg),
|
||||
UsageLog: NewUsageLogClient(cfg),
|
||||
User: NewUserClient(cfg),
|
||||
@ -304,8 +310,9 @@ func (c *Client) Use(hooks ...Hook) {
|
||||
c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead,
|
||||
c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, c.PromoCode,
|
||||
c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting,
|
||||
c.UsageCleanupTask, c.UsageLog, c.User, c.UserAllowedGroup,
|
||||
c.UserAttributeDefinition, c.UserAttributeValue, c.UserSubscription,
|
||||
c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User,
|
||||
c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
|
||||
c.UserSubscription,
|
||||
} {
|
||||
n.Use(hooks...)
|
||||
}
|
||||
@ -318,8 +325,9 @@ func (c *Client) Intercept(interceptors ...Interceptor) {
|
||||
c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead,
|
||||
c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, c.PromoCode,
|
||||
c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting,
|
||||
c.UsageCleanupTask, c.UsageLog, c.User, c.UserAllowedGroup,
|
||||
c.UserAttributeDefinition, c.UserAttributeValue, c.UserSubscription,
|
||||
c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User,
|
||||
c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
|
||||
c.UserSubscription,
|
||||
} {
|
||||
n.Intercept(interceptors...)
|
||||
}
|
||||
@ -356,6 +364,8 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) {
|
||||
return c.SecuritySecret.mutate(ctx, m)
|
||||
case *SettingMutation:
|
||||
return c.Setting.mutate(ctx, m)
|
||||
case *TLSFingerprintProfileMutation:
|
||||
return c.TLSFingerprintProfile.mutate(ctx, m)
|
||||
case *UsageCleanupTaskMutation:
|
||||
return c.UsageCleanupTask.mutate(ctx, m)
|
||||
case *UsageLogMutation:
|
||||
@ -2612,6 +2622,139 @@ func (c *SettingClient) mutate(ctx context.Context, m *SettingMutation) (Value,
|
||||
}
|
||||
}
|
||||
|
||||
// TLSFingerprintProfileClient is a client for the TLSFingerprintProfile schema.
|
||||
type TLSFingerprintProfileClient struct {
|
||||
config
|
||||
}
|
||||
|
||||
// NewTLSFingerprintProfileClient returns a client for the TLSFingerprintProfile from the given config.
|
||||
func NewTLSFingerprintProfileClient(c config) *TLSFingerprintProfileClient {
|
||||
return &TLSFingerprintProfileClient{config: c}
|
||||
}
|
||||
|
||||
// Use adds a list of mutation hooks to the hooks stack.
|
||||
// A call to `Use(f, g, h)` equals to `tlsfingerprintprofile.Hooks(f(g(h())))`.
|
||||
func (c *TLSFingerprintProfileClient) Use(hooks ...Hook) {
|
||||
c.hooks.TLSFingerprintProfile = append(c.hooks.TLSFingerprintProfile, hooks...)
|
||||
}
|
||||
|
||||
// Intercept adds a list of query interceptors to the interceptors stack.
|
||||
// A call to `Intercept(f, g, h)` equals to `tlsfingerprintprofile.Intercept(f(g(h())))`.
|
||||
func (c *TLSFingerprintProfileClient) Intercept(interceptors ...Interceptor) {
|
||||
c.inters.TLSFingerprintProfile = append(c.inters.TLSFingerprintProfile, interceptors...)
|
||||
}
|
||||
|
||||
// Create returns a builder for creating a TLSFingerprintProfile entity.
|
||||
func (c *TLSFingerprintProfileClient) Create() *TLSFingerprintProfileCreate {
|
||||
mutation := newTLSFingerprintProfileMutation(c.config, OpCreate)
|
||||
return &TLSFingerprintProfileCreate{config: c.config, hooks: c.Hooks(), mutation: mutation}
|
||||
}
|
||||
|
||||
// CreateBulk returns a builder for creating a bulk of TLSFingerprintProfile entities.
|
||||
func (c *TLSFingerprintProfileClient) CreateBulk(builders ...*TLSFingerprintProfileCreate) *TLSFingerprintProfileCreateBulk {
|
||||
return &TLSFingerprintProfileCreateBulk{config: c.config, builders: builders}
|
||||
}
|
||||
|
||||
// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates
|
||||
// a builder and applies setFunc on it.
|
||||
func (c *TLSFingerprintProfileClient) MapCreateBulk(slice any, setFunc func(*TLSFingerprintProfileCreate, int)) *TLSFingerprintProfileCreateBulk {
|
||||
rv := reflect.ValueOf(slice)
|
||||
if rv.Kind() != reflect.Slice {
|
||||
return &TLSFingerprintProfileCreateBulk{err: fmt.Errorf("calling to TLSFingerprintProfileClient.MapCreateBulk with wrong type %T, need slice", slice)}
|
||||
}
|
||||
builders := make([]*TLSFingerprintProfileCreate, rv.Len())
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
builders[i] = c.Create()
|
||||
setFunc(builders[i], i)
|
||||
}
|
||||
return &TLSFingerprintProfileCreateBulk{config: c.config, builders: builders}
|
||||
}
|
||||
|
||||
// Update returns an update builder for TLSFingerprintProfile.
|
||||
func (c *TLSFingerprintProfileClient) Update() *TLSFingerprintProfileUpdate {
|
||||
mutation := newTLSFingerprintProfileMutation(c.config, OpUpdate)
|
||||
return &TLSFingerprintProfileUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation}
|
||||
}
|
||||
|
||||
// UpdateOne returns an update builder for the given entity.
|
||||
func (c *TLSFingerprintProfileClient) UpdateOne(_m *TLSFingerprintProfile) *TLSFingerprintProfileUpdateOne {
|
||||
mutation := newTLSFingerprintProfileMutation(c.config, OpUpdateOne, withTLSFingerprintProfile(_m))
|
||||
return &TLSFingerprintProfileUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation}
|
||||
}
|
||||
|
||||
// UpdateOneID returns an update builder for the given id.
|
||||
func (c *TLSFingerprintProfileClient) UpdateOneID(id int64) *TLSFingerprintProfileUpdateOne {
|
||||
mutation := newTLSFingerprintProfileMutation(c.config, OpUpdateOne, withTLSFingerprintProfileID(id))
|
||||
return &TLSFingerprintProfileUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation}
|
||||
}
|
||||
|
||||
// Delete returns a delete builder for TLSFingerprintProfile.
|
||||
func (c *TLSFingerprintProfileClient) Delete() *TLSFingerprintProfileDelete {
|
||||
mutation := newTLSFingerprintProfileMutation(c.config, OpDelete)
|
||||
return &TLSFingerprintProfileDelete{config: c.config, hooks: c.Hooks(), mutation: mutation}
|
||||
}
|
||||
|
||||
// DeleteOne returns a builder for deleting the given entity.
|
||||
func (c *TLSFingerprintProfileClient) DeleteOne(_m *TLSFingerprintProfile) *TLSFingerprintProfileDeleteOne {
|
||||
return c.DeleteOneID(_m.ID)
|
||||
}
|
||||
|
||||
// DeleteOneID returns a builder for deleting the given entity by its id.
|
||||
func (c *TLSFingerprintProfileClient) DeleteOneID(id int64) *TLSFingerprintProfileDeleteOne {
|
||||
builder := c.Delete().Where(tlsfingerprintprofile.ID(id))
|
||||
builder.mutation.id = &id
|
||||
builder.mutation.op = OpDeleteOne
|
||||
return &TLSFingerprintProfileDeleteOne{builder}
|
||||
}
|
||||
|
||||
// Query returns a query builder for TLSFingerprintProfile.
|
||||
func (c *TLSFingerprintProfileClient) Query() *TLSFingerprintProfileQuery {
|
||||
return &TLSFingerprintProfileQuery{
|
||||
config: c.config,
|
||||
ctx: &QueryContext{Type: TypeTLSFingerprintProfile},
|
||||
inters: c.Interceptors(),
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a TLSFingerprintProfile entity by its id.
|
||||
func (c *TLSFingerprintProfileClient) Get(ctx context.Context, id int64) (*TLSFingerprintProfile, error) {
|
||||
return c.Query().Where(tlsfingerprintprofile.ID(id)).Only(ctx)
|
||||
}
|
||||
|
||||
// GetX is like Get, but panics if an error occurs.
|
||||
func (c *TLSFingerprintProfileClient) GetX(ctx context.Context, id int64) *TLSFingerprintProfile {
|
||||
obj, err := c.Get(ctx, id)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// Hooks returns the client hooks.
|
||||
func (c *TLSFingerprintProfileClient) Hooks() []Hook {
|
||||
return c.hooks.TLSFingerprintProfile
|
||||
}
|
||||
|
||||
// Interceptors returns the client interceptors.
|
||||
func (c *TLSFingerprintProfileClient) Interceptors() []Interceptor {
|
||||
return c.inters.TLSFingerprintProfile
|
||||
}
|
||||
|
||||
func (c *TLSFingerprintProfileClient) mutate(ctx context.Context, m *TLSFingerprintProfileMutation) (Value, error) {
|
||||
switch m.Op() {
|
||||
case OpCreate:
|
||||
return (&TLSFingerprintProfileCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
|
||||
case OpUpdate:
|
||||
return (&TLSFingerprintProfileUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
|
||||
case OpUpdateOne:
|
||||
return (&TLSFingerprintProfileUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
|
||||
case OpDelete, OpDeleteOne:
|
||||
return (&TLSFingerprintProfileDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx)
|
||||
default:
|
||||
return nil, fmt.Errorf("ent: unknown TLSFingerprintProfile mutation op: %q", m.Op())
|
||||
}
|
||||
}
|
||||
|
||||
// UsageCleanupTaskClient is a client for the UsageCleanupTask schema.
|
||||
type UsageCleanupTaskClient struct {
|
||||
config
|
||||
@ -3889,16 +4032,16 @@ type (
|
||||
hooks struct {
|
||||
APIKey, Account, AccountGroup, Announcement, AnnouncementRead,
|
||||
ErrorPassthroughRule, Group, IdempotencyRecord, PromoCode, PromoCodeUsage,
|
||||
Proxy, RedeemCode, SecuritySecret, Setting, UsageCleanupTask, UsageLog, User,
|
||||
UserAllowedGroup, UserAttributeDefinition, UserAttributeValue,
|
||||
UserSubscription []ent.Hook
|
||||
Proxy, RedeemCode, SecuritySecret, Setting, TLSFingerprintProfile,
|
||||
UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition,
|
||||
UserAttributeValue, UserSubscription []ent.Hook
|
||||
}
|
||||
inters struct {
|
||||
APIKey, Account, AccountGroup, Announcement, AnnouncementRead,
|
||||
ErrorPassthroughRule, Group, IdempotencyRecord, PromoCode, PromoCodeUsage,
|
||||
Proxy, RedeemCode, SecuritySecret, Setting, UsageCleanupTask, UsageLog, User,
|
||||
UserAllowedGroup, UserAttributeDefinition, UserAttributeValue,
|
||||
UserSubscription []ent.Interceptor
|
||||
Proxy, RedeemCode, SecuritySecret, Setting, TLSFingerprintProfile,
|
||||
UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition,
|
||||
UserAttributeValue, UserSubscription []ent.Interceptor
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
|
||||
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
|
||||
"github.com/Wei-Shaw/sub2api/ent/setting"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagelog"
|
||||
"github.com/Wei-Shaw/sub2api/ent/user"
|
||||
@ -107,6 +108,7 @@ func checkColumn(t, c string) error {
|
||||
redeemcode.Table: redeemcode.ValidColumn,
|
||||
securitysecret.Table: securitysecret.ValidColumn,
|
||||
setting.Table: setting.ValidColumn,
|
||||
tlsfingerprintprofile.Table: tlsfingerprintprofile.ValidColumn,
|
||||
usagecleanuptask.Table: usagecleanuptask.ValidColumn,
|
||||
usagelog.Table: usagelog.ValidColumn,
|
||||
user.Table: user.ValidColumn,
|
||||
|
||||
@ -177,6 +177,18 @@ func (f SettingFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, err
|
||||
return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.SettingMutation", m)
|
||||
}
|
||||
|
||||
// The TLSFingerprintProfileFunc type is an adapter to allow the use of ordinary
|
||||
// function as TLSFingerprintProfile mutator.
|
||||
type TLSFingerprintProfileFunc func(context.Context, *ent.TLSFingerprintProfileMutation) (ent.Value, error)
|
||||
|
||||
// Mutate calls f(ctx, m).
|
||||
func (f TLSFingerprintProfileFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) {
|
||||
if mv, ok := m.(*ent.TLSFingerprintProfileMutation); ok {
|
||||
return f(ctx, mv)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.TLSFingerprintProfileMutation", m)
|
||||
}
|
||||
|
||||
// The UsageCleanupTaskFunc type is an adapter to allow the use of ordinary
|
||||
// function as UsageCleanupTask mutator.
|
||||
type UsageCleanupTaskFunc func(context.Context, *ent.UsageCleanupTaskMutation) (ent.Value, error)
|
||||
|
||||
@ -23,6 +23,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
|
||||
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
|
||||
"github.com/Wei-Shaw/sub2api/ent/setting"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagelog"
|
||||
"github.com/Wei-Shaw/sub2api/ent/user"
|
||||
@ -466,6 +467,33 @@ func (f TraverseSetting) Traverse(ctx context.Context, q ent.Query) error {
|
||||
return fmt.Errorf("unexpected query type %T. expect *ent.SettingQuery", q)
|
||||
}
|
||||
|
||||
// The TLSFingerprintProfileFunc type is an adapter to allow the use of ordinary function as a Querier.
|
||||
type TLSFingerprintProfileFunc func(context.Context, *ent.TLSFingerprintProfileQuery) (ent.Value, error)
|
||||
|
||||
// Query calls f(ctx, q).
|
||||
func (f TLSFingerprintProfileFunc) Query(ctx context.Context, q ent.Query) (ent.Value, error) {
|
||||
if q, ok := q.(*ent.TLSFingerprintProfileQuery); ok {
|
||||
return f(ctx, q)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected query type %T. expect *ent.TLSFingerprintProfileQuery", q)
|
||||
}
|
||||
|
||||
// The TraverseTLSFingerprintProfile type is an adapter to allow the use of ordinary function as Traverser.
|
||||
type TraverseTLSFingerprintProfile func(context.Context, *ent.TLSFingerprintProfileQuery) error
|
||||
|
||||
// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline.
|
||||
func (f TraverseTLSFingerprintProfile) Intercept(next ent.Querier) ent.Querier {
|
||||
return next
|
||||
}
|
||||
|
||||
// Traverse calls f(ctx, q).
|
||||
func (f TraverseTLSFingerprintProfile) Traverse(ctx context.Context, q ent.Query) error {
|
||||
if q, ok := q.(*ent.TLSFingerprintProfileQuery); ok {
|
||||
return f(ctx, q)
|
||||
}
|
||||
return fmt.Errorf("unexpected query type %T. expect *ent.TLSFingerprintProfileQuery", q)
|
||||
}
|
||||
|
||||
// The UsageCleanupTaskFunc type is an adapter to allow the use of ordinary function as a Querier.
|
||||
type UsageCleanupTaskFunc func(context.Context, *ent.UsageCleanupTaskQuery) (ent.Value, error)
|
||||
|
||||
@ -686,6 +714,8 @@ func NewQuery(q ent.Query) (Query, error) {
|
||||
return &query[*ent.SecuritySecretQuery, predicate.SecuritySecret, securitysecret.OrderOption]{typ: ent.TypeSecuritySecret, tq: q}, nil
|
||||
case *ent.SettingQuery:
|
||||
return &query[*ent.SettingQuery, predicate.Setting, setting.OrderOption]{typ: ent.TypeSetting, tq: q}, nil
|
||||
case *ent.TLSFingerprintProfileQuery:
|
||||
return &query[*ent.TLSFingerprintProfileQuery, predicate.TLSFingerprintProfile, tlsfingerprintprofile.OrderOption]{typ: ent.TypeTLSFingerprintProfile, tq: q}, nil
|
||||
case *ent.UsageCleanupTaskQuery:
|
||||
return &query[*ent.UsageCleanupTaskQuery, predicate.UsageCleanupTask, usagecleanuptask.OrderOption]{typ: ent.TypeUsageCleanupTask, tq: q}, nil
|
||||
case *ent.UsageLogQuery:
|
||||
|
||||
@ -673,6 +673,30 @@ var (
|
||||
Columns: SettingsColumns,
|
||||
PrimaryKey: []*schema.Column{SettingsColumns[0]},
|
||||
}
|
||||
// TLSFingerprintProfilesColumns holds the columns for the "tls_fingerprint_profiles" table.
|
||||
TLSFingerprintProfilesColumns = []*schema.Column{
|
||||
{Name: "id", Type: field.TypeInt64, Increment: true},
|
||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "name", Type: field.TypeString, Unique: true, Size: 100},
|
||||
{Name: "description", Type: field.TypeString, Nullable: true, Size: 2147483647},
|
||||
{Name: "enable_grease", Type: field.TypeBool, Default: false},
|
||||
{Name: "cipher_suites", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "curves", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "point_formats", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "signature_algorithms", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "alpn_protocols", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "supported_versions", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "key_share_groups", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "psk_modes", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "extensions", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
}
|
||||
// TLSFingerprintProfilesTable holds the schema information for the "tls_fingerprint_profiles" table.
|
||||
TLSFingerprintProfilesTable = &schema.Table{
|
||||
Name: "tls_fingerprint_profiles",
|
||||
Columns: TLSFingerprintProfilesColumns,
|
||||
PrimaryKey: []*schema.Column{TLSFingerprintProfilesColumns[0]},
|
||||
}
|
||||
// UsageCleanupTasksColumns holds the columns for the "usage_cleanup_tasks" table.
|
||||
UsageCleanupTasksColumns = []*schema.Column{
|
||||
{Name: "id", Type: field.TypeInt64, Increment: true},
|
||||
@ -1111,6 +1135,7 @@ var (
|
||||
RedeemCodesTable,
|
||||
SecuritySecretsTable,
|
||||
SettingsTable,
|
||||
TLSFingerprintProfilesTable,
|
||||
UsageCleanupTasksTable,
|
||||
UsageLogsTable,
|
||||
UsersTable,
|
||||
@ -1175,6 +1200,9 @@ func init() {
|
||||
SettingsTable.Annotation = &entsql.Annotation{
|
||||
Table: "settings",
|
||||
}
|
||||
TLSFingerprintProfilesTable.Annotation = &entsql.Annotation{
|
||||
Table: "tls_fingerprint_profiles",
|
||||
}
|
||||
UsageCleanupTasksTable.Annotation = &entsql.Annotation{
|
||||
Table: "usage_cleanup_tasks",
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -48,6 +48,9 @@ type SecuritySecret func(*sql.Selector)
|
||||
// Setting is the predicate function for setting builders.
|
||||
type Setting func(*sql.Selector)
|
||||
|
||||
// TLSFingerprintProfile is the predicate function for tlsfingerprintprofile builders.
|
||||
type TLSFingerprintProfile func(*sql.Selector)
|
||||
|
||||
// UsageCleanupTask is the predicate function for usagecleanuptask builders.
|
||||
type UsageCleanupTask func(*sql.Selector)
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema"
|
||||
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
|
||||
"github.com/Wei-Shaw/sub2api/ent/setting"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagelog"
|
||||
"github.com/Wei-Shaw/sub2api/ent/user"
|
||||
@ -746,6 +747,43 @@ func init() {
|
||||
setting.DefaultUpdatedAt = settingDescUpdatedAt.Default.(func() time.Time)
|
||||
// setting.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
|
||||
setting.UpdateDefaultUpdatedAt = settingDescUpdatedAt.UpdateDefault.(func() time.Time)
|
||||
tlsfingerprintprofileMixin := schema.TLSFingerprintProfile{}.Mixin()
|
||||
tlsfingerprintprofileMixinFields0 := tlsfingerprintprofileMixin[0].Fields()
|
||||
_ = tlsfingerprintprofileMixinFields0
|
||||
tlsfingerprintprofileFields := schema.TLSFingerprintProfile{}.Fields()
|
||||
_ = tlsfingerprintprofileFields
|
||||
// tlsfingerprintprofileDescCreatedAt is the schema descriptor for created_at field.
|
||||
tlsfingerprintprofileDescCreatedAt := tlsfingerprintprofileMixinFields0[0].Descriptor()
|
||||
// tlsfingerprintprofile.DefaultCreatedAt holds the default value on creation for the created_at field.
|
||||
tlsfingerprintprofile.DefaultCreatedAt = tlsfingerprintprofileDescCreatedAt.Default.(func() time.Time)
|
||||
// tlsfingerprintprofileDescUpdatedAt is the schema descriptor for updated_at field.
|
||||
tlsfingerprintprofileDescUpdatedAt := tlsfingerprintprofileMixinFields0[1].Descriptor()
|
||||
// tlsfingerprintprofile.DefaultUpdatedAt holds the default value on creation for the updated_at field.
|
||||
tlsfingerprintprofile.DefaultUpdatedAt = tlsfingerprintprofileDescUpdatedAt.Default.(func() time.Time)
|
||||
// tlsfingerprintprofile.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
|
||||
tlsfingerprintprofile.UpdateDefaultUpdatedAt = tlsfingerprintprofileDescUpdatedAt.UpdateDefault.(func() time.Time)
|
||||
// tlsfingerprintprofileDescName is the schema descriptor for name field.
|
||||
tlsfingerprintprofileDescName := tlsfingerprintprofileFields[0].Descriptor()
|
||||
// tlsfingerprintprofile.NameValidator is a validator for the "name" field. It is called by the builders before save.
|
||||
tlsfingerprintprofile.NameValidator = func() func(string) error {
|
||||
validators := tlsfingerprintprofileDescName.Validators
|
||||
fns := [...]func(string) error{
|
||||
validators[0].(func(string) error),
|
||||
validators[1].(func(string) error),
|
||||
}
|
||||
return func(name string) error {
|
||||
for _, fn := range fns {
|
||||
if err := fn(name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
// tlsfingerprintprofileDescEnableGrease is the schema descriptor for enable_grease field.
|
||||
tlsfingerprintprofileDescEnableGrease := tlsfingerprintprofileFields[2].Descriptor()
|
||||
// tlsfingerprintprofile.DefaultEnableGrease holds the default value on creation for the enable_grease field.
|
||||
tlsfingerprintprofile.DefaultEnableGrease = tlsfingerprintprofileDescEnableGrease.Default.(bool)
|
||||
usagecleanuptaskMixin := schema.UsageCleanupTask{}.Mixin()
|
||||
usagecleanuptaskMixinFields0 := usagecleanuptaskMixin[0].Fields()
|
||||
_ = usagecleanuptaskMixinFields0
|
||||
|
||||
100
backend/ent/schema/tls_fingerprint_profile.go
Normal file
100
backend/ent/schema/tls_fingerprint_profile.go
Normal file
@ -0,0 +1,100 @@
|
||||
// Package schema 定义 Ent ORM 的数据库 schema。
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/entsql"
|
||||
"entgo.io/ent/schema"
|
||||
"entgo.io/ent/schema/field"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfile 定义 TLS 指纹配置模板的 schema。
|
||||
//
|
||||
// TLS 指纹模板用于模拟特定客户端(如 Claude Code / Node.js)的 TLS 握手特征。
|
||||
// 每个模板包含完整的 ClientHello 参数:加密套件、曲线、扩展等。
|
||||
// 通过 Account.Extra.tls_fingerprint_profile_id 绑定到具体账号。
|
||||
type TLSFingerprintProfile struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
// Annotations 返回 schema 的注解配置。
|
||||
func (TLSFingerprintProfile) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "tls_fingerprint_profiles"},
|
||||
}
|
||||
}
|
||||
|
||||
// Mixin 返回该 schema 使用的混入组件。
|
||||
func (TLSFingerprintProfile) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
// Fields 定义 TLS 指纹模板实体的所有字段。
|
||||
func (TLSFingerprintProfile) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
// name: 模板名称,唯一标识
|
||||
field.String("name").
|
||||
MaxLen(100).
|
||||
NotEmpty().
|
||||
Unique(),
|
||||
|
||||
// description: 模板描述
|
||||
field.Text("description").
|
||||
Optional().
|
||||
Nillable(),
|
||||
|
||||
// enable_grease: 是否启用 GREASE 扩展(Chrome 使用,Node.js 不使用)
|
||||
field.Bool("enable_grease").
|
||||
Default(false),
|
||||
|
||||
// cipher_suites: TLS 加密套件列表(顺序敏感,影响 JA3)
|
||||
field.JSON("cipher_suites", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// curves: 椭圆曲线/支持的组列表
|
||||
field.JSON("curves", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// point_formats: EC 点格式列表
|
||||
field.JSON("point_formats", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// signature_algorithms: 签名算法列表
|
||||
field.JSON("signature_algorithms", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// alpn_protocols: ALPN 协议列表(如 ["http/1.1"])
|
||||
field.JSON("alpn_protocols", []string{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// supported_versions: 支持的 TLS 版本列表(如 [0x0304, 0x0303])
|
||||
field.JSON("supported_versions", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// key_share_groups: Key Share 中发送的曲线组(如 [29] 即 X25519)
|
||||
field.JSON("key_share_groups", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// psk_modes: PSK 密钥交换模式(如 [1] 即 psk_dhe_ke)
|
||||
field.JSON("psk_modes", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// extensions: TLS 扩展类型 ID 列表,按发送顺序排列
|
||||
field.JSON("extensions", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
}
|
||||
}
|
||||
275
backend/ent/tlsfingerprintprofile.go
Normal file
275
backend/ent/tlsfingerprintprofile.go
Normal file
@ -0,0 +1,275 @@
|
||||
// Code generated by ent, DO NOT EDIT.
|
||||
|
||||
package ent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfile is the model entity for the TLSFingerprintProfile schema.
|
||||
type TLSFingerprintProfile struct {
|
||||
config `json:"-"`
|
||||
// ID of the ent.
|
||||
ID int64 `json:"id,omitempty"`
|
||||
// CreatedAt holds the value of the "created_at" field.
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
// UpdatedAt holds the value of the "updated_at" field.
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
// Name holds the value of the "name" field.
|
||||
Name string `json:"name,omitempty"`
|
||||
// Description holds the value of the "description" field.
|
||||
Description *string `json:"description,omitempty"`
|
||||
// EnableGrease holds the value of the "enable_grease" field.
|
||||
EnableGrease bool `json:"enable_grease,omitempty"`
|
||||
// CipherSuites holds the value of the "cipher_suites" field.
|
||||
CipherSuites []uint16 `json:"cipher_suites,omitempty"`
|
||||
// Curves holds the value of the "curves" field.
|
||||
Curves []uint16 `json:"curves,omitempty"`
|
||||
// PointFormats holds the value of the "point_formats" field.
|
||||
PointFormats []uint16 `json:"point_formats,omitempty"`
|
||||
// SignatureAlgorithms holds the value of the "signature_algorithms" field.
|
||||
SignatureAlgorithms []uint16 `json:"signature_algorithms,omitempty"`
|
||||
// AlpnProtocols holds the value of the "alpn_protocols" field.
|
||||
AlpnProtocols []string `json:"alpn_protocols,omitempty"`
|
||||
// SupportedVersions holds the value of the "supported_versions" field.
|
||||
SupportedVersions []uint16 `json:"supported_versions,omitempty"`
|
||||
// KeyShareGroups holds the value of the "key_share_groups" field.
|
||||
KeyShareGroups []uint16 `json:"key_share_groups,omitempty"`
|
||||
// PskModes holds the value of the "psk_modes" field.
|
||||
PskModes []uint16 `json:"psk_modes,omitempty"`
|
||||
// Extensions holds the value of the "extensions" field.
|
||||
Extensions []uint16 `json:"extensions,omitempty"`
|
||||
selectValues sql.SelectValues
|
||||
}
|
||||
|
||||
// scanValues returns the types for scanning values from sql.Rows.
|
||||
func (*TLSFingerprintProfile) scanValues(columns []string) ([]any, error) {
|
||||
values := make([]any, len(columns))
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case tlsfingerprintprofile.FieldCipherSuites, tlsfingerprintprofile.FieldCurves, tlsfingerprintprofile.FieldPointFormats, tlsfingerprintprofile.FieldSignatureAlgorithms, tlsfingerprintprofile.FieldAlpnProtocols, tlsfingerprintprofile.FieldSupportedVersions, tlsfingerprintprofile.FieldKeyShareGroups, tlsfingerprintprofile.FieldPskModes, tlsfingerprintprofile.FieldExtensions:
|
||||
values[i] = new([]byte)
|
||||
case tlsfingerprintprofile.FieldEnableGrease:
|
||||
values[i] = new(sql.NullBool)
|
||||
case tlsfingerprintprofile.FieldID:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case tlsfingerprintprofile.FieldName, tlsfingerprintprofile.FieldDescription:
|
||||
values[i] = new(sql.NullString)
|
||||
case tlsfingerprintprofile.FieldCreatedAt, tlsfingerprintprofile.FieldUpdatedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
default:
|
||||
values[i] = new(sql.UnknownType)
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// assignValues assigns the values that were returned from sql.Rows (after scanning)
|
||||
// to the TLSFingerprintProfile fields.
|
||||
func (_m *TLSFingerprintProfile) assignValues(columns []string, values []any) error {
|
||||
if m, n := len(values), len(columns); m < n {
|
||||
return fmt.Errorf("mismatch number of scan values: %d != %d", m, n)
|
||||
}
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case tlsfingerprintprofile.FieldID:
|
||||
value, ok := values[i].(*sql.NullInt64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field id", value)
|
||||
}
|
||||
_m.ID = int64(value.Int64)
|
||||
case tlsfingerprintprofile.FieldCreatedAt:
|
||||
if value, ok := values[i].(*sql.NullTime); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field created_at", values[i])
|
||||
} else if value.Valid {
|
||||
_m.CreatedAt = value.Time
|
||||
}
|
||||
case tlsfingerprintprofile.FieldUpdatedAt:
|
||||
if value, ok := values[i].(*sql.NullTime); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field updated_at", values[i])
|
||||
} else if value.Valid {
|
||||
_m.UpdatedAt = value.Time
|
||||
}
|
||||
case tlsfingerprintprofile.FieldName:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field name", values[i])
|
||||
} else if value.Valid {
|
||||
_m.Name = value.String
|
||||
}
|
||||
case tlsfingerprintprofile.FieldDescription:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field description", values[i])
|
||||
} else if value.Valid {
|
||||
_m.Description = new(string)
|
||||
*_m.Description = value.String
|
||||
}
|
||||
case tlsfingerprintprofile.FieldEnableGrease:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field enable_grease", values[i])
|
||||
} else if value.Valid {
|
||||
_m.EnableGrease = value.Bool
|
||||
}
|
||||
case tlsfingerprintprofile.FieldCipherSuites:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field cipher_suites", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.CipherSuites); err != nil {
|
||||
return fmt.Errorf("unmarshal field cipher_suites: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldCurves:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field curves", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.Curves); err != nil {
|
||||
return fmt.Errorf("unmarshal field curves: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldPointFormats:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field point_formats", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.PointFormats); err != nil {
|
||||
return fmt.Errorf("unmarshal field point_formats: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldSignatureAlgorithms:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field signature_algorithms", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.SignatureAlgorithms); err != nil {
|
||||
return fmt.Errorf("unmarshal field signature_algorithms: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldAlpnProtocols:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field alpn_protocols", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.AlpnProtocols); err != nil {
|
||||
return fmt.Errorf("unmarshal field alpn_protocols: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldSupportedVersions:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field supported_versions", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.SupportedVersions); err != nil {
|
||||
return fmt.Errorf("unmarshal field supported_versions: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldKeyShareGroups:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field key_share_groups", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.KeyShareGroups); err != nil {
|
||||
return fmt.Errorf("unmarshal field key_share_groups: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldPskModes:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field psk_modes", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.PskModes); err != nil {
|
||||
return fmt.Errorf("unmarshal field psk_modes: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldExtensions:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field extensions", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.Extensions); err != nil {
|
||||
return fmt.Errorf("unmarshal field extensions: %w", err)
|
||||
}
|
||||
}
|
||||
default:
|
||||
_m.selectValues.Set(columns[i], values[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value returns the ent.Value that was dynamically selected and assigned to the TLSFingerprintProfile.
|
||||
// This includes values selected through modifiers, order, etc.
|
||||
func (_m *TLSFingerprintProfile) Value(name string) (ent.Value, error) {
|
||||
return _m.selectValues.Get(name)
|
||||
}
|
||||
|
||||
// Update returns a builder for updating this TLSFingerprintProfile.
|
||||
// Note that you need to call TLSFingerprintProfile.Unwrap() before calling this method if this TLSFingerprintProfile
|
||||
// was returned from a transaction, and the transaction was committed or rolled back.
|
||||
func (_m *TLSFingerprintProfile) Update() *TLSFingerprintProfileUpdateOne {
|
||||
return NewTLSFingerprintProfileClient(_m.config).UpdateOne(_m)
|
||||
}
|
||||
|
||||
// Unwrap unwraps the TLSFingerprintProfile entity that was returned from a transaction after it was closed,
|
||||
// so that all future queries will be executed through the driver which created the transaction.
|
||||
func (_m *TLSFingerprintProfile) Unwrap() *TLSFingerprintProfile {
|
||||
_tx, ok := _m.config.driver.(*txDriver)
|
||||
if !ok {
|
||||
panic("ent: TLSFingerprintProfile is not a transactional entity")
|
||||
}
|
||||
_m.config.driver = _tx.drv
|
||||
return _m
|
||||
}
|
||||
|
||||
// String implements the fmt.Stringer.
|
||||
func (_m *TLSFingerprintProfile) String() string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("TLSFingerprintProfile(")
|
||||
builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID))
|
||||
builder.WriteString("created_at=")
|
||||
builder.WriteString(_m.CreatedAt.Format(time.ANSIC))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("updated_at=")
|
||||
builder.WriteString(_m.UpdatedAt.Format(time.ANSIC))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("name=")
|
||||
builder.WriteString(_m.Name)
|
||||
builder.WriteString(", ")
|
||||
if v := _m.Description; v != nil {
|
||||
builder.WriteString("description=")
|
||||
builder.WriteString(*v)
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("enable_grease=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.EnableGrease))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("cipher_suites=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.CipherSuites))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("curves=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.Curves))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("point_formats=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.PointFormats))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("signature_algorithms=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.SignatureAlgorithms))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("alpn_protocols=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.AlpnProtocols))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("supported_versions=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.SupportedVersions))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("key_share_groups=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.KeyShareGroups))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("psk_modes=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.PskModes))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("extensions=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.Extensions))
|
||||
builder.WriteByte(')')
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// TLSFingerprintProfiles is a parsable slice of TLSFingerprintProfile.
|
||||
type TLSFingerprintProfiles []*TLSFingerprintProfile
|
||||
121
backend/ent/tlsfingerprintprofile/tlsfingerprintprofile.go
Normal file
121
backend/ent/tlsfingerprintprofile/tlsfingerprintprofile.go
Normal file
@ -0,0 +1,121 @@
|
||||
// Code generated by ent, DO NOT EDIT.
|
||||
|
||||
package tlsfingerprintprofile
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
)
|
||||
|
||||
const (
|
||||
// Label holds the string label denoting the tlsfingerprintprofile type in the database.
|
||||
Label = "tls_fingerprint_profile"
|
||||
// FieldID holds the string denoting the id field in the database.
|
||||
FieldID = "id"
|
||||
// FieldCreatedAt holds the string denoting the created_at field in the database.
|
||||
FieldCreatedAt = "created_at"
|
||||
// FieldUpdatedAt holds the string denoting the updated_at field in the database.
|
||||
FieldUpdatedAt = "updated_at"
|
||||
// FieldName holds the string denoting the name field in the database.
|
||||
FieldName = "name"
|
||||
// FieldDescription holds the string denoting the description field in the database.
|
||||
FieldDescription = "description"
|
||||
// FieldEnableGrease holds the string denoting the enable_grease field in the database.
|
||||
FieldEnableGrease = "enable_grease"
|
||||
// FieldCipherSuites holds the string denoting the cipher_suites field in the database.
|
||||
FieldCipherSuites = "cipher_suites"
|
||||
// FieldCurves holds the string denoting the curves field in the database.
|
||||
FieldCurves = "curves"
|
||||
// FieldPointFormats holds the string denoting the point_formats field in the database.
|
||||
FieldPointFormats = "point_formats"
|
||||
// FieldSignatureAlgorithms holds the string denoting the signature_algorithms field in the database.
|
||||
FieldSignatureAlgorithms = "signature_algorithms"
|
||||
// FieldAlpnProtocols holds the string denoting the alpn_protocols field in the database.
|
||||
FieldAlpnProtocols = "alpn_protocols"
|
||||
// FieldSupportedVersions holds the string denoting the supported_versions field in the database.
|
||||
FieldSupportedVersions = "supported_versions"
|
||||
// FieldKeyShareGroups holds the string denoting the key_share_groups field in the database.
|
||||
FieldKeyShareGroups = "key_share_groups"
|
||||
// FieldPskModes holds the string denoting the psk_modes field in the database.
|
||||
FieldPskModes = "psk_modes"
|
||||
// FieldExtensions holds the string denoting the extensions field in the database.
|
||||
FieldExtensions = "extensions"
|
||||
// Table holds the table name of the tlsfingerprintprofile in the database.
|
||||
Table = "tls_fingerprint_profiles"
|
||||
)
|
||||
|
||||
// Columns holds all SQL columns for tlsfingerprintprofile fields.
|
||||
var Columns = []string{
|
||||
FieldID,
|
||||
FieldCreatedAt,
|
||||
FieldUpdatedAt,
|
||||
FieldName,
|
||||
FieldDescription,
|
||||
FieldEnableGrease,
|
||||
FieldCipherSuites,
|
||||
FieldCurves,
|
||||
FieldPointFormats,
|
||||
FieldSignatureAlgorithms,
|
||||
FieldAlpnProtocols,
|
||||
FieldSupportedVersions,
|
||||
FieldKeyShareGroups,
|
||||
FieldPskModes,
|
||||
FieldExtensions,
|
||||
}
|
||||
|
||||
// ValidColumn reports if the column name is valid (part of the table columns).
|
||||
func ValidColumn(column string) bool {
|
||||
for i := range Columns {
|
||||
if column == Columns[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
// DefaultCreatedAt holds the default value on creation for the "created_at" field.
|
||||
DefaultCreatedAt func() time.Time
|
||||
// DefaultUpdatedAt holds the default value on creation for the "updated_at" field.
|
||||
DefaultUpdatedAt func() time.Time
|
||||
// UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field.
|
||||
UpdateDefaultUpdatedAt func() time.Time
|
||||
// NameValidator is a validator for the "name" field. It is called by the builders before save.
|
||||
NameValidator func(string) error
|
||||
// DefaultEnableGrease holds the default value on creation for the "enable_grease" field.
|
||||
DefaultEnableGrease bool
|
||||
)
|
||||
|
||||
// OrderOption defines the ordering options for the TLSFingerprintProfile queries.
|
||||
type OrderOption func(*sql.Selector)
|
||||
|
||||
// ByID orders the results by the id field.
|
||||
func ByID(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldID, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByCreatedAt orders the results by the created_at field.
|
||||
func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldCreatedAt, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByUpdatedAt orders the results by the updated_at field.
|
||||
func ByUpdatedAt(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldUpdatedAt, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByName orders the results by the name field.
|
||||
func ByName(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldName, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByDescription orders the results by the description field.
|
||||
func ByDescription(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldDescription, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByEnableGrease orders the results by the enable_grease field.
|
||||
func ByEnableGrease(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldEnableGrease, opts...).ToFunc()
|
||||
}
|
||||
415
backend/ent/tlsfingerprintprofile/where.go
Normal file
415
backend/ent/tlsfingerprintprofile/where.go
Normal file
@ -0,0 +1,415 @@
|
||||
// Code generated by ent, DO NOT EDIT.
|
||||
|
||||
package tlsfingerprintprofile
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/Wei-Shaw/sub2api/ent/predicate"
|
||||
)
|
||||
|
||||
// ID filters vertices based on their ID field.
|
||||
func ID(id int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldID, id))
|
||||
}
|
||||
|
||||
// IDEQ applies the EQ predicate on the ID field.
|
||||
func IDEQ(id int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldID, id))
|
||||
}
|
||||
|
||||
// IDNEQ applies the NEQ predicate on the ID field.
|
||||
func IDNEQ(id int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldID, id))
|
||||
}
|
||||
|
||||
// IDIn applies the In predicate on the ID field.
|
||||
func IDIn(ids ...int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldID, ids...))
|
||||
}
|
||||
|
||||
// IDNotIn applies the NotIn predicate on the ID field.
|
||||
func IDNotIn(ids ...int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldID, ids...))
|
||||
}
|
||||
|
||||
// IDGT applies the GT predicate on the ID field.
|
||||
func IDGT(id int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldID, id))
|
||||
}
|
||||
|
||||
// IDGTE applies the GTE predicate on the ID field.
|
||||
func IDGTE(id int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldID, id))
|
||||
}
|
||||
|
||||
// IDLT applies the LT predicate on the ID field.
|
||||
func IDLT(id int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldID, id))
|
||||
}
|
||||
|
||||
// IDLTE applies the LTE predicate on the ID field.
|
||||
func IDLTE(id int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldID, id))
|
||||
}
|
||||
|
||||
// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ.
|
||||
func CreatedAt(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldCreatedAt, v))
|
||||
}
|
||||
|
||||
// UpdatedAt applies equality check predicate on the "updated_at" field. It's identical to UpdatedAtEQ.
|
||||
func UpdatedAt(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldUpdatedAt, v))
|
||||
}
|
||||
|
||||
// Name applies equality check predicate on the "name" field. It's identical to NameEQ.
|
||||
func Name(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldName, v))
|
||||
}
|
||||
|
||||
// Description applies equality check predicate on the "description" field. It's identical to DescriptionEQ.
|
||||
func Description(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldDescription, v))
|
||||
}
|
||||
|
||||
// EnableGrease applies equality check predicate on the "enable_grease" field. It's identical to EnableGreaseEQ.
|
||||
func EnableGrease(v bool) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldEnableGrease, v))
|
||||
}
|
||||
|
||||
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
|
||||
func CreatedAtEQ(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldCreatedAt, v))
|
||||
}
|
||||
|
||||
// CreatedAtNEQ applies the NEQ predicate on the "created_at" field.
|
||||
func CreatedAtNEQ(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldCreatedAt, v))
|
||||
}
|
||||
|
||||
// CreatedAtIn applies the In predicate on the "created_at" field.
|
||||
func CreatedAtIn(vs ...time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldCreatedAt, vs...))
|
||||
}
|
||||
|
||||
// CreatedAtNotIn applies the NotIn predicate on the "created_at" field.
|
||||
func CreatedAtNotIn(vs ...time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldCreatedAt, vs...))
|
||||
}
|
||||
|
||||
// CreatedAtGT applies the GT predicate on the "created_at" field.
|
||||
func CreatedAtGT(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldCreatedAt, v))
|
||||
}
|
||||
|
||||
// CreatedAtGTE applies the GTE predicate on the "created_at" field.
|
||||
func CreatedAtGTE(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldCreatedAt, v))
|
||||
}
|
||||
|
||||
// CreatedAtLT applies the LT predicate on the "created_at" field.
|
||||
func CreatedAtLT(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldCreatedAt, v))
|
||||
}
|
||||
|
||||
// CreatedAtLTE applies the LTE predicate on the "created_at" field.
|
||||
func CreatedAtLTE(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldCreatedAt, v))
|
||||
}
|
||||
|
||||
// UpdatedAtEQ applies the EQ predicate on the "updated_at" field.
|
||||
func UpdatedAtEQ(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldUpdatedAt, v))
|
||||
}
|
||||
|
||||
// UpdatedAtNEQ applies the NEQ predicate on the "updated_at" field.
|
||||
func UpdatedAtNEQ(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldUpdatedAt, v))
|
||||
}
|
||||
|
||||
// UpdatedAtIn applies the In predicate on the "updated_at" field.
|
||||
func UpdatedAtIn(vs ...time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldUpdatedAt, vs...))
|
||||
}
|
||||
|
||||
// UpdatedAtNotIn applies the NotIn predicate on the "updated_at" field.
|
||||
func UpdatedAtNotIn(vs ...time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldUpdatedAt, vs...))
|
||||
}
|
||||
|
||||
// UpdatedAtGT applies the GT predicate on the "updated_at" field.
|
||||
func UpdatedAtGT(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldUpdatedAt, v))
|
||||
}
|
||||
|
||||
// UpdatedAtGTE applies the GTE predicate on the "updated_at" field.
|
||||
func UpdatedAtGTE(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldUpdatedAt, v))
|
||||
}
|
||||
|
||||
// UpdatedAtLT applies the LT predicate on the "updated_at" field.
|
||||
func UpdatedAtLT(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldUpdatedAt, v))
|
||||
}
|
||||
|
||||
// UpdatedAtLTE applies the LTE predicate on the "updated_at" field.
|
||||
func UpdatedAtLTE(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldUpdatedAt, v))
|
||||
}
|
||||
|
||||
// NameEQ applies the EQ predicate on the "name" field.
|
||||
func NameEQ(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldName, v))
|
||||
}
|
||||
|
||||
// NameNEQ applies the NEQ predicate on the "name" field.
|
||||
func NameNEQ(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldName, v))
|
||||
}
|
||||
|
||||
// NameIn applies the In predicate on the "name" field.
|
||||
func NameIn(vs ...string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldName, vs...))
|
||||
}
|
||||
|
||||
// NameNotIn applies the NotIn predicate on the "name" field.
|
||||
func NameNotIn(vs ...string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldName, vs...))
|
||||
}
|
||||
|
||||
// NameGT applies the GT predicate on the "name" field.
|
||||
func NameGT(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldName, v))
|
||||
}
|
||||
|
||||
// NameGTE applies the GTE predicate on the "name" field.
|
||||
func NameGTE(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldName, v))
|
||||
}
|
||||
|
||||
// NameLT applies the LT predicate on the "name" field.
|
||||
func NameLT(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldName, v))
|
||||
}
|
||||
|
||||
// NameLTE applies the LTE predicate on the "name" field.
|
||||
func NameLTE(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldName, v))
|
||||
}
|
||||
|
||||
// NameContains applies the Contains predicate on the "name" field.
|
||||
func NameContains(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldContains(FieldName, v))
|
||||
}
|
||||
|
||||
// NameHasPrefix applies the HasPrefix predicate on the "name" field.
|
||||
func NameHasPrefix(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldHasPrefix(FieldName, v))
|
||||
}
|
||||
|
||||
// NameHasSuffix applies the HasSuffix predicate on the "name" field.
|
||||
func NameHasSuffix(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldHasSuffix(FieldName, v))
|
||||
}
|
||||
|
||||
// NameEqualFold applies the EqualFold predicate on the "name" field.
|
||||
func NameEqualFold(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEqualFold(FieldName, v))
|
||||
}
|
||||
|
||||
// NameContainsFold applies the ContainsFold predicate on the "name" field.
|
||||
func NameContainsFold(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldContainsFold(FieldName, v))
|
||||
}
|
||||
|
||||
// DescriptionEQ applies the EQ predicate on the "description" field.
|
||||
func DescriptionEQ(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionNEQ applies the NEQ predicate on the "description" field.
|
||||
func DescriptionNEQ(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionIn applies the In predicate on the "description" field.
|
||||
func DescriptionIn(vs ...string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldDescription, vs...))
|
||||
}
|
||||
|
||||
// DescriptionNotIn applies the NotIn predicate on the "description" field.
|
||||
func DescriptionNotIn(vs ...string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldDescription, vs...))
|
||||
}
|
||||
|
||||
// DescriptionGT applies the GT predicate on the "description" field.
|
||||
func DescriptionGT(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionGTE applies the GTE predicate on the "description" field.
|
||||
func DescriptionGTE(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionLT applies the LT predicate on the "description" field.
|
||||
func DescriptionLT(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionLTE applies the LTE predicate on the "description" field.
|
||||
func DescriptionLTE(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionContains applies the Contains predicate on the "description" field.
|
||||
func DescriptionContains(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldContains(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionHasPrefix applies the HasPrefix predicate on the "description" field.
|
||||
func DescriptionHasPrefix(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldHasPrefix(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionHasSuffix applies the HasSuffix predicate on the "description" field.
|
||||
func DescriptionHasSuffix(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldHasSuffix(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionIsNil applies the IsNil predicate on the "description" field.
|
||||
func DescriptionIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldDescription))
|
||||
}
|
||||
|
||||
// DescriptionNotNil applies the NotNil predicate on the "description" field.
|
||||
func DescriptionNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldDescription))
|
||||
}
|
||||
|
||||
// DescriptionEqualFold applies the EqualFold predicate on the "description" field.
|
||||
func DescriptionEqualFold(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEqualFold(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionContainsFold applies the ContainsFold predicate on the "description" field.
|
||||
func DescriptionContainsFold(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldContainsFold(FieldDescription, v))
|
||||
}
|
||||
|
||||
// EnableGreaseEQ applies the EQ predicate on the "enable_grease" field.
|
||||
func EnableGreaseEQ(v bool) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldEnableGrease, v))
|
||||
}
|
||||
|
||||
// EnableGreaseNEQ applies the NEQ predicate on the "enable_grease" field.
|
||||
func EnableGreaseNEQ(v bool) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldEnableGrease, v))
|
||||
}
|
||||
|
||||
// CipherSuitesIsNil applies the IsNil predicate on the "cipher_suites" field.
|
||||
func CipherSuitesIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldCipherSuites))
|
||||
}
|
||||
|
||||
// CipherSuitesNotNil applies the NotNil predicate on the "cipher_suites" field.
|
||||
func CipherSuitesNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldCipherSuites))
|
||||
}
|
||||
|
||||
// CurvesIsNil applies the IsNil predicate on the "curves" field.
|
||||
func CurvesIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldCurves))
|
||||
}
|
||||
|
||||
// CurvesNotNil applies the NotNil predicate on the "curves" field.
|
||||
func CurvesNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldCurves))
|
||||
}
|
||||
|
||||
// PointFormatsIsNil applies the IsNil predicate on the "point_formats" field.
|
||||
func PointFormatsIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldPointFormats))
|
||||
}
|
||||
|
||||
// PointFormatsNotNil applies the NotNil predicate on the "point_formats" field.
|
||||
func PointFormatsNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldPointFormats))
|
||||
}
|
||||
|
||||
// SignatureAlgorithmsIsNil applies the IsNil predicate on the "signature_algorithms" field.
|
||||
func SignatureAlgorithmsIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldSignatureAlgorithms))
|
||||
}
|
||||
|
||||
// SignatureAlgorithmsNotNil applies the NotNil predicate on the "signature_algorithms" field.
|
||||
func SignatureAlgorithmsNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldSignatureAlgorithms))
|
||||
}
|
||||
|
||||
// AlpnProtocolsIsNil applies the IsNil predicate on the "alpn_protocols" field.
|
||||
func AlpnProtocolsIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldAlpnProtocols))
|
||||
}
|
||||
|
||||
// AlpnProtocolsNotNil applies the NotNil predicate on the "alpn_protocols" field.
|
||||
func AlpnProtocolsNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldAlpnProtocols))
|
||||
}
|
||||
|
||||
// SupportedVersionsIsNil applies the IsNil predicate on the "supported_versions" field.
|
||||
func SupportedVersionsIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldSupportedVersions))
|
||||
}
|
||||
|
||||
// SupportedVersionsNotNil applies the NotNil predicate on the "supported_versions" field.
|
||||
func SupportedVersionsNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldSupportedVersions))
|
||||
}
|
||||
|
||||
// KeyShareGroupsIsNil applies the IsNil predicate on the "key_share_groups" field.
|
||||
func KeyShareGroupsIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldKeyShareGroups))
|
||||
}
|
||||
|
||||
// KeyShareGroupsNotNil applies the NotNil predicate on the "key_share_groups" field.
|
||||
func KeyShareGroupsNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldKeyShareGroups))
|
||||
}
|
||||
|
||||
// PskModesIsNil applies the IsNil predicate on the "psk_modes" field.
|
||||
func PskModesIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldPskModes))
|
||||
}
|
||||
|
||||
// PskModesNotNil applies the NotNil predicate on the "psk_modes" field.
|
||||
func PskModesNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldPskModes))
|
||||
}
|
||||
|
||||
// ExtensionsIsNil applies the IsNil predicate on the "extensions" field.
|
||||
func ExtensionsIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldExtensions))
|
||||
}
|
||||
|
||||
// ExtensionsNotNil applies the NotNil predicate on the "extensions" field.
|
||||
func ExtensionsNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldExtensions))
|
||||
}
|
||||
|
||||
// And groups predicates with the AND operator between them.
|
||||
func And(predicates ...predicate.TLSFingerprintProfile) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.AndPredicates(predicates...))
|
||||
}
|
||||
|
||||
// Or groups predicates with the OR operator between them.
|
||||
func Or(predicates ...predicate.TLSFingerprintProfile) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.OrPredicates(predicates...))
|
||||
}
|
||||
|
||||
// Not applies the not operator on the given predicate.
|
||||
func Not(p predicate.TLSFingerprintProfile) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.NotPredicates(p))
|
||||
}
|
||||
1341
backend/ent/tlsfingerprintprofile_create.go
Normal file
1341
backend/ent/tlsfingerprintprofile_create.go
Normal file
File diff suppressed because it is too large
Load Diff
88
backend/ent/tlsfingerprintprofile_delete.go
Normal file
88
backend/ent/tlsfingerprintprofile_delete.go
Normal file
@ -0,0 +1,88 @@
|
||||
// Code generated by ent, DO NOT EDIT.
|
||||
|
||||
package ent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"entgo.io/ent/dialect/sql/sqlgraph"
|
||||
"entgo.io/ent/schema/field"
|
||||
"github.com/Wei-Shaw/sub2api/ent/predicate"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfileDelete is the builder for deleting a TLSFingerprintProfile entity.
|
||||
type TLSFingerprintProfileDelete struct {
|
||||
config
|
||||
hooks []Hook
|
||||
mutation *TLSFingerprintProfileMutation
|
||||
}
|
||||
|
||||
// Where appends a list predicates to the TLSFingerprintProfileDelete builder.
|
||||
func (_d *TLSFingerprintProfileDelete) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileDelete {
|
||||
_d.mutation.Where(ps...)
|
||||
return _d
|
||||
}
|
||||
|
||||
// Exec executes the deletion query and returns how many vertices were deleted.
|
||||
func (_d *TLSFingerprintProfileDelete) Exec(ctx context.Context) (int, error) {
|
||||
return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks)
|
||||
}
|
||||
|
||||
// ExecX is like Exec, but panics if an error occurs.
|
||||
func (_d *TLSFingerprintProfileDelete) ExecX(ctx context.Context) int {
|
||||
n, err := _d.Exec(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (_d *TLSFingerprintProfileDelete) sqlExec(ctx context.Context) (int, error) {
|
||||
_spec := sqlgraph.NewDeleteSpec(tlsfingerprintprofile.Table, sqlgraph.NewFieldSpec(tlsfingerprintprofile.FieldID, field.TypeInt64))
|
||||
if ps := _d.mutation.predicates; len(ps) > 0 {
|
||||
_spec.Predicate = func(selector *sql.Selector) {
|
||||
for i := range ps {
|
||||
ps[i](selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec)
|
||||
if err != nil && sqlgraph.IsConstraintError(err) {
|
||||
err = &ConstraintError{msg: err.Error(), wrap: err}
|
||||
}
|
||||
_d.mutation.done = true
|
||||
return affected, err
|
||||
}
|
||||
|
||||
// TLSFingerprintProfileDeleteOne is the builder for deleting a single TLSFingerprintProfile entity.
|
||||
type TLSFingerprintProfileDeleteOne struct {
|
||||
_d *TLSFingerprintProfileDelete
|
||||
}
|
||||
|
||||
// Where appends a list predicates to the TLSFingerprintProfileDelete builder.
|
||||
func (_d *TLSFingerprintProfileDeleteOne) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileDeleteOne {
|
||||
_d._d.mutation.Where(ps...)
|
||||
return _d
|
||||
}
|
||||
|
||||
// Exec executes the deletion query.
|
||||
func (_d *TLSFingerprintProfileDeleteOne) Exec(ctx context.Context) error {
|
||||
n, err := _d._d.Exec(ctx)
|
||||
switch {
|
||||
case err != nil:
|
||||
return err
|
||||
case n == 0:
|
||||
return &NotFoundError{tlsfingerprintprofile.Label}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ExecX is like Exec, but panics if an error occurs.
|
||||
func (_d *TLSFingerprintProfileDeleteOne) ExecX(ctx context.Context) {
|
||||
if err := _d.Exec(ctx); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
564
backend/ent/tlsfingerprintprofile_query.go
Normal file
564
backend/ent/tlsfingerprintprofile_query.go
Normal file
@ -0,0 +1,564 @@
|
||||
// Code generated by ent, DO NOT EDIT.
|
||||
|
||||
package ent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"entgo.io/ent/dialect/sql/sqlgraph"
|
||||
"entgo.io/ent/schema/field"
|
||||
"github.com/Wei-Shaw/sub2api/ent/predicate"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfileQuery is the builder for querying TLSFingerprintProfile entities.
|
||||
type TLSFingerprintProfileQuery struct {
|
||||
config
|
||||
ctx *QueryContext
|
||||
order []tlsfingerprintprofile.OrderOption
|
||||
inters []Interceptor
|
||||
predicates []predicate.TLSFingerprintProfile
|
||||
modifiers []func(*sql.Selector)
|
||||
// intermediate query (i.e. traversal path).
|
||||
sql *sql.Selector
|
||||
path func(context.Context) (*sql.Selector, error)
|
||||
}
|
||||
|
||||
// Where adds a new predicate for the TLSFingerprintProfileQuery builder.
|
||||
func (_q *TLSFingerprintProfileQuery) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileQuery {
|
||||
_q.predicates = append(_q.predicates, ps...)
|
||||
return _q
|
||||
}
|
||||
|
||||
// Limit the number of records to be returned by this query.
|
||||
func (_q *TLSFingerprintProfileQuery) Limit(limit int) *TLSFingerprintProfileQuery {
|
||||
_q.ctx.Limit = &limit
|
||||
return _q
|
||||
}
|
||||
|
||||
// Offset to start from.
|
||||
func (_q *TLSFingerprintProfileQuery) Offset(offset int) *TLSFingerprintProfileQuery {
|
||||
_q.ctx.Offset = &offset
|
||||
return _q
|
||||
}
|
||||
|
||||
// Unique configures the query builder to filter duplicate records on query.
|
||||
// By default, unique is set to true, and can be disabled using this method.
|
||||
func (_q *TLSFingerprintProfileQuery) Unique(unique bool) *TLSFingerprintProfileQuery {
|
||||
_q.ctx.Unique = &unique
|
||||
return _q
|
||||
}
|
||||
|
||||
// Order specifies how the records should be ordered.
|
||||
func (_q *TLSFingerprintProfileQuery) Order(o ...tlsfingerprintprofile.OrderOption) *TLSFingerprintProfileQuery {
|
||||
_q.order = append(_q.order, o...)
|
||||
return _q
|
||||
}
|
||||
|
||||
// First returns the first TLSFingerprintProfile entity from the query.
|
||||
// Returns a *NotFoundError when no TLSFingerprintProfile was found.
|
||||
func (_q *TLSFingerprintProfileQuery) First(ctx context.Context) (*TLSFingerprintProfile, error) {
|
||||
nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return nil, &NotFoundError{tlsfingerprintprofile.Label}
|
||||
}
|
||||
return nodes[0], nil
|
||||
}
|
||||
|
||||
// FirstX is like First, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) FirstX(ctx context.Context) *TLSFingerprintProfile {
|
||||
node, err := _q.First(ctx)
|
||||
if err != nil && !IsNotFound(err) {
|
||||
panic(err)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// FirstID returns the first TLSFingerprintProfile ID from the query.
|
||||
// Returns a *NotFoundError when no TLSFingerprintProfile ID was found.
|
||||
func (_q *TLSFingerprintProfileQuery) FirstID(ctx context.Context) (id int64, err error) {
|
||||
var ids []int64
|
||||
if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil {
|
||||
return
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
err = &NotFoundError{tlsfingerprintprofile.Label}
|
||||
return
|
||||
}
|
||||
return ids[0], nil
|
||||
}
|
||||
|
||||
// FirstIDX is like FirstID, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) FirstIDX(ctx context.Context) int64 {
|
||||
id, err := _q.FirstID(ctx)
|
||||
if err != nil && !IsNotFound(err) {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Only returns a single TLSFingerprintProfile entity found by the query, ensuring it only returns one.
|
||||
// Returns a *NotSingularError when more than one TLSFingerprintProfile entity is found.
|
||||
// Returns a *NotFoundError when no TLSFingerprintProfile entities are found.
|
||||
func (_q *TLSFingerprintProfileQuery) Only(ctx context.Context) (*TLSFingerprintProfile, error) {
|
||||
nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch len(nodes) {
|
||||
case 1:
|
||||
return nodes[0], nil
|
||||
case 0:
|
||||
return nil, &NotFoundError{tlsfingerprintprofile.Label}
|
||||
default:
|
||||
return nil, &NotSingularError{tlsfingerprintprofile.Label}
|
||||
}
|
||||
}
|
||||
|
||||
// OnlyX is like Only, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) OnlyX(ctx context.Context) *TLSFingerprintProfile {
|
||||
node, err := _q.Only(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// OnlyID is like Only, but returns the only TLSFingerprintProfile ID in the query.
|
||||
// Returns a *NotSingularError when more than one TLSFingerprintProfile ID is found.
|
||||
// Returns a *NotFoundError when no entities are found.
|
||||
func (_q *TLSFingerprintProfileQuery) OnlyID(ctx context.Context) (id int64, err error) {
|
||||
var ids []int64
|
||||
if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil {
|
||||
return
|
||||
}
|
||||
switch len(ids) {
|
||||
case 1:
|
||||
id = ids[0]
|
||||
case 0:
|
||||
err = &NotFoundError{tlsfingerprintprofile.Label}
|
||||
default:
|
||||
err = &NotSingularError{tlsfingerprintprofile.Label}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// OnlyIDX is like OnlyID, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) OnlyIDX(ctx context.Context) int64 {
|
||||
id, err := _q.OnlyID(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// All executes the query and returns a list of TLSFingerprintProfiles.
|
||||
func (_q *TLSFingerprintProfileQuery) All(ctx context.Context) ([]*TLSFingerprintProfile, error) {
|
||||
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll)
|
||||
if err := _q.prepareQuery(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qr := querierAll[[]*TLSFingerprintProfile, *TLSFingerprintProfileQuery]()
|
||||
return withInterceptors[[]*TLSFingerprintProfile](ctx, _q, qr, _q.inters)
|
||||
}
|
||||
|
||||
// AllX is like All, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) AllX(ctx context.Context) []*TLSFingerprintProfile {
|
||||
nodes, err := _q.All(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// IDs executes the query and returns a list of TLSFingerprintProfile IDs.
|
||||
func (_q *TLSFingerprintProfileQuery) IDs(ctx context.Context) (ids []int64, err error) {
|
||||
if _q.ctx.Unique == nil && _q.path != nil {
|
||||
_q.Unique(true)
|
||||
}
|
||||
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs)
|
||||
if err = _q.Select(tlsfingerprintprofile.FieldID).Scan(ctx, &ids); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// IDsX is like IDs, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) IDsX(ctx context.Context) []int64 {
|
||||
ids, err := _q.IDs(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// Count returns the count of the given query.
|
||||
func (_q *TLSFingerprintProfileQuery) Count(ctx context.Context) (int, error) {
|
||||
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount)
|
||||
if err := _q.prepareQuery(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return withInterceptors[int](ctx, _q, querierCount[*TLSFingerprintProfileQuery](), _q.inters)
|
||||
}
|
||||
|
||||
// CountX is like Count, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) CountX(ctx context.Context) int {
|
||||
count, err := _q.Count(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// Exist returns true if the query has elements in the graph.
|
||||
func (_q *TLSFingerprintProfileQuery) Exist(ctx context.Context) (bool, error) {
|
||||
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist)
|
||||
switch _, err := _q.FirstID(ctx); {
|
||||
case IsNotFound(err):
|
||||
return false, nil
|
||||
case err != nil:
|
||||
return false, fmt.Errorf("ent: check existence: %w", err)
|
||||
default:
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ExistX is like Exist, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) ExistX(ctx context.Context) bool {
|
||||
exist, err := _q.Exist(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return exist
|
||||
}
|
||||
|
||||
// Clone returns a duplicate of the TLSFingerprintProfileQuery builder, including all associated steps. It can be
|
||||
// used to prepare common query builders and use them differently after the clone is made.
|
||||
func (_q *TLSFingerprintProfileQuery) Clone() *TLSFingerprintProfileQuery {
|
||||
if _q == nil {
|
||||
return nil
|
||||
}
|
||||
return &TLSFingerprintProfileQuery{
|
||||
config: _q.config,
|
||||
ctx: _q.ctx.Clone(),
|
||||
order: append([]tlsfingerprintprofile.OrderOption{}, _q.order...),
|
||||
inters: append([]Interceptor{}, _q.inters...),
|
||||
predicates: append([]predicate.TLSFingerprintProfile{}, _q.predicates...),
|
||||
// clone intermediate query.
|
||||
sql: _q.sql.Clone(),
|
||||
path: _q.path,
|
||||
}
|
||||
}
|
||||
|
||||
// GroupBy is used to group vertices by one or more fields/columns.
|
||||
// It is often used with aggregate functions, like: count, max, mean, min, sum.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// var v []struct {
|
||||
// CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
// Count int `json:"count,omitempty"`
|
||||
// }
|
||||
//
|
||||
// client.TLSFingerprintProfile.Query().
|
||||
// GroupBy(tlsfingerprintprofile.FieldCreatedAt).
|
||||
// Aggregate(ent.Count()).
|
||||
// Scan(ctx, &v)
|
||||
func (_q *TLSFingerprintProfileQuery) GroupBy(field string, fields ...string) *TLSFingerprintProfileGroupBy {
|
||||
_q.ctx.Fields = append([]string{field}, fields...)
|
||||
grbuild := &TLSFingerprintProfileGroupBy{build: _q}
|
||||
grbuild.flds = &_q.ctx.Fields
|
||||
grbuild.label = tlsfingerprintprofile.Label
|
||||
grbuild.scan = grbuild.Scan
|
||||
return grbuild
|
||||
}
|
||||
|
||||
// Select allows the selection one or more fields/columns for the given query,
|
||||
// instead of selecting all fields in the entity.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// var v []struct {
|
||||
// CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
// }
|
||||
//
|
||||
// client.TLSFingerprintProfile.Query().
|
||||
// Select(tlsfingerprintprofile.FieldCreatedAt).
|
||||
// Scan(ctx, &v)
|
||||
func (_q *TLSFingerprintProfileQuery) Select(fields ...string) *TLSFingerprintProfileSelect {
|
||||
_q.ctx.Fields = append(_q.ctx.Fields, fields...)
|
||||
sbuild := &TLSFingerprintProfileSelect{TLSFingerprintProfileQuery: _q}
|
||||
sbuild.label = tlsfingerprintprofile.Label
|
||||
sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan
|
||||
return sbuild
|
||||
}
|
||||
|
||||
// Aggregate returns a TLSFingerprintProfileSelect configured with the given aggregations.
|
||||
func (_q *TLSFingerprintProfileQuery) Aggregate(fns ...AggregateFunc) *TLSFingerprintProfileSelect {
|
||||
return _q.Select().Aggregate(fns...)
|
||||
}
|
||||
|
||||
func (_q *TLSFingerprintProfileQuery) prepareQuery(ctx context.Context) error {
|
||||
for _, inter := range _q.inters {
|
||||
if inter == nil {
|
||||
return fmt.Errorf("ent: uninitialized interceptor (forgotten import ent/runtime?)")
|
||||
}
|
||||
if trv, ok := inter.(Traverser); ok {
|
||||
if err := trv.Traverse(ctx, _q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, f := range _q.ctx.Fields {
|
||||
if !tlsfingerprintprofile.ValidColumn(f) {
|
||||
return &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)}
|
||||
}
|
||||
}
|
||||
if _q.path != nil {
|
||||
prev, err := _q.path(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_q.sql = prev
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (_q *TLSFingerprintProfileQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*TLSFingerprintProfile, error) {
|
||||
var (
|
||||
nodes = []*TLSFingerprintProfile{}
|
||||
_spec = _q.querySpec()
|
||||
)
|
||||
_spec.ScanValues = func(columns []string) ([]any, error) {
|
||||
return (*TLSFingerprintProfile).scanValues(nil, columns)
|
||||
}
|
||||
_spec.Assign = func(columns []string, values []any) error {
|
||||
node := &TLSFingerprintProfile{config: _q.config}
|
||||
nodes = append(nodes, node)
|
||||
return node.assignValues(columns, values)
|
||||
}
|
||||
if len(_q.modifiers) > 0 {
|
||||
_spec.Modifiers = _q.modifiers
|
||||
}
|
||||
for i := range hooks {
|
||||
hooks[i](ctx, _spec)
|
||||
}
|
||||
if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return nodes, nil
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func (_q *TLSFingerprintProfileQuery) sqlCount(ctx context.Context) (int, error) {
|
||||
_spec := _q.querySpec()
|
||||
if len(_q.modifiers) > 0 {
|
||||
_spec.Modifiers = _q.modifiers
|
||||
}
|
||||
_spec.Node.Columns = _q.ctx.Fields
|
||||
if len(_q.ctx.Fields) > 0 {
|
||||
_spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique
|
||||
}
|
||||
return sqlgraph.CountNodes(ctx, _q.driver, _spec)
|
||||
}
|
||||
|
||||
func (_q *TLSFingerprintProfileQuery) querySpec() *sqlgraph.QuerySpec {
|
||||
_spec := sqlgraph.NewQuerySpec(tlsfingerprintprofile.Table, tlsfingerprintprofile.Columns, sqlgraph.NewFieldSpec(tlsfingerprintprofile.FieldID, field.TypeInt64))
|
||||
_spec.From = _q.sql
|
||||
if unique := _q.ctx.Unique; unique != nil {
|
||||
_spec.Unique = *unique
|
||||
} else if _q.path != nil {
|
||||
_spec.Unique = true
|
||||
}
|
||||
if fields := _q.ctx.Fields; len(fields) > 0 {
|
||||
_spec.Node.Columns = make([]string, 0, len(fields))
|
||||
_spec.Node.Columns = append(_spec.Node.Columns, tlsfingerprintprofile.FieldID)
|
||||
for i := range fields {
|
||||
if fields[i] != tlsfingerprintprofile.FieldID {
|
||||
_spec.Node.Columns = append(_spec.Node.Columns, fields[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
if ps := _q.predicates; len(ps) > 0 {
|
||||
_spec.Predicate = func(selector *sql.Selector) {
|
||||
for i := range ps {
|
||||
ps[i](selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
if limit := _q.ctx.Limit; limit != nil {
|
||||
_spec.Limit = *limit
|
||||
}
|
||||
if offset := _q.ctx.Offset; offset != nil {
|
||||
_spec.Offset = *offset
|
||||
}
|
||||
if ps := _q.order; len(ps) > 0 {
|
||||
_spec.Order = func(selector *sql.Selector) {
|
||||
for i := range ps {
|
||||
ps[i](selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
return _spec
|
||||
}
|
||||
|
||||
func (_q *TLSFingerprintProfileQuery) sqlQuery(ctx context.Context) *sql.Selector {
|
||||
builder := sql.Dialect(_q.driver.Dialect())
|
||||
t1 := builder.Table(tlsfingerprintprofile.Table)
|
||||
columns := _q.ctx.Fields
|
||||
if len(columns) == 0 {
|
||||
columns = tlsfingerprintprofile.Columns
|
||||
}
|
||||
selector := builder.Select(t1.Columns(columns...)...).From(t1)
|
||||
if _q.sql != nil {
|
||||
selector = _q.sql
|
||||
selector.Select(selector.Columns(columns...)...)
|
||||
}
|
||||
if _q.ctx.Unique != nil && *_q.ctx.Unique {
|
||||
selector.Distinct()
|
||||
}
|
||||
for _, m := range _q.modifiers {
|
||||
m(selector)
|
||||
}
|
||||
for _, p := range _q.predicates {
|
||||
p(selector)
|
||||
}
|
||||
for _, p := range _q.order {
|
||||
p(selector)
|
||||
}
|
||||
if offset := _q.ctx.Offset; offset != nil {
|
||||
// limit is mandatory for offset clause. We start
|
||||
// with default value, and override it below if needed.
|
||||
selector.Offset(*offset).Limit(math.MaxInt32)
|
||||
}
|
||||
if limit := _q.ctx.Limit; limit != nil {
|
||||
selector.Limit(*limit)
|
||||
}
|
||||
return selector
|
||||
}
|
||||
|
||||
// ForUpdate locks the selected rows against concurrent updates, and prevent them from being
|
||||
// updated, deleted or "selected ... for update" by other sessions, until the transaction is
|
||||
// either committed or rolled-back.
|
||||
func (_q *TLSFingerprintProfileQuery) ForUpdate(opts ...sql.LockOption) *TLSFingerprintProfileQuery {
|
||||
if _q.driver.Dialect() == dialect.Postgres {
|
||||
_q.Unique(false)
|
||||
}
|
||||
_q.modifiers = append(_q.modifiers, func(s *sql.Selector) {
|
||||
s.ForUpdate(opts...)
|
||||
})
|
||||
return _q
|
||||
}
|
||||
|
||||
// ForShare behaves similarly to ForUpdate, except that it acquires a shared mode lock
|
||||
// on any rows that are read. Other sessions can read the rows, but cannot modify them
|
||||
// until your transaction commits.
|
||||
func (_q *TLSFingerprintProfileQuery) ForShare(opts ...sql.LockOption) *TLSFingerprintProfileQuery {
|
||||
if _q.driver.Dialect() == dialect.Postgres {
|
||||
_q.Unique(false)
|
||||
}
|
||||
_q.modifiers = append(_q.modifiers, func(s *sql.Selector) {
|
||||
s.ForShare(opts...)
|
||||
})
|
||||
return _q
|
||||
}
|
||||
|
||||
// TLSFingerprintProfileGroupBy is the group-by builder for TLSFingerprintProfile entities.
|
||||
type TLSFingerprintProfileGroupBy struct {
|
||||
selector
|
||||
build *TLSFingerprintProfileQuery
|
||||
}
|
||||
|
||||
// Aggregate adds the given aggregation functions to the group-by query.
|
||||
func (_g *TLSFingerprintProfileGroupBy) Aggregate(fns ...AggregateFunc) *TLSFingerprintProfileGroupBy {
|
||||
_g.fns = append(_g.fns, fns...)
|
||||
return _g
|
||||
}
|
||||
|
||||
// Scan applies the selector query and scans the result into the given value.
|
||||
func (_g *TLSFingerprintProfileGroupBy) Scan(ctx context.Context, v any) error {
|
||||
ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy)
|
||||
if err := _g.build.prepareQuery(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return scanWithInterceptors[*TLSFingerprintProfileQuery, *TLSFingerprintProfileGroupBy](ctx, _g.build, _g, _g.build.inters, v)
|
||||
}
|
||||
|
||||
func (_g *TLSFingerprintProfileGroupBy) sqlScan(ctx context.Context, root *TLSFingerprintProfileQuery, v any) error {
|
||||
selector := root.sqlQuery(ctx).Select()
|
||||
aggregation := make([]string, 0, len(_g.fns))
|
||||
for _, fn := range _g.fns {
|
||||
aggregation = append(aggregation, fn(selector))
|
||||
}
|
||||
if len(selector.SelectedColumns()) == 0 {
|
||||
columns := make([]string, 0, len(*_g.flds)+len(_g.fns))
|
||||
for _, f := range *_g.flds {
|
||||
columns = append(columns, selector.C(f))
|
||||
}
|
||||
columns = append(columns, aggregation...)
|
||||
selector.Select(columns...)
|
||||
}
|
||||
selector.GroupBy(selector.Columns(*_g.flds...)...)
|
||||
if err := selector.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
rows := &sql.Rows{}
|
||||
query, args := selector.Query()
|
||||
if err := _g.build.driver.Query(ctx, query, args, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
return sql.ScanSlice(rows, v)
|
||||
}
|
||||
|
||||
// TLSFingerprintProfileSelect is the builder for selecting fields of TLSFingerprintProfile entities.
|
||||
type TLSFingerprintProfileSelect struct {
|
||||
*TLSFingerprintProfileQuery
|
||||
selector
|
||||
}
|
||||
|
||||
// Aggregate adds the given aggregation functions to the selector query.
|
||||
func (_s *TLSFingerprintProfileSelect) Aggregate(fns ...AggregateFunc) *TLSFingerprintProfileSelect {
|
||||
_s.fns = append(_s.fns, fns...)
|
||||
return _s
|
||||
}
|
||||
|
||||
// Scan applies the selector query and scans the result into the given value.
|
||||
func (_s *TLSFingerprintProfileSelect) Scan(ctx context.Context, v any) error {
|
||||
ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect)
|
||||
if err := _s.prepareQuery(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return scanWithInterceptors[*TLSFingerprintProfileQuery, *TLSFingerprintProfileSelect](ctx, _s.TLSFingerprintProfileQuery, _s, _s.inters, v)
|
||||
}
|
||||
|
||||
func (_s *TLSFingerprintProfileSelect) sqlScan(ctx context.Context, root *TLSFingerprintProfileQuery, v any) error {
|
||||
selector := root.sqlQuery(ctx)
|
||||
aggregation := make([]string, 0, len(_s.fns))
|
||||
for _, fn := range _s.fns {
|
||||
aggregation = append(aggregation, fn(selector))
|
||||
}
|
||||
switch n := len(*_s.selector.flds); {
|
||||
case n == 0 && len(aggregation) > 0:
|
||||
selector.Select(aggregation...)
|
||||
case n != 0 && len(aggregation) > 0:
|
||||
selector.AppendSelect(aggregation...)
|
||||
}
|
||||
rows := &sql.Rows{}
|
||||
query, args := selector.Query()
|
||||
if err := _s.driver.Query(ctx, query, args, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
return sql.ScanSlice(rows, v)
|
||||
}
|
||||
881
backend/ent/tlsfingerprintprofile_update.go
Normal file
881
backend/ent/tlsfingerprintprofile_update.go
Normal file
@ -0,0 +1,881 @@
|
||||
// Code generated by ent, DO NOT EDIT.
|
||||
|
||||
package ent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"entgo.io/ent/dialect/sql/sqlgraph"
|
||||
"entgo.io/ent/dialect/sql/sqljson"
|
||||
"entgo.io/ent/schema/field"
|
||||
"github.com/Wei-Shaw/sub2api/ent/predicate"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfileUpdate is the builder for updating TLSFingerprintProfile entities.
|
||||
type TLSFingerprintProfileUpdate struct {
|
||||
config
|
||||
hooks []Hook
|
||||
mutation *TLSFingerprintProfileMutation
|
||||
}
|
||||
|
||||
// Where appends a list predicates to the TLSFingerprintProfileUpdate builder.
|
||||
func (_u *TLSFingerprintProfileUpdate) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.Where(ps...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetUpdatedAt sets the "updated_at" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetUpdatedAt(v time.Time) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetUpdatedAt(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetName sets the "name" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetName(v string) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetName(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableName sets the "name" field if the given value is not nil.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetNillableName(v *string) *TLSFingerprintProfileUpdate {
|
||||
if v != nil {
|
||||
_u.SetName(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetDescription sets the "description" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetDescription(v string) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetDescription(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableDescription sets the "description" field if the given value is not nil.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetNillableDescription(v *string) *TLSFingerprintProfileUpdate {
|
||||
if v != nil {
|
||||
_u.SetDescription(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearDescription clears the value of the "description" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearDescription() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearDescription()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetEnableGrease sets the "enable_grease" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetEnableGrease(v bool) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetEnableGrease(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableEnableGrease sets the "enable_grease" field if the given value is not nil.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetNillableEnableGrease(v *bool) *TLSFingerprintProfileUpdate {
|
||||
if v != nil {
|
||||
_u.SetEnableGrease(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCipherSuites sets the "cipher_suites" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetCipherSuites(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetCipherSuites(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendCipherSuites appends value to the "cipher_suites" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendCipherSuites(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendCipherSuites(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearCipherSuites clears the value of the "cipher_suites" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearCipherSuites() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearCipherSuites()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCurves sets the "curves" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetCurves(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetCurves(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendCurves appends value to the "curves" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendCurves(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendCurves(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearCurves clears the value of the "curves" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearCurves() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearCurves()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPointFormats sets the "point_formats" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetPointFormats(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetPointFormats(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendPointFormats appends value to the "point_formats" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendPointFormats(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendPointFormats(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearPointFormats clears the value of the "point_formats" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearPointFormats() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearPointFormats()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetSignatureAlgorithms sets the "signature_algorithms" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetSignatureAlgorithms(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetSignatureAlgorithms(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendSignatureAlgorithms appends value to the "signature_algorithms" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendSignatureAlgorithms(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendSignatureAlgorithms(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearSignatureAlgorithms clears the value of the "signature_algorithms" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearSignatureAlgorithms() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearSignatureAlgorithms()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetAlpnProtocols sets the "alpn_protocols" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetAlpnProtocols(v []string) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetAlpnProtocols(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendAlpnProtocols appends value to the "alpn_protocols" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendAlpnProtocols(v []string) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendAlpnProtocols(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearAlpnProtocols clears the value of the "alpn_protocols" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearAlpnProtocols() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearAlpnProtocols()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetSupportedVersions sets the "supported_versions" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetSupportedVersions(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetSupportedVersions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendSupportedVersions appends value to the "supported_versions" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendSupportedVersions(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendSupportedVersions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearSupportedVersions clears the value of the "supported_versions" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearSupportedVersions() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearSupportedVersions()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetKeyShareGroups sets the "key_share_groups" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetKeyShareGroups(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetKeyShareGroups(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendKeyShareGroups appends value to the "key_share_groups" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendKeyShareGroups(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendKeyShareGroups(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearKeyShareGroups clears the value of the "key_share_groups" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearKeyShareGroups() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearKeyShareGroups()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPskModes sets the "psk_modes" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetPskModes(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetPskModes(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendPskModes appends value to the "psk_modes" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendPskModes(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendPskModes(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearPskModes clears the value of the "psk_modes" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearPskModes() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearPskModes()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetExtensions sets the "extensions" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetExtensions(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetExtensions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendExtensions appends value to the "extensions" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendExtensions(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendExtensions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearExtensions clears the value of the "extensions" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearExtensions() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearExtensions()
|
||||
return _u
|
||||
}
|
||||
|
||||
// Mutation returns the TLSFingerprintProfileMutation object of the builder.
|
||||
func (_u *TLSFingerprintProfileUpdate) Mutation() *TLSFingerprintProfileMutation {
|
||||
return _u.mutation
|
||||
}
|
||||
|
||||
// Save executes the query and returns the number of nodes affected by the update operation.
|
||||
func (_u *TLSFingerprintProfileUpdate) Save(ctx context.Context) (int, error) {
|
||||
_u.defaults()
|
||||
return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks)
|
||||
}
|
||||
|
||||
// SaveX is like Save, but panics if an error occurs.
|
||||
func (_u *TLSFingerprintProfileUpdate) SaveX(ctx context.Context) int {
|
||||
affected, err := _u.Save(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return affected
|
||||
}
|
||||
|
||||
// Exec executes the query.
|
||||
func (_u *TLSFingerprintProfileUpdate) Exec(ctx context.Context) error {
|
||||
_, err := _u.Save(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExecX is like Exec, but panics if an error occurs.
|
||||
func (_u *TLSFingerprintProfileUpdate) ExecX(ctx context.Context) {
|
||||
if err := _u.Exec(ctx); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// defaults sets the default values of the builder before save.
|
||||
func (_u *TLSFingerprintProfileUpdate) defaults() {
|
||||
if _, ok := _u.mutation.UpdatedAt(); !ok {
|
||||
v := tlsfingerprintprofile.UpdateDefaultUpdatedAt()
|
||||
_u.mutation.SetUpdatedAt(v)
|
||||
}
|
||||
}
|
||||
|
||||
// check runs all checks and user-defined validators on the builder.
|
||||
func (_u *TLSFingerprintProfileUpdate) check() error {
|
||||
if v, ok := _u.mutation.Name(); ok {
|
||||
if err := tlsfingerprintprofile.NameValidator(v); err != nil {
|
||||
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "TLSFingerprintProfile.name": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (_u *TLSFingerprintProfileUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if err := _u.check(); err != nil {
|
||||
return _node, err
|
||||
}
|
||||
_spec := sqlgraph.NewUpdateSpec(tlsfingerprintprofile.Table, tlsfingerprintprofile.Columns, sqlgraph.NewFieldSpec(tlsfingerprintprofile.FieldID, field.TypeInt64))
|
||||
if ps := _u.mutation.predicates; len(ps) > 0 {
|
||||
_spec.Predicate = func(selector *sql.Selector) {
|
||||
for i := range ps {
|
||||
ps[i](selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
if value, ok := _u.mutation.UpdatedAt(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldUpdatedAt, field.TypeTime, value)
|
||||
}
|
||||
if value, ok := _u.mutation.Name(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldName, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.Description(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldDescription, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.DescriptionCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldDescription, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.EnableGrease(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldEnableGrease, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.CipherSuites(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldCipherSuites, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedCipherSuites(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldCipherSuites, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.CipherSuitesCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldCipherSuites, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.Curves(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldCurves, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedCurves(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldCurves, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.CurvesCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldCurves, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.PointFormats(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldPointFormats, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedPointFormats(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldPointFormats, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.PointFormatsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldPointFormats, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.SignatureAlgorithms(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldSignatureAlgorithms, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedSignatureAlgorithms(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldSignatureAlgorithms, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.SignatureAlgorithmsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldSignatureAlgorithms, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.AlpnProtocols(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldAlpnProtocols, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedAlpnProtocols(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldAlpnProtocols, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.AlpnProtocolsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldAlpnProtocols, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.SupportedVersions(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldSupportedVersions, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedSupportedVersions(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldSupportedVersions, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.SupportedVersionsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldSupportedVersions, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.KeyShareGroups(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldKeyShareGroups, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedKeyShareGroups(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldKeyShareGroups, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.KeyShareGroupsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldKeyShareGroups, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.PskModes(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldPskModes, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedPskModes(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldPskModes, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.PskModesCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldPskModes, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.Extensions(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldExtensions, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedExtensions(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldExtensions, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.ExtensionsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldExtensions, field.TypeJSON)
|
||||
}
|
||||
if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil {
|
||||
if _, ok := err.(*sqlgraph.NotFoundError); ok {
|
||||
err = &NotFoundError{tlsfingerprintprofile.Label}
|
||||
} else if sqlgraph.IsConstraintError(err) {
|
||||
err = &ConstraintError{msg: err.Error(), wrap: err}
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
_u.mutation.done = true
|
||||
return _node, nil
|
||||
}
|
||||
|
||||
// TLSFingerprintProfileUpdateOne is the builder for updating a single TLSFingerprintProfile entity.
|
||||
type TLSFingerprintProfileUpdateOne struct {
|
||||
config
|
||||
fields []string
|
||||
hooks []Hook
|
||||
mutation *TLSFingerprintProfileMutation
|
||||
}
|
||||
|
||||
// SetUpdatedAt sets the "updated_at" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetUpdatedAt(v time.Time) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetUpdatedAt(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetName sets the "name" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetName(v string) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetName(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableName sets the "name" field if the given value is not nil.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetNillableName(v *string) *TLSFingerprintProfileUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetName(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetDescription sets the "description" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetDescription(v string) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetDescription(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableDescription sets the "description" field if the given value is not nil.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetNillableDescription(v *string) *TLSFingerprintProfileUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetDescription(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearDescription clears the value of the "description" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearDescription() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearDescription()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetEnableGrease sets the "enable_grease" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetEnableGrease(v bool) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetEnableGrease(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableEnableGrease sets the "enable_grease" field if the given value is not nil.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetNillableEnableGrease(v *bool) *TLSFingerprintProfileUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetEnableGrease(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCipherSuites sets the "cipher_suites" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetCipherSuites(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetCipherSuites(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendCipherSuites appends value to the "cipher_suites" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendCipherSuites(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendCipherSuites(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearCipherSuites clears the value of the "cipher_suites" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearCipherSuites() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearCipherSuites()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCurves sets the "curves" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetCurves(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetCurves(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendCurves appends value to the "curves" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendCurves(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendCurves(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearCurves clears the value of the "curves" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearCurves() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearCurves()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPointFormats sets the "point_formats" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetPointFormats(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetPointFormats(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendPointFormats appends value to the "point_formats" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendPointFormats(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendPointFormats(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearPointFormats clears the value of the "point_formats" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearPointFormats() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearPointFormats()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetSignatureAlgorithms sets the "signature_algorithms" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetSignatureAlgorithms(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetSignatureAlgorithms(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendSignatureAlgorithms appends value to the "signature_algorithms" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendSignatureAlgorithms(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendSignatureAlgorithms(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearSignatureAlgorithms clears the value of the "signature_algorithms" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearSignatureAlgorithms() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearSignatureAlgorithms()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetAlpnProtocols sets the "alpn_protocols" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetAlpnProtocols(v []string) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetAlpnProtocols(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendAlpnProtocols appends value to the "alpn_protocols" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendAlpnProtocols(v []string) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendAlpnProtocols(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearAlpnProtocols clears the value of the "alpn_protocols" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearAlpnProtocols() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearAlpnProtocols()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetSupportedVersions sets the "supported_versions" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetSupportedVersions(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetSupportedVersions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendSupportedVersions appends value to the "supported_versions" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendSupportedVersions(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendSupportedVersions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearSupportedVersions clears the value of the "supported_versions" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearSupportedVersions() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearSupportedVersions()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetKeyShareGroups sets the "key_share_groups" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetKeyShareGroups(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetKeyShareGroups(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendKeyShareGroups appends value to the "key_share_groups" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendKeyShareGroups(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendKeyShareGroups(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearKeyShareGroups clears the value of the "key_share_groups" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearKeyShareGroups() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearKeyShareGroups()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPskModes sets the "psk_modes" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetPskModes(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetPskModes(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendPskModes appends value to the "psk_modes" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendPskModes(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendPskModes(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearPskModes clears the value of the "psk_modes" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearPskModes() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearPskModes()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetExtensions sets the "extensions" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetExtensions(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetExtensions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendExtensions appends value to the "extensions" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendExtensions(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendExtensions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearExtensions clears the value of the "extensions" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearExtensions() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearExtensions()
|
||||
return _u
|
||||
}
|
||||
|
||||
// Mutation returns the TLSFingerprintProfileMutation object of the builder.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) Mutation() *TLSFingerprintProfileMutation {
|
||||
return _u.mutation
|
||||
}
|
||||
|
||||
// Where appends a list predicates to the TLSFingerprintProfileUpdate builder.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.Where(ps...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// Select allows selecting one or more fields (columns) of the returned entity.
|
||||
// The default is selecting all fields defined in the entity schema.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) Select(field string, fields ...string) *TLSFingerprintProfileUpdateOne {
|
||||
_u.fields = append([]string{field}, fields...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// Save executes the query and returns the updated TLSFingerprintProfile entity.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) Save(ctx context.Context) (*TLSFingerprintProfile, error) {
|
||||
_u.defaults()
|
||||
return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks)
|
||||
}
|
||||
|
||||
// SaveX is like Save, but panics if an error occurs.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SaveX(ctx context.Context) *TLSFingerprintProfile {
|
||||
node, err := _u.Save(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// Exec executes the query on the entity.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) Exec(ctx context.Context) error {
|
||||
_, err := _u.Save(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExecX is like Exec, but panics if an error occurs.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ExecX(ctx context.Context) {
|
||||
if err := _u.Exec(ctx); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// defaults sets the default values of the builder before save.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) defaults() {
|
||||
if _, ok := _u.mutation.UpdatedAt(); !ok {
|
||||
v := tlsfingerprintprofile.UpdateDefaultUpdatedAt()
|
||||
_u.mutation.SetUpdatedAt(v)
|
||||
}
|
||||
}
|
||||
|
||||
// check runs all checks and user-defined validators on the builder.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) check() error {
|
||||
if v, ok := _u.mutation.Name(); ok {
|
||||
if err := tlsfingerprintprofile.NameValidator(v); err != nil {
|
||||
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "TLSFingerprintProfile.name": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (_u *TLSFingerprintProfileUpdateOne) sqlSave(ctx context.Context) (_node *TLSFingerprintProfile, err error) {
|
||||
if err := _u.check(); err != nil {
|
||||
return _node, err
|
||||
}
|
||||
_spec := sqlgraph.NewUpdateSpec(tlsfingerprintprofile.Table, tlsfingerprintprofile.Columns, sqlgraph.NewFieldSpec(tlsfingerprintprofile.FieldID, field.TypeInt64))
|
||||
id, ok := _u.mutation.ID()
|
||||
if !ok {
|
||||
return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "TLSFingerprintProfile.id" for update`)}
|
||||
}
|
||||
_spec.Node.ID.Value = id
|
||||
if fields := _u.fields; len(fields) > 0 {
|
||||
_spec.Node.Columns = make([]string, 0, len(fields))
|
||||
_spec.Node.Columns = append(_spec.Node.Columns, tlsfingerprintprofile.FieldID)
|
||||
for _, f := range fields {
|
||||
if !tlsfingerprintprofile.ValidColumn(f) {
|
||||
return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)}
|
||||
}
|
||||
if f != tlsfingerprintprofile.FieldID {
|
||||
_spec.Node.Columns = append(_spec.Node.Columns, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ps := _u.mutation.predicates; len(ps) > 0 {
|
||||
_spec.Predicate = func(selector *sql.Selector) {
|
||||
for i := range ps {
|
||||
ps[i](selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
if value, ok := _u.mutation.UpdatedAt(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldUpdatedAt, field.TypeTime, value)
|
||||
}
|
||||
if value, ok := _u.mutation.Name(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldName, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.Description(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldDescription, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.DescriptionCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldDescription, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.EnableGrease(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldEnableGrease, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.CipherSuites(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldCipherSuites, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedCipherSuites(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldCipherSuites, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.CipherSuitesCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldCipherSuites, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.Curves(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldCurves, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedCurves(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldCurves, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.CurvesCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldCurves, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.PointFormats(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldPointFormats, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedPointFormats(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldPointFormats, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.PointFormatsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldPointFormats, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.SignatureAlgorithms(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldSignatureAlgorithms, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedSignatureAlgorithms(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldSignatureAlgorithms, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.SignatureAlgorithmsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldSignatureAlgorithms, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.AlpnProtocols(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldAlpnProtocols, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedAlpnProtocols(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldAlpnProtocols, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.AlpnProtocolsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldAlpnProtocols, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.SupportedVersions(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldSupportedVersions, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedSupportedVersions(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldSupportedVersions, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.SupportedVersionsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldSupportedVersions, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.KeyShareGroups(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldKeyShareGroups, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedKeyShareGroups(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldKeyShareGroups, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.KeyShareGroupsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldKeyShareGroups, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.PskModes(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldPskModes, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedPskModes(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldPskModes, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.PskModesCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldPskModes, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.Extensions(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldExtensions, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedExtensions(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldExtensions, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.ExtensionsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldExtensions, field.TypeJSON)
|
||||
}
|
||||
_node = &TLSFingerprintProfile{config: _u.config}
|
||||
_spec.Assign = _node.assignValues
|
||||
_spec.ScanValues = _node.scanValues
|
||||
if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil {
|
||||
if _, ok := err.(*sqlgraph.NotFoundError); ok {
|
||||
err = &NotFoundError{tlsfingerprintprofile.Label}
|
||||
} else if sqlgraph.IsConstraintError(err) {
|
||||
err = &ConstraintError{msg: err.Error(), wrap: err}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
_u.mutation.done = true
|
||||
return _node, nil
|
||||
}
|
||||
@ -42,6 +42,8 @@ type Tx struct {
|
||||
SecuritySecret *SecuritySecretClient
|
||||
// Setting is the client for interacting with the Setting builders.
|
||||
Setting *SettingClient
|
||||
// TLSFingerprintProfile is the client for interacting with the TLSFingerprintProfile builders.
|
||||
TLSFingerprintProfile *TLSFingerprintProfileClient
|
||||
// UsageCleanupTask is the client for interacting with the UsageCleanupTask builders.
|
||||
UsageCleanupTask *UsageCleanupTaskClient
|
||||
// UsageLog is the client for interacting with the UsageLog builders.
|
||||
@ -201,6 +203,7 @@ func (tx *Tx) init() {
|
||||
tx.RedeemCode = NewRedeemCodeClient(tx.config)
|
||||
tx.SecuritySecret = NewSecuritySecretClient(tx.config)
|
||||
tx.Setting = NewSettingClient(tx.config)
|
||||
tx.TLSFingerprintProfile = NewTLSFingerprintProfileClient(tx.config)
|
||||
tx.UsageCleanupTask = NewUsageCleanupTaskClient(tx.config)
|
||||
tx.UsageLog = NewUsageLogClient(tx.config)
|
||||
tx.User = NewUserClient(tx.config)
|
||||
|
||||
@ -656,17 +656,33 @@ type TLSFingerprintConfig struct {
|
||||
}
|
||||
|
||||
// TLSProfileConfig 单个TLS指纹模板的配置
|
||||
// 所有列表字段为空时使用内置默认值(Claude CLI 2.x / Node.js 20.x)
|
||||
// 建议通过 TLS 指纹采集工具 (tests/tls-fingerprint-web) 获取完整配置
|
||||
type TLSProfileConfig struct {
|
||||
// Name: 模板显示名称
|
||||
Name string `mapstructure:"name"`
|
||||
// EnableGREASE: 是否启用GREASE扩展(Chrome使用,Node.js不使用)
|
||||
EnableGREASE bool `mapstructure:"enable_grease"`
|
||||
// CipherSuites: TLS加密套件列表(空则使用内置默认值)
|
||||
// CipherSuites: TLS加密套件列表
|
||||
CipherSuites []uint16 `mapstructure:"cipher_suites"`
|
||||
// Curves: 椭圆曲线列表(空则使用内置默认值)
|
||||
// Curves: 椭圆曲线列表
|
||||
Curves []uint16 `mapstructure:"curves"`
|
||||
// PointFormats: 点格式列表(空则使用内置默认值)
|
||||
PointFormats []uint8 `mapstructure:"point_formats"`
|
||||
// PointFormats: 点格式列表
|
||||
PointFormats []uint16 `mapstructure:"point_formats"`
|
||||
// SignatureAlgorithms: 签名算法列表
|
||||
SignatureAlgorithms []uint16 `mapstructure:"signature_algorithms"`
|
||||
// ALPNProtocols: ALPN协议列表(如 ["h2", "http/1.1"])
|
||||
ALPNProtocols []string `mapstructure:"alpn_protocols"`
|
||||
// SupportedVersions: 支持的TLS版本列表(如 [0x0304, 0x0303] 即 TLS1.3, TLS1.2)
|
||||
SupportedVersions []uint16 `mapstructure:"supported_versions"`
|
||||
// KeyShareGroups: Key Share中发送的曲线组(如 [29] 即 X25519)
|
||||
KeyShareGroups []uint16 `mapstructure:"key_share_groups"`
|
||||
// PSKModes: PSK密钥交换模式(如 [1] 即 psk_dhe_ke)
|
||||
PSKModes []uint16 `mapstructure:"psk_modes"`
|
||||
// Extensions: TLS扩展类型ID列表,按发送顺序排列
|
||||
// 空则使用内置默认顺序 [0,11,10,35,16,22,23,13,43,45,51]
|
||||
// GREASE值(如0x0a0a)会自动插入GREASE扩展
|
||||
Extensions []uint16 `mapstructure:"extensions"`
|
||||
}
|
||||
|
||||
// GatewaySchedulingConfig accounts scheduling configuration.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -536,6 +537,10 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
if execErr != nil {
|
||||
return nil, execErr
|
||||
}
|
||||
// Antigravity OAuth: 新账号直接设置隐私
|
||||
h.adminService.ForceAntigravityPrivacy(ctx, account)
|
||||
// OpenAI OAuth: 新账号直接设置隐私
|
||||
h.adminService.ForceOpenAIPrivacy(ctx, account)
|
||||
return h.buildAccountResponseWithRuntime(ctx, account), nil
|
||||
})
|
||||
if err != nil {
|
||||
@ -782,6 +787,8 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
|
||||
if account.IsOpenAI() {
|
||||
tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(ctx, account)
|
||||
if err != nil {
|
||||
// 刷新失败但 access_token 可能仍有效,尝试设置隐私
|
||||
h.adminService.EnsureOpenAIPrivacy(ctx, account)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@ -883,6 +890,8 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
|
||||
|
||||
// OpenAI OAuth: 刷新成功后检查并设置 privacy_mode
|
||||
h.adminService.EnsureOpenAIPrivacy(ctx, updatedAccount)
|
||||
// Antigravity OAuth: 刷新成功后检查并设置 privacy_mode
|
||||
h.adminService.EnsureAntigravityPrivacy(ctx, updatedAccount)
|
||||
|
||||
return updatedAccount, "", nil
|
||||
}
|
||||
@ -1154,6 +1163,9 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
success := 0
|
||||
failed := 0
|
||||
results := make([]gin.H, 0, len(req.Accounts))
|
||||
// 收集需要异步设置隐私的 OAuth 账号
|
||||
var antigravityPrivacyAccounts []*service.Account
|
||||
var openaiPrivacyAccounts []*service.Account
|
||||
|
||||
for _, item := range req.Accounts {
|
||||
if item.RateMultiplier != nil && *item.RateMultiplier < 0 {
|
||||
@ -1196,6 +1208,15 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
})
|
||||
continue
|
||||
}
|
||||
// 收集需要异步设置隐私的 OAuth 账号
|
||||
if account.Type == service.AccountTypeOAuth {
|
||||
switch account.Platform {
|
||||
case service.PlatformAntigravity:
|
||||
antigravityPrivacyAccounts = append(antigravityPrivacyAccounts, account)
|
||||
case service.PlatformOpenAI:
|
||||
openaiPrivacyAccounts = append(openaiPrivacyAccounts, account)
|
||||
}
|
||||
}
|
||||
success++
|
||||
results = append(results, gin.H{
|
||||
"name": item.Name,
|
||||
@ -1204,6 +1225,37 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// 异步设置隐私,避免批量创建时阻塞请求
|
||||
adminSvc := h.adminService
|
||||
if len(antigravityPrivacyAccounts) > 0 {
|
||||
accounts := antigravityPrivacyAccounts
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("batch_create_antigravity_privacy_panic", "recover", r)
|
||||
}
|
||||
}()
|
||||
bgCtx := context.Background()
|
||||
for _, acc := range accounts {
|
||||
adminSvc.ForceAntigravityPrivacy(bgCtx, acc)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if len(openaiPrivacyAccounts) > 0 {
|
||||
accounts := openaiPrivacyAccounts
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("batch_create_openai_privacy_panic", "recover", r)
|
||||
}
|
||||
}()
|
||||
bgCtx := context.Background()
|
||||
for _, acc := range accounts {
|
||||
adminSvc.ForceOpenAIPrivacy(bgCtx, acc)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
@ -1869,6 +1921,51 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
||||
response.Success(c, models)
|
||||
}
|
||||
|
||||
// SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account
|
||||
// POST /api/v1/admin/accounts/:id/set-privacy
|
||||
func (h *AccountHandler) SetPrivacy(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.NotFound(c, "Account not found")
|
||||
return
|
||||
}
|
||||
if account.Type != service.AccountTypeOAuth {
|
||||
response.BadRequest(c, "Only OAuth accounts support privacy setting")
|
||||
return
|
||||
}
|
||||
var mode string
|
||||
switch account.Platform {
|
||||
case service.PlatformOpenAI:
|
||||
mode = h.adminService.ForceOpenAIPrivacy(c.Request.Context(), account)
|
||||
case service.PlatformAntigravity:
|
||||
mode = h.adminService.ForceAntigravityPrivacy(c.Request.Context(), account)
|
||||
default:
|
||||
response.BadRequest(c, "Only OpenAI and Antigravity OAuth accounts support privacy setting")
|
||||
return
|
||||
}
|
||||
if mode == "" {
|
||||
response.BadRequest(c, "Cannot set privacy: missing access_token")
|
||||
return
|
||||
}
|
||||
// 从 DB 重新读取以确保返回最新状态
|
||||
updated, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
// 隐私已设置成功但读取失败,回退到内存更新
|
||||
if account.Extra == nil {
|
||||
account.Extra = make(map[string]any)
|
||||
}
|
||||
account.Extra["privacy_mode"] = mode
|
||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||
return
|
||||
}
|
||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updated))
|
||||
}
|
||||
|
||||
// RefreshTier handles refreshing Google One tier for a single account
|
||||
// POST /api/v1/admin/accounts/:id/refresh-tier
|
||||
func (h *AccountHandler) RefreshTier(c *gin.Context) {
|
||||
|
||||
@ -445,6 +445,18 @@ func (s *stubAdminService) EnsureOpenAIPrivacy(ctx context.Context, account *ser
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *stubAdminService) EnsureAntigravityPrivacy(ctx context.Context, account *service.Account) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ForceOpenAIPrivacy(ctx context.Context, account *service.Account) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ForceAntigravityPrivacy(ctx context.Context, account *service.Account) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*service.ReplaceUserGroupResult, error) {
|
||||
return &service.ReplaceUserGroupResult{MigratedKeys: 0}, nil
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,234 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfileHandler 处理 TLS 指纹模板的 HTTP 请求
|
||||
type TLSFingerprintProfileHandler struct {
|
||||
service *service.TLSFingerprintProfileService
|
||||
}
|
||||
|
||||
// NewTLSFingerprintProfileHandler 创建 TLS 指纹模板处理器
|
||||
func NewTLSFingerprintProfileHandler(service *service.TLSFingerprintProfileService) *TLSFingerprintProfileHandler {
|
||||
return &TLSFingerprintProfileHandler{service: service}
|
||||
}
|
||||
|
||||
// CreateTLSFingerprintProfileRequest 创建模板请求
|
||||
type CreateTLSFingerprintProfileRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description *string `json:"description"`
|
||||
EnableGREASE *bool `json:"enable_grease"`
|
||||
CipherSuites []uint16 `json:"cipher_suites"`
|
||||
Curves []uint16 `json:"curves"`
|
||||
PointFormats []uint16 `json:"point_formats"`
|
||||
SignatureAlgorithms []uint16 `json:"signature_algorithms"`
|
||||
ALPNProtocols []string `json:"alpn_protocols"`
|
||||
SupportedVersions []uint16 `json:"supported_versions"`
|
||||
KeyShareGroups []uint16 `json:"key_share_groups"`
|
||||
PSKModes []uint16 `json:"psk_modes"`
|
||||
Extensions []uint16 `json:"extensions"`
|
||||
}
|
||||
|
||||
// UpdateTLSFingerprintProfileRequest 更新模板请求(部分更新)
|
||||
type UpdateTLSFingerprintProfileRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
EnableGREASE *bool `json:"enable_grease"`
|
||||
CipherSuites []uint16 `json:"cipher_suites"`
|
||||
Curves []uint16 `json:"curves"`
|
||||
PointFormats []uint16 `json:"point_formats"`
|
||||
SignatureAlgorithms []uint16 `json:"signature_algorithms"`
|
||||
ALPNProtocols []string `json:"alpn_protocols"`
|
||||
SupportedVersions []uint16 `json:"supported_versions"`
|
||||
KeyShareGroups []uint16 `json:"key_share_groups"`
|
||||
PSKModes []uint16 `json:"psk_modes"`
|
||||
Extensions []uint16 `json:"extensions"`
|
||||
}
|
||||
|
||||
// List 获取所有模板
|
||||
// GET /api/v1/admin/tls-fingerprint-profiles
|
||||
func (h *TLSFingerprintProfileHandler) List(c *gin.Context) {
|
||||
profiles, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, profiles)
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取模板
|
||||
// GET /api/v1/admin/tls-fingerprint-profiles/:id
|
||||
func (h *TLSFingerprintProfileHandler) GetByID(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid profile ID")
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := h.service.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if profile == nil {
|
||||
response.NotFound(c, "Profile not found")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, profile)
|
||||
}
|
||||
|
||||
// Create 创建模板
|
||||
// POST /api/v1/admin/tls-fingerprint-profiles
|
||||
func (h *TLSFingerprintProfileHandler) Create(c *gin.Context) {
|
||||
var req CreateTLSFingerprintProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
profile := &model.TLSFingerprintProfile{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
CipherSuites: req.CipherSuites,
|
||||
Curves: req.Curves,
|
||||
PointFormats: req.PointFormats,
|
||||
SignatureAlgorithms: req.SignatureAlgorithms,
|
||||
ALPNProtocols: req.ALPNProtocols,
|
||||
SupportedVersions: req.SupportedVersions,
|
||||
KeyShareGroups: req.KeyShareGroups,
|
||||
PSKModes: req.PSKModes,
|
||||
Extensions: req.Extensions,
|
||||
}
|
||||
|
||||
if req.EnableGREASE != nil {
|
||||
profile.EnableGREASE = *req.EnableGREASE
|
||||
}
|
||||
|
||||
created, err := h.service.Create(c.Request.Context(), profile)
|
||||
if err != nil {
|
||||
if _, ok := err.(*model.ValidationError); ok {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, created)
|
||||
}
|
||||
|
||||
// Update 更新模板(支持部分更新)
|
||||
// PUT /api/v1/admin/tls-fingerprint-profiles/:id
|
||||
func (h *TLSFingerprintProfileHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid profile ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateTLSFingerprintProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := h.service.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
response.NotFound(c, "Profile not found")
|
||||
return
|
||||
}
|
||||
|
||||
// 部分更新
|
||||
profile := &model.TLSFingerprintProfile{
|
||||
ID: id,
|
||||
Name: existing.Name,
|
||||
Description: existing.Description,
|
||||
EnableGREASE: existing.EnableGREASE,
|
||||
CipherSuites: existing.CipherSuites,
|
||||
Curves: existing.Curves,
|
||||
PointFormats: existing.PointFormats,
|
||||
SignatureAlgorithms: existing.SignatureAlgorithms,
|
||||
ALPNProtocols: existing.ALPNProtocols,
|
||||
SupportedVersions: existing.SupportedVersions,
|
||||
KeyShareGroups: existing.KeyShareGroups,
|
||||
PSKModes: existing.PSKModes,
|
||||
Extensions: existing.Extensions,
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
profile.Name = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
profile.Description = req.Description
|
||||
}
|
||||
if req.EnableGREASE != nil {
|
||||
profile.EnableGREASE = *req.EnableGREASE
|
||||
}
|
||||
if req.CipherSuites != nil {
|
||||
profile.CipherSuites = req.CipherSuites
|
||||
}
|
||||
if req.Curves != nil {
|
||||
profile.Curves = req.Curves
|
||||
}
|
||||
if req.PointFormats != nil {
|
||||
profile.PointFormats = req.PointFormats
|
||||
}
|
||||
if req.SignatureAlgorithms != nil {
|
||||
profile.SignatureAlgorithms = req.SignatureAlgorithms
|
||||
}
|
||||
if req.ALPNProtocols != nil {
|
||||
profile.ALPNProtocols = req.ALPNProtocols
|
||||
}
|
||||
if req.SupportedVersions != nil {
|
||||
profile.SupportedVersions = req.SupportedVersions
|
||||
}
|
||||
if req.KeyShareGroups != nil {
|
||||
profile.KeyShareGroups = req.KeyShareGroups
|
||||
}
|
||||
if req.PSKModes != nil {
|
||||
profile.PSKModes = req.PSKModes
|
||||
}
|
||||
if req.Extensions != nil {
|
||||
profile.Extensions = req.Extensions
|
||||
}
|
||||
|
||||
updated, err := h.service.Update(c.Request.Context(), profile)
|
||||
if err != nil {
|
||||
if _, ok := err.(*model.ValidationError); ok {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, updated)
|
||||
}
|
||||
|
||||
// Delete 删除模板
|
||||
// DELETE /api/v1/admin/tls-fingerprint-profiles/:id
|
||||
func (h *TLSFingerprintProfileHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid profile ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Delete(c.Request.Context(), id); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "Profile deleted successfully"})
|
||||
}
|
||||
@ -252,6 +252,10 @@ func AccountFromServiceShallow(a *service.Account) *Account {
|
||||
enabled := true
|
||||
out.EnableTLSFingerprint = &enabled
|
||||
}
|
||||
// TLS指纹模板ID
|
||||
if profileID := a.GetTLSFingerprintProfileID(); profileID > 0 {
|
||||
out.TLSFingerprintProfileID = &profileID
|
||||
}
|
||||
// 会话ID伪装开关
|
||||
if a.IsSessionIDMaskingEnabled() {
|
||||
enabled := true
|
||||
|
||||
@ -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
|
||||
|
||||
@ -185,7 +185,8 @@ type Account struct {
|
||||
|
||||
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
// 从 extra 字段提取,方便前端显示和编辑
|
||||
EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"`
|
||||
EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"`
|
||||
TLSFingerprintProfileID *int64 `json:"tls_fingerprint_profile_id,omitempty"`
|
||||
|
||||
// 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -75,8 +75,10 @@ func (f *fakeGroupRepo) ListActive(context.Context) ([]service.Group, error) { r
|
||||
func (f *fakeGroupRepo) ListActiveByPlatform(context.Context, string) ([]service.Group, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil }
|
||||
func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, int64, error) { return 0, 0, nil }
|
||||
func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil }
|
||||
func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, int64, error) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
func (f *fakeGroupRepo) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
@ -158,6 +160,7 @@ func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*servi
|
||||
nil, // rpmCache
|
||||
nil, // digestStore
|
||||
nil, // settingService
|
||||
nil, // tlsFPProfileService
|
||||
)
|
||||
|
||||
// RunModeSimple:跳过计费检查,避免引入 repo/cache 依赖。
|
||||
|
||||
@ -6,29 +6,30 @@ import (
|
||||
|
||||
// AdminHandlers contains all admin-related HTTP handlers
|
||||
type AdminHandlers struct {
|
||||
Dashboard *admin.DashboardHandler
|
||||
User *admin.UserHandler
|
||||
Group *admin.GroupHandler
|
||||
Account *admin.AccountHandler
|
||||
Announcement *admin.AnnouncementHandler
|
||||
DataManagement *admin.DataManagementHandler
|
||||
Backup *admin.BackupHandler
|
||||
OAuth *admin.OAuthHandler
|
||||
OpenAIOAuth *admin.OpenAIOAuthHandler
|
||||
GeminiOAuth *admin.GeminiOAuthHandler
|
||||
AntigravityOAuth *admin.AntigravityOAuthHandler
|
||||
Proxy *admin.ProxyHandler
|
||||
Redeem *admin.RedeemHandler
|
||||
Promo *admin.PromoHandler
|
||||
Setting *admin.SettingHandler
|
||||
Ops *admin.OpsHandler
|
||||
System *admin.SystemHandler
|
||||
Subscription *admin.SubscriptionHandler
|
||||
Usage *admin.UsageHandler
|
||||
UserAttribute *admin.UserAttributeHandler
|
||||
ErrorPassthrough *admin.ErrorPassthroughHandler
|
||||
APIKey *admin.AdminAPIKeyHandler
|
||||
ScheduledTest *admin.ScheduledTestHandler
|
||||
Dashboard *admin.DashboardHandler
|
||||
User *admin.UserHandler
|
||||
Group *admin.GroupHandler
|
||||
Account *admin.AccountHandler
|
||||
Announcement *admin.AnnouncementHandler
|
||||
DataManagement *admin.DataManagementHandler
|
||||
Backup *admin.BackupHandler
|
||||
OAuth *admin.OAuthHandler
|
||||
OpenAIOAuth *admin.OpenAIOAuthHandler
|
||||
GeminiOAuth *admin.GeminiOAuthHandler
|
||||
AntigravityOAuth *admin.AntigravityOAuthHandler
|
||||
Proxy *admin.ProxyHandler
|
||||
Redeem *admin.RedeemHandler
|
||||
Promo *admin.PromoHandler
|
||||
Setting *admin.SettingHandler
|
||||
Ops *admin.OpsHandler
|
||||
System *admin.SystemHandler
|
||||
Subscription *admin.SubscriptionHandler
|
||||
Usage *admin.UsageHandler
|
||||
UserAttribute *admin.UserAttributeHandler
|
||||
ErrorPassthrough *admin.ErrorPassthroughHandler
|
||||
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
|
||||
APIKey *admin.AdminAPIKeyHandler
|
||||
ScheduledTest *admin.ScheduledTestHandler
|
||||
}
|
||||
|
||||
// Handlers contains all HTTP handlers
|
||||
|
||||
@ -2224,7 +2224,7 @@ func (s *stubSoraClientForHandler) GetVideoTask(_ context.Context, _ *service.Ac
|
||||
func newMinimalGatewayService(accountRepo service.AccountRepository) *service.GatewayService {
|
||||
return service.NewGatewayService(
|
||||
accountRepo, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -464,6 +464,7 @@ func TestSoraGatewayHandler_ChatCompletions(t *testing.T) {
|
||||
nil, // rpmCache
|
||||
nil, // digestStore
|
||||
nil, // settingService
|
||||
nil, // tlsFPProfileService
|
||||
)
|
||||
|
||||
soraClient := &stubSoraClient{imageURLs: []string{"https://example.com/a.png"}}
|
||||
|
||||
@ -30,33 +30,35 @@ func ProvideAdminHandlers(
|
||||
usageHandler *admin.UsageHandler,
|
||||
userAttributeHandler *admin.UserAttributeHandler,
|
||||
errorPassthroughHandler *admin.ErrorPassthroughHandler,
|
||||
tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler,
|
||||
apiKeyHandler *admin.AdminAPIKeyHandler,
|
||||
scheduledTestHandler *admin.ScheduledTestHandler,
|
||||
) *AdminHandlers {
|
||||
return &AdminHandlers{
|
||||
Dashboard: dashboardHandler,
|
||||
User: userHandler,
|
||||
Group: groupHandler,
|
||||
Account: accountHandler,
|
||||
Announcement: announcementHandler,
|
||||
DataManagement: dataManagementHandler,
|
||||
Backup: backupHandler,
|
||||
OAuth: oauthHandler,
|
||||
OpenAIOAuth: openaiOAuthHandler,
|
||||
GeminiOAuth: geminiOAuthHandler,
|
||||
AntigravityOAuth: antigravityOAuthHandler,
|
||||
Proxy: proxyHandler,
|
||||
Redeem: redeemHandler,
|
||||
Promo: promoHandler,
|
||||
Setting: settingHandler,
|
||||
Ops: opsHandler,
|
||||
System: systemHandler,
|
||||
Subscription: subscriptionHandler,
|
||||
Usage: usageHandler,
|
||||
UserAttribute: userAttributeHandler,
|
||||
ErrorPassthrough: errorPassthroughHandler,
|
||||
APIKey: apiKeyHandler,
|
||||
ScheduledTest: scheduledTestHandler,
|
||||
Dashboard: dashboardHandler,
|
||||
User: userHandler,
|
||||
Group: groupHandler,
|
||||
Account: accountHandler,
|
||||
Announcement: announcementHandler,
|
||||
DataManagement: dataManagementHandler,
|
||||
Backup: backupHandler,
|
||||
OAuth: oauthHandler,
|
||||
OpenAIOAuth: openaiOAuthHandler,
|
||||
GeminiOAuth: geminiOAuthHandler,
|
||||
AntigravityOAuth: antigravityOAuthHandler,
|
||||
Proxy: proxyHandler,
|
||||
Redeem: redeemHandler,
|
||||
Promo: promoHandler,
|
||||
Setting: settingHandler,
|
||||
Ops: opsHandler,
|
||||
System: systemHandler,
|
||||
Subscription: subscriptionHandler,
|
||||
Usage: usageHandler,
|
||||
UserAttribute: userAttributeHandler,
|
||||
ErrorPassthrough: errorPassthroughHandler,
|
||||
TLSFingerprintProfile: tlsFingerprintProfileHandler,
|
||||
APIKey: apiKeyHandler,
|
||||
ScheduledTest: scheduledTestHandler,
|
||||
}
|
||||
}
|
||||
|
||||
@ -145,6 +147,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewUsageHandler,
|
||||
admin.NewUserAttributeHandler,
|
||||
admin.NewErrorPassthroughHandler,
|
||||
admin.NewTLSFingerprintProfileHandler,
|
||||
admin.NewAdminAPIKeyHandler,
|
||||
admin.NewScheduledTestHandler,
|
||||
|
||||
|
||||
54
backend/internal/model/tls_fingerprint_profile.go
Normal file
54
backend/internal/model/tls_fingerprint_profile.go
Normal file
@ -0,0 +1,54 @@
|
||||
// Package model 定义服务层使用的数据模型。
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfile TLS 指纹配置模板
|
||||
// 包含完整的 ClientHello 参数,用于模拟特定客户端的 TLS 握手特征
|
||||
type TLSFingerprintProfile struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
EnableGREASE bool `json:"enable_grease"`
|
||||
CipherSuites []uint16 `json:"cipher_suites"`
|
||||
Curves []uint16 `json:"curves"`
|
||||
PointFormats []uint16 `json:"point_formats"`
|
||||
SignatureAlgorithms []uint16 `json:"signature_algorithms"`
|
||||
ALPNProtocols []string `json:"alpn_protocols"`
|
||||
SupportedVersions []uint16 `json:"supported_versions"`
|
||||
KeyShareGroups []uint16 `json:"key_share_groups"`
|
||||
PSKModes []uint16 `json:"psk_modes"`
|
||||
Extensions []uint16 `json:"extensions"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Validate 验证模板配置的有效性
|
||||
func (p *TLSFingerprintProfile) Validate() error {
|
||||
if p.Name == "" {
|
||||
return &ValidationError{Field: "name", Message: "name is required"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToTLSProfile 将领域模型转换为运行时使用的 tlsfingerprint.Profile
|
||||
// 空切片字段会在 dialer 中 fallback 到内置默认值
|
||||
func (p *TLSFingerprintProfile) ToTLSProfile() *tlsfingerprint.Profile {
|
||||
return &tlsfingerprint.Profile{
|
||||
Name: p.Name,
|
||||
EnableGREASE: p.EnableGREASE,
|
||||
CipherSuites: p.CipherSuites,
|
||||
Curves: p.Curves,
|
||||
PointFormats: p.PointFormats,
|
||||
SignatureAlgorithms: p.SignatureAlgorithms,
|
||||
ALPNProtocols: p.ALPNProtocols,
|
||||
SupportedVersions: p.SupportedVersions,
|
||||
KeyShareGroups: p.KeyShareGroups,
|
||||
PSKModes: p.PSKModes,
|
||||
Extensions: p.Extensions,
|
||||
}
|
||||
}
|
||||
@ -78,7 +78,9 @@ type UserInfo struct {
|
||||
// LoadCodeAssistRequest loadCodeAssist 请求
|
||||
type LoadCodeAssistRequest struct {
|
||||
Metadata struct {
|
||||
IDEType string `json:"ideType"`
|
||||
IDEType string `json:"ideType"`
|
||||
IDEVersion string `json:"ideVersion"`
|
||||
IDEName string `json:"ideName"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
|
||||
@ -223,6 +225,23 @@ func (r *LoadCodeAssistResponse) GetAvailableCredits() []AvailableCredit {
|
||||
return r.PaidTier.AvailableCredits
|
||||
}
|
||||
|
||||
// TierIDToPlanType 将 tier ID 映射为用户可见的套餐名。
|
||||
func TierIDToPlanType(tierID string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(tierID)) {
|
||||
case "free-tier":
|
||||
return "Free"
|
||||
case "g1-pro-tier":
|
||||
return "Pro"
|
||||
case "g1-ultra-tier":
|
||||
return "Ultra"
|
||||
default:
|
||||
if tierID == "" {
|
||||
return "Free"
|
||||
}
|
||||
return tierID
|
||||
}
|
||||
}
|
||||
|
||||
// Client Antigravity API 客户端
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
@ -421,6 +440,8 @@ func (c *Client) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo
|
||||
func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadCodeAssistResponse, map[string]any, error) {
|
||||
reqBody := LoadCodeAssistRequest{}
|
||||
reqBody.Metadata.IDEType = "ANTIGRAVITY"
|
||||
reqBody.Metadata.IDEVersion = "1.20.6"
|
||||
reqBody.Metadata.IDEName = "antigravity"
|
||||
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
@ -704,3 +725,139 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
|
||||
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
|
||||
// ── Privacy API ──────────────────────────────────────────────────────
|
||||
|
||||
// privacyBaseURL 隐私设置 API 仅使用 daily 端点(与 Antigravity 客户端行为一致)
|
||||
const privacyBaseURL = antigravityDailyBaseURL
|
||||
|
||||
// SetUserSettingsRequest setUserSettings 请求体
|
||||
type SetUserSettingsRequest struct {
|
||||
UserSettings map[string]any `json:"user_settings"`
|
||||
}
|
||||
|
||||
// FetchUserInfoRequest fetchUserInfo 请求体
|
||||
type FetchUserInfoRequest struct {
|
||||
Project string `json:"project"`
|
||||
}
|
||||
|
||||
// FetchUserInfoResponse fetchUserInfo 响应体
|
||||
type FetchUserInfoResponse struct {
|
||||
UserSettings map[string]any `json:"userSettings,omitempty"`
|
||||
RegionCode string `json:"regionCode,omitempty"`
|
||||
}
|
||||
|
||||
// IsPrivate 判断隐私是否已设置:userSettings 为空或不含 telemetryEnabled 表示已设置
|
||||
func (r *FetchUserInfoResponse) IsPrivate() bool {
|
||||
if r == nil || r.UserSettings == nil {
|
||||
return true
|
||||
}
|
||||
_, hasTelemetry := r.UserSettings["telemetryEnabled"]
|
||||
return !hasTelemetry
|
||||
}
|
||||
|
||||
// SetUserSettingsResponse setUserSettings 响应体
|
||||
type SetUserSettingsResponse struct {
|
||||
UserSettings map[string]any `json:"userSettings,omitempty"`
|
||||
}
|
||||
|
||||
// IsSuccess 判断 setUserSettings 是否成功:返回 {"userSettings":{}} 且无 telemetryEnabled
|
||||
func (r *SetUserSettingsResponse) IsSuccess() bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
// 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 {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
apiURL := privacyBaseURL + "/v1internal:setUserSettings"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", "gl-node/22.21.1")
|
||||
req.Host = "daily-cloudcode-pa.googleapis.com"
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("setUserSettings 请求失败: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("setUserSettings 失败 (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result SetUserSettingsResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("响应解析失败: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FetchUserInfo 调用 fetchUserInfo API 获取用户隐私设置状态
|
||||
func (c *Client) FetchUserInfo(ctx context.Context, accessToken, projectID string) (*FetchUserInfoResponse, error) {
|
||||
reqBody := FetchUserInfoRequest{Project: projectID}
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
apiURL := privacyBaseURL + "/v1internal:fetchUserInfo"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", "gl-node/22.21.1")
|
||||
req.Host = "daily-cloudcode-pa.googleapis.com"
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetchUserInfo 请求失败: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("fetchUserInfo 失败 (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result FetchUserInfoResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("响应解析失败: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
@ -250,6 +250,27 @@ func TestGetTier_两者都为nil(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTierIDToPlanType(t *testing.T) {
|
||||
tests := []struct {
|
||||
tierID string
|
||||
want string
|
||||
}{
|
||||
{"free-tier", "Free"},
|
||||
{"g1-pro-tier", "Pro"},
|
||||
{"g1-ultra-tier", "Ultra"},
|
||||
{"FREE-TIER", "Free"},
|
||||
{"", "Free"},
|
||||
{"unknown-tier", "unknown-tier"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.tierID, func(t *testing.T) {
|
||||
if got := TierIDToPlanType(tt.tierID); got != tt.want {
|
||||
t.Errorf("TierIDToPlanType(%q) = %q, want %q", tt.tierID, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NewClient
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -800,6 +821,12 @@ type redirectRoundTripper struct {
|
||||
transport http.RoundTripper
|
||||
}
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func (rt *redirectRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
originalURL := req.URL.String()
|
||||
for prefix, target := range rt.redirects {
|
||||
@ -1271,6 +1298,12 @@ func TestClient_LoadCodeAssist_Success_RealCall(t *testing.T) {
|
||||
if reqBody.Metadata.IDEType != "ANTIGRAVITY" {
|
||||
t.Errorf("IDEType 不匹配: got %s, want ANTIGRAVITY", reqBody.Metadata.IDEType)
|
||||
}
|
||||
if strings.TrimSpace(reqBody.Metadata.IDEVersion) == "" {
|
||||
t.Errorf("IDEVersion 不应为空")
|
||||
}
|
||||
if reqBody.Metadata.IDEName != "antigravity" {
|
||||
t.Errorf("IDEName 不匹配: got %s, want antigravity", reqBody.Metadata.IDEName)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@ -17,12 +17,19 @@ import (
|
||||
)
|
||||
|
||||
// Profile contains TLS fingerprint configuration.
|
||||
// All slice fields use built-in defaults when empty.
|
||||
type Profile struct {
|
||||
Name string // Profile name for identification
|
||||
CipherSuites []uint16
|
||||
Curves []uint16
|
||||
PointFormats []uint8
|
||||
EnableGREASE bool
|
||||
Name string // Profile name for identification
|
||||
CipherSuites []uint16
|
||||
Curves []uint16
|
||||
PointFormats []uint16
|
||||
EnableGREASE bool
|
||||
SignatureAlgorithms []uint16 // Empty uses defaultSignatureAlgorithms
|
||||
ALPNProtocols []string // Empty uses ["http/1.1"]
|
||||
SupportedVersions []uint16 // Empty uses [TLS1.3, TLS1.2]
|
||||
KeyShareGroups []uint16 // Empty uses [X25519]
|
||||
PSKModes []uint16 // Empty uses [psk_dhe_ke]
|
||||
Extensions []uint16 // Extension type IDs in order; empty uses default Node.js 24.x order
|
||||
}
|
||||
|
||||
// Dialer creates TLS connections with custom fingerprints.
|
||||
@ -45,154 +52,67 @@ type SOCKS5ProxyDialer struct {
|
||||
proxyURL *url.URL
|
||||
}
|
||||
|
||||
// Default TLS fingerprint values captured from Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)
|
||||
// Captured using: tshark -i lo -f "tcp port 8443" -Y "tls.handshake.type == 1" -V
|
||||
// JA3 Hash: 1a28e69016765d92e3b381168d68922c
|
||||
//
|
||||
// Note: JA3/JA4 may have slight variations due to:
|
||||
// - Session ticket presence/absence
|
||||
// - Extension negotiation state
|
||||
// Default TLS fingerprint values captured from Claude Code (Node.js 24.x)
|
||||
// Captured via tls-fingerprint-web capture server
|
||||
// JA3 Hash: 44f88fca027f27bab4bb08d4af15f23e
|
||||
// JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
|
||||
var (
|
||||
// defaultCipherSuites contains all 59 cipher suites from Claude CLI
|
||||
// defaultCipherSuites contains the 17 cipher suites from Node.js 24.x
|
||||
// Order is critical for JA3 fingerprint matching
|
||||
defaultCipherSuites = []uint16{
|
||||
// TLS 1.3 cipher suites (MUST be first)
|
||||
// TLS 1.3 cipher suites
|
||||
0x1301, // TLS_AES_128_GCM_SHA256
|
||||
0x1302, // TLS_AES_256_GCM_SHA384
|
||||
0x1303, // TLS_CHACHA20_POLY1305_SHA256
|
||||
0x1301, // TLS_AES_128_GCM_SHA256
|
||||
|
||||
// ECDHE + AES-GCM
|
||||
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
0xc02b, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
|
||||
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
0xc02c, // TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
|
||||
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
|
||||
// DHE + AES-GCM
|
||||
0x009e, // TLS_DHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
|
||||
// ECDHE/DHE + AES-CBC-SHA256/384
|
||||
0xc027, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256
|
||||
0x0067, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA256
|
||||
0xc028, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384
|
||||
0x006b, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA256
|
||||
|
||||
// DHE-DSS/RSA + AES-GCM
|
||||
0x00a3, // TLS_DHE_DSS_WITH_AES_256_GCM_SHA384
|
||||
0x009f, // TLS_DHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
|
||||
// ChaCha20-Poly1305
|
||||
// ECDHE + ChaCha20-Poly1305
|
||||
0xcca9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
0xcca8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
0xccaa, // TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
|
||||
// AES-CCM (256-bit)
|
||||
0xc0af, // TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8
|
||||
0xc0ad, // TLS_ECDHE_ECDSA_WITH_AES_256_CCM
|
||||
0xc0a3, // TLS_DHE_RSA_WITH_AES_256_CCM_8
|
||||
0xc09f, // TLS_DHE_RSA_WITH_AES_256_CCM
|
||||
|
||||
// ARIA (256-bit)
|
||||
0xc05d, // TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384
|
||||
0xc061, // TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384
|
||||
0xc057, // TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384
|
||||
0xc053, // TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384
|
||||
|
||||
// DHE-DSS + AES-GCM (128-bit)
|
||||
0x00a2, // TLS_DHE_DSS_WITH_AES_128_GCM_SHA256
|
||||
|
||||
// AES-CCM (128-bit)
|
||||
0xc0ae, // TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8
|
||||
0xc0ac, // TLS_ECDHE_ECDSA_WITH_AES_128_CCM
|
||||
0xc0a2, // TLS_DHE_RSA_WITH_AES_128_CCM_8
|
||||
0xc09e, // TLS_DHE_RSA_WITH_AES_128_CCM
|
||||
|
||||
// ARIA (128-bit)
|
||||
0xc05c, // TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256
|
||||
0xc060, // TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256
|
||||
0xc056, // TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256
|
||||
0xc052, // TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256
|
||||
|
||||
// ECDHE/DHE + AES-CBC-SHA384/256 (more)
|
||||
0xc024, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384
|
||||
0x006a, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA256
|
||||
0xc023, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
|
||||
0x0040, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA256
|
||||
|
||||
// ECDHE/DHE + AES-CBC-SHA (legacy)
|
||||
0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
|
||||
0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
|
||||
0x0039, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA
|
||||
0x0038, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA
|
||||
// ECDHE + AES-CBC-SHA (legacy fallback)
|
||||
0xc009, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
|
||||
0xc013, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
|
||||
0x0033, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA
|
||||
0x0032, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA
|
||||
0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
|
||||
0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
|
||||
|
||||
// RSA + AES-GCM/CCM/ARIA (non-PFS, 256-bit)
|
||||
0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384
|
||||
0xc0a1, // TLS_RSA_WITH_AES_256_CCM_8
|
||||
0xc09d, // TLS_RSA_WITH_AES_256_CCM
|
||||
0xc051, // TLS_RSA_WITH_ARIA_256_GCM_SHA384
|
||||
|
||||
// RSA + AES-GCM/CCM/ARIA (non-PFS, 128-bit)
|
||||
// RSA + AES-GCM (non-PFS)
|
||||
0x009c, // TLS_RSA_WITH_AES_128_GCM_SHA256
|
||||
0xc0a0, // TLS_RSA_WITH_AES_128_CCM_8
|
||||
0xc09c, // TLS_RSA_WITH_AES_128_CCM
|
||||
0xc050, // TLS_RSA_WITH_ARIA_128_GCM_SHA256
|
||||
0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384
|
||||
|
||||
// RSA + AES-CBC (non-PFS, legacy)
|
||||
0x003d, // TLS_RSA_WITH_AES_256_CBC_SHA256
|
||||
0x003c, // TLS_RSA_WITH_AES_128_CBC_SHA256
|
||||
0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA
|
||||
// RSA + AES-CBC-SHA (non-PFS, legacy)
|
||||
0x002f, // TLS_RSA_WITH_AES_128_CBC_SHA
|
||||
|
||||
// Renegotiation indication
|
||||
0x00ff, // TLS_EMPTY_RENEGOTIATION_INFO_SCSV
|
||||
0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA
|
||||
}
|
||||
|
||||
// defaultCurves contains the 10 supported groups from Claude CLI (including FFDHE)
|
||||
// defaultCurves contains the 3 supported groups from Node.js 24.x
|
||||
defaultCurves = []utls.CurveID{
|
||||
utls.X25519, // 0x001d
|
||||
utls.CurveP256, // 0x0017 (secp256r1)
|
||||
utls.CurveID(0x001e), // x448
|
||||
utls.CurveP521, // 0x0019 (secp521r1)
|
||||
utls.CurveP384, // 0x0018 (secp384r1)
|
||||
utls.CurveID(0x0100), // ffdhe2048
|
||||
utls.CurveID(0x0101), // ffdhe3072
|
||||
utls.CurveID(0x0102), // ffdhe4096
|
||||
utls.CurveID(0x0103), // ffdhe6144
|
||||
utls.CurveID(0x0104), // ffdhe8192
|
||||
utls.X25519, // 0x001d
|
||||
utls.CurveP256, // 0x0017 (secp256r1)
|
||||
utls.CurveP384, // 0x0018 (secp384r1)
|
||||
}
|
||||
|
||||
// defaultPointFormats contains all 3 point formats from Claude CLI
|
||||
defaultPointFormats = []uint8{
|
||||
// defaultPointFormats contains point formats from Node.js 24.x
|
||||
defaultPointFormats = []uint16{
|
||||
0, // uncompressed
|
||||
1, // ansiX962_compressed_prime
|
||||
2, // ansiX962_compressed_char2
|
||||
}
|
||||
|
||||
// defaultSignatureAlgorithms contains the 20 signature algorithms from Claude CLI
|
||||
// defaultSignatureAlgorithms contains the 9 signature algorithms from Node.js 24.x
|
||||
defaultSignatureAlgorithms = []utls.SignatureScheme{
|
||||
0x0403, // ecdsa_secp256r1_sha256
|
||||
0x0503, // ecdsa_secp384r1_sha384
|
||||
0x0603, // ecdsa_secp521r1_sha512
|
||||
0x0807, // ed25519
|
||||
0x0808, // ed448
|
||||
0x0809, // rsa_pss_pss_sha256
|
||||
0x080a, // rsa_pss_pss_sha384
|
||||
0x080b, // rsa_pss_pss_sha512
|
||||
0x0804, // rsa_pss_rsae_sha256
|
||||
0x0805, // rsa_pss_rsae_sha384
|
||||
0x0806, // rsa_pss_rsae_sha512
|
||||
0x0401, // rsa_pkcs1_sha256
|
||||
0x0503, // ecdsa_secp384r1_sha384
|
||||
0x0805, // rsa_pss_rsae_sha384
|
||||
0x0501, // rsa_pkcs1_sha384
|
||||
0x0806, // rsa_pss_rsae_sha512
|
||||
0x0601, // rsa_pkcs1_sha512
|
||||
0x0303, // ecdsa_sha224
|
||||
0x0301, // rsa_pkcs1_sha224
|
||||
0x0302, // dsa_sha224
|
||||
0x0402, // dsa_sha256
|
||||
0x0502, // dsa_sha384
|
||||
0x0602, // dsa_sha512
|
||||
0x0201, // rsa_pkcs1_sha1
|
||||
}
|
||||
)
|
||||
|
||||
@ -256,49 +176,7 @@ func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr st
|
||||
slog.Debug("tls_fingerprint_socks5_tunnel_established")
|
||||
|
||||
// Step 3: Perform TLS handshake on the tunnel with utls fingerprint
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
slog.Debug("tls_fingerprint_socks5_starting_handshake", "host", host)
|
||||
|
||||
// Build ClientHello specification from profile (Node.js/Claude CLI fingerprint)
|
||||
spec := buildClientHelloSpecFromProfile(d.profile)
|
||||
slog.Debug("tls_fingerprint_socks5_clienthello_spec",
|
||||
"cipher_suites", len(spec.CipherSuites),
|
||||
"extensions", len(spec.Extensions),
|
||||
"compression_methods", spec.CompressionMethods,
|
||||
"tls_vers_max", spec.TLSVersMax,
|
||||
"tls_vers_min", spec.TLSVersMin)
|
||||
|
||||
if d.profile != nil {
|
||||
slog.Debug("tls_fingerprint_socks5_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE)
|
||||
}
|
||||
|
||||
// Create uTLS connection on the tunnel
|
||||
tlsConn := utls.UClient(conn, &utls.Config{
|
||||
ServerName: host,
|
||||
}, utls.HelloCustom)
|
||||
|
||||
if err := tlsConn.ApplyPreset(spec); err != nil {
|
||||
slog.Debug("tls_fingerprint_socks5_apply_preset_failed", "error", err)
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("apply TLS preset: %w", err)
|
||||
}
|
||||
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
slog.Debug("tls_fingerprint_socks5_handshake_failed", "error", err)
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("TLS handshake failed: %w", err)
|
||||
}
|
||||
|
||||
state := tlsConn.ConnectionState()
|
||||
slog.Debug("tls_fingerprint_socks5_handshake_success",
|
||||
"version", state.Version,
|
||||
"cipher_suite", state.CipherSuite,
|
||||
"alpn", state.NegotiatedProtocol)
|
||||
|
||||
return tlsConn, nil
|
||||
return performTLSHandshake(ctx, conn, d.profile, addr)
|
||||
}
|
||||
|
||||
// DialTLSContext establishes a TLS connection through HTTP proxy with the configured fingerprint.
|
||||
@ -358,7 +236,8 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri
|
||||
slog.Debug("tls_fingerprint_http_proxy_read_response_failed", "error", err)
|
||||
return nil, fmt.Errorf("read CONNECT response: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
// CONNECT response has no body; do not defer resp.Body.Close() as it wraps the
|
||||
// same conn that will be used for the TLS handshake.
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = conn.Close()
|
||||
@ -368,47 +247,7 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri
|
||||
slog.Debug("tls_fingerprint_http_proxy_tunnel_established")
|
||||
|
||||
// Step 4: Perform TLS handshake on the tunnel with utls fingerprint
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
slog.Debug("tls_fingerprint_http_proxy_starting_handshake", "host", host)
|
||||
|
||||
// Build ClientHello specification (reuse the shared method)
|
||||
spec := buildClientHelloSpecFromProfile(d.profile)
|
||||
slog.Debug("tls_fingerprint_http_proxy_clienthello_spec",
|
||||
"cipher_suites", len(spec.CipherSuites),
|
||||
"extensions", len(spec.Extensions))
|
||||
|
||||
if d.profile != nil {
|
||||
slog.Debug("tls_fingerprint_http_proxy_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE)
|
||||
}
|
||||
|
||||
// Create uTLS connection on the tunnel
|
||||
// Note: TLS 1.3 cipher suites are handled automatically by utls when TLS 1.3 is in SupportedVersions
|
||||
tlsConn := utls.UClient(conn, &utls.Config{
|
||||
ServerName: host,
|
||||
}, utls.HelloCustom)
|
||||
|
||||
if err := tlsConn.ApplyPreset(spec); err != nil {
|
||||
slog.Debug("tls_fingerprint_http_proxy_apply_preset_failed", "error", err)
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("apply TLS preset: %w", err)
|
||||
}
|
||||
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
slog.Debug("tls_fingerprint_http_proxy_handshake_failed", "error", err)
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("TLS handshake failed: %w", err)
|
||||
}
|
||||
|
||||
state := tlsConn.ConnectionState()
|
||||
slog.Debug("tls_fingerprint_http_proxy_handshake_success",
|
||||
"version", state.Version,
|
||||
"cipher_suite", state.CipherSuite,
|
||||
"alpn", state.NegotiatedProtocol)
|
||||
|
||||
return tlsConn, nil
|
||||
return performTLSHandshake(ctx, conn, d.profile, addr)
|
||||
}
|
||||
|
||||
// DialTLSContext establishes a TLS connection with the configured fingerprint.
|
||||
@ -423,53 +262,35 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.
|
||||
}
|
||||
slog.Debug("tls_fingerprint_tcp_connected", "addr", addr)
|
||||
|
||||
// Extract hostname for SNI
|
||||
// Perform TLS handshake with utls fingerprint
|
||||
return performTLSHandshake(ctx, conn, d.profile, addr)
|
||||
}
|
||||
|
||||
// performTLSHandshake performs the uTLS handshake on an established connection.
|
||||
// It builds a ClientHello spec from the profile, applies it, and completes the handshake.
|
||||
// On failure, conn is closed and an error is returned.
|
||||
func performTLSHandshake(ctx context.Context, conn net.Conn, profile *Profile, addr string) (net.Conn, error) {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
slog.Debug("tls_fingerprint_sni_hostname", "host", host)
|
||||
|
||||
// Build ClientHello specification
|
||||
spec := d.buildClientHelloSpec()
|
||||
slog.Debug("tls_fingerprint_clienthello_spec",
|
||||
"cipher_suites", len(spec.CipherSuites),
|
||||
"extensions", len(spec.Extensions))
|
||||
spec := buildClientHelloSpecFromProfile(profile)
|
||||
tlsConn := utls.UClient(conn, &utls.Config{ServerName: host}, utls.HelloCustom)
|
||||
|
||||
// Log profile info
|
||||
if d.profile != nil {
|
||||
slog.Debug("tls_fingerprint_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE)
|
||||
} else {
|
||||
slog.Debug("tls_fingerprint_using_default_profile")
|
||||
}
|
||||
|
||||
// Create uTLS connection
|
||||
// Note: TLS 1.3 cipher suites are handled automatically by utls when TLS 1.3 is in SupportedVersions
|
||||
tlsConn := utls.UClient(conn, &utls.Config{
|
||||
ServerName: host,
|
||||
}, utls.HelloCustom)
|
||||
|
||||
// Apply fingerprint
|
||||
if err := tlsConn.ApplyPreset(spec); err != nil {
|
||||
slog.Debug("tls_fingerprint_apply_preset_failed", "error", err)
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("apply TLS preset: %w", err)
|
||||
}
|
||||
slog.Debug("tls_fingerprint_preset_applied")
|
||||
|
||||
// Perform TLS handshake
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
slog.Debug("tls_fingerprint_handshake_failed",
|
||||
"error", err,
|
||||
"local_addr", conn.LocalAddr(),
|
||||
"remote_addr", conn.RemoteAddr())
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("TLS handshake failed: %w", err)
|
||||
}
|
||||
|
||||
// Log successful handshake details
|
||||
state := tlsConn.ConnectionState()
|
||||
slog.Debug("tls_fingerprint_handshake_success",
|
||||
"host", host,
|
||||
"version", state.Version,
|
||||
"cipher_suite", state.CipherSuite,
|
||||
"alpn", state.NegotiatedProtocol)
|
||||
@ -477,11 +298,6 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.
|
||||
return tlsConn, nil
|
||||
}
|
||||
|
||||
// buildClientHelloSpec constructs the ClientHello specification based on the profile.
|
||||
func (d *Dialer) buildClientHelloSpec() *utls.ClientHelloSpec {
|
||||
return buildClientHelloSpecFromProfile(d.profile)
|
||||
}
|
||||
|
||||
// toUTLSCurves converts uint16 slice to utls.CurveID slice.
|
||||
func toUTLSCurves(curves []uint16) []utls.CurveID {
|
||||
result := make([]utls.CurveID, len(curves))
|
||||
@ -491,70 +307,143 @@ func toUTLSCurves(curves []uint16) []utls.CurveID {
|
||||
return result
|
||||
}
|
||||
|
||||
// defaultExtensionOrder is the Node.js 24.x extension order.
|
||||
// Used when Profile.Extensions is empty.
|
||||
var defaultExtensionOrder = []uint16{
|
||||
0, // server_name
|
||||
65037, // encrypted_client_hello
|
||||
23, // extended_master_secret
|
||||
65281, // renegotiation_info
|
||||
10, // supported_groups
|
||||
11, // ec_point_formats
|
||||
35, // session_ticket
|
||||
16, // alpn
|
||||
5, // status_request
|
||||
13, // signature_algorithms
|
||||
18, // signed_certificate_timestamp
|
||||
51, // key_share
|
||||
45, // psk_key_exchange_modes
|
||||
43, // supported_versions
|
||||
}
|
||||
|
||||
// isGREASEValue checks if a uint16 value matches the TLS GREASE pattern (0x?a?a).
|
||||
func isGREASEValue(v uint16) bool {
|
||||
return v&0x0f0f == 0x0a0a && v>>8 == v&0xff
|
||||
}
|
||||
|
||||
// buildClientHelloSpecFromProfile constructs ClientHelloSpec from a Profile.
|
||||
// This is a standalone function that can be used by both Dialer and HTTPProxyDialer.
|
||||
func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
|
||||
// Get cipher suites
|
||||
var cipherSuites []uint16
|
||||
// Resolve effective values (profile overrides or built-in defaults)
|
||||
cipherSuites := defaultCipherSuites
|
||||
if profile != nil && len(profile.CipherSuites) > 0 {
|
||||
cipherSuites = profile.CipherSuites
|
||||
} else {
|
||||
cipherSuites = defaultCipherSuites
|
||||
}
|
||||
|
||||
// Get curves
|
||||
var curves []utls.CurveID
|
||||
curves := defaultCurves
|
||||
if profile != nil && len(profile.Curves) > 0 {
|
||||
curves = toUTLSCurves(profile.Curves)
|
||||
} else {
|
||||
curves = defaultCurves
|
||||
}
|
||||
|
||||
// Get point formats
|
||||
var pointFormats []uint8
|
||||
pointFormats := defaultPointFormats
|
||||
if profile != nil && len(profile.PointFormats) > 0 {
|
||||
pointFormats = profile.PointFormats
|
||||
} else {
|
||||
pointFormats = defaultPointFormats
|
||||
}
|
||||
|
||||
// Check if GREASE is enabled
|
||||
signatureAlgorithms := defaultSignatureAlgorithms
|
||||
if profile != nil && len(profile.SignatureAlgorithms) > 0 {
|
||||
signatureAlgorithms = make([]utls.SignatureScheme, len(profile.SignatureAlgorithms))
|
||||
for i, s := range profile.SignatureAlgorithms {
|
||||
signatureAlgorithms[i] = utls.SignatureScheme(s)
|
||||
}
|
||||
}
|
||||
|
||||
alpnProtocols := []string{"http/1.1"}
|
||||
if profile != nil && len(profile.ALPNProtocols) > 0 {
|
||||
alpnProtocols = profile.ALPNProtocols
|
||||
}
|
||||
|
||||
supportedVersions := []uint16{utls.VersionTLS13, utls.VersionTLS12}
|
||||
if profile != nil && len(profile.SupportedVersions) > 0 {
|
||||
supportedVersions = profile.SupportedVersions
|
||||
}
|
||||
|
||||
keyShareGroups := []utls.CurveID{utls.X25519}
|
||||
if profile != nil && len(profile.KeyShareGroups) > 0 {
|
||||
keyShareGroups = toUTLSCurves(profile.KeyShareGroups)
|
||||
}
|
||||
|
||||
pskModes := []uint16{uint16(utls.PskModeDHE)}
|
||||
if profile != nil && len(profile.PSKModes) > 0 {
|
||||
pskModes = profile.PSKModes
|
||||
}
|
||||
|
||||
enableGREASE := profile != nil && profile.EnableGREASE
|
||||
|
||||
extensions := make([]utls.TLSExtension, 0, 16)
|
||||
|
||||
if enableGREASE {
|
||||
extensions = append(extensions, &utls.UtlsGREASEExtension{})
|
||||
// Build key shares
|
||||
keyShares := make([]utls.KeyShare, len(keyShareGroups))
|
||||
for i, g := range keyShareGroups {
|
||||
keyShares[i] = utls.KeyShare{Group: g}
|
||||
}
|
||||
|
||||
// SNI extension - MUST be explicitly added for HelloCustom mode
|
||||
// utls will populate the server name from Config.ServerName
|
||||
extensions = append(extensions, &utls.SNIExtension{})
|
||||
// Determine extension order
|
||||
extOrder := defaultExtensionOrder
|
||||
if profile != nil && len(profile.Extensions) > 0 {
|
||||
extOrder = profile.Extensions
|
||||
}
|
||||
|
||||
// Claude CLI extension order (captured from tshark):
|
||||
// server_name(0), ec_point_formats(11), supported_groups(10), session_ticket(35),
|
||||
// alpn(16), encrypt_then_mac(22), extended_master_secret(23),
|
||||
// signature_algorithms(13), supported_versions(43),
|
||||
// psk_key_exchange_modes(45), key_share(51)
|
||||
extensions = append(extensions,
|
||||
&utls.SupportedPointsExtension{SupportedPoints: pointFormats},
|
||||
&utls.SupportedCurvesExtension{Curves: curves},
|
||||
&utls.SessionTicketExtension{},
|
||||
&utls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}},
|
||||
&utls.GenericExtension{Id: 22},
|
||||
&utls.ExtendedMasterSecretExtension{},
|
||||
&utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: defaultSignatureAlgorithms},
|
||||
&utls.SupportedVersionsExtension{Versions: []uint16{
|
||||
utls.VersionTLS13,
|
||||
utls.VersionTLS12,
|
||||
}},
|
||||
&utls.PSKKeyExchangeModesExtension{Modes: []uint8{utls.PskModeDHE}},
|
||||
&utls.KeyShareExtension{KeyShares: []utls.KeyShare{
|
||||
{Group: utls.X25519},
|
||||
}},
|
||||
)
|
||||
// Build extensions list from the ordered IDs.
|
||||
// Parametric extensions (curves, sigalgs, etc.) are populated with resolved profile values.
|
||||
// Unknown IDs use GenericExtension (sends type ID with empty data).
|
||||
extensions := make([]utls.TLSExtension, 0, len(extOrder)+2)
|
||||
for _, id := range extOrder {
|
||||
if isGREASEValue(id) {
|
||||
extensions = append(extensions, &utls.UtlsGREASEExtension{})
|
||||
continue
|
||||
}
|
||||
switch id {
|
||||
case 0: // server_name
|
||||
extensions = append(extensions, &utls.SNIExtension{})
|
||||
case 5: // status_request (OCSP)
|
||||
extensions = append(extensions, &utls.StatusRequestExtension{})
|
||||
case 10: // supported_groups
|
||||
extensions = append(extensions, &utls.SupportedCurvesExtension{Curves: curves})
|
||||
case 11: // ec_point_formats
|
||||
extensions = append(extensions, &utls.SupportedPointsExtension{SupportedPoints: toUint8s(pointFormats)})
|
||||
case 13: // signature_algorithms
|
||||
extensions = append(extensions, &utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: signatureAlgorithms})
|
||||
case 16: // alpn
|
||||
extensions = append(extensions, &utls.ALPNExtension{AlpnProtocols: alpnProtocols})
|
||||
case 18: // signed_certificate_timestamp
|
||||
extensions = append(extensions, &utls.SCTExtension{})
|
||||
case 23: // extended_master_secret
|
||||
extensions = append(extensions, &utls.ExtendedMasterSecretExtension{})
|
||||
case 35: // session_ticket
|
||||
extensions = append(extensions, &utls.SessionTicketExtension{})
|
||||
case 43: // supported_versions
|
||||
extensions = append(extensions, &utls.SupportedVersionsExtension{Versions: supportedVersions})
|
||||
case 45: // psk_key_exchange_modes
|
||||
extensions = append(extensions, &utls.PSKKeyExchangeModesExtension{Modes: toUint8s(pskModes)})
|
||||
case 50: // signature_algorithms_cert
|
||||
extensions = append(extensions, &utls.SignatureAlgorithmsCertExtension{SupportedSignatureAlgorithms: signatureAlgorithms})
|
||||
case 51: // key_share
|
||||
extensions = append(extensions, &utls.KeyShareExtension{KeyShares: keyShares})
|
||||
case 0xfe0d: // encrypted_client_hello (ECH, 65037)
|
||||
// Send GREASE ECH with random payload — mimics Node.js behavior when no real ECHConfig is available.
|
||||
// An empty GenericExtension causes "error decoding message" from servers that validate ECH format.
|
||||
extensions = append(extensions, &utls.GREASEEncryptedClientHelloExtension{})
|
||||
case 0xff01: // renegotiation_info
|
||||
extensions = append(extensions, &utls.RenegotiationInfoExtension{})
|
||||
default:
|
||||
// Unknown extension — send as GenericExtension (type ID + empty data).
|
||||
// This covers encrypt_then_mac(22) and any future extensions.
|
||||
extensions = append(extensions, &utls.GenericExtension{Id: id})
|
||||
}
|
||||
}
|
||||
|
||||
if enableGREASE {
|
||||
// For default extension order with EnableGREASE, wrap with GREASE bookends
|
||||
if enableGREASE && (profile == nil || len(profile.Extensions) == 0) {
|
||||
extensions = append([]utls.TLSExtension{&utls.UtlsGREASEExtension{}}, extensions...)
|
||||
extensions = append(extensions, &utls.UtlsGREASEExtension{})
|
||||
}
|
||||
|
||||
@ -566,3 +455,12 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
|
||||
TLSVersMin: utls.VersionTLS10,
|
||||
}
|
||||
}
|
||||
|
||||
// toUint8s converts []uint16 to []uint8 (for utls fields that require []uint8).
|
||||
func toUint8s(vals []uint16) []uint8 {
|
||||
out := make([]uint8, len(vals))
|
||||
for i, v := range vals {
|
||||
out[i] = uint8(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
368
backend/internal/pkg/tlsfingerprint/dialer_capture_test.go
Normal file
368
backend/internal/pkg/tlsfingerprint/dialer_capture_test.go
Normal file
@ -0,0 +1,368 @@
|
||||
//go:build integration
|
||||
|
||||
package tlsfingerprint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
utls "github.com/refraction-networking/utls"
|
||||
)
|
||||
|
||||
// CapturedFingerprint mirrors the Fingerprint struct from tls-fingerprint-web.
|
||||
// Used to deserialize the JSON response from the capture server.
|
||||
type CapturedFingerprint struct {
|
||||
JA3Raw string `json:"ja3_raw"`
|
||||
JA3Hash string `json:"ja3_hash"`
|
||||
JA4 string `json:"ja4"`
|
||||
HTTP2 string `json:"http2"`
|
||||
CipherSuites []int `json:"cipher_suites"`
|
||||
Curves []int `json:"curves"`
|
||||
PointFormats []int `json:"point_formats"`
|
||||
Extensions []int `json:"extensions"`
|
||||
SignatureAlgorithms []int `json:"signature_algorithms"`
|
||||
ALPNProtocols []string `json:"alpn_protocols"`
|
||||
SupportedVersions []int `json:"supported_versions"`
|
||||
KeyShareGroups []int `json:"key_share_groups"`
|
||||
PSKModes []int `json:"psk_modes"`
|
||||
CompressCertAlgos []int `json:"compress_cert_algos"`
|
||||
EnableGREASE bool `json:"enable_grease"`
|
||||
}
|
||||
|
||||
// TestDialerAgainstCaptureServer connects to the tls-fingerprint-web capture server
|
||||
// and verifies that the dialer's TLS fingerprint matches the configured Profile.
|
||||
//
|
||||
// Default capture server: https://tls.sub2api.org:8090
|
||||
// Override with env: TLSFINGERPRINT_CAPTURE_URL=https://localhost:8443
|
||||
//
|
||||
// Run: go test -v -run TestDialerAgainstCaptureServer ./internal/pkg/tlsfingerprint/...
|
||||
func TestDialerAgainstCaptureServer(t *testing.T) {
|
||||
captureURL := os.Getenv("TLSFINGERPRINT_CAPTURE_URL")
|
||||
if captureURL == "" {
|
||||
captureURL = "https://tls.sub2api.org:8090"
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
profile *Profile
|
||||
}{
|
||||
{
|
||||
name: "default_profile",
|
||||
profile: &Profile{
|
||||
Name: "default",
|
||||
EnableGREASE: false,
|
||||
// All empty → uses built-in defaults
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "linux_x64_node_v22171",
|
||||
profile: &Profile{
|
||||
Name: "linux_x64_node_v22171",
|
||||
EnableGREASE: false,
|
||||
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
|
||||
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
|
||||
PointFormats: []uint16{0, 1, 2},
|
||||
SignatureAlgorithms: []uint16{0x0403, 0x0503, 0x0603, 0x0807, 0x0808, 0x0809, 0x080a, 0x080b, 0x0804, 0x0805, 0x0806, 0x0401, 0x0501, 0x0601, 0x0303, 0x0301, 0x0302, 0x0402, 0x0502, 0x0602},
|
||||
ALPNProtocols: []string{"http/1.1"},
|
||||
SupportedVersions: []uint16{0x0304, 0x0303},
|
||||
KeyShareGroups: []uint16{29},
|
||||
PSKModes: []uint16{1},
|
||||
Extensions: []uint16{0, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "macos_arm64_node_v2430",
|
||||
profile: &Profile{
|
||||
Name: "MacOS_arm64_node_v2430",
|
||||
EnableGREASE: false,
|
||||
CipherSuites: []uint16{4865, 4866, 4867, 49195, 49199, 49196, 49200, 52393, 52392, 49161, 49171, 49162, 49172, 156, 157, 47, 53},
|
||||
Curves: []uint16{29, 23, 24},
|
||||
PointFormats: []uint16{0},
|
||||
SignatureAlgorithms: []uint16{0x0403, 0x0804, 0x0401, 0x0503, 0x0805, 0x0501, 0x0806, 0x0601, 0x0201},
|
||||
ALPNProtocols: []string{"http/1.1"},
|
||||
SupportedVersions: []uint16{0x0304, 0x0303},
|
||||
KeyShareGroups: []uint16{29},
|
||||
PSKModes: []uint16{1},
|
||||
Extensions: []uint16{0, 65037, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
captured := fetchCapturedFingerprint(t, captureURL, tc.profile)
|
||||
if captured == nil {
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("JA3 Hash: %s", captured.JA3Hash)
|
||||
t.Logf("JA4: %s", captured.JA4)
|
||||
|
||||
// Resolve effective profile values (what the dialer actually uses)
|
||||
effectiveCipherSuites := tc.profile.CipherSuites
|
||||
if len(effectiveCipherSuites) == 0 {
|
||||
effectiveCipherSuites = defaultCipherSuites
|
||||
}
|
||||
effectiveCurves := tc.profile.Curves
|
||||
if len(effectiveCurves) == 0 {
|
||||
effectiveCurves = make([]uint16, len(defaultCurves))
|
||||
for i, c := range defaultCurves {
|
||||
effectiveCurves[i] = uint16(c)
|
||||
}
|
||||
}
|
||||
effectivePointFormats := tc.profile.PointFormats
|
||||
if len(effectivePointFormats) == 0 {
|
||||
effectivePointFormats = defaultPointFormats
|
||||
}
|
||||
effectiveSigAlgs := tc.profile.SignatureAlgorithms
|
||||
if len(effectiveSigAlgs) == 0 {
|
||||
effectiveSigAlgs = make([]uint16, len(defaultSignatureAlgorithms))
|
||||
for i, s := range defaultSignatureAlgorithms {
|
||||
effectiveSigAlgs[i] = uint16(s)
|
||||
}
|
||||
}
|
||||
effectiveALPN := tc.profile.ALPNProtocols
|
||||
if len(effectiveALPN) == 0 {
|
||||
effectiveALPN = []string{"http/1.1"}
|
||||
}
|
||||
effectiveVersions := tc.profile.SupportedVersions
|
||||
if len(effectiveVersions) == 0 {
|
||||
effectiveVersions = []uint16{0x0304, 0x0303}
|
||||
}
|
||||
effectiveKeyShare := tc.profile.KeyShareGroups
|
||||
if len(effectiveKeyShare) == 0 {
|
||||
effectiveKeyShare = []uint16{29} // X25519
|
||||
}
|
||||
effectivePSKModes := tc.profile.PSKModes
|
||||
if len(effectivePSKModes) == 0 {
|
||||
effectivePSKModes = []uint16{1} // psk_dhe_ke
|
||||
}
|
||||
|
||||
// Verify each field
|
||||
assertIntSliceEqual(t, "cipher_suites", uint16sToInts(effectiveCipherSuites), captured.CipherSuites)
|
||||
assertIntSliceEqual(t, "curves", uint16sToInts(effectiveCurves), captured.Curves)
|
||||
assertIntSliceEqual(t, "point_formats", uint16sToInts(effectivePointFormats), captured.PointFormats)
|
||||
assertIntSliceEqual(t, "signature_algorithms", uint16sToInts(effectiveSigAlgs), captured.SignatureAlgorithms)
|
||||
assertStringSliceEqual(t, "alpn_protocols", effectiveALPN, captured.ALPNProtocols)
|
||||
assertIntSliceEqual(t, "supported_versions", uint16sToInts(effectiveVersions), captured.SupportedVersions)
|
||||
assertIntSliceEqual(t, "key_share_groups", uint16sToInts(effectiveKeyShare), captured.KeyShareGroups)
|
||||
assertIntSliceEqual(t, "psk_modes", uint16sToInts(effectivePSKModes), captured.PSKModes)
|
||||
|
||||
if captured.EnableGREASE != tc.profile.EnableGREASE {
|
||||
t.Errorf("enable_grease: got %v, want %v", captured.EnableGREASE, tc.profile.EnableGREASE)
|
||||
} else {
|
||||
t.Logf(" enable_grease: %v OK", captured.EnableGREASE)
|
||||
}
|
||||
|
||||
// Verify extension order
|
||||
// Use profile.Extensions if set, otherwise the default order (Node.js 24.x)
|
||||
expectedExtOrder := uint16sToInts(defaultExtensionOrder)
|
||||
if len(tc.profile.Extensions) > 0 {
|
||||
expectedExtOrder = uint16sToInts(tc.profile.Extensions)
|
||||
}
|
||||
// Strip GREASE values from both expected and captured for comparison
|
||||
var filteredExpected, filteredActual []int
|
||||
for _, e := range expectedExtOrder {
|
||||
if !isGREASEValue(uint16(e)) {
|
||||
filteredExpected = append(filteredExpected, e)
|
||||
}
|
||||
}
|
||||
for _, e := range captured.Extensions {
|
||||
if !isGREASEValue(uint16(e)) {
|
||||
filteredActual = append(filteredActual, e)
|
||||
}
|
||||
}
|
||||
assertIntSliceEqual(t, "extensions (order, non-GREASE)", filteredExpected, filteredActual)
|
||||
|
||||
// Print full captured data as JSON for debugging
|
||||
capturedJSON, _ := json.MarshalIndent(captured, " ", " ")
|
||||
t.Logf("Full captured fingerprint:\n %s", string(capturedJSON))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fetchCapturedFingerprint(t *testing.T, captureURL string, profile *Profile) *CapturedFingerprint {
|
||||
t.Helper()
|
||||
|
||||
dialer := NewDialer(profile, nil)
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialTLSContext: dialer.DialTLSContext,
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", captureURL, strings.NewReader(`{"model":"test"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("create request: %v", err)
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var fp CapturedFingerprint
|
||||
if err := json.Unmarshal(body, &fp); err != nil {
|
||||
t.Logf("Response body: %s", string(body))
|
||||
t.Fatalf("parse response: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &fp
|
||||
}
|
||||
|
||||
func uint16sToInts(vals []uint16) []int {
|
||||
result := make([]int, len(vals))
|
||||
for i, v := range vals {
|
||||
result[i] = int(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func assertIntSliceEqual(t *testing.T, name string, expected, actual []int) {
|
||||
t.Helper()
|
||||
if len(expected) != len(actual) {
|
||||
t.Errorf("%s: length mismatch: got %d, want %d", name, len(actual), len(expected))
|
||||
if len(actual) < 20 && len(expected) < 20 {
|
||||
t.Errorf(" got: %v", actual)
|
||||
t.Errorf(" want: %v", expected)
|
||||
}
|
||||
return
|
||||
}
|
||||
mismatches := 0
|
||||
for i := range expected {
|
||||
if expected[i] != actual[i] {
|
||||
if mismatches < 5 {
|
||||
t.Errorf("%s[%d]: got %d (0x%04x), want %d (0x%04x)", name, i, actual[i], actual[i], expected[i], expected[i])
|
||||
}
|
||||
mismatches++
|
||||
}
|
||||
}
|
||||
if mismatches == 0 {
|
||||
t.Logf(" %s: %d items OK", name, len(expected))
|
||||
} else if mismatches > 5 {
|
||||
t.Errorf(" %s: %d/%d mismatches (showing first 5)", name, mismatches, len(expected))
|
||||
}
|
||||
}
|
||||
|
||||
func assertStringSliceEqual(t *testing.T, name string, expected, actual []string) {
|
||||
t.Helper()
|
||||
if len(expected) != len(actual) {
|
||||
t.Errorf("%s: length mismatch: got %d (%v), want %d (%v)", name, len(actual), actual, len(expected), expected)
|
||||
return
|
||||
}
|
||||
for i := range expected {
|
||||
if expected[i] != actual[i] {
|
||||
t.Errorf("%s[%d]: got %q, want %q", name, i, actual[i], expected[i])
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Logf(" %s: %v OK", name, expected)
|
||||
}
|
||||
|
||||
// TestBuildClientHelloSpecNewFields tests that new Profile fields are correctly applied.
|
||||
func TestBuildClientHelloSpecNewFields(t *testing.T) {
|
||||
// Test custom ALPN, versions, key shares, PSK modes
|
||||
profile := &Profile{
|
||||
Name: "custom_full",
|
||||
EnableGREASE: false,
|
||||
CipherSuites: []uint16{0x1301, 0x1302},
|
||||
Curves: []uint16{29, 23},
|
||||
PointFormats: []uint16{0},
|
||||
SignatureAlgorithms: []uint16{0x0403, 0x0804},
|
||||
ALPNProtocols: []string{"h2", "http/1.1"},
|
||||
SupportedVersions: []uint16{0x0304},
|
||||
KeyShareGroups: []uint16{29, 23},
|
||||
PSKModes: []uint16{1},
|
||||
}
|
||||
|
||||
spec := buildClientHelloSpecFromProfile(profile)
|
||||
|
||||
// Verify cipher suites
|
||||
if len(spec.CipherSuites) != 2 || spec.CipherSuites[0] != 0x1301 {
|
||||
t.Errorf("cipher suites: got %v", spec.CipherSuites)
|
||||
}
|
||||
|
||||
// Check extensions for expected values
|
||||
var foundALPN, foundVersions, foundKeyShare, foundPSK, foundSigAlgs bool
|
||||
for _, ext := range spec.Extensions {
|
||||
switch e := ext.(type) {
|
||||
case *utls.ALPNExtension:
|
||||
foundALPN = true
|
||||
if len(e.AlpnProtocols) != 2 || e.AlpnProtocols[0] != "h2" {
|
||||
t.Errorf("ALPN: got %v, want [h2, http/1.1]", e.AlpnProtocols)
|
||||
}
|
||||
case *utls.SupportedVersionsExtension:
|
||||
foundVersions = true
|
||||
if len(e.Versions) != 1 || e.Versions[0] != 0x0304 {
|
||||
t.Errorf("versions: got %v, want [0x0304]", e.Versions)
|
||||
}
|
||||
case *utls.KeyShareExtension:
|
||||
foundKeyShare = true
|
||||
if len(e.KeyShares) != 2 {
|
||||
t.Errorf("key shares: got %d entries, want 2", len(e.KeyShares))
|
||||
}
|
||||
case *utls.PSKKeyExchangeModesExtension:
|
||||
foundPSK = true
|
||||
if len(e.Modes) != 1 || e.Modes[0] != 1 {
|
||||
t.Errorf("PSK modes: got %v, want [1]", e.Modes)
|
||||
}
|
||||
case *utls.SignatureAlgorithmsExtension:
|
||||
foundSigAlgs = true
|
||||
if len(e.SupportedSignatureAlgorithms) != 2 {
|
||||
t.Errorf("sig algs: got %d, want 2", len(e.SupportedSignatureAlgorithms))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for name, found := range map[string]bool{
|
||||
"ALPN": foundALPN, "Versions": foundVersions, "KeyShare": foundKeyShare,
|
||||
"PSK": foundPSK, "SigAlgs": foundSigAlgs,
|
||||
} {
|
||||
if !found {
|
||||
t.Errorf("extension %s not found in spec", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Test nil profile uses all defaults
|
||||
specDefault := buildClientHelloSpecFromProfile(nil)
|
||||
for _, ext := range specDefault.Extensions {
|
||||
switch e := ext.(type) {
|
||||
case *utls.ALPNExtension:
|
||||
if len(e.AlpnProtocols) != 1 || e.AlpnProtocols[0] != "http/1.1" {
|
||||
t.Errorf("default ALPN: got %v, want [http/1.1]", e.AlpnProtocols)
|
||||
}
|
||||
case *utls.SupportedVersionsExtension:
|
||||
if len(e.Versions) != 2 {
|
||||
t.Errorf("default versions: got %v, want 2 entries", e.Versions)
|
||||
}
|
||||
case *utls.KeyShareExtension:
|
||||
if len(e.KeyShares) != 1 {
|
||||
t.Errorf("default key shares: got %d, want 1", len(e.KeyShares))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("TestBuildClientHelloSpecNewFields passed")
|
||||
}
|
||||
@ -40,16 +40,15 @@ func skipIfExternalServiceUnavailable(t *testing.T, err error) {
|
||||
|
||||
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
|
||||
// This test uses tls.peet.ws to verify the fingerprint.
|
||||
// Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x)
|
||||
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP)
|
||||
// Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
|
||||
// Expected JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
|
||||
func TestJA3Fingerprint(t *testing.T) {
|
||||
// Skip if network is unavailable or if running in short mode
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := &Profile{
|
||||
Name: "Claude CLI Test",
|
||||
Name: "Default Profile Test",
|
||||
EnableGREASE: false,
|
||||
}
|
||||
dialer := NewDialer(profile, nil)
|
||||
@ -61,7 +60,6 @@ func TestJA3Fingerprint(t *testing.T) {
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Use tls.peet.ws fingerprint detection API
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@ -69,7 +67,7 @@ func TestJA3Fingerprint(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0")
|
||||
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/24.3.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
skipIfExternalServiceUnavailable(t, err)
|
||||
@ -86,71 +84,23 @@ func TestJA3Fingerprint(t *testing.T) {
|
||||
t.Fatalf("failed to parse fingerprint response: %v", err)
|
||||
}
|
||||
|
||||
// Log all fingerprint information
|
||||
t.Logf("JA3: %s", fpResp.TLS.JA3)
|
||||
t.Logf("JA3 Hash: %s", fpResp.TLS.JA3Hash)
|
||||
t.Logf("JA4: %s", fpResp.TLS.JA4)
|
||||
t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint)
|
||||
t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash)
|
||||
|
||||
// Verify JA3 hash matches expected value
|
||||
expectedJA3Hash := "1a28e69016765d92e3b381168d68922c"
|
||||
expectedJA3Hash := "44f88fca027f27bab4bb08d4af15f23e"
|
||||
if fpResp.TLS.JA3Hash == expectedJA3Hash {
|
||||
t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash)
|
||||
t.Logf("✓ JA3 hash matches: %s", expectedJA3Hash)
|
||||
} else {
|
||||
t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash)
|
||||
}
|
||||
|
||||
// Verify JA4 fingerprint
|
||||
// JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash]
|
||||
// Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP)
|
||||
// The suffix _a33745022dd6_1f22a2ca17c4 should match
|
||||
expectedJA4Suffix := "_a33745022dd6_1f22a2ca17c4"
|
||||
if strings.HasSuffix(fpResp.TLS.JA4, expectedJA4Suffix) {
|
||||
t.Logf("✓ JA4 suffix matches expected value: %s", expectedJA4Suffix)
|
||||
expectedJA4CipherHash := "_5b57614c22b0_"
|
||||
if strings.Contains(fpResp.TLS.JA4, expectedJA4CipherHash) {
|
||||
t.Logf("✓ JA4 cipher hash matches: %s", expectedJA4CipherHash)
|
||||
} else {
|
||||
t.Errorf("✗ JA4 suffix mismatch: got %s, expected suffix %s", fpResp.TLS.JA4, expectedJA4Suffix)
|
||||
t.Errorf("✗ JA4 cipher hash mismatch: got %s, expected containing %s", fpResp.TLS.JA4, expectedJA4CipherHash)
|
||||
}
|
||||
|
||||
// Verify JA4 prefix (t13d5911h1 or t13i5911h1)
|
||||
// d = domain (SNI present), i = IP (no SNI)
|
||||
// Since we connect to tls.peet.ws (domain), we expect 'd'
|
||||
expectedJA4Prefix := "t13d5911h1"
|
||||
if strings.HasPrefix(fpResp.TLS.JA4, expectedJA4Prefix) {
|
||||
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 59=ciphers, 11=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
|
||||
} else {
|
||||
// Also accept 'i' variant for IP connections
|
||||
altPrefix := "t13i5911h1"
|
||||
if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) {
|
||||
t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix)
|
||||
} else {
|
||||
t.Errorf("✗ JA4 prefix mismatch: got %s, expected %s or %s", fpResp.TLS.JA4, expectedJA4Prefix, altPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning)
|
||||
if strings.Contains(fpResp.TLS.JA3, "4866-4867-4865") {
|
||||
t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites")
|
||||
} else {
|
||||
t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites")
|
||||
}
|
||||
|
||||
// Verify extension list (should be 11 extensions including SNI)
|
||||
// Expected: 0-11-10-35-16-22-23-13-43-45-51
|
||||
expectedExtensions := "0-11-10-35-16-22-23-13-43-45-51"
|
||||
if strings.Contains(fpResp.TLS.JA3, expectedExtensions) {
|
||||
t.Logf("✓ JA3 contains expected extension list: %s", expectedExtensions)
|
||||
} else {
|
||||
t.Logf("Warning: JA3 extension list may differ")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileExpectation defines expected fingerprint values for a profile.
|
||||
type TestProfileExpectation struct {
|
||||
Profile *Profile
|
||||
ExpectedJA3 string // Expected JA3 hash (empty = don't check)
|
||||
ExpectedJA4 string // Expected full JA4 (empty = don't check)
|
||||
JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check)
|
||||
}
|
||||
|
||||
// TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
|
||||
@ -164,30 +114,24 @@ func TestAllProfiles(t *testing.T) {
|
||||
// These profiles are from config.yaml gateway.tls_fingerprint.profiles
|
||||
profiles := []TestProfileExpectation{
|
||||
{
|
||||
// Linux x64 Node.js v22.17.1
|
||||
// Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c
|
||||
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4
|
||||
// Default profile (Node.js 24.x)
|
||||
Profile: &Profile{
|
||||
Name: "default_node_v24",
|
||||
EnableGREASE: false,
|
||||
},
|
||||
JA4CipherHash: "5b57614c22b0",
|
||||
},
|
||||
{
|
||||
// Linux x64 Node.js v22.17.1 (explicit profile with v22 extensions)
|
||||
Profile: &Profile{
|
||||
Name: "linux_x64_node_v22171",
|
||||
EnableGREASE: false,
|
||||
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
|
||||
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
|
||||
PointFormats: []uint8{0, 1, 2},
|
||||
PointFormats: []uint16{0, 1, 2},
|
||||
Extensions: []uint16{0, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51},
|
||||
},
|
||||
JA4CipherHash: "a33745022dd6", // stable part
|
||||
},
|
||||
{
|
||||
// MacOS arm64 Node.js v22.18.0
|
||||
// Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea
|
||||
// Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406
|
||||
Profile: &Profile{
|
||||
Name: "macos_arm64_node_v22180",
|
||||
EnableGREASE: false,
|
||||
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
|
||||
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
|
||||
PointFormats: []uint8{0, 1, 2},
|
||||
},
|
||||
JA4CipherHash: "a33745022dd6", // stable part (same cipher suites)
|
||||
JA4CipherHash: "a33745022dd6",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -55,13 +55,13 @@ func TestDialerBasicConnection(t *testing.T) {
|
||||
|
||||
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
|
||||
// This test uses tls.peet.ws to verify the fingerprint.
|
||||
// Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x)
|
||||
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP)
|
||||
// Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
|
||||
// Expected JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
|
||||
func TestJA3Fingerprint(t *testing.T) {
|
||||
skipNetworkTest(t)
|
||||
|
||||
profile := &Profile{
|
||||
Name: "Claude CLI Test",
|
||||
Name: "Default Profile Test",
|
||||
EnableGREASE: false,
|
||||
}
|
||||
dialer := NewDialer(profile, nil)
|
||||
@ -81,7 +81,7 @@ func TestJA3Fingerprint(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0")
|
||||
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/24.3.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
@ -107,34 +107,28 @@ func TestJA3Fingerprint(t *testing.T) {
|
||||
t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint)
|
||||
t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash)
|
||||
|
||||
// Verify JA3 hash matches expected value
|
||||
expectedJA3Hash := "1a28e69016765d92e3b381168d68922c"
|
||||
// Verify JA3 hash matches expected value (Node.js 24.x default)
|
||||
expectedJA3Hash := "44f88fca027f27bab4bb08d4af15f23e"
|
||||
if fpResp.TLS.JA3Hash == expectedJA3Hash {
|
||||
t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash)
|
||||
} else {
|
||||
t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash)
|
||||
}
|
||||
|
||||
// Verify JA4 fingerprint
|
||||
// JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash]
|
||||
// Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP)
|
||||
// The suffix _a33745022dd6_1f22a2ca17c4 should match
|
||||
expectedJA4Suffix := "_a33745022dd6_1f22a2ca17c4"
|
||||
if strings.HasSuffix(fpResp.TLS.JA4, expectedJA4Suffix) {
|
||||
t.Logf("✓ JA4 suffix matches expected value: %s", expectedJA4Suffix)
|
||||
// Verify JA4 cipher hash (stable middle part)
|
||||
expectedJA4CipherHash := "_5b57614c22b0_"
|
||||
if strings.Contains(fpResp.TLS.JA4, expectedJA4CipherHash) {
|
||||
t.Logf("✓ JA4 cipher hash matches: %s", expectedJA4CipherHash)
|
||||
} else {
|
||||
t.Errorf("✗ JA4 suffix mismatch: got %s, expected suffix %s", fpResp.TLS.JA4, expectedJA4Suffix)
|
||||
t.Errorf("✗ JA4 cipher hash mismatch: got %s, expected containing %s", fpResp.TLS.JA4, expectedJA4CipherHash)
|
||||
}
|
||||
|
||||
// Verify JA4 prefix (t13d5911h1 or t13i5911h1)
|
||||
// d = domain (SNI present), i = IP (no SNI)
|
||||
// Since we connect to tls.peet.ws (domain), we expect 'd'
|
||||
expectedJA4Prefix := "t13d5911h1"
|
||||
// Verify JA4 prefix (t13d1714h1 or t13i1714h1)
|
||||
expectedJA4Prefix := "t13d1714h1"
|
||||
if strings.HasPrefix(fpResp.TLS.JA4, expectedJA4Prefix) {
|
||||
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 59=ciphers, 11=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
|
||||
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 17=ciphers, 14=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
|
||||
} else {
|
||||
// Also accept 'i' variant for IP connections
|
||||
altPrefix := "t13i5911h1"
|
||||
altPrefix := "t13i1714h1"
|
||||
if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) {
|
||||
t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix)
|
||||
} else {
|
||||
@ -142,16 +136,15 @@ func TestJA3Fingerprint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning)
|
||||
if strings.Contains(fpResp.TLS.JA3, "4866-4867-4865") {
|
||||
// Verify JA3 contains expected TLS 1.3 cipher suites
|
||||
if strings.Contains(fpResp.TLS.JA3, "4865-4866-4867") {
|
||||
t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites")
|
||||
} else {
|
||||
t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites")
|
||||
}
|
||||
|
||||
// Verify extension list (should be 11 extensions including SNI)
|
||||
// Expected: 0-11-10-35-16-22-23-13-43-45-51
|
||||
expectedExtensions := "0-11-10-35-16-22-23-13-43-45-51"
|
||||
// Verify extension list (14 extensions, Node.js 24.x order)
|
||||
expectedExtensions := "0-65037-23-65281-10-11-35-16-5-13-18-51-45-43"
|
||||
if strings.Contains(fpResp.TLS.JA3, expectedExtensions) {
|
||||
t.Logf("✓ JA3 contains expected extension list: %s", expectedExtensions)
|
||||
} else {
|
||||
@ -186,8 +179,8 @@ func TestDialerWithProfile(t *testing.T) {
|
||||
// Build specs and compare
|
||||
// Note: We can't directly compare JA3 without making network requests
|
||||
// but we can verify the specs are different
|
||||
spec1 := dialer1.buildClientHelloSpec()
|
||||
spec2 := dialer2.buildClientHelloSpec()
|
||||
spec1 := buildClientHelloSpecFromProfile(dialer1.profile)
|
||||
spec2 := buildClientHelloSpecFromProfile(dialer2.profile)
|
||||
|
||||
// Profile with GREASE should have more extensions
|
||||
if len(spec2.Extensions) <= len(spec1.Extensions) {
|
||||
@ -296,47 +289,33 @@ func mustParseURL(rawURL string) *url.URL {
|
||||
return u
|
||||
}
|
||||
|
||||
// TestProfileExpectation defines expected fingerprint values for a profile.
|
||||
type TestProfileExpectation struct {
|
||||
Profile *Profile
|
||||
ExpectedJA3 string // Expected JA3 hash (empty = don't check)
|
||||
ExpectedJA4 string // Expected full JA4 (empty = don't check)
|
||||
JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check)
|
||||
}
|
||||
|
||||
// TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
|
||||
// Run with: go test -v -run TestAllProfiles ./internal/pkg/tlsfingerprint/...
|
||||
func TestAllProfiles(t *testing.T) {
|
||||
skipNetworkTest(t)
|
||||
|
||||
// Define all profiles to test with their expected fingerprints
|
||||
// These profiles are from config.yaml gateway.tls_fingerprint.profiles
|
||||
profiles := []TestProfileExpectation{
|
||||
{
|
||||
// Linux x64 Node.js v22.17.1
|
||||
// Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c
|
||||
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4
|
||||
// Default profile (Node.js 24.x)
|
||||
// JA3 Hash: 44f88fca027f27bab4bb08d4af15f23e
|
||||
// JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
|
||||
Profile: &Profile{
|
||||
Name: "default_node_v24",
|
||||
EnableGREASE: false,
|
||||
},
|
||||
JA4CipherHash: "5b57614c22b0",
|
||||
},
|
||||
{
|
||||
// Linux x64 Node.js v22.17.1 (explicit profile)
|
||||
Profile: &Profile{
|
||||
Name: "linux_x64_node_v22171",
|
||||
EnableGREASE: false,
|
||||
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
|
||||
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
|
||||
PointFormats: []uint8{0, 1, 2},
|
||||
PointFormats: []uint16{0, 1, 2},
|
||||
Extensions: []uint16{0, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51},
|
||||
},
|
||||
JA4CipherHash: "a33745022dd6", // stable part
|
||||
},
|
||||
{
|
||||
// MacOS arm64 Node.js v22.18.0
|
||||
// Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea
|
||||
// Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406
|
||||
Profile: &Profile{
|
||||
Name: "macos_arm64_node_v22180",
|
||||
EnableGREASE: false,
|
||||
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
|
||||
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
|
||||
PointFormats: []uint8{0, 1, 2},
|
||||
},
|
||||
JA4CipherHash: "a33745022dd6", // stable part (same cipher suites)
|
||||
JA4CipherHash: "a33745022dd6",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -1,171 +0,0 @@
|
||||
// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients.
|
||||
package tlsfingerprint
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
)
|
||||
|
||||
// DefaultProfileName is the name of the built-in Claude CLI profile.
|
||||
const DefaultProfileName = "claude_cli_v2"
|
||||
|
||||
// Registry manages TLS fingerprint profiles.
|
||||
// It holds a collection of profiles that can be used for TLS fingerprint simulation.
|
||||
// Profiles are selected based on account ID using modulo operation.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
profiles map[string]*Profile
|
||||
profileNames []string // Sorted list of profile names for deterministic selection
|
||||
}
|
||||
|
||||
// NewRegistry creates a new TLS fingerprint profile registry.
|
||||
// It initializes with the built-in default profile.
|
||||
func NewRegistry() *Registry {
|
||||
r := &Registry{
|
||||
profiles: make(map[string]*Profile),
|
||||
profileNames: make([]string, 0),
|
||||
}
|
||||
|
||||
// Register the built-in default profile
|
||||
r.registerBuiltinProfile()
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// NewRegistryFromConfig creates a new registry and loads profiles from config.
|
||||
// If the config has custom profiles defined, they will be merged with the built-in default.
|
||||
func NewRegistryFromConfig(cfg *config.TLSFingerprintConfig) *Registry {
|
||||
r := NewRegistry()
|
||||
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
slog.Debug("tls_registry_disabled", "reason", "disabled or no config")
|
||||
return r
|
||||
}
|
||||
|
||||
// Load custom profiles from config
|
||||
for name, profileCfg := range cfg.Profiles {
|
||||
profile := &Profile{
|
||||
Name: profileCfg.Name,
|
||||
EnableGREASE: profileCfg.EnableGREASE,
|
||||
CipherSuites: profileCfg.CipherSuites,
|
||||
Curves: profileCfg.Curves,
|
||||
PointFormats: profileCfg.PointFormats,
|
||||
}
|
||||
|
||||
// If the profile has empty values, they will use defaults in dialer
|
||||
r.RegisterProfile(name, profile)
|
||||
slog.Debug("tls_registry_loaded_profile", "key", name, "name", profileCfg.Name)
|
||||
}
|
||||
|
||||
slog.Debug("tls_registry_initialized", "profile_count", len(r.profileNames), "profiles", r.profileNames)
|
||||
return r
|
||||
}
|
||||
|
||||
// registerBuiltinProfile adds the default Claude CLI profile to the registry.
|
||||
func (r *Registry) registerBuiltinProfile() {
|
||||
defaultProfile := &Profile{
|
||||
Name: "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)",
|
||||
EnableGREASE: false, // Node.js does not use GREASE
|
||||
// Empty slices will cause dialer to use built-in defaults
|
||||
CipherSuites: nil,
|
||||
Curves: nil,
|
||||
PointFormats: nil,
|
||||
}
|
||||
r.RegisterProfile(DefaultProfileName, defaultProfile)
|
||||
}
|
||||
|
||||
// RegisterProfile adds or updates a profile in the registry.
|
||||
func (r *Registry) RegisterProfile(name string, profile *Profile) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Check if this is a new profile
|
||||
_, exists := r.profiles[name]
|
||||
r.profiles[name] = profile
|
||||
|
||||
if !exists {
|
||||
r.profileNames = append(r.profileNames, name)
|
||||
// Keep names sorted for deterministic selection
|
||||
sort.Strings(r.profileNames)
|
||||
}
|
||||
}
|
||||
|
||||
// GetProfile returns a profile by name.
|
||||
// Returns nil if the profile does not exist.
|
||||
func (r *Registry) GetProfile(name string) *Profile {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.profiles[name]
|
||||
}
|
||||
|
||||
// GetDefaultProfile returns the built-in default profile.
|
||||
func (r *Registry) GetDefaultProfile() *Profile {
|
||||
return r.GetProfile(DefaultProfileName)
|
||||
}
|
||||
|
||||
// GetProfileByAccountID returns a profile for the given account ID.
|
||||
// The profile is selected using: profileNames[accountID % len(profiles)]
|
||||
// This ensures deterministic profile assignment for each account.
|
||||
func (r *Registry) GetProfileByAccountID(accountID int64) *Profile {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if len(r.profileNames) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use modulo to select profile index
|
||||
// Use absolute value to handle negative IDs (though unlikely)
|
||||
idx := accountID
|
||||
if idx < 0 {
|
||||
idx = -idx
|
||||
}
|
||||
selectedIndex := int(idx % int64(len(r.profileNames)))
|
||||
selectedName := r.profileNames[selectedIndex]
|
||||
|
||||
return r.profiles[selectedName]
|
||||
}
|
||||
|
||||
// ProfileCount returns the number of registered profiles.
|
||||
func (r *Registry) ProfileCount() int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.profiles)
|
||||
}
|
||||
|
||||
// ProfileNames returns a sorted list of all registered profile names.
|
||||
func (r *Registry) ProfileNames() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
// Return a copy to prevent modification
|
||||
names := make([]string, len(r.profileNames))
|
||||
copy(names, r.profileNames)
|
||||
return names
|
||||
}
|
||||
|
||||
// Global registry instance for convenience
|
||||
var globalRegistry *Registry
|
||||
var globalRegistryOnce sync.Once
|
||||
|
||||
// GlobalRegistry returns the global TLS fingerprint registry.
|
||||
// The registry is lazily initialized with the default profile.
|
||||
func GlobalRegistry() *Registry {
|
||||
globalRegistryOnce.Do(func() {
|
||||
globalRegistry = NewRegistry()
|
||||
})
|
||||
return globalRegistry
|
||||
}
|
||||
|
||||
// InitGlobalRegistry initializes the global registry with configuration.
|
||||
// This should be called during application startup.
|
||||
// It is safe to call multiple times; subsequent calls will update the registry.
|
||||
func InitGlobalRegistry(cfg *config.TLSFingerprintConfig) *Registry {
|
||||
globalRegistryOnce.Do(func() {
|
||||
globalRegistry = NewRegistryFromConfig(cfg)
|
||||
})
|
||||
return globalRegistry
|
||||
}
|
||||
@ -1,243 +0,0 @@
|
||||
package tlsfingerprint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
)
|
||||
|
||||
func TestNewRegistry(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
// Should have exactly one profile (the default)
|
||||
if r.ProfileCount() != 1 {
|
||||
t.Errorf("expected 1 profile, got %d", r.ProfileCount())
|
||||
}
|
||||
|
||||
// Should have the default profile
|
||||
profile := r.GetDefaultProfile()
|
||||
if profile == nil {
|
||||
t.Error("expected default profile to exist")
|
||||
}
|
||||
|
||||
// Default profile name should be in the list
|
||||
names := r.ProfileNames()
|
||||
if len(names) != 1 || names[0] != DefaultProfileName {
|
||||
t.Errorf("expected profile names to be [%s], got %v", DefaultProfileName, names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterProfile(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
// Register a new profile
|
||||
customProfile := &Profile{
|
||||
Name: "Custom Profile",
|
||||
EnableGREASE: true,
|
||||
}
|
||||
r.RegisterProfile("custom", customProfile)
|
||||
|
||||
// Should now have 2 profiles
|
||||
if r.ProfileCount() != 2 {
|
||||
t.Errorf("expected 2 profiles, got %d", r.ProfileCount())
|
||||
}
|
||||
|
||||
// Should be able to retrieve the custom profile
|
||||
retrieved := r.GetProfile("custom")
|
||||
if retrieved == nil {
|
||||
t.Fatal("expected custom profile to exist")
|
||||
}
|
||||
if retrieved.Name != "Custom Profile" {
|
||||
t.Errorf("expected profile name 'Custom Profile', got '%s'", retrieved.Name)
|
||||
}
|
||||
if !retrieved.EnableGREASE {
|
||||
t.Error("expected EnableGREASE to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProfile(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
// Get existing profile
|
||||
profile := r.GetProfile(DefaultProfileName)
|
||||
if profile == nil {
|
||||
t.Error("expected default profile to exist")
|
||||
}
|
||||
|
||||
// Get non-existing profile
|
||||
nonExistent := r.GetProfile("nonexistent")
|
||||
if nonExistent != nil {
|
||||
t.Error("expected nil for non-existent profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProfileByAccountID(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
// With only default profile, all account IDs should return the same profile
|
||||
for i := int64(0); i < 10; i++ {
|
||||
profile := r.GetProfileByAccountID(i)
|
||||
if profile == nil {
|
||||
t.Errorf("expected profile for account %d, got nil", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Add more profiles
|
||||
r.RegisterProfile("profile_a", &Profile{Name: "Profile A"})
|
||||
r.RegisterProfile("profile_b", &Profile{Name: "Profile B"})
|
||||
|
||||
// Now we have 3 profiles: claude_cli_v2, profile_a, profile_b
|
||||
// Names are sorted, so order is: claude_cli_v2, profile_a, profile_b
|
||||
expectedOrder := []string{DefaultProfileName, "profile_a", "profile_b"}
|
||||
names := r.ProfileNames()
|
||||
for i, name := range expectedOrder {
|
||||
if names[i] != name {
|
||||
t.Errorf("expected name at index %d to be %s, got %s", i, name, names[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Test modulo selection
|
||||
// Account ID 0 % 3 = 0 -> claude_cli_v2
|
||||
// Account ID 1 % 3 = 1 -> profile_a
|
||||
// Account ID 2 % 3 = 2 -> profile_b
|
||||
// Account ID 3 % 3 = 0 -> claude_cli_v2
|
||||
testCases := []struct {
|
||||
accountID int64
|
||||
expectedName string
|
||||
}{
|
||||
{0, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"},
|
||||
{1, "Profile A"},
|
||||
{2, "Profile B"},
|
||||
{3, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"},
|
||||
{4, "Profile A"},
|
||||
{5, "Profile B"},
|
||||
{100, "Profile A"}, // 100 % 3 = 1
|
||||
{-1, "Profile A"}, // |-1| % 3 = 1
|
||||
{-3, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"}, // |-3| % 3 = 0
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
profile := r.GetProfileByAccountID(tc.accountID)
|
||||
if profile == nil {
|
||||
t.Errorf("expected profile for account %d, got nil", tc.accountID)
|
||||
continue
|
||||
}
|
||||
if profile.Name != tc.expectedName {
|
||||
t.Errorf("account %d: expected profile name '%s', got '%s'", tc.accountID, tc.expectedName, profile.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRegistryFromConfig(t *testing.T) {
|
||||
// Test with nil config
|
||||
r := NewRegistryFromConfig(nil)
|
||||
if r.ProfileCount() != 1 {
|
||||
t.Errorf("expected 1 profile with nil config, got %d", r.ProfileCount())
|
||||
}
|
||||
|
||||
// Test with disabled config
|
||||
disabledCfg := &config.TLSFingerprintConfig{
|
||||
Enabled: false,
|
||||
}
|
||||
r = NewRegistryFromConfig(disabledCfg)
|
||||
if r.ProfileCount() != 1 {
|
||||
t.Errorf("expected 1 profile with disabled config, got %d", r.ProfileCount())
|
||||
}
|
||||
|
||||
// Test with enabled config and custom profiles
|
||||
enabledCfg := &config.TLSFingerprintConfig{
|
||||
Enabled: true,
|
||||
Profiles: map[string]config.TLSProfileConfig{
|
||||
"custom1": {
|
||||
Name: "Custom Profile 1",
|
||||
EnableGREASE: true,
|
||||
},
|
||||
"custom2": {
|
||||
Name: "Custom Profile 2",
|
||||
EnableGREASE: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
r = NewRegistryFromConfig(enabledCfg)
|
||||
|
||||
// Should have 3 profiles: default + 2 custom
|
||||
if r.ProfileCount() != 3 {
|
||||
t.Errorf("expected 3 profiles, got %d", r.ProfileCount())
|
||||
}
|
||||
|
||||
// Check custom profiles exist
|
||||
custom1 := r.GetProfile("custom1")
|
||||
if custom1 == nil || custom1.Name != "Custom Profile 1" {
|
||||
t.Error("expected custom1 profile to exist with correct name")
|
||||
}
|
||||
custom2 := r.GetProfile("custom2")
|
||||
if custom2 == nil || custom2.Name != "Custom Profile 2" {
|
||||
t.Error("expected custom2 profile to exist with correct name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileNames(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
// Add profiles in non-alphabetical order
|
||||
r.RegisterProfile("zebra", &Profile{Name: "Zebra"})
|
||||
r.RegisterProfile("alpha", &Profile{Name: "Alpha"})
|
||||
r.RegisterProfile("beta", &Profile{Name: "Beta"})
|
||||
|
||||
names := r.ProfileNames()
|
||||
|
||||
// Should be sorted alphabetically
|
||||
expected := []string{"alpha", "beta", DefaultProfileName, "zebra"}
|
||||
if len(names) != len(expected) {
|
||||
t.Errorf("expected %d names, got %d", len(expected), len(names))
|
||||
}
|
||||
for i, name := range expected {
|
||||
if names[i] != name {
|
||||
t.Errorf("expected name at index %d to be %s, got %s", i, name, names[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Test that returned slice is a copy (modifying it shouldn't affect registry)
|
||||
names[0] = "modified"
|
||||
originalNames := r.ProfileNames()
|
||||
if originalNames[0] == "modified" {
|
||||
t.Error("modifying returned slice should not affect registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
// Run concurrent reads and writes
|
||||
done := make(chan bool)
|
||||
|
||||
// Writers
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int) {
|
||||
for j := 0; j < 100; j++ {
|
||||
r.RegisterProfile("concurrent"+string(rune('0'+id)), &Profile{Name: "Concurrent"})
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Readers
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int) {
|
||||
for j := 0; j < 100; j++ {
|
||||
_ = r.ProfileCount()
|
||||
_ = r.ProfileNames()
|
||||
_ = r.GetProfileByAccountID(int64(id * j))
|
||||
_ = r.GetProfile(DefaultProfileName)
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 20; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Test should pass without data races (run with -race flag)
|
||||
}
|
||||
@ -8,6 +8,14 @@ type FingerprintResponse struct {
|
||||
HTTP2 any `json:"http2"`
|
||||
}
|
||||
|
||||
// TestProfileExpectation defines expected fingerprint values for a profile.
|
||||
type TestProfileExpectation struct {
|
||||
Profile *Profile
|
||||
ExpectedJA3 string // Expected JA3 hash (empty = don't check)
|
||||
ExpectedJA4 string // Expected full JA4 (empty = don't check)
|
||||
JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check)
|
||||
}
|
||||
|
||||
// TLSInfo contains TLS fingerprint details.
|
||||
type TLSInfo struct {
|
||||
JA3 string `json:"ja3"`
|
||||
|
||||
@ -212,7 +212,7 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
|
||||
SetContext(ctx).
|
||||
SetHeader("Accept", "application/json, text/plain, */*").
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("User-Agent", "axios/1.8.4").
|
||||
SetHeader("User-Agent", "axios/1.13.6").
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(s.tokenURL)
|
||||
@ -250,7 +250,7 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
|
||||
SetContext(ctx).
|
||||
SetHeader("Accept", "application/json, text/plain, */*").
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("User-Agent", "axios/1.8.4").
|
||||
SetHeader("User-Agent", "axios/1.13.6").
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(s.tokenURL)
|
||||
|
||||
@ -68,10 +68,9 @@ func (s *claudeUsageService) FetchUsageWithOptions(ctx context.Context, opts *se
|
||||
|
||||
var resp *http.Response
|
||||
|
||||
// 如果启用 TLS 指纹且有 HTTPUpstream,使用 DoWithTLS
|
||||
if opts.EnableTLSFingerprint && s.httpUpstream != nil {
|
||||
// accountConcurrency 传 0 使用默认连接池配置,usage 请求不需要特殊的并发设置
|
||||
resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, true)
|
||||
// 如果有 TLS 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)
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -13,6 +15,8 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
|
||||
@ -143,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() {
|
||||
@ -154,26 +161,14 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
|
||||
}
|
||||
|
||||
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
|
||||
// 根据 enableTLSFingerprint 参数决定是否使用 TLS 指纹
|
||||
//
|
||||
// 参数:
|
||||
// - req: HTTP 请求对象
|
||||
// - proxyURL: 代理地址,空字符串表示直连
|
||||
// - accountID: 账户 ID,用于账户级隔离和 TLS 指纹模板选择
|
||||
// - accountConcurrency: 账户并发限制,用于动态调整连接池大小
|
||||
// - enableTLSFingerprint: 是否启用 TLS 指纹伪装
|
||||
//
|
||||
// TLS 指纹说明:
|
||||
// - 当 enableTLSFingerprint=true 时,使用 utls 库模拟 Claude CLI 的 TLS 指纹
|
||||
// - 指纹模板根据 accountID % len(profiles) 自动选择
|
||||
// - 支持直连、HTTP/HTTPS 代理、SOCKS5 代理三种场景
|
||||
func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
// 如果未启用 TLS 指纹,直接使用标准请求路径
|
||||
if !enableTLSFingerprint {
|
||||
// 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)
|
||||
}
|
||||
|
||||
// TLS 指纹已启用,记录调试日志
|
||||
targetHost := ""
|
||||
if req != nil && req.URL != nil {
|
||||
targetHost = req.URL.Host
|
||||
@ -182,43 +177,28 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
|
||||
if proxyURL != "" {
|
||||
proxyInfo = proxyURL
|
||||
}
|
||||
slog.Debug("tls_fingerprint_enabled", "account_id", accountID, "target", targetHost, "proxy", proxyInfo)
|
||||
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
|
||||
}
|
||||
|
||||
// 获取 TLS 指纹 Profile
|
||||
registry := tlsfingerprint.GlobalRegistry()
|
||||
profile := registry.GetProfileByAccountID(accountID)
|
||||
if profile == nil {
|
||||
// 如果获取不到 profile,回退到普通请求
|
||||
slog.Debug("tls_fingerprint_no_profile", "account_id", accountID, "fallback", "standard_request")
|
||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
slog.Debug("tls_fingerprint_using_profile", "account_id", accountID, "profile", profile.Name, "grease", profile.EnableGREASE)
|
||||
|
||||
// 获取或创建带 TLS 指纹的客户端
|
||||
entry, err := s.acquireClientWithTLS(proxyURL, accountID, accountConcurrency, profile)
|
||||
if err != nil {
|
||||
slog.Debug("tls_fingerprint_acquire_client_failed", "account_id", accountID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 执行请求
|
||||
resp, err := entry.client.Do(req)
|
||||
if err != nil {
|
||||
// 请求失败,立即减少计数
|
||||
atomic.AddInt64(&entry.inFlight, -1)
|
||||
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
|
||||
slog.Debug("tls_fingerprint_request_failed", "account_id", accountID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.Debug("tls_fingerprint_request_success", "account_id", accountID, "status", resp.StatusCode)
|
||||
decompressResponseBody(resp)
|
||||
|
||||
// 包装响应体,在关闭时自动减少计数并更新时间戳
|
||||
resp.Body = wrapTrackedBody(resp.Body, func() {
|
||||
atomic.AddInt64(&entry.inFlight, -1)
|
||||
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
|
||||
@ -884,3 +864,56 @@ func wrapTrackedBody(body io.ReadCloser, onClose func()) io.ReadCloser {
|
||||
}
|
||||
return &trackedBody{ReadCloser: body, onClose: onClose}
|
||||
}
|
||||
|
||||
// decompressResponseBody 根据 Content-Encoding 解压响应体。
|
||||
// 当请求显式设置了 accept-encoding 时,Go 的 Transport 不会自动解压,需要手动处理。
|
||||
// 解压成功后会删除 Content-Encoding 和 Content-Length header(长度已不准确)。
|
||||
func decompressResponseBody(resp *http.Response) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return
|
||||
}
|
||||
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 // 解压失败,保持原样
|
||||
}
|
||||
reader = gr
|
||||
case "br":
|
||||
reader = brotli.NewReader(resp.Body)
|
||||
case "deflate":
|
||||
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()
|
||||
}
|
||||
|
||||
122
backend/internal/repository/tls_fingerprint_profile_cache.go
Normal file
122
backend/internal/repository/tls_fingerprint_profile_cache.go
Normal file
@ -0,0 +1,122 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
tlsFPProfileCacheKey = "tls_fingerprint_profiles"
|
||||
tlsFPProfilePubSubKey = "tls_fingerprint_profiles_updated"
|
||||
tlsFPProfileCacheTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
type tlsFingerprintProfileCache struct {
|
||||
rdb *redis.Client
|
||||
localCache []*model.TLSFingerprintProfile
|
||||
localMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewTLSFingerprintProfileCache 创建 TLS 指纹模板缓存
|
||||
func NewTLSFingerprintProfileCache(rdb *redis.Client) service.TLSFingerprintProfileCache {
|
||||
return &tlsFingerprintProfileCache{
|
||||
rdb: rdb,
|
||||
}
|
||||
}
|
||||
|
||||
// Get 从缓存获取模板列表
|
||||
func (c *tlsFingerprintProfileCache) Get(ctx context.Context) ([]*model.TLSFingerprintProfile, bool) {
|
||||
c.localMu.RLock()
|
||||
if c.localCache != nil {
|
||||
profiles := c.localCache
|
||||
c.localMu.RUnlock()
|
||||
return profiles, true
|
||||
}
|
||||
c.localMu.RUnlock()
|
||||
|
||||
data, err := c.rdb.Get(ctx, tlsFPProfileCacheKey).Bytes()
|
||||
if err != nil {
|
||||
if err != redis.Nil {
|
||||
slog.Warn("tls_fp_profile_cache_get_failed", "error", err)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var profiles []*model.TLSFingerprintProfile
|
||||
if err := json.Unmarshal(data, &profiles); err != nil {
|
||||
slog.Warn("tls_fp_profile_cache_unmarshal_failed", "error", err)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
c.localMu.Lock()
|
||||
c.localCache = profiles
|
||||
c.localMu.Unlock()
|
||||
|
||||
return profiles, true
|
||||
}
|
||||
|
||||
// Set 设置缓存
|
||||
func (c *tlsFingerprintProfileCache) Set(ctx context.Context, profiles []*model.TLSFingerprintProfile) error {
|
||||
data, err := json.Marshal(profiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.rdb.Set(ctx, tlsFPProfileCacheKey, data, tlsFPProfileCacheTTL).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.localMu.Lock()
|
||||
c.localCache = profiles
|
||||
c.localMu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Invalidate 使缓存失效
|
||||
func (c *tlsFingerprintProfileCache) Invalidate(ctx context.Context) error {
|
||||
c.localMu.Lock()
|
||||
c.localCache = nil
|
||||
c.localMu.Unlock()
|
||||
|
||||
return c.rdb.Del(ctx, tlsFPProfileCacheKey).Err()
|
||||
}
|
||||
|
||||
// NotifyUpdate 通知其他实例刷新缓存
|
||||
func (c *tlsFingerprintProfileCache) NotifyUpdate(ctx context.Context) error {
|
||||
return c.rdb.Publish(ctx, tlsFPProfilePubSubKey, "refresh").Err()
|
||||
}
|
||||
|
||||
// SubscribeUpdates 订阅缓存更新通知
|
||||
func (c *tlsFingerprintProfileCache) SubscribeUpdates(ctx context.Context, handler func()) {
|
||||
go func() {
|
||||
sub := c.rdb.Subscribe(ctx, tlsFPProfilePubSubKey)
|
||||
defer func() { _ = sub.Close() }()
|
||||
|
||||
ch := sub.Channel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Debug("tls_fp_profile_cache_subscriber_stopped", "reason", "context_done")
|
||||
return
|
||||
case msg := <-ch:
|
||||
if msg == nil {
|
||||
slog.Warn("tls_fp_profile_cache_subscriber_stopped", "reason", "channel_closed")
|
||||
return
|
||||
}
|
||||
c.localMu.Lock()
|
||||
c.localCache = nil
|
||||
c.localMu.Unlock()
|
||||
|
||||
handler()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
213
backend/internal/repository/tls_fingerprint_profile_repo.go
Normal file
213
backend/internal/repository/tls_fingerprint_profile_repo.go
Normal file
@ -0,0 +1,213 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
type tlsFingerprintProfileRepository struct {
|
||||
client *ent.Client
|
||||
}
|
||||
|
||||
// NewTLSFingerprintProfileRepository 创建 TLS 指纹模板仓库
|
||||
func NewTLSFingerprintProfileRepository(client *ent.Client) service.TLSFingerprintProfileRepository {
|
||||
return &tlsFingerprintProfileRepository{client: client}
|
||||
}
|
||||
|
||||
// List 获取所有模板
|
||||
func (r *tlsFingerprintProfileRepository) List(ctx context.Context) ([]*model.TLSFingerprintProfile, error) {
|
||||
profiles, err := r.client.TLSFingerprintProfile.Query().
|
||||
Order(ent.Asc(tlsfingerprintprofile.FieldName)).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*model.TLSFingerprintProfile, len(profiles))
|
||||
for i, p := range profiles {
|
||||
result[i] = r.toModel(p)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取模板
|
||||
func (r *tlsFingerprintProfileRepository) GetByID(ctx context.Context, id int64) (*model.TLSFingerprintProfile, error) {
|
||||
p, err := r.client.TLSFingerprintProfile.Get(ctx, id)
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return r.toModel(p), nil
|
||||
}
|
||||
|
||||
// Create 创建模板
|
||||
func (r *tlsFingerprintProfileRepository) Create(ctx context.Context, p *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
|
||||
builder := r.client.TLSFingerprintProfile.Create().
|
||||
SetName(p.Name).
|
||||
SetEnableGrease(p.EnableGREASE)
|
||||
|
||||
if p.Description != nil {
|
||||
builder.SetDescription(*p.Description)
|
||||
}
|
||||
if len(p.CipherSuites) > 0 {
|
||||
builder.SetCipherSuites(p.CipherSuites)
|
||||
}
|
||||
if len(p.Curves) > 0 {
|
||||
builder.SetCurves(p.Curves)
|
||||
}
|
||||
if len(p.PointFormats) > 0 {
|
||||
builder.SetPointFormats(p.PointFormats)
|
||||
}
|
||||
if len(p.SignatureAlgorithms) > 0 {
|
||||
builder.SetSignatureAlgorithms(p.SignatureAlgorithms)
|
||||
}
|
||||
if len(p.ALPNProtocols) > 0 {
|
||||
builder.SetAlpnProtocols(p.ALPNProtocols)
|
||||
}
|
||||
if len(p.SupportedVersions) > 0 {
|
||||
builder.SetSupportedVersions(p.SupportedVersions)
|
||||
}
|
||||
if len(p.KeyShareGroups) > 0 {
|
||||
builder.SetKeyShareGroups(p.KeyShareGroups)
|
||||
}
|
||||
if len(p.PSKModes) > 0 {
|
||||
builder.SetPskModes(p.PSKModes)
|
||||
}
|
||||
if len(p.Extensions) > 0 {
|
||||
builder.SetExtensions(p.Extensions)
|
||||
}
|
||||
|
||||
created, err := builder.Save(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toModel(created), nil
|
||||
}
|
||||
|
||||
// Update 更新模板
|
||||
func (r *tlsFingerprintProfileRepository) Update(ctx context.Context, p *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
|
||||
builder := r.client.TLSFingerprintProfile.UpdateOneID(p.ID).
|
||||
SetName(p.Name).
|
||||
SetEnableGrease(p.EnableGREASE)
|
||||
|
||||
if p.Description != nil {
|
||||
builder.SetDescription(*p.Description)
|
||||
} else {
|
||||
builder.ClearDescription()
|
||||
}
|
||||
|
||||
if len(p.CipherSuites) > 0 {
|
||||
builder.SetCipherSuites(p.CipherSuites)
|
||||
} else {
|
||||
builder.ClearCipherSuites()
|
||||
}
|
||||
if len(p.Curves) > 0 {
|
||||
builder.SetCurves(p.Curves)
|
||||
} else {
|
||||
builder.ClearCurves()
|
||||
}
|
||||
if len(p.PointFormats) > 0 {
|
||||
builder.SetPointFormats(p.PointFormats)
|
||||
} else {
|
||||
builder.ClearPointFormats()
|
||||
}
|
||||
if len(p.SignatureAlgorithms) > 0 {
|
||||
builder.SetSignatureAlgorithms(p.SignatureAlgorithms)
|
||||
} else {
|
||||
builder.ClearSignatureAlgorithms()
|
||||
}
|
||||
if len(p.ALPNProtocols) > 0 {
|
||||
builder.SetAlpnProtocols(p.ALPNProtocols)
|
||||
} else {
|
||||
builder.ClearAlpnProtocols()
|
||||
}
|
||||
if len(p.SupportedVersions) > 0 {
|
||||
builder.SetSupportedVersions(p.SupportedVersions)
|
||||
} else {
|
||||
builder.ClearSupportedVersions()
|
||||
}
|
||||
if len(p.KeyShareGroups) > 0 {
|
||||
builder.SetKeyShareGroups(p.KeyShareGroups)
|
||||
} else {
|
||||
builder.ClearKeyShareGroups()
|
||||
}
|
||||
if len(p.PSKModes) > 0 {
|
||||
builder.SetPskModes(p.PSKModes)
|
||||
} else {
|
||||
builder.ClearPskModes()
|
||||
}
|
||||
if len(p.Extensions) > 0 {
|
||||
builder.SetExtensions(p.Extensions)
|
||||
} else {
|
||||
builder.ClearExtensions()
|
||||
}
|
||||
|
||||
updated, err := builder.Save(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toModel(updated), nil
|
||||
}
|
||||
|
||||
// Delete 删除模板
|
||||
func (r *tlsFingerprintProfileRepository) Delete(ctx context.Context, id int64) error {
|
||||
return r.client.TLSFingerprintProfile.DeleteOneID(id).Exec(ctx)
|
||||
}
|
||||
|
||||
// toModel 将 Ent 实体转换为服务模型
|
||||
func (r *tlsFingerprintProfileRepository) toModel(e *ent.TLSFingerprintProfile) *model.TLSFingerprintProfile {
|
||||
p := &model.TLSFingerprintProfile{
|
||||
ID: e.ID,
|
||||
Name: e.Name,
|
||||
Description: e.Description,
|
||||
EnableGREASE: e.EnableGrease,
|
||||
CipherSuites: e.CipherSuites,
|
||||
Curves: e.Curves,
|
||||
PointFormats: e.PointFormats,
|
||||
SignatureAlgorithms: e.SignatureAlgorithms,
|
||||
ALPNProtocols: e.AlpnProtocols,
|
||||
SupportedVersions: e.SupportedVersions,
|
||||
KeyShareGroups: e.KeyShareGroups,
|
||||
PSKModes: e.PskModes,
|
||||
Extensions: e.Extensions,
|
||||
CreatedAt: e.CreatedAt,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
}
|
||||
|
||||
// 确保切片不为 nil
|
||||
if p.CipherSuites == nil {
|
||||
p.CipherSuites = []uint16{}
|
||||
}
|
||||
if p.Curves == nil {
|
||||
p.Curves = []uint16{}
|
||||
}
|
||||
if p.PointFormats == nil {
|
||||
p.PointFormats = []uint16{}
|
||||
}
|
||||
if p.SignatureAlgorithms == nil {
|
||||
p.SignatureAlgorithms = []uint16{}
|
||||
}
|
||||
if p.ALPNProtocols == nil {
|
||||
p.ALPNProtocols = []string{}
|
||||
}
|
||||
if p.SupportedVersions == nil {
|
||||
p.SupportedVersions = []uint16{}
|
||||
}
|
||||
if p.KeyShareGroups == nil {
|
||||
p.KeyShareGroups = []uint16{}
|
||||
}
|
||||
if p.PSKModes == nil {
|
||||
p.PSKModes = []uint16{}
|
||||
}
|
||||
if p.Extensions == nil {
|
||||
p.Extensions = []uint16{}
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
@ -73,6 +73,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewUserAttributeValueRepository,
|
||||
NewUserGroupRateRepository,
|
||||
NewErrorPassthroughRepository,
|
||||
NewTLSFingerprintProfileRepository,
|
||||
|
||||
// Cache implementations
|
||||
NewGatewayCache,
|
||||
@ -96,6 +97,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewTotpCache,
|
||||
NewRefreshTokenCache,
|
||||
NewErrorPassthroughCache,
|
||||
NewTLSFingerprintProfileCache,
|
||||
|
||||
// Encryptors
|
||||
NewAESEncryptor,
|
||||
|
||||
@ -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": []
|
||||
}
|
||||
|
||||
@ -79,6 +79,9 @@ func RegisterAdminRoutes(
|
||||
// 错误透传规则管理
|
||||
registerErrorPassthroughRoutes(admin, h)
|
||||
|
||||
// TLS 指纹模板管理
|
||||
registerTLSFingerprintProfileRoutes(admin, h)
|
||||
|
||||
// API Key 管理
|
||||
registerAdminAPIKeyRoutes(admin, h)
|
||||
|
||||
@ -257,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)
|
||||
@ -552,3 +556,14 @@ func registerErrorPassthroughRoutes(admin *gin.RouterGroup, h *handler.Handlers)
|
||||
rules.DELETE("/:id", h.Admin.ErrorPassthrough.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
func registerTLSFingerprintProfileRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
profiles := admin.Group("/tls-fingerprint-profiles")
|
||||
{
|
||||
profiles.GET("", h.Admin.TLSFingerprintProfile.List)
|
||||
profiles.GET("/:id", h.Admin.TLSFingerprintProfile.GetByID)
|
||||
profiles.POST("", h.Admin.TLSFingerprintProfile.Create)
|
||||
profiles.PUT("/:id", h.Admin.TLSFingerprintProfile.Update)
|
||||
profiles.DELETE("/:id", h.Admin.TLSFingerprintProfile.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1165,6 +1165,31 @@ func (a *Account) IsTLSFingerprintEnabled() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// GetTLSFingerprintProfileID 获取账号绑定的 TLS 指纹模板 ID
|
||||
// 返回 0 表示未绑定(使用内置默认 profile)
|
||||
func (a *Account) GetTLSFingerprintProfileID() int64 {
|
||||
if a.Extra == nil {
|
||||
return 0
|
||||
}
|
||||
v, ok := a.Extra["tls_fingerprint_profile_id"]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch id := v.(type) {
|
||||
case float64:
|
||||
return int64(id)
|
||||
case int64:
|
||||
return id
|
||||
case int:
|
||||
return int64(id)
|
||||
case json.Number:
|
||||
if i, err := id.Int64(); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetUserMsgQueueMode 获取用户消息队列模式
|
||||
// "serialize" = 串行队列, "throttle" = 软性限速, "" = 未设置(使用全局配置)
|
||||
func (a *Account) GetUserMsgQueueMode() string {
|
||||
|
||||
@ -23,6 +23,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/soraerror"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
"github.com/gin-gonic/gin"
|
||||
@ -69,6 +70,7 @@ type AccountTestService struct {
|
||||
antigravityGatewayService *AntigravityGatewayService
|
||||
httpUpstream HTTPUpstream
|
||||
cfg *config.Config
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
soraTestGuardMu sync.Mutex
|
||||
soraTestLastRun map[int64]time.Time
|
||||
soraTestCooldown time.Duration
|
||||
@ -83,6 +85,7 @@ func NewAccountTestService(
|
||||
antigravityGatewayService *AntigravityGatewayService,
|
||||
httpUpstream HTTPUpstream,
|
||||
cfg *config.Config,
|
||||
tlsFPProfileService *TLSFingerprintProfileService,
|
||||
) *AccountTestService {
|
||||
return &AccountTestService{
|
||||
accountRepo: accountRepo,
|
||||
@ -90,6 +93,7 @@ func NewAccountTestService(
|
||||
antigravityGatewayService: antigravityGatewayService,
|
||||
httpUpstream: httpUpstream,
|
||||
cfg: cfg,
|
||||
tlsFPProfileService: tlsFPProfileService,
|
||||
soraTestLastRun: make(map[int64]time.Time),
|
||||
soraTestCooldown: defaultSoraTestCooldown,
|
||||
}
|
||||
@ -300,7 +304,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@ -390,7 +394,7 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, false)
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, nil)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@ -520,7 +524,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@ -610,7 +614,7 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@ -881,9 +885,9 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
enableSoraTLSFingerprint := s.shouldEnableSoraTLSFingerprint()
|
||||
soraTLSProfile := s.resolveSoraTLSProfile()
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, enableSoraTLSFingerprint)
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, soraTLSProfile)
|
||||
if err != nil {
|
||||
recorder.addStep("me", "failed", 0, "network_error", err.Error())
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
@ -948,7 +952,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
|
||||
subReq.Header.Set("Origin", "https://sora.chatgpt.com")
|
||||
subReq.Header.Set("Referer", "https://sora.chatgpt.com/")
|
||||
|
||||
subResp, subErr := s.httpUpstream.DoWithTLS(subReq, proxyURL, account.ID, account.Concurrency, enableSoraTLSFingerprint)
|
||||
subResp, subErr := s.httpUpstream.DoWithTLS(subReq, proxyURL, account.ID, account.Concurrency, soraTLSProfile)
|
||||
if subErr != nil {
|
||||
recorder.addStep("subscription", "failed", 0, "network_error", subErr.Error())
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Subscription check skipped: %s", subErr.Error())})
|
||||
@ -977,7 +981,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
|
||||
}
|
||||
|
||||
// 追加 Sora2 能力探测(对齐 sora2api 的测试思路):邀请码 + 剩余额度。
|
||||
s.testSora2Capabilities(c, ctx, account, authToken, proxyURL, enableSoraTLSFingerprint, recorder)
|
||||
s.testSora2Capabilities(c, ctx, account, authToken, proxyURL, soraTLSProfile, recorder)
|
||||
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
@ -990,7 +994,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
account *Account,
|
||||
authToken string,
|
||||
proxyURL string,
|
||||
enableTLSFingerprint bool,
|
||||
tlsProfile *tlsfingerprint.Profile,
|
||||
recorder *soraProbeRecorder,
|
||||
) {
|
||||
inviteStatus, inviteHeader, inviteBody, err := s.fetchSoraTestEndpoint(
|
||||
@ -999,7 +1003,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
authToken,
|
||||
soraInviteMineURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
tlsProfile,
|
||||
)
|
||||
if err != nil {
|
||||
if recorder != nil {
|
||||
@ -1016,7 +1020,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
authToken,
|
||||
soraBootstrapURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
tlsProfile,
|
||||
)
|
||||
if bootstrapErr == nil && bootstrapStatus == http.StatusOK {
|
||||
if recorder != nil {
|
||||
@ -1029,7 +1033,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
authToken,
|
||||
soraInviteMineURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
tlsProfile,
|
||||
)
|
||||
if err != nil {
|
||||
if recorder != nil {
|
||||
@ -1081,7 +1085,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
authToken,
|
||||
soraRemainingURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
tlsProfile,
|
||||
)
|
||||
if remainingErr != nil {
|
||||
if recorder != nil {
|
||||
@ -1122,7 +1126,7 @@ func (s *AccountTestService) fetchSoraTestEndpoint(
|
||||
authToken string,
|
||||
url string,
|
||||
proxyURL string,
|
||||
enableTLSFingerprint bool,
|
||||
tlsProfile *tlsfingerprint.Profile,
|
||||
) (int, http.Header, []byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
@ -1135,7 +1139,7 @@ func (s *AccountTestService) fetchSoraTestEndpoint(
|
||||
req.Header.Set("Origin", "https://sora.chatgpt.com")
|
||||
req.Header.Set("Referer", "https://sora.chatgpt.com/")
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, enableTLSFingerprint)
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
@ -1224,11 +1228,12 @@ func parseSoraRemainingSummary(body []byte) string {
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func (s *AccountTestService) shouldEnableSoraTLSFingerprint() bool {
|
||||
if s == nil || s.cfg == nil {
|
||||
return true
|
||||
func (s *AccountTestService) resolveSoraTLSProfile() *tlsfingerprint.Profile {
|
||||
if s == nil || s.cfg == nil || !s.cfg.Sora.Client.DisableTLSFingerprint {
|
||||
// Sora TLS fingerprint enabled — use built-in default profile
|
||||
return &tlsfingerprint.Profile{Name: "Built-in Default (Sora)"}
|
||||
}
|
||||
return !s.cfg.Sora.Client.DisableTLSFingerprint
|
||||
return nil // disabled
|
||||
}
|
||||
|
||||
func isCloudflareChallengeResponse(statusCode int, headers http.Header, body []byte) bool {
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@ -24,9 +25,9 @@ func (u *queuedHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*htt
|
||||
return nil, fmt.Errorf("unexpected Do call")
|
||||
}
|
||||
|
||||
func (u *queuedHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (u *queuedHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
u.requests = append(u.requests, req)
|
||||
u.tlsFlags = append(u.tlsFlags, enableTLSFingerprint)
|
||||
u.tlsFlags = append(u.tlsFlags, profile != nil)
|
||||
if len(u.responses) == 0 {
|
||||
return nil, fmt.Errorf("no mocked response")
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import (
|
||||
openaipkg "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/sync/singleflight"
|
||||
@ -241,11 +242,11 @@ type ClaudeUsageResponse struct {
|
||||
|
||||
// ClaudeUsageFetchOptions 包含获取 Claude 用量数据所需的所有选项
|
||||
type ClaudeUsageFetchOptions struct {
|
||||
AccessToken string // OAuth access token
|
||||
ProxyURL string // 代理 URL(可选)
|
||||
AccountID int64 // 账号 ID(用于 TLS 指纹选择)
|
||||
EnableTLSFingerprint bool // 是否启用 TLS 指纹伪装
|
||||
Fingerprint *Fingerprint // 缓存的指纹信息(User-Agent 等)
|
||||
AccessToken string // OAuth access token
|
||||
ProxyURL string // 代理 URL(可选)
|
||||
AccountID int64 // 账号 ID(用于连接池隔离)
|
||||
TLSProfile *tlsfingerprint.Profile // TLS 指纹 Profile(nil 表示不启用)
|
||||
Fingerprint *Fingerprint // 缓存的指纹信息(User-Agent 等)
|
||||
}
|
||||
|
||||
// ClaudeUsageFetcher fetches usage data from Anthropic OAuth API
|
||||
@ -264,6 +265,7 @@ type AccountUsageService struct {
|
||||
antigravityQuotaFetcher *AntigravityQuotaFetcher
|
||||
cache *UsageCache
|
||||
identityCache IdentityCache
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
}
|
||||
|
||||
// NewAccountUsageService 创建AccountUsageService实例
|
||||
@ -275,6 +277,7 @@ func NewAccountUsageService(
|
||||
antigravityQuotaFetcher *AntigravityQuotaFetcher,
|
||||
cache *UsageCache,
|
||||
identityCache IdentityCache,
|
||||
tlsFPProfileService *TLSFingerprintProfileService,
|
||||
) *AccountUsageService {
|
||||
return &AccountUsageService{
|
||||
accountRepo: accountRepo,
|
||||
@ -284,6 +287,7 @@ func NewAccountUsageService(
|
||||
antigravityQuotaFetcher: antigravityQuotaFetcher,
|
||||
cache: cache,
|
||||
identityCache: identityCache,
|
||||
tlsFPProfileService: tlsFPProfileService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1155,10 +1159,10 @@ func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *A
|
||||
|
||||
// 构建完整的选项
|
||||
opts := &ClaudeUsageFetchOptions{
|
||||
AccessToken: accessToken,
|
||||
ProxyURL: proxyURL,
|
||||
AccountID: account.ID,
|
||||
EnableTLSFingerprint: account.IsTLSFingerprintEnabled(),
|
||||
AccessToken: accessToken,
|
||||
ProxyURL: proxyURL,
|
||||
AccountID: account.ID,
|
||||
TLSProfile: s.tlsFPProfileService.ResolveTLSProfile(account),
|
||||
}
|
||||
|
||||
// 尝试获取缓存的 Fingerprint(包含 User-Agent 等信息)
|
||||
|
||||
@ -65,6 +65,12 @@ type AdminService interface {
|
||||
SetAccountError(ctx context.Context, id int64, errorMsg string) error
|
||||
// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号 privacy_mode,未设置则尝试关闭训练数据共享并持久化。
|
||||
EnsureOpenAIPrivacy(ctx context.Context, account *Account) string
|
||||
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号 privacy_mode,未设置则调用 setUserSettings 并持久化。
|
||||
EnsureAntigravityPrivacy(ctx context.Context, account *Account) string
|
||||
// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。
|
||||
ForceOpenAIPrivacy(ctx context.Context, account *Account) string
|
||||
// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
|
||||
ForceAntigravityPrivacy(ctx context.Context, account *Account) string
|
||||
SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error)
|
||||
BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error)
|
||||
CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error
|
||||
@ -2635,10 +2641,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)
|
||||
@ -2661,3 +2665,115 @@ func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Acc
|
||||
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode})
|
||||
return mode
|
||||
}
|
||||
|
||||
// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。
|
||||
func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Account) string {
|
||||
if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
|
||||
return ""
|
||||
}
|
||||
if s.privacyClientFactory == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
token, _ := account.Credentials["access_token"].(string)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if account.ProxyID != nil {
|
||||
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
|
||||
proxyURL = p.URL()
|
||||
}
|
||||
}
|
||||
|
||||
mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL)
|
||||
if mode == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
|
||||
logger.LegacyPrintf("service.admin", "force_update_openai_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
|
||||
return mode
|
||||
}
|
||||
if account.Extra == nil {
|
||||
account.Extra = make(map[string]any)
|
||||
}
|
||||
account.Extra["privacy_mode"] = mode
|
||||
return mode
|
||||
}
|
||||
|
||||
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。
|
||||
// 如果 Extra["privacy_mode"] 已存在(无论成功或失败),直接跳过。
|
||||
// 仅对从未设置过隐私的账号执行 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
|
||||
}
|
||||
}
|
||||
|
||||
token, _ := account.Credentials["access_token"].(string)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
projectID, _ := account.Credentials["project_id"].(string)
|
||||
|
||||
var proxyURL string
|
||||
if account.ProxyID != nil {
|
||||
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
|
||||
proxyURL = p.URL()
|
||||
}
|
||||
}
|
||||
|
||||
mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
|
||||
if mode == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
|
||||
logger.LegacyPrintf("service.admin", "update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
|
||||
return mode
|
||||
}
|
||||
applyAntigravityPrivacyMode(account, mode)
|
||||
return mode
|
||||
}
|
||||
|
||||
// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
|
||||
func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account *Account) string {
|
||||
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
|
||||
return ""
|
||||
}
|
||||
|
||||
token, _ := account.Credentials["access_token"].(string)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
projectID, _ := account.Credentials["project_id"].(string)
|
||||
|
||||
var proxyURL string
|
||||
if account.ProxyID != nil {
|
||||
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
|
||||
proxyURL = p.URL()
|
||||
}
|
||||
}
|
||||
|
||||
mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
|
||||
if mode == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
|
||||
logger.LegacyPrintf("service.admin", "force_update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
|
||||
return mode
|
||||
}
|
||||
applyAntigravityPrivacyMode(account, mode)
|
||||
return mode
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@ -130,7 +131,7 @@ func (s *httpUpstreamStub) Do(_ *http.Request, _ string, _ int64, _ int) (*http.
|
||||
return s.resp, s.err
|
||||
}
|
||||
|
||||
func (s *httpUpstreamStub) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ bool) (*http.Response, error) {
|
||||
func (s *httpUpstreamStub) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return s.resp, s.err
|
||||
}
|
||||
|
||||
@ -171,7 +172,7 @@ func (s *queuedHTTPUpstreamStub) Do(req *http.Request, _ string, _ int64, _ int)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *queuedHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ bool) (*http.Response, error) {
|
||||
func (s *queuedHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return s.Do(req, proxyURL, accountID, concurrency)
|
||||
}
|
||||
|
||||
|
||||
@ -89,7 +89,8 @@ type AntigravityTokenInfo struct {
|
||||
TokenType string `json:"token_type"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
ProjectIDMissing bool `json:"-"` // LoadCodeAssist 未返回 project_id
|
||||
ProjectIDMissing bool `json:"-"`
|
||||
PlanType string `json:"-"`
|
||||
}
|
||||
|
||||
// ExchangeCode 用 authorization code 交换 token
|
||||
@ -145,13 +146,17 @@ func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *Antig
|
||||
result.Email = userInfo.Email
|
||||
}
|
||||
|
||||
// 获取 project_id(部分账户类型可能没有),失败时重试
|
||||
projectID, loadErr := s.loadProjectIDWithRetry(ctx, tokenResp.AccessToken, proxyURL, 3)
|
||||
// 获取 project_id + plan_type(部分账户类型可能没有),失败时重试
|
||||
loadResult, loadErr := s.loadProjectIDWithRetry(ctx, tokenResp.AccessToken, proxyURL, 3)
|
||||
if loadErr != nil {
|
||||
fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v\n", loadErr)
|
||||
result.ProjectIDMissing = true
|
||||
} else {
|
||||
result.ProjectID = projectID
|
||||
}
|
||||
if loadResult != nil {
|
||||
result.ProjectID = loadResult.ProjectID
|
||||
if loadResult.Subscription != nil {
|
||||
result.PlanType = loadResult.Subscription.PlanType
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@ -230,13 +235,17 @@ func (s *AntigravityOAuthService) ValidateRefreshToken(ctx context.Context, refr
|
||||
tokenInfo.Email = userInfo.Email
|
||||
}
|
||||
|
||||
// 获取 project_id(容错,失败不阻塞)
|
||||
projectID, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
|
||||
// 获取 project_id + plan_type(容错,失败不阻塞)
|
||||
loadResult, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
|
||||
if loadErr != nil {
|
||||
fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v\n", loadErr)
|
||||
tokenInfo.ProjectIDMissing = true
|
||||
} else {
|
||||
tokenInfo.ProjectID = projectID
|
||||
}
|
||||
if loadResult != nil {
|
||||
tokenInfo.ProjectID = loadResult.ProjectID
|
||||
if loadResult.Subscription != nil {
|
||||
tokenInfo.PlanType = loadResult.Subscription.PlanType
|
||||
}
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
@ -288,33 +297,42 @@ func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, accou
|
||||
tokenInfo.Email = existingEmail
|
||||
}
|
||||
|
||||
// 每次刷新都调用 LoadCodeAssist 获取 project_id,失败时重试
|
||||
// 每次刷新都调用 LoadCodeAssist 获取 project_id + plan_type,失败时重试
|
||||
existingProjectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||
projectID, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
|
||||
loadResult, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
|
||||
|
||||
if loadErr != nil {
|
||||
// LoadCodeAssist 失败,保留原有 project_id
|
||||
tokenInfo.ProjectID = existingProjectID
|
||||
// 只有从未获取过 project_id 且本次也获取失败时,才标记为真正缺失
|
||||
// 如果之前有 project_id,本次只是临时故障,不应标记为错误
|
||||
if existingProjectID == "" {
|
||||
tokenInfo.ProjectIDMissing = true
|
||||
}
|
||||
} else {
|
||||
tokenInfo.ProjectID = projectID
|
||||
}
|
||||
if loadResult != nil {
|
||||
if loadResult.ProjectID != "" {
|
||||
tokenInfo.ProjectID = loadResult.ProjectID
|
||||
}
|
||||
if loadResult.Subscription != nil {
|
||||
tokenInfo.PlanType = loadResult.Subscription.PlanType
|
||||
}
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
// loadProjectIDWithRetry 带重试机制获取 project_id
|
||||
// 返回 project_id 和错误,失败时会重试指定次数
|
||||
func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, accessToken, proxyURL string, maxRetries int) (string, error) {
|
||||
// loadCodeAssistResult 封装 loadProjectIDWithRetry 的返回结果,
|
||||
// 同时携带从 LoadCodeAssist 响应中提取的 plan_type 信息。
|
||||
type loadCodeAssistResult struct {
|
||||
ProjectID string
|
||||
Subscription *AntigravitySubscriptionResult
|
||||
}
|
||||
|
||||
// loadProjectIDWithRetry 带重试机制获取 project_id,同时从响应中提取 plan_type。
|
||||
func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, accessToken, proxyURL string, maxRetries int) (*loadCodeAssistResult, error) {
|
||||
var lastErr error
|
||||
var lastSubscription *AntigravitySubscriptionResult
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// 指数退避:1s, 2s, 4s
|
||||
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
|
||||
if backoff > 8*time.Second {
|
||||
backoff = 8 * time.Second
|
||||
@ -324,24 +342,34 @@ func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, ac
|
||||
|
||||
client, err := antigravity.NewClient(proxyURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create antigravity client failed: %w", err)
|
||||
return nil, fmt.Errorf("create antigravity client failed: %w", err)
|
||||
}
|
||||
loadResp, loadRaw, err := client.LoadCodeAssist(ctx, accessToken)
|
||||
|
||||
if loadResp != nil {
|
||||
sub := NormalizeAntigravitySubscription(loadResp)
|
||||
lastSubscription = &sub
|
||||
}
|
||||
|
||||
if err == nil && loadResp != nil && loadResp.CloudAICompanionProject != "" {
|
||||
return loadResp.CloudAICompanionProject, nil
|
||||
return &loadCodeAssistResult{
|
||||
ProjectID: loadResp.CloudAICompanionProject,
|
||||
Subscription: lastSubscription,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if projectID, onboardErr := tryOnboardProjectID(ctx, client, accessToken, loadRaw); onboardErr == nil && projectID != "" {
|
||||
return projectID, nil
|
||||
return &loadCodeAssistResult{
|
||||
ProjectID: projectID,
|
||||
Subscription: lastSubscription,
|
||||
}, nil
|
||||
} else if onboardErr != nil {
|
||||
lastErr = onboardErr
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 记录错误
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else if loadResp == nil {
|
||||
@ -351,7 +379,10 @@ func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, ac
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("获取 project_id 失败 (重试 %d 次后): %w", maxRetries, lastErr)
|
||||
if lastSubscription != nil {
|
||||
return &loadCodeAssistResult{Subscription: lastSubscription}, fmt.Errorf("获取 project_id 失败 (重试 %d 次后): %w", maxRetries, lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("获取 project_id 失败 (重试 %d 次后): %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func tryOnboardProjectID(ctx context.Context, client *antigravity.Client, accessToken string, loadRaw map[string]any) (string, error) {
|
||||
@ -410,7 +441,11 @@ func (s *AntigravityOAuthService) FillProjectID(ctx context.Context, account *Ac
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
return s.loadProjectIDWithRetry(ctx, accessToken, proxyURL, 3)
|
||||
result, err := s.loadProjectIDWithRetry(ctx, accessToken, proxyURL, 3)
|
||||
if result != nil {
|
||||
return result.ProjectID, err
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// BuildAccountCredentials 构建账户凭证
|
||||
@ -431,6 +466,9 @@ func (s *AntigravityOAuthService) BuildAccountCredentials(tokenInfo *Antigravity
|
||||
if tokenInfo.ProjectID != "" {
|
||||
creds["project_id"] = tokenInfo.ProjectID
|
||||
}
|
||||
if tokenInfo.PlanType != "" {
|
||||
creds["plan_type"] = tokenInfo.PlanType
|
||||
}
|
||||
return creds
|
||||
}
|
||||
|
||||
|
||||
81
backend/internal/service/antigravity_privacy_service.go
Normal file
81
backend/internal/service/antigravity_privacy_service.go
Normal file
@ -0,0 +1,81 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
)
|
||||
|
||||
const (
|
||||
AntigravityPrivacySet = "privacy_set"
|
||||
AntigravityPrivacyFailed = "privacy_set_failed"
|
||||
)
|
||||
|
||||
// setAntigravityPrivacy 调用 Antigravity API 设置隐私并验证结果。
|
||||
// 流程:
|
||||
// 1. setUserSettings 清空设置 → 检查返回值 {"userSettings":{}}
|
||||
// 2. fetchUserInfo 二次验证隐私是否已生效(需要 project_id)
|
||||
//
|
||||
// 返回 privacy_mode 值:"privacy_set" 成功,"privacy_set_failed" 失败,空串表示无法执行。
|
||||
func setAntigravityPrivacy(ctx context.Context, accessToken, projectID, proxyURL string) string {
|
||||
if accessToken == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := antigravity.NewClient(proxyURL)
|
||||
if err != nil {
|
||||
slog.Warn("antigravity_privacy_client_error", "error", err.Error())
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
|
||||
// 第 1 步:调用 setUserSettings,检查返回值
|
||||
setResp, err := client.SetUserSettings(ctx, accessToken)
|
||||
if err != nil {
|
||||
slog.Warn("antigravity_privacy_set_failed", "error", err.Error())
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
if !setResp.IsSuccess() {
|
||||
slog.Warn("antigravity_privacy_set_response_not_empty",
|
||||
"user_settings", setResp.UserSettings,
|
||||
)
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
|
||||
// 第 2 步:调用 fetchUserInfo 二次验证隐私是否已生效
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
slog.Warn("antigravity_privacy_missing_project_id")
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
userInfo, err := client.FetchUserInfo(ctx, accessToken, projectID)
|
||||
if err != nil {
|
||||
slog.Warn("antigravity_privacy_verify_failed", "error", err.Error())
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
if !userInfo.IsPrivate() {
|
||||
slog.Warn("antigravity_privacy_verify_not_private",
|
||||
"user_settings", userInfo.UserSettings,
|
||||
)
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
|
||||
slog.Info("antigravity_privacy_set_success")
|
||||
return AntigravityPrivacySet
|
||||
}
|
||||
|
||||
func applyAntigravityPrivacyMode(account *Account, mode string) {
|
||||
if account == nil || strings.TrimSpace(mode) == "" {
|
||||
return
|
||||
}
|
||||
extra := make(map[string]any, len(account.Extra)+1)
|
||||
for k, v := range account.Extra {
|
||||
extra[k] = v
|
||||
}
|
||||
extra["privacy_mode"] = mode
|
||||
account.Extra = extra
|
||||
}
|
||||
67
backend/internal/service/antigravity_privacy_service_test.go
Normal file
67
backend/internal/service/antigravity_privacy_service_test.go
Normal file
@ -0,0 +1,67 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
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{}
|
||||
|
||||
applyAntigravityPrivacyMode(account, AntigravityPrivacySet)
|
||||
|
||||
if account.Extra == nil {
|
||||
t.Fatal("expected account.Extra to be initialized")
|
||||
}
|
||||
if got := account.Extra["privacy_mode"]; got != AntigravityPrivacySet {
|
||||
t.Fatalf("expected privacy_mode %q, got %v", AntigravityPrivacySet, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAntigravityPrivacyMode_PreservedBySubscriptionResult(t *testing.T) {
|
||||
account := &Account{
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"existing": "value",
|
||||
},
|
||||
}
|
||||
applyAntigravityPrivacyMode(account, AntigravityPrivacySet)
|
||||
|
||||
_, extra := applyAntigravitySubscriptionResult(account, AntigravitySubscriptionResult{
|
||||
PlanType: "Pro",
|
||||
})
|
||||
|
||||
if got := extra["privacy_mode"]; got != AntigravityPrivacySet {
|
||||
t.Fatalf("expected subscription writeback to keep privacy_mode %q, got %v", AntigravityPrivacySet, got)
|
||||
}
|
||||
if got := extra["existing"]; got != "value" {
|
||||
t.Fatalf("expected existing extra fields to be preserved, got %v", got)
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@ -40,7 +41,7 @@ func (r *recordingOKUpstream) Do(req *http.Request, proxyURL string, accountID i
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *recordingOKUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (r *recordingOKUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return r.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
@ -61,7 +62,7 @@ func (s *stubAntigravityUpstream) Do(req *http.Request, proxyURL string, account
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *stubAntigravityUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (s *stubAntigravityUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@ -93,7 +94,7 @@ func (m *mockSmartRetryUpstream) Do(req *http.Request, proxyURL string, accountI
|
||||
}, respErr
|
||||
}
|
||||
|
||||
func (m *mockSmartRetryUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (m *mockSmartRetryUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return m.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
|
||||
38
backend/internal/service/antigravity_subscription_service.go
Normal file
38
backend/internal/service/antigravity_subscription_service.go
Normal file
@ -0,0 +1,38 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
)
|
||||
|
||||
const antigravitySubscriptionAbnormal = "abnormal"
|
||||
|
||||
// AntigravitySubscriptionResult 表示订阅检测后的规范化结果。
|
||||
type AntigravitySubscriptionResult struct {
|
||||
PlanType string
|
||||
SubscriptionStatus string
|
||||
SubscriptionError string
|
||||
}
|
||||
|
||||
// NormalizeAntigravitySubscription 从 LoadCodeAssistResponse 提取 plan_type + 异常状态。
|
||||
// 使用 GetTier()(返回 tier ID)+ TierIDToPlanType 映射。
|
||||
func NormalizeAntigravitySubscription(resp *antigravity.LoadCodeAssistResponse) AntigravitySubscriptionResult {
|
||||
if resp == nil {
|
||||
return AntigravitySubscriptionResult{PlanType: "Free"}
|
||||
}
|
||||
if len(resp.IneligibleTiers) > 0 {
|
||||
result := AntigravitySubscriptionResult{
|
||||
PlanType: "Abnormal",
|
||||
SubscriptionStatus: antigravitySubscriptionAbnormal,
|
||||
}
|
||||
if resp.IneligibleTiers[0] != nil {
|
||||
result.SubscriptionError = strings.TrimSpace(resp.IneligibleTiers[0].ReasonMessage)
|
||||
}
|
||||
return result
|
||||
}
|
||||
tierID := resp.GetTier()
|
||||
return AntigravitySubscriptionResult{
|
||||
PlanType: antigravity.TierIDToPlanType(tierID),
|
||||
}
|
||||
}
|
||||
@ -235,6 +235,12 @@ const (
|
||||
|
||||
// SettingKeyBackendModeEnabled Backend 模式:禁用用户注册和自助服务,仅管理员可登录
|
||||
SettingKeyBackendModeEnabled = "backend_mode_enabled"
|
||||
|
||||
// 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).
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@ -35,7 +36,7 @@ func (u *epFixedUpstream) Do(req *http.Request, proxyURL string, accountID int64
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *epFixedUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (u *epFixedUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return u.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
@ -60,7 +61,7 @@ func (u *anthropicHTTPUpstreamRecorder) Do(req *http.Request, proxyURL string, a
|
||||
return u.resp, nil
|
||||
}
|
||||
|
||||
func (u *anthropicHTTPUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (u *anthropicHTTPUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return u.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
@ -175,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`, "透传输出不应被网关改写")
|
||||
@ -257,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"))
|
||||
@ -684,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) {
|
||||
@ -755,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -120,7 +120,7 @@ func (s *GatewayService) ForwardAsChatCompletions(
|
||||
}
|
||||
|
||||
// 11. Send request
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
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.IsTLSFingerprintEnabled())
|
||||
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()
|
||||
|
||||
@ -124,6 +124,27 @@ func TestSystemIncludesClaudeCodePrompt(t *testing.T) {
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
// json.RawMessage cases (conversion path: ForwardAsResponses / ForwardAsChatCompletions)
|
||||
{
|
||||
name: "json.RawMessage string with Claude Code prompt",
|
||||
system: json.RawMessage(`"` + claudeCodeSystemPrompt + `"`),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "json.RawMessage string without Claude Code prompt",
|
||||
system: json.RawMessage(`"You are a helpful assistant"`),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "json.RawMessage nil (empty)",
|
||||
system: json.RawMessage(nil),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "json.RawMessage empty string",
|
||||
system: json.RawMessage(`""`),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@ -202,6 +223,29 @@ func TestInjectClaudeCodePrompt(t *testing.T) {
|
||||
wantSystemLen: 1,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
},
|
||||
// json.RawMessage cases (conversion path: ForwardAsResponses / ForwardAsChatCompletions)
|
||||
{
|
||||
name: "json.RawMessage string system",
|
||||
body: `{"model":"claude-3","system":"Custom prompt"}`,
|
||||
system: json.RawMessage(`"Custom prompt"`),
|
||||
wantSystemLen: 2,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
wantSecondText: claudePrefix + "\n\nCustom prompt",
|
||||
},
|
||||
{
|
||||
name: "json.RawMessage nil system",
|
||||
body: `{"model":"claude-3"}`,
|
||||
system: json.RawMessage(nil),
|
||||
wantSystemLen: 1,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
},
|
||||
{
|
||||
name: "json.RawMessage Claude Code prompt (should not duplicate)",
|
||||
body: `{"model":"claude-3","system":"` + claudeCodeSystemPrompt + `"}`,
|
||||
system: json.RawMessage(`"` + claudeCodeSystemPrompt + `"`),
|
||||
wantSystemLen: 1,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@ -40,6 +40,7 @@ func newGatewayRecordUsageServiceForTest(usageRepo UsageLogRepository, userRepo
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
mathrand "math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@ -366,6 +367,7 @@ var allowedHeaders = map[string]bool{
|
||||
"sec-fetch-mode": true,
|
||||
"user-agent": true,
|
||||
"content-type": true,
|
||||
"accept-encoding": true,
|
||||
}
|
||||
|
||||
// GatewayCache 定义网关服务的缓存操作接口。
|
||||
@ -563,6 +565,8 @@ type GatewayService struct {
|
||||
responseHeaderFilter *responseheaders.CompiledHeaderFilter
|
||||
debugModelRouting atomic.Bool
|
||||
debugClaudeMimic atomic.Bool
|
||||
debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
}
|
||||
|
||||
// NewGatewayService creates a new GatewayService
|
||||
@ -589,6 +593,7 @@ func NewGatewayService(
|
||||
rpmCache RPMCache,
|
||||
digestStore *DigestSessionStore,
|
||||
settingService *SettingService,
|
||||
tlsFPProfileService *TLSFingerprintProfileService,
|
||||
) *GatewayService {
|
||||
userGroupRateTTL := resolveUserGroupRateCacheTTL(cfg)
|
||||
modelsListTTL := resolveModelsListCacheTTL(cfg)
|
||||
@ -620,6 +625,7 @@ func NewGatewayService(
|
||||
modelsListCache: gocache.New(modelsListTTL, time.Minute),
|
||||
modelsListCacheTTL: modelsListTTL,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
tlsFPProfileService: tlsFPProfileService,
|
||||
}
|
||||
svc.userGroupRateResolver = newUserGroupRateResolver(
|
||||
userGroupRateRepo,
|
||||
@ -630,6 +636,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
|
||||
}
|
||||
|
||||
@ -3740,9 +3749,28 @@ func isClaudeCodeRequest(ctx context.Context, c *gin.Context, parsed *ParsedRequ
|
||||
return isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID)
|
||||
}
|
||||
|
||||
// normalizeSystemParam 将 json.RawMessage 类型的 system 参数转为标准 Go 类型(string / []any / nil),
|
||||
// 避免 type switch 中 json.RawMessage(底层 []byte)无法匹配 case string / case []any / case nil 的问题。
|
||||
// 这是 Go 的 typed nil 陷阱:(json.RawMessage, nil) ≠ (nil, nil)。
|
||||
func normalizeSystemParam(system any) any {
|
||||
raw, ok := system.(json.RawMessage)
|
||||
if !ok {
|
||||
return system
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// systemIncludesClaudeCodePrompt 检查 system 中是否已包含 Claude Code 提示词
|
||||
// 使用前缀匹配支持多种变体(标准版、Agent SDK 版等)
|
||||
func systemIncludesClaudeCodePrompt(system any) bool {
|
||||
system = normalizeSystemParam(system)
|
||||
switch v := system.(type) {
|
||||
case string:
|
||||
return hasClaudeCodePrefix(v)
|
||||
@ -3771,6 +3799,7 @@ func hasClaudeCodePrefix(text string) bool {
|
||||
// injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词
|
||||
// 处理 null、字符串、数组三种格式
|
||||
func injectClaudeCodePrompt(body []byte, system any) []byte {
|
||||
system = normalizeSystemParam(system)
|
||||
claudeCodeBlock, err := marshalAnthropicSystemTextBlock(claudeCodeSystemPrompt, true)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.gateway", "Warning: failed to build Claude Code prompt block: %v", err)
|
||||
@ -4048,8 +4077,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
|
||||
@ -4066,9 +4102,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4116,9 +4156,12 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
// 解析 TLS 指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析)
|
||||
tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account)
|
||||
|
||||
// 调试日志:记录即将转发的账号信息
|
||||
logger.LegacyPrintf("service.gateway", "[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s",
|
||||
account.ID, account.Name, account.Platform, account.Type, account.IsTLSFingerprintEnabled(), proxyURL)
|
||||
account.ID, account.Name, account.Platform, account.Type, tlsProfile, proxyURL)
|
||||
// Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400.
|
||||
body = StripEmptyTextBlocks(body)
|
||||
|
||||
@ -4138,7 +4181,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
@ -4171,7 +4214,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,
|
||||
@ -4216,7 +4259,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, account.IsTLSFingerprintEnabled())
|
||||
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)
|
||||
@ -4226,7 +4269,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,
|
||||
@ -4251,7 +4294,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, account.IsTLSFingerprintEnabled())
|
||||
retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if retryErr2 == nil {
|
||||
resp = retryResp2
|
||||
break
|
||||
@ -4322,7 +4365,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, account.IsTLSFingerprintEnabled())
|
||||
budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if retryErr == nil {
|
||||
resp = budgetRetryResp
|
||||
break
|
||||
@ -4628,7 +4671,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
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()
|
||||
@ -4840,8 +4883,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4851,13 +4895,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
|
||||
@ -5346,7 +5390,7 @@ func (s *GatewayService) executeBedrockUpstream(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, false)
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, nil)
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
@ -5591,8 +5635,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)
|
||||
@ -5600,40 +5648,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5643,15 +5694,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
|
||||
@ -5667,31 +5718,41 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === 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" {
|
||||
@ -5771,24 +5832,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 {
|
||||
@ -6083,18 +6141,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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -6112,6 +6171,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 {
|
||||
@ -7958,7 +8070,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
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")
|
||||
@ -7980,13 +8092,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.IsTLSFingerprintEnabled())
|
||||
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)
|
||||
@ -8075,7 +8187,7 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
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{
|
||||
@ -8197,8 +8309,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8239,15 +8352,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8258,40 +8379,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
|
||||
@ -8302,30 +8421,30 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8496,42 +8615,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())
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@ -36,7 +37,7 @@ func (s *geminiCompatHTTPUpstreamStub) Do(req *http.Request, proxyURL string, ac
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (s *geminiCompatHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (s *geminiCompatHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
|
||||
157
backend/internal/service/header_util.go
Normal file
157
backend/internal/service/header_util.go
Normal file
@ -0,0 +1,157 @@
|
||||
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",
|
||||
}
|
||||
|
||||
// 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",
|
||||
"content-type",
|
||||
"anthropic-beta",
|
||||
"accept-language",
|
||||
"sec-fetch-mode",
|
||||
"accept-encoding",
|
||||
"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
|
||||
}
|
||||
@ -1,55 +1,24 @@
|
||||
package service
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
)
|
||||
|
||||
// HTTPUpstream 上游 HTTP 请求接口
|
||||
// 用于向上游 API(Claude、OpenAI、Gemini 等)发送请求
|
||||
// 这是一个通用接口,可用于任何基于 HTTP 的上游服务
|
||||
//
|
||||
// 设计说明:
|
||||
// - 支持可选代理配置
|
||||
// - 支持账户级连接池隔离
|
||||
// - 实现类负责连接池管理和复用
|
||||
// - 支持可选的 TLS 指纹伪装
|
||||
type HTTPUpstream interface {
|
||||
// Do 执行 HTTP 请求
|
||||
//
|
||||
// 参数:
|
||||
// - req: HTTP 请求对象,由调用方构建
|
||||
// - proxyURL: 代理服务器地址,空字符串表示直连
|
||||
// - accountID: 账户 ID,用于连接池隔离(隔离策略为 account 或 account_proxy 时生效)
|
||||
// - accountConcurrency: 账户并发限制,用于动态调整连接池大小
|
||||
//
|
||||
// 返回:
|
||||
// - *http.Response: HTTP 响应,调用方必须关闭 Body
|
||||
// - error: 请求错误(网络错误、超时等)
|
||||
//
|
||||
// 注意:
|
||||
// - 调用方必须关闭 resp.Body,否则会导致连接泄漏
|
||||
// - 响应体可能已被包装以跟踪请求生命周期
|
||||
// Do 执行 HTTP 请求(不启用 TLS 指纹)
|
||||
Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error)
|
||||
|
||||
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
|
||||
//
|
||||
// 参数:
|
||||
// - req: HTTP 请求对象,由调用方构建
|
||||
// - proxyURL: 代理服务器地址,空字符串表示直连
|
||||
// - accountID: 账户 ID,用于连接池隔离和 TLS 指纹模板选择
|
||||
// - accountConcurrency: 账户并发限制,用于动态调整连接池大小
|
||||
// - enableTLSFingerprint: 是否启用 TLS 指纹伪装
|
||||
// profile 参数:
|
||||
// - nil: 不启用 TLS 指纹,行为与 Do 方法相同
|
||||
// - non-nil: 使用指定的 Profile 进行 TLS 指纹伪装
|
||||
//
|
||||
// 返回:
|
||||
// - *http.Response: HTTP 响应,调用方必须关闭 Body
|
||||
// - error: 请求错误(网络错误、超时等)
|
||||
//
|
||||
// TLS 指纹说明:
|
||||
// - 当 enableTLSFingerprint=true 时,使用 utls 库模拟 Claude CLI 的 TLS 指纹
|
||||
// - TLS 指纹模板根据 accountID % len(profiles) 自动选择
|
||||
// - 支持直连、HTTP/HTTPS 代理、SOCKS5 代理三种场景
|
||||
// - 如果 enableTLSFingerprint=false,行为与 Do 方法相同
|
||||
//
|
||||
// 注意:
|
||||
// - 调用方必须关闭 resp.Body,否则会导致连接泄漏
|
||||
// - TLS 指纹客户端与普通客户端使用不同的缓存键,互不影响
|
||||
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error)
|
||||
// Profile 由调用方通过 TLSFingerprintProfileService 解析后传入,
|
||||
// 支持按账号绑定的数据库 profile 或内置默认 profile。
|
||||
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error)
|
||||
}
|
||||
|
||||
@ -174,6 +174,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
|
||||
@ -181,27 +182,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
@ -43,7 +44,7 @@ func (u *httpUpstreamRecorder) Do(req *http.Request, proxyURL string, accountID
|
||||
return u.resp, nil
|
||||
}
|
||||
|
||||
func (u *httpUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (u *httpUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return u.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
@ -57,7 +58,7 @@ func (u *httpUpstreamSequenceRecorder) Do(req *http.Request, proxyURL string, ac
|
||||
return u.responses[len(u.responses)-1], nil
|
||||
}
|
||||
|
||||
func (u *httpUpstreamSequenceRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (u *httpUpstreamSequenceRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return u.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// RateLimitService 处理限流和过载状态管理
|
||||
@ -149,6 +150,17 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
|
||||
}
|
||||
// 其他 400 错误(如参数问题)不处理,不禁用账号
|
||||
case 401:
|
||||
// OpenAI: token_invalidated / token_revoked 表示 token 被永久作废(非过期),直接标记 error
|
||||
openai401Code := extractUpstreamErrorCode(responseBody)
|
||||
if account.Platform == PlatformOpenAI && (openai401Code == "token_invalidated" || openai401Code == "token_revoked") {
|
||||
msg := "Token revoked (401): account authentication permanently revoked"
|
||||
if upstreamMsg != "" {
|
||||
msg = "Token revoked (401): " + upstreamMsg
|
||||
}
|
||||
s.handleAuthError(ctx, account, msg)
|
||||
shouldDisable = true
|
||||
break
|
||||
}
|
||||
// OAuth 账号在 401 错误时临时不可调度(给 token 刷新窗口);非 OAuth 账号保持原有 SetError 行为。
|
||||
// Antigravity 除外:其 401 由 applyErrorPolicy 的 temp_unschedulable_rules 自行控制。
|
||||
if account.Type == AccountTypeOAuth && account.Platform != PlatformAntigravity {
|
||||
@ -192,6 +204,13 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
|
||||
shouldDisable = true
|
||||
}
|
||||
case 402:
|
||||
// OpenAI: deactivated_workspace 表示工作区已停用,直接标记 error
|
||||
if account.Platform == PlatformOpenAI && gjson.GetBytes(responseBody, "detail.code").String() == "deactivated_workspace" {
|
||||
msg := "Workspace deactivated (402): workspace has been deactivated"
|
||||
s.handleAuthError(ctx, account, msg)
|
||||
shouldDisable = true
|
||||
break
|
||||
}
|
||||
// 支付要求:余额不足或计费问题,停止调度
|
||||
msg := "Payment required (402): insufficient balance or billing issue"
|
||||
if upstreamMsg != "" {
|
||||
|
||||
@ -79,6 +79,20 @@ const backendModeCacheTTL = 60 * time.Second
|
||||
const backendModeErrorTTL = 5 * time.Second
|
||||
const backendModeDBTimeout = 5 * time.Second
|
||||
|
||||
// cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL)
|
||||
type cachedGatewayForwardingSettings struct {
|
||||
fingerprintUnification bool
|
||||
metadataPassthrough bool
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
var gatewayForwardingCache atomic.Value // *cachedGatewayForwardingSettings
|
||||
var gatewayForwardingSF singleflight.Group
|
||||
|
||||
const gatewayForwardingCacheTTL = 60 * time.Second
|
||||
const gatewayForwardingErrorTTL = 5 * time.Second
|
||||
const gatewayForwardingDBTimeout = 5 * time.Second
|
||||
|
||||
// DefaultSubscriptionGroupReader validates group references used by default subscriptions.
|
||||
type DefaultSubscriptionGroupReader interface {
|
||||
GetByID(ctx context.Context, id int64) (*Group, error)
|
||||
@ -510,6 +524,10 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
||||
// Backend Mode
|
||||
updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled)
|
||||
|
||||
// Gateway forwarding behavior
|
||||
updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification)
|
||||
updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough)
|
||||
|
||||
err = s.settingRepo.SetMultiple(ctx, updates)
|
||||
if err == nil {
|
||||
// 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口
|
||||
@ -524,6 +542,12 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
||||
value: settings.BackendModeEnabled,
|
||||
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
|
||||
})
|
||||
gatewayForwardingSF.Forget("gateway_forwarding")
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||
fingerprintUnification: settings.EnableFingerprintUnification,
|
||||
metadataPassthrough: settings.EnableMetadataPassthrough,
|
||||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||||
})
|
||||
if s.onUpdate != nil {
|
||||
s.onUpdate() // Invalidate cache after settings update
|
||||
}
|
||||
@ -626,6 +650,57 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// GetGatewayForwardingSettings returns cached gateway forwarding settings.
|
||||
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path.
|
||||
// Returns (fingerprintUnification, metadataPassthrough).
|
||||
func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough bool) {
|
||||
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.fingerprintUnification, cached.metadataPassthrough
|
||||
}
|
||||
}
|
||||
type gwfResult struct {
|
||||
fp, mp bool
|
||||
}
|
||||
val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) {
|
||||
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return gwfResult{cached.fingerprintUnification, cached.metadataPassthrough}, nil
|
||||
}
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
|
||||
defer cancel()
|
||||
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
|
||||
SettingKeyEnableFingerprintUnification,
|
||||
SettingKeyEnableMetadataPassthrough,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("failed to get gateway forwarding settings", "error", err)
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||
fingerprintUnification: true,
|
||||
metadataPassthrough: false,
|
||||
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
|
||||
})
|
||||
return gwfResult{true, false}, nil
|
||||
}
|
||||
fp := true
|
||||
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
||||
fp = v == "true"
|
||||
}
|
||||
mp := values[SettingKeyEnableMetadataPassthrough] == "true"
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||
fingerprintUnification: fp,
|
||||
metadataPassthrough: mp,
|
||||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||||
})
|
||||
return gwfResult{fp, mp}, nil
|
||||
})
|
||||
if r, ok := val.(gwfResult); ok {
|
||||
return r.fp, r.mp
|
||||
}
|
||||
return true, false // fail-open defaults
|
||||
}
|
||||
|
||||
// IsEmailVerifyEnabled 检查是否开启邮件验证
|
||||
func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled)
|
||||
@ -923,6 +998,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
// 分组隔离
|
||||
result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true"
|
||||
|
||||
// Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false)
|
||||
if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
||||
result.EnableFingerprintUnification = v == "true"
|
||||
} else {
|
||||
result.EnableFingerprintUnification = true // default: enabled (current behavior)
|
||||
}
|
||||
result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true"
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@ -75,6 +75,10 @@ type SystemSettings struct {
|
||||
|
||||
// Backend 模式:禁用用户注册和自助服务,仅管理员可登录
|
||||
BackendModeEnabled bool
|
||||
|
||||
// Gateway forwarding behavior
|
||||
EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true)
|
||||
EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
|
||||
}
|
||||
|
||||
type DefaultSubscriptionSetting struct {
|
||||
@ -186,9 +190,11 @@ func DefaultStreamTimeoutSettings() *StreamTimeoutSettings {
|
||||
|
||||
// RectifierSettings 请求整流器配置
|
||||
type RectifierSettings struct {
|
||||
Enabled bool `json:"enabled"` // 总开关
|
||||
ThinkingSignatureEnabled bool `json:"thinking_signature_enabled"` // Thinking 签名整流
|
||||
ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"` // Thinking Budget 整流
|
||||
Enabled bool `json:"enabled"` // 总开关
|
||||
ThinkingSignatureEnabled bool `json:"thinking_signature_enabled"` // Thinking 签名整流
|
||||
ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"` // Thinking Budget 整流
|
||||
APIKeySignatureEnabled bool `json:"apikey_signature_enabled"` // API Key 签名整流开关
|
||||
APIKeySignaturePatterns []string `json:"apikey_signature_patterns"` // API Key 自定义匹配关键词
|
||||
}
|
||||
|
||||
// DefaultRectifierSettings 返回默认的整流器配置(全部启用)
|
||||
|
||||
259
backend/internal/service/tls_fingerprint_profile_service.go
Normal file
259
backend/internal/service/tls_fingerprint_profile_service.go
Normal file
@ -0,0 +1,259 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfileRepository 定义 TLS 指纹模板的数据访问接口
|
||||
type TLSFingerprintProfileRepository interface {
|
||||
List(ctx context.Context) ([]*model.TLSFingerprintProfile, error)
|
||||
GetByID(ctx context.Context, id int64) (*model.TLSFingerprintProfile, error)
|
||||
Create(ctx context.Context, profile *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error)
|
||||
Update(ctx context.Context, profile *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error)
|
||||
Delete(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
// TLSFingerprintProfileCache 定义 TLS 指纹模板的缓存接口
|
||||
type TLSFingerprintProfileCache interface {
|
||||
Get(ctx context.Context) ([]*model.TLSFingerprintProfile, bool)
|
||||
Set(ctx context.Context, profiles []*model.TLSFingerprintProfile) error
|
||||
Invalidate(ctx context.Context) error
|
||||
NotifyUpdate(ctx context.Context) error
|
||||
SubscribeUpdates(ctx context.Context, handler func())
|
||||
}
|
||||
|
||||
// TLSFingerprintProfileService TLS 指纹模板管理服务
|
||||
type TLSFingerprintProfileService struct {
|
||||
repo TLSFingerprintProfileRepository
|
||||
cache TLSFingerprintProfileCache
|
||||
|
||||
// 本地 ID→Profile 映射缓存,用于 DoWithTLS 热路径快速查找
|
||||
localCache map[int64]*model.TLSFingerprintProfile
|
||||
localMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewTLSFingerprintProfileService 创建 TLS 指纹模板服务
|
||||
func NewTLSFingerprintProfileService(
|
||||
repo TLSFingerprintProfileRepository,
|
||||
cache TLSFingerprintProfileCache,
|
||||
) *TLSFingerprintProfileService {
|
||||
svc := &TLSFingerprintProfileService{
|
||||
repo: repo,
|
||||
cache: cache,
|
||||
localCache: make(map[int64]*model.TLSFingerprintProfile),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := svc.reloadFromDB(ctx); err != nil {
|
||||
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to load profiles from DB on startup: %v", err)
|
||||
if fallbackErr := svc.refreshLocalCache(ctx); fallbackErr != nil {
|
||||
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to load profiles from cache fallback on startup: %v", fallbackErr)
|
||||
}
|
||||
}
|
||||
|
||||
if cache != nil {
|
||||
cache.SubscribeUpdates(ctx, func() {
|
||||
if err := svc.refreshLocalCache(context.Background()); err != nil {
|
||||
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to refresh cache on notification: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
// List 获取所有模板
|
||||
func (s *TLSFingerprintProfileService) List(ctx context.Context) ([]*model.TLSFingerprintProfile, error) {
|
||||
return s.repo.List(ctx)
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取模板
|
||||
func (s *TLSFingerprintProfileService) GetByID(ctx context.Context, id int64) (*model.TLSFingerprintProfile, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Create 创建模板
|
||||
func (s *TLSFingerprintProfileService) Create(ctx context.Context, profile *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
|
||||
if err := profile.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
created, err := s.repo.Create(ctx, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshCtx, cancel := s.newCacheRefreshContext()
|
||||
defer cancel()
|
||||
s.invalidateAndNotify(refreshCtx)
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// Update 更新模板
|
||||
func (s *TLSFingerprintProfileService) Update(ctx context.Context, profile *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
|
||||
if err := profile.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updated, err := s.repo.Update(ctx, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshCtx, cancel := s.newCacheRefreshContext()
|
||||
defer cancel()
|
||||
s.invalidateAndNotify(refreshCtx)
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// Delete 删除模板
|
||||
func (s *TLSFingerprintProfileService) Delete(ctx context.Context, id int64) error {
|
||||
if err := s.repo.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
refreshCtx, cancel := s.newCacheRefreshContext()
|
||||
defer cancel()
|
||||
s.invalidateAndNotify(refreshCtx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- 热路径:运行时 Profile 查找 ---
|
||||
|
||||
// GetProfileByID 根据 ID 从本地缓存获取 Profile(用于 DoWithTLS 热路径)
|
||||
// 返回 nil 表示未找到,调用方应 fallback 到内置默认 Profile
|
||||
func (s *TLSFingerprintProfileService) GetProfileByID(id int64) *tlsfingerprint.Profile {
|
||||
s.localMu.RLock()
|
||||
p, ok := s.localCache[id]
|
||||
s.localMu.RUnlock()
|
||||
|
||||
if ok && p != nil {
|
||||
return p.ToTLSProfile()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRandomProfile 从本地缓存中随机选择一个 Profile
|
||||
func (s *TLSFingerprintProfileService) getRandomProfile() *tlsfingerprint.Profile {
|
||||
s.localMu.RLock()
|
||||
defer s.localMu.RUnlock()
|
||||
|
||||
if len(s.localCache) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 收集所有 profile
|
||||
profiles := make([]*model.TLSFingerprintProfile, 0, len(s.localCache))
|
||||
for _, p := range s.localCache {
|
||||
if p != nil {
|
||||
profiles = append(profiles, p)
|
||||
}
|
||||
}
|
||||
if len(profiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return profiles[rand.IntN(len(profiles))].ToTLSProfile()
|
||||
}
|
||||
|
||||
// ResolveTLSProfile 根据 Account 的配置解析出运行时 TLS Profile
|
||||
//
|
||||
// 逻辑:
|
||||
// 1. 未启用 TLS 指纹 → 返回 nil(不伪装)
|
||||
// 2. 启用 + 绑定了 profile_id → 从缓存查找对应 profile
|
||||
// 3. 启用 + 未绑定或找不到 → 返回空 Profile(使用代码内置默认值)
|
||||
func (s *TLSFingerprintProfileService) ResolveTLSProfile(account *Account) *tlsfingerprint.Profile {
|
||||
if account == nil || !account.IsTLSFingerprintEnabled() {
|
||||
return nil
|
||||
}
|
||||
id := account.GetTLSFingerprintProfileID()
|
||||
if id > 0 {
|
||||
if p := s.GetProfileByID(id); p != nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if id == -1 {
|
||||
// 随机选择一个 profile
|
||||
if p := s.getRandomProfile(); p != nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
// TLS 启用但无绑定 profile → 空 Profile → dialer 使用内置默认值
|
||||
return &tlsfingerprint.Profile{Name: "Built-in Default (Node.js 24.x)"}
|
||||
}
|
||||
|
||||
// --- 缓存管理 ---
|
||||
|
||||
func (s *TLSFingerprintProfileService) refreshLocalCache(ctx context.Context) error {
|
||||
if s.cache != nil {
|
||||
if profiles, ok := s.cache.Get(ctx); ok {
|
||||
s.setLocalCache(profiles)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return s.reloadFromDB(ctx)
|
||||
}
|
||||
|
||||
func (s *TLSFingerprintProfileService) reloadFromDB(ctx context.Context) error {
|
||||
profiles, err := s.repo.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
if err := s.cache.Set(ctx, profiles); err != nil {
|
||||
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to set cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.setLocalCache(profiles)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TLSFingerprintProfileService) setLocalCache(profiles []*model.TLSFingerprintProfile) {
|
||||
m := make(map[int64]*model.TLSFingerprintProfile, len(profiles))
|
||||
for _, p := range profiles {
|
||||
m[p.ID] = p
|
||||
}
|
||||
|
||||
s.localMu.Lock()
|
||||
s.localCache = m
|
||||
s.localMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *TLSFingerprintProfileService) newCacheRefreshContext() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 3*time.Second)
|
||||
}
|
||||
|
||||
func (s *TLSFingerprintProfileService) invalidateAndNotify(ctx context.Context) {
|
||||
if s.cache != nil {
|
||||
if err := s.cache.Invalidate(ctx); err != nil {
|
||||
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to invalidate cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.reloadFromDB(ctx); err != nil {
|
||||
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to refresh local cache: %v", err)
|
||||
s.localMu.Lock()
|
||||
s.localCache = make(map[int64]*model.TLSFingerprintProfile)
|
||||
s.localMu.Unlock()
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
if err := s.cache.NotifyUpdate(ctx); err != nil {
|
||||
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to notify cache update: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -128,7 +128,7 @@ func (s *TokenRefreshService) Start() {
|
||||
)
|
||||
}
|
||||
|
||||
// Stop 停止刷新服务
|
||||
// Stop 停止刷新服务(可安全多次调用)
|
||||
func (s *TokenRefreshService) Stop() {
|
||||
close(s.stopCh)
|
||||
s.wg.Wait()
|
||||
@ -300,6 +300,8 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc
|
||||
"error", setErr,
|
||||
)
|
||||
}
|
||||
// 刷新失败但 access_token 可能仍有效,尝试设置隐私
|
||||
s.ensureOpenAIPrivacy(ctx, account)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -327,6 +329,9 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc
|
||||
"error", lastErr,
|
||||
)
|
||||
|
||||
// 刷新失败但 access_token 可能仍有效,尝试设置隐私
|
||||
s.ensureOpenAIPrivacy(ctx, account)
|
||||
|
||||
// 设置临时不可调度 10 分钟(不标记 error,保持 status=active 让下个刷新周期能继续尝试)
|
||||
until := time.Now().Add(tokenRefreshTempUnschedDuration)
|
||||
reason := fmt.Sprintf("token refresh retry exhausted: %v", lastErr)
|
||||
@ -404,6 +409,8 @@ func (s *TokenRefreshService) postRefreshActions(ctx context.Context, account *A
|
||||
}
|
||||
// OpenAI OAuth: 刷新成功后,检查是否已设置 privacy_mode,未设置则尝试关闭训练数据共享
|
||||
s.ensureOpenAIPrivacy(ctx, account)
|
||||
// Antigravity OAuth: 刷新成功后,检查是否已设置 privacy_mode,未设置则调用 setUserSettings
|
||||
s.ensureAntigravityPrivacy(ctx, account)
|
||||
}
|
||||
|
||||
// errRefreshSkipped 表示刷新被跳过(锁竞争或已被其他路径刷新),不计入 failed 或 refreshed
|
||||
@ -441,11 +448,8 @@ func (s *TokenRefreshService) ensureOpenAIPrivacy(ctx context.Context, account *
|
||||
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)
|
||||
@ -477,3 +481,50 @@ func (s *TokenRefreshService) ensureOpenAIPrivacy(ctx context.Context, account *
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ensureAntigravityPrivacy 后台刷新中检查 Antigravity OAuth 账号隐私状态。
|
||||
// 仅做 Extra["privacy_mode"] 存在性检查,不发起 HTTP 请求,避免每轮循环产生额外网络开销。
|
||||
// 用户可通过前端 SetPrivacy 按钮强制重新设置。
|
||||
func (s *TokenRefreshService) ensureAntigravityPrivacy(ctx context.Context, account *Account) {
|
||||
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
|
||||
return
|
||||
}
|
||||
// 已设置过(无论成功或失败)则跳过,不发 HTTP
|
||||
if account.Extra != nil {
|
||||
if _, ok := account.Extra["privacy_mode"]; ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
token, _ := account.Credentials["access_token"].(string)
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
|
||||
projectID, _ := account.Credentials["project_id"].(string)
|
||||
|
||||
var proxyURL string
|
||||
if account.ProxyID != nil && s.proxyRepo != nil {
|
||||
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
|
||||
proxyURL = p.URL()
|
||||
}
|
||||
}
|
||||
|
||||
mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
|
||||
if mode == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
|
||||
slog.Warn("token_refresh.update_antigravity_privacy_mode_failed",
|
||||
"account_id", account.ID,
|
||||
"error", err,
|
||||
)
|
||||
} else {
|
||||
applyAntigravityPrivacyMode(account, mode)
|
||||
slog.Info("token_refresh.antigravity_privacy_mode_set",
|
||||
"account_id", account.ID,
|
||||
"privacy_mode", mode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -482,6 +482,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewUsageCache,
|
||||
NewTotpService,
|
||||
NewErrorPassthroughService,
|
||||
NewTLSFingerprintProfileService,
|
||||
NewDigestSessionStore,
|
||||
ProvideIdempotencyCoordinator,
|
||||
ProvideSystemOperationLockService,
|
||||
|
||||
29
backend/migrations/080_create_tls_fingerprint_profiles.sql
Normal file
29
backend/migrations/080_create_tls_fingerprint_profiles.sql
Normal file
@ -0,0 +1,29 @@
|
||||
-- Create tls_fingerprint_profiles table for managing TLS fingerprint templates.
|
||||
-- Each profile contains ClientHello parameters to simulate specific client TLS handshake characteristics.
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
SET LOCAL statement_timeout = '10min';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tls_fingerprint_profiles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
enable_grease BOOLEAN NOT NULL DEFAULT false,
|
||||
cipher_suites JSONB,
|
||||
curves JSONB,
|
||||
point_formats JSONB,
|
||||
signature_algorithms JSONB,
|
||||
alpn_protocols JSONB,
|
||||
supported_versions JSONB,
|
||||
key_share_groups JSONB,
|
||||
psk_modes JSONB,
|
||||
extensions JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE tls_fingerprint_profiles IS 'TLS fingerprint templates for simulating specific client TLS handshake characteristics';
|
||||
COMMENT ON COLUMN tls_fingerprint_profiles.name IS 'Unique profile name, e.g. "macOS Node.js v24"';
|
||||
COMMENT ON COLUMN tls_fingerprint_profiles.enable_grease IS 'Whether to insert GREASE values in ClientHello extensions';
|
||||
COMMENT ON COLUMN tls_fingerprint_profiles.cipher_suites IS 'TLS cipher suite list as JSON array of uint16 (order-sensitive, affects JA3)';
|
||||
COMMENT ON COLUMN tls_fingerprint_profiles.extensions IS 'TLS extension type IDs in send order as JSON array of uint16';
|
||||
@ -627,6 +627,16 @@ export async function batchRefresh(accountIds: number[]): Promise<BatchOperation
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Set privacy for an Antigravity OAuth account
|
||||
* @param id - Account ID
|
||||
* @returns Updated account
|
||||
*/
|
||||
export async function setPrivacy(id: number): Promise<Account> {
|
||||
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/set-privacy`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const accountsAPI = {
|
||||
list,
|
||||
listWithEtag,
|
||||
@ -663,7 +673,8 @@ export const accountsAPI = {
|
||||
importData,
|
||||
getAntigravityDefaultModelMapping,
|
||||
batchClearError,
|
||||
batchRefresh
|
||||
batchRefresh,
|
||||
setPrivacy
|
||||
}
|
||||
|
||||
export default accountsAPI
|
||||
|
||||
@ -24,6 +24,7 @@ import dataManagementAPI from './dataManagement'
|
||||
import apiKeysAPI from './apiKeys'
|
||||
import scheduledTestsAPI from './scheduledTests'
|
||||
import backupAPI from './backup'
|
||||
import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
|
||||
|
||||
/**
|
||||
* Unified admin API object for convenient access
|
||||
@ -49,7 +50,8 @@ export const adminAPI = {
|
||||
dataManagement: dataManagementAPI,
|
||||
apiKeys: apiKeysAPI,
|
||||
scheduledTests: scheduledTestsAPI,
|
||||
backup: backupAPI
|
||||
backup: backupAPI,
|
||||
tlsFingerprintProfiles: tlsFingerprintProfileAPI
|
||||
}
|
||||
|
||||
export {
|
||||
@ -73,7 +75,8 @@ export {
|
||||
dataManagementAPI,
|
||||
apiKeysAPI,
|
||||
scheduledTestsAPI,
|
||||
backupAPI
|
||||
backupAPI,
|
||||
tlsFingerprintProfileAPI
|
||||
}
|
||||
|
||||
export default adminAPI
|
||||
@ -82,3 +85,4 @@ export default adminAPI
|
||||
export type { BalanceHistoryItem } from './users'
|
||||
export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from './errorPassthrough'
|
||||
export type { BackupAgentHealth, DataManagementConfig } from './dataManagement'
|
||||
export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest } from './tlsFingerprintProfile'
|
||||
|
||||
@ -86,6 +86,10 @@ export interface SystemSettings {
|
||||
|
||||
// 分组隔离
|
||||
allow_ungrouped_key_scheduling: boolean
|
||||
|
||||
// Gateway forwarding behavior
|
||||
enable_fingerprint_unification: boolean
|
||||
enable_metadata_passthrough: boolean
|
||||
}
|
||||
|
||||
export interface UpdateSettingsRequest {
|
||||
@ -142,6 +146,8 @@ export interface UpdateSettingsRequest {
|
||||
min_claude_code_version?: string
|
||||
max_claude_code_version?: string
|
||||
allow_ungrouped_key_scheduling?: boolean
|
||||
enable_fingerprint_unification?: boolean
|
||||
enable_metadata_passthrough?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@ -317,6 +323,8 @@ export interface RectifierSettings {
|
||||
enabled: boolean
|
||||
thinking_signature_enabled: boolean
|
||||
thinking_budget_enabled: boolean
|
||||
apikey_signature_enabled: boolean
|
||||
apikey_signature_patterns: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
98
frontend/src/api/admin/tlsFingerprintProfile.ts
Normal file
98
frontend/src/api/admin/tlsFingerprintProfile.ts
Normal file
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Admin TLS Fingerprint Profile API endpoints
|
||||
* Handles TLS fingerprint profile CRUD for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
/**
|
||||
* TLS fingerprint profile interface
|
||||
*/
|
||||
export interface TLSFingerprintProfile {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
enable_grease: boolean
|
||||
cipher_suites: number[]
|
||||
curves: number[]
|
||||
point_formats: number[]
|
||||
signature_algorithms: number[]
|
||||
alpn_protocols: string[]
|
||||
supported_versions: number[]
|
||||
key_share_groups: number[]
|
||||
psk_modes: number[]
|
||||
extensions: number[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create profile request
|
||||
*/
|
||||
export interface CreateProfileRequest {
|
||||
name: string
|
||||
description?: string | null
|
||||
enable_grease?: boolean
|
||||
cipher_suites?: number[]
|
||||
curves?: number[]
|
||||
point_formats?: number[]
|
||||
signature_algorithms?: number[]
|
||||
alpn_protocols?: string[]
|
||||
supported_versions?: number[]
|
||||
key_share_groups?: number[]
|
||||
psk_modes?: number[]
|
||||
extensions?: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Update profile request
|
||||
*/
|
||||
export interface UpdateProfileRequest {
|
||||
name?: string
|
||||
description?: string | null
|
||||
enable_grease?: boolean
|
||||
cipher_suites?: number[]
|
||||
curves?: number[]
|
||||
point_formats?: number[]
|
||||
signature_algorithms?: number[]
|
||||
alpn_protocols?: string[]
|
||||
supported_versions?: number[]
|
||||
key_share_groups?: number[]
|
||||
psk_modes?: number[]
|
||||
extensions?: number[]
|
||||
}
|
||||
|
||||
export async function list(): Promise<TLSFingerprintProfile[]> {
|
||||
const { data } = await apiClient.get<TLSFingerprintProfile[]>('/admin/tls-fingerprint-profiles')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getById(id: number): Promise<TLSFingerprintProfile> {
|
||||
const { data } = await apiClient.get<TLSFingerprintProfile>(`/admin/tls-fingerprint-profiles/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function create(profileData: CreateProfileRequest): Promise<TLSFingerprintProfile> {
|
||||
const { data } = await apiClient.post<TLSFingerprintProfile>('/admin/tls-fingerprint-profiles', profileData)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function update(id: number, updates: UpdateProfileRequest): Promise<TLSFingerprintProfile> {
|
||||
const { data } = await apiClient.put<TLSFingerprintProfile>(`/admin/tls-fingerprint-profiles/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteProfile(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/tls-fingerprint-profiles/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const tlsFingerprintProfileAPI = {
|
||||
list,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteProfile
|
||||
}
|
||||
|
||||
export default tlsFingerprintProfileAPI
|
||||
@ -31,6 +31,57 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI passthrough -->
|
||||
<div
|
||||
v-if="allOpenAIPassthroughCapable"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex-1 pr-4">
|
||||
<label
|
||||
id="bulk-edit-openai-passthrough-label"
|
||||
class="input-label mb-0"
|
||||
for="bulk-edit-openai-passthrough-enabled"
|
||||
>
|
||||
{{ t('admin.accounts.openai.oauthPassthrough') }}
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.oauthPassthroughDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="enableOpenAIPassthrough"
|
||||
id="bulk-edit-openai-passthrough-enabled"
|
||||
type="checkbox"
|
||||
aria-controls="bulk-edit-openai-passthrough-body"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="bulk-edit-openai-passthrough-body"
|
||||
:class="!enableOpenAIPassthrough && 'pointer-events-none opacity-50'"
|
||||
role="group"
|
||||
aria-labelledby="bulk-edit-openai-passthrough-label"
|
||||
>
|
||||
<button
|
||||
id="bulk-edit-openai-passthrough-toggle"
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
openaiPassthroughEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
@click="openaiPassthroughEnabled = !openaiPassthroughEnabled"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
openaiPassthroughEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Base URL (API Key only) -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
@ -89,100 +140,30 @@
|
||||
role="group"
|
||||
aria-labelledby="bulk-edit-model-restriction-label"
|
||||
>
|
||||
<!-- Mode Toggle -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'whitelist'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
@click="modelRestrictionMode = 'whitelist'"
|
||||
>
|
||||
<svg
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.modelWhitelist') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'mapping'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
@click="modelRestrictionMode = 'mapping'"
|
||||
>
|
||||
<svg
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.modelMapping') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.selectAllowedModels') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ModelWhitelistSelector
|
||||
v-model="allowedModels"
|
||||
:platforms="selectedPlatforms"
|
||||
/>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0">{{
|
||||
t('admin.accounts.supportsAllModels')
|
||||
}}</span>
|
||||
<div
|
||||
v-if="isOpenAIModelRestrictionDisabled"
|
||||
class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"
|
||||
>
|
||||
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||||
{{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mapping Mode -->
|
||||
<div v-else>
|
||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||
<template v-else>
|
||||
<!-- Mode Toggle -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'whitelist'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
@click="modelRestrictionMode = 'whitelist'"
|
||||
>
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@ -191,96 +172,177 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.mapRequestModels') }}
|
||||
</p>
|
||||
{{ t('admin.accounts.modelWhitelist') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'mapping'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
@click="modelRestrictionMode = 'mapping'"
|
||||
>
|
||||
<svg
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.modelMapping') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Model Mapping List -->
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="mapping.from"
|
||||
type="text"
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.requestModel')"
|
||||
/>
|
||||
<svg
|
||||
class="h-4 w-4 flex-shrink-0 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14 5l7 7m0 0l-7 7m7-7H3"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="mapping.to"
|
||||
type="text"
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.actualModel')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
@click="removeModelMapping(index)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.selectAllowedModels') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ModelWhitelistSelector
|
||||
v-model="allowedModels"
|
||||
:platforms="selectedPlatforms"
|
||||
/>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0">{{
|
||||
t('admin.accounts.supportsAllModels')
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mapping Mode -->
|
||||
<div v-else>
|
||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.mapRequestModels') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Mapping List -->
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="mapping.from"
|
||||
type="text"
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.requestModel')"
|
||||
/>
|
||||
<svg
|
||||
class="h-4 w-4 flex-shrink-0 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14 5l7 7m0 0l-7 7m7-7H3"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="mapping.to"
|
||||
type="text"
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.actualModel')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
@click="removeModelMapping(index)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||||
@click="addModelMapping"
|
||||
>
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.addMapping') }}
|
||||
</button>
|
||||
|
||||
<!-- Quick Add Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in filteredPresets"
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||
@click="addPresetMapping(preset.from, preset.to)"
|
||||
>
|
||||
+ {{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||||
@click="addModelMapping"
|
||||
>
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.addMapping') }}
|
||||
</button>
|
||||
|
||||
<!-- Quick Add Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in filteredPresets"
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||
@click="addPresetMapping(preset.from, preset.to)"
|
||||
>
|
||||
+ {{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -865,7 +927,6 @@ import {
|
||||
resolveOpenAIWSModeConcurrencyHintKey
|
||||
} from '@/utils/openaiWsMode'
|
||||
import type { OpenAIWSMode } from '@/utils/openaiWsMode'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
accountIds: number[]
|
||||
@ -887,6 +948,15 @@ const appStore = useAppStore()
|
||||
// Platform awareness
|
||||
const isMixedPlatform = computed(() => props.selectedPlatforms.length > 1)
|
||||
|
||||
const allOpenAIPassthroughCapable = computed(() => {
|
||||
return (
|
||||
props.selectedPlatforms.length === 1 &&
|
||||
props.selectedPlatforms[0] === 'openai' &&
|
||||
props.selectedTypes.length > 0 &&
|
||||
props.selectedTypes.every(t => t === 'oauth' || t === 'apikey')
|
||||
)
|
||||
})
|
||||
|
||||
const allOpenAIOAuth = computed(() => {
|
||||
return (
|
||||
props.selectedPlatforms.length === 1 &&
|
||||
@ -939,6 +1009,7 @@ const enablePriority = ref(false)
|
||||
const enableRateMultiplier = ref(false)
|
||||
const enableStatus = ref(false)
|
||||
const enableGroups = ref(false)
|
||||
const enableOpenAIPassthrough = ref(false)
|
||||
const enableOpenAIWSMode = ref(false)
|
||||
const enableRpmLimit = ref(false)
|
||||
|
||||
@ -961,6 +1032,7 @@ const priority = ref(1)
|
||||
const rateMultiplier = ref(1)
|
||||
const status = ref<'active' | 'inactive'>('active')
|
||||
const groupIds = ref<number[]>([])
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const rpmLimitEnabled = ref(false)
|
||||
const bulkBaseRpm = ref<number | null>(null)
|
||||
@ -988,6 +1060,13 @@ const statusOptions = computed(() => [
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
])
|
||||
const isOpenAIModelRestrictionDisabled = computed(
|
||||
() =>
|
||||
allOpenAIPassthroughCapable.value &&
|
||||
enableOpenAIPassthrough.value &&
|
||||
openaiPassthroughEnabled.value
|
||||
)
|
||||
|
||||
const openAIWSModeOptions = computed(() => [
|
||||
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
|
||||
{ value: OPENAI_WS_MODE_PASSTHROUGH, label: t('admin.accounts.openai.wsModePassthrough') }
|
||||
@ -1123,7 +1202,15 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
}
|
||||
}
|
||||
|
||||
if (enableModelRestriction.value) {
|
||||
if (enableOpenAIPassthrough.value) {
|
||||
const extra = ensureExtra()
|
||||
extra.openai_passthrough = openaiPassthroughEnabled.value
|
||||
if (!openaiPassthroughEnabled.value) {
|
||||
extra.openai_oauth_passthrough = false
|
||||
}
|
||||
}
|
||||
|
||||
if (enableModelRestriction.value && !isOpenAIModelRestrictionDisabled.value) {
|
||||
// 统一使用 model_mapping 字段
|
||||
if (modelRestrictionMode.value === 'whitelist') {
|
||||
// 白名单模式:将模型转换为 model_mapping 格式(key=value)
|
||||
@ -1243,6 +1330,7 @@ const handleSubmit = async () => {
|
||||
|
||||
const hasAnyFieldEnabled =
|
||||
enableBaseUrl.value ||
|
||||
enableOpenAIPassthrough.value ||
|
||||
enableModelRestriction.value ||
|
||||
enableCustomErrorCodes.value ||
|
||||
enableInterceptWarmup.value ||
|
||||
@ -1345,11 +1433,13 @@ watch(
|
||||
enableRateMultiplier.value = false
|
||||
enableStatus.value = false
|
||||
enableGroups.value = false
|
||||
enableOpenAIPassthrough.value = false
|
||||
enableOpenAIWSMode.value = false
|
||||
enableRpmLimit.value = false
|
||||
|
||||
// Reset all values
|
||||
baseUrl.value = ''
|
||||
openaiPassthroughEnabled.value = false
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
allowedModels.value = []
|
||||
modelMappings.value = []
|
||||
|
||||
@ -2169,6 +2169,14 @@
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Profile selector -->
|
||||
<div v-if="tlsFingerprintEnabled" class="mt-3">
|
||||
<select v-model="tlsFingerprintProfileId" class="input">
|
||||
<option :value="null">{{ t('admin.accounts.quotaControl.tlsFingerprint.defaultProfile') }}</option>
|
||||
<option v-if="tlsFingerprintProfiles.length > 0" :value="-1">{{ t('admin.accounts.quotaControl.tlsFingerprint.randomProfile') }}</option>
|
||||
<option v-for="p in tlsFingerprintProfiles" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session ID Masking -->
|
||||
@ -3082,6 +3090,8 @@ const umqModeOptions = computed(() => [
|
||||
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
|
||||
])
|
||||
const tlsFingerprintEnabled = ref(false)
|
||||
const tlsFingerprintProfileId = ref<number | null>(null)
|
||||
const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([])
|
||||
const sessionIdMaskingEnabled = ref(false)
|
||||
const cacheTTLOverrideEnabled = ref(false)
|
||||
const cacheTTLOverrideTarget = ref<string>('5m')
|
||||
@ -3247,6 +3257,10 @@ watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
// Load TLS fingerprint profiles
|
||||
adminAPI.tlsFingerprintProfiles.list()
|
||||
.then(profiles => { tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name })) })
|
||||
.catch(() => { tlsFingerprintProfiles.value = [] })
|
||||
// Modal opened - fill related models
|
||||
allowedModels.value = [...getModelsByPlatform(form.platform)]
|
||||
// Antigravity: 默认使用映射模式并填充默认映射
|
||||
@ -3747,6 +3761,7 @@ const resetForm = () => {
|
||||
rpmStickyBuffer.value = null
|
||||
userMsgQueueMode.value = ''
|
||||
tlsFingerprintEnabled.value = false
|
||||
tlsFingerprintProfileId.value = null
|
||||
sessionIdMaskingEnabled.value = false
|
||||
cacheTTLOverrideEnabled.value = false
|
||||
cacheTTLOverrideTarget.value = '5m'
|
||||
@ -4825,6 +4840,9 @@ const handleAnthropicExchange = async (authCode: string) => {
|
||||
// Add TLS fingerprint settings
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
extra.enable_tls_fingerprint = true
|
||||
if (tlsFingerprintProfileId.value) {
|
||||
extra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
|
||||
}
|
||||
}
|
||||
|
||||
// Add session ID masking settings
|
||||
@ -4940,6 +4958,9 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
// Add TLS fingerprint settings
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
extra.enable_tls_fingerprint = true
|
||||
if (tlsFingerprintProfileId.value) {
|
||||
extra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
|
||||
}
|
||||
}
|
||||
|
||||
// Add session ID masking settings
|
||||
|
||||
@ -1504,6 +1504,14 @@
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Profile selector -->
|
||||
<div v-if="tlsFingerprintEnabled" class="mt-3">
|
||||
<select v-model="tlsFingerprintProfileId" class="input">
|
||||
<option :value="null">{{ t('admin.accounts.quotaControl.tlsFingerprint.defaultProfile') }}</option>
|
||||
<option v-if="tlsFingerprintProfiles.length > 0" :value="-1">{{ t('admin.accounts.quotaControl.tlsFingerprint.randomProfile') }}</option>
|
||||
<option v-for="p in tlsFingerprintProfiles" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session ID Masking -->
|
||||
@ -1841,6 +1849,8 @@ const umqModeOptions = computed(() => [
|
||||
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
|
||||
])
|
||||
const tlsFingerprintEnabled = ref(false)
|
||||
const tlsFingerprintProfileId = ref<number | null>(null)
|
||||
const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([])
|
||||
const sessionIdMaskingEnabled = ref(false)
|
||||
const cacheTTLOverrideEnabled = ref(false)
|
||||
const cacheTTLOverrideTarget = ref<string>('5m')
|
||||
@ -2255,11 +2265,21 @@ watch(
|
||||
}
|
||||
if (!wasShow || newAccount !== previousAccount) {
|
||||
syncFormFromAccount(newAccount)
|
||||
loadTLSProfiles()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const loadTLSProfiles = async () => {
|
||||
try {
|
||||
const profiles = await adminAPI.tlsFingerprintProfiles.list()
|
||||
tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name }))
|
||||
} catch {
|
||||
tlsFingerprintProfiles.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// Model mapping helpers
|
||||
const addModelMapping = () => {
|
||||
modelMappings.value.push({ from: '', to: '' })
|
||||
@ -2458,6 +2478,7 @@ function loadQuotaControlSettings(account: Account) {
|
||||
rpmStickyBuffer.value = null
|
||||
userMsgQueueMode.value = ''
|
||||
tlsFingerprintEnabled.value = false
|
||||
tlsFingerprintProfileId.value = null
|
||||
sessionIdMaskingEnabled.value = false
|
||||
cacheTTLOverrideEnabled.value = false
|
||||
cacheTTLOverrideTarget.value = '5m'
|
||||
@ -2495,6 +2516,7 @@ function loadQuotaControlSettings(account: Account) {
|
||||
if (account.enable_tls_fingerprint === true) {
|
||||
tlsFingerprintEnabled.value = true
|
||||
}
|
||||
tlsFingerprintProfileId.value = account.tls_fingerprint_profile_id ?? null
|
||||
|
||||
// Load session ID masking setting
|
||||
if (account.session_id_masking_enabled === true) {
|
||||
@ -2932,8 +2954,14 @@ const handleSubmit = async () => {
|
||||
// TLS fingerprint setting
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
newExtra.enable_tls_fingerprint = true
|
||||
if (tlsFingerprintProfileId.value) {
|
||||
newExtra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
|
||||
} else {
|
||||
delete newExtra.tls_fingerprint_profile_id
|
||||
}
|
||||
} else {
|
||||
delete newExtra.enable_tls_fingerprint
|
||||
delete newExtra.tls_fingerprint_profile_id
|
||||
}
|
||||
|
||||
// Session ID masking setting
|
||||
|
||||
@ -130,6 +130,25 @@ describe('BulkEditAccountModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('OpenAI 账号批量编辑可开启自动透传', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['oauth']
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-openai-passthrough-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-openai-passthrough-toggle').trigger('click')
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
extra: {
|
||||
openai_passthrough: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('OpenAI OAuth 批量编辑应提交 OAuth 专属 WS mode 字段', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
@ -158,4 +177,44 @@ describe('BulkEditAccountModal', () => {
|
||||
|
||||
expect(wrapper.find('#bulk-edit-openai-ws-mode-enabled').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('OpenAI 账号批量编辑可关闭自动透传', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['apikey']
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-openai-passthrough-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
extra: {
|
||||
openai_passthrough: false,
|
||||
openai_oauth_passthrough: false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('开启 OpenAI 自动透传时不再同时提交模型限制', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['oauth']
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-openai-passthrough-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-openai-passthrough-toggle').trigger('click')
|
||||
await wrapper.get('#bulk-edit-model-restriction-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
extra: {
|
||||
openai_passthrough: true
|
||||
}
|
||||
})
|
||||
expect(wrapper.text()).toContain('admin.accounts.openai.modelRestrictionDisabledByPassthrough')
|
||||
})
|
||||
})
|
||||
|
||||
625
frontend/src/components/admin/TLSFingerprintProfilesModal.vue
Normal file
625
frontend/src/components/admin/TLSFingerprintProfilesModal.vue
Normal file
@ -0,0 +1,625 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.tlsFingerprintProfiles.title')"
|
||||
width="wide"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.description') }}
|
||||
</p>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary btn-sm">
|
||||
<Icon name="plus" size="sm" class="mr-1" />
|
||||
{{ t('admin.tlsFingerprintProfiles.createProfile') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Profiles Table -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<Icon name="refresh" size="lg" class="animate-spin text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="profiles.length === 0" class="py-8 text-center">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700">
|
||||
<Icon name="shield" size="lg" class="text-gray-400" />
|
||||
</div>
|
||||
<h4 class="mb-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.tlsFingerprintProfiles.noProfiles') }}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.createFirstProfile') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-96 overflow-auto rounded-lg border border-gray-200 dark:border-dark-600">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="sticky top-0 bg-gray-50 dark:bg-dark-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.columns.name') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.columns.description') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.columns.grease') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.columns.alpn') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.columns.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
|
||||
<tr v-for="profile in profiles" :key="profile.id" class="hover:bg-gray-50 dark:hover:bg-dark-700">
|
||||
<td class="px-3 py-2">
|
||||
<div class="font-medium text-gray-900 dark:text-white text-sm">{{ profile.name }}</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div v-if="profile.description" class="text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate">
|
||||
{{ profile.description }}
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400 dark:text-gray-600">—</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<Icon
|
||||
:name="profile.enable_grease ? 'check' : 'lock'"
|
||||
size="sm"
|
||||
:class="profile.enable_grease ? 'text-green-500' : 'text-gray-400'"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div v-if="profile.alpn_protocols?.length" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="proto in profile.alpn_protocols.slice(0, 3)"
|
||||
:key="proto"
|
||||
class="badge badge-primary text-xs"
|
||||
>
|
||||
{{ proto }}
|
||||
</span>
|
||||
<span v-if="profile.alpn_protocols.length > 3" class="text-xs text-gray-500">
|
||||
+{{ profile.alpn_protocols.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400 dark:text-gray-600">—</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="handleEdit(profile)"
|
||||
class="p-1 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
:title="t('common.edit')"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(profile)"
|
||||
class="p-1 text-gray-500 hover:text-red-600 dark:hover:text-red-400"
|
||||
:title="t('common.delete')"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<button @click="$emit('close')" class="btn btn-secondary">
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<BaseDialog
|
||||
:show="showCreateModal || showEditModal"
|
||||
:title="showEditModal ? t('admin.tlsFingerprintProfiles.editProfile') : t('admin.tlsFingerprintProfiles.createProfile')"
|
||||
width="wide"
|
||||
:z-index="60"
|
||||
@close="closeFormModal"
|
||||
>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Paste YAML -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.tlsFingerprintProfiles.form.pasteYaml') }}</label>
|
||||
<textarea
|
||||
v-model="yamlInput"
|
||||
rows="4"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="t('admin.tlsFingerprintProfiles.form.pasteYamlPlaceholder')"
|
||||
@paste="handleYamlPaste"
|
||||
/>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<button type="button" @click="parseYamlInput" class="btn btn-secondary btn-sm">
|
||||
{{ t('admin.tlsFingerprintProfiles.form.parseYaml') }}
|
||||
</button>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.form.pasteYamlHint') }}
|
||||
<a href="https://tls.sub2api.org" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 underline">{{ t('admin.tlsFingerprintProfiles.form.openCollector') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-200 dark:border-dark-600" />
|
||||
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.tlsFingerprintProfiles.form.name') }}</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
:placeholder="t('admin.tlsFingerprintProfiles.form.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.tlsFingerprintProfiles.form.description') }}</label>
|
||||
<input
|
||||
v-model="form.description"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.tlsFingerprintProfiles.form.descriptionPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GREASE Toggle -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="form.enable_grease = !form.enable_grease"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
form.enable_grease ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
form.enable_grease ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.tlsFingerprintProfiles.form.enableGrease') }}
|
||||
</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.form.enableGreaseHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TLS Array Fields - 2 column grid -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.cipherSuites') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.cipher_suites"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'0x1301, 0x1302, 0xc02c'"
|
||||
/>
|
||||
<p class="input-hint text-xs">{{ t('admin.tlsFingerprintProfiles.form.cipherSuitesHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.curves') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.curves"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'29, 23, 24'"
|
||||
/>
|
||||
<p class="input-hint text-xs">{{ t('admin.tlsFingerprintProfiles.form.curvesHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.signatureAlgorithms') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.signature_algorithms"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'0x0403, 0x0804, 0x0401'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.supportedVersions') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.supported_versions"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'0x0304, 0x0303'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.keyShareGroups') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.key_share_groups"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'29, 23'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.extensions') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.extensions"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'0x0000, 0x0005, 0x000a'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.pointFormats') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.point_formats"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'0'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.pskModes') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.psk_modes"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'1'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ALPN Protocols - full width -->
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.alpnProtocols') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.alpn_protocols"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'h2, http/1.1'"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="closeFormModal" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button @click="handleSubmit" :disabled="submitting" class="btn btn-primary">
|
||||
<Icon v-if="submitting" name="refresh" size="sm" class="mr-1 animate-spin" />
|
||||
{{ showEditModal ? t('common.update') : t('common.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Confirmation -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('admin.tlsFingerprintProfiles.deleteProfile')"
|
||||
:message="t('admin.tlsFingerprintProfiles.deleteConfirmMessage', { name: deletingProfile?.name })"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { TLSFingerprintProfile } from '@/api/admin/tlsFingerprintProfile'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
void emit // suppress unused warning - emit is used via $emit in template
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const profiles = ref<TLSFingerprintProfile[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const editingProfile = ref<TLSFingerprintProfile | null>(null)
|
||||
const deletingProfile = ref<TLSFingerprintProfile | null>(null)
|
||||
const yamlInput = ref('')
|
||||
|
||||
// Raw string inputs for array fields
|
||||
const fieldInputs = reactive({
|
||||
cipher_suites: '',
|
||||
curves: '',
|
||||
point_formats: '',
|
||||
signature_algorithms: '',
|
||||
alpn_protocols: '',
|
||||
supported_versions: '',
|
||||
key_share_groups: '',
|
||||
psk_modes: '',
|
||||
extensions: ''
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: null as string | null,
|
||||
enable_grease: false
|
||||
})
|
||||
|
||||
// Load profiles when dialog opens
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
loadProfiles()
|
||||
}
|
||||
})
|
||||
|
||||
const loadProfiles = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
profiles.value = await adminAPI.tlsFingerprintProfiles.list()
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.tlsFingerprintProfiles.loadFailed'))
|
||||
console.error('Error loading TLS fingerprint profiles:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.name = ''
|
||||
form.description = null
|
||||
form.enable_grease = false
|
||||
fieldInputs.cipher_suites = ''
|
||||
fieldInputs.curves = ''
|
||||
fieldInputs.point_formats = ''
|
||||
fieldInputs.signature_algorithms = ''
|
||||
fieldInputs.alpn_protocols = ''
|
||||
fieldInputs.supported_versions = ''
|
||||
fieldInputs.key_share_groups = ''
|
||||
fieldInputs.psk_modes = ''
|
||||
fieldInputs.extensions = ''
|
||||
yamlInput.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML output from tls-fingerprint-web and fill form fields.
|
||||
* Expected format:
|
||||
* # comment lines
|
||||
* profile_key:
|
||||
* name: "Profile Name"
|
||||
* enable_grease: false
|
||||
* cipher_suites: [4866, 4867, ...]
|
||||
* alpn_protocols: ["h2", "http/1.1"]
|
||||
* ...
|
||||
*/
|
||||
const parseYamlInput = () => {
|
||||
const text = yamlInput.value.trim()
|
||||
if (!text) return
|
||||
|
||||
// Simple YAML parser for flat key-value structure
|
||||
// Extracts "key: value" lines, handling arrays like [1, 2, 3] and ["h2", "http/1.1"]
|
||||
const lines = text.split('\n')
|
||||
|
||||
let foundName = false
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
// Skip comments and empty lines
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
|
||||
// Match "key: value" pattern (must have at least 2 leading spaces to be a property)
|
||||
const match = trimmed.match(/^(\w+):\s*(.+)$/)
|
||||
if (!match) continue
|
||||
|
||||
const [, key, rawValue] = match
|
||||
const value = rawValue.trim()
|
||||
|
||||
switch (key) {
|
||||
case 'name': {
|
||||
// Remove surrounding quotes
|
||||
const unquoted = value.replace(/^["']|["']$/g, '')
|
||||
if (unquoted) {
|
||||
form.name = unquoted
|
||||
foundName = true
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'enable_grease':
|
||||
form.enable_grease = value === 'true'
|
||||
break
|
||||
case 'cipher_suites':
|
||||
case 'curves':
|
||||
case 'point_formats':
|
||||
case 'signature_algorithms':
|
||||
case 'supported_versions':
|
||||
case 'key_share_groups':
|
||||
case 'psk_modes':
|
||||
case 'extensions': {
|
||||
// Parse YAML array: [1, 2, 3] — values are decimal integers from tls-fingerprint-web
|
||||
const arrMatch = value.match(/^\[(.*)?\]$/)
|
||||
if (arrMatch) {
|
||||
const inner = arrMatch[1] || ''
|
||||
fieldInputs[key as keyof typeof fieldInputs] = inner
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0)
|
||||
.join(', ')
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'alpn_protocols': {
|
||||
// Parse string array: ["h2", "http/1.1"]
|
||||
const arrMatch = value.match(/^\[(.*)?\]$/)
|
||||
if (arrMatch) {
|
||||
const inner = arrMatch[1] || ''
|
||||
fieldInputs.alpn_protocols = inner
|
||||
.split(',')
|
||||
.map(s => s.trim().replace(/^["']|["']$/g, ''))
|
||||
.filter(s => s.length > 0)
|
||||
.join(', ')
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundName) {
|
||||
appStore.showSuccess(t('admin.tlsFingerprintProfiles.form.yamlParsed'))
|
||||
} else {
|
||||
appStore.showError(t('admin.tlsFingerprintProfiles.form.yamlParseFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-parse on paste event
|
||||
const handleYamlPaste = () => {
|
||||
// Use nextTick to ensure v-model has updated
|
||||
setTimeout(() => parseYamlInput(), 50)
|
||||
}
|
||||
|
||||
const closeFormModal = () => {
|
||||
showCreateModal.value = false
|
||||
showEditModal.value = false
|
||||
editingProfile.value = null
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// Parse a comma-separated string of numbers supporting both hex (0x...) and decimal
|
||||
const parseNumericArray = (input: string): number[] => {
|
||||
if (!input.trim()) return []
|
||||
return input
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0)
|
||||
.map(s => s.startsWith('0x') || s.startsWith('0X') ? parseInt(s, 16) : parseInt(s, 10))
|
||||
.filter(n => !isNaN(n))
|
||||
}
|
||||
|
||||
// Parse a comma-separated string of string values
|
||||
const parseStringArray = (input: string): string[] => {
|
||||
if (!input.trim()) return []
|
||||
return input
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0)
|
||||
}
|
||||
|
||||
// Format a number as hex with 0x prefix and 4-digit padding
|
||||
const formatHex = (n: number): string => '0x' + n.toString(16).padStart(4, '0')
|
||||
|
||||
// Format numeric arrays for display in textarea (null-safe)
|
||||
const formatNumericArray = (arr: number[] | null | undefined): string => (arr ?? []).map(formatHex).join(', ')
|
||||
|
||||
// For point_formats and psk_modes (uint8), show as plain numbers (null-safe)
|
||||
const formatPlainNumericArray = (arr: number[] | null | undefined): string => (arr ?? []).join(', ')
|
||||
|
||||
const handleEdit = (profile: TLSFingerprintProfile) => {
|
||||
editingProfile.value = profile
|
||||
form.name = profile.name
|
||||
form.description = profile.description
|
||||
form.enable_grease = profile.enable_grease
|
||||
fieldInputs.cipher_suites = formatNumericArray(profile.cipher_suites)
|
||||
fieldInputs.curves = formatPlainNumericArray(profile.curves)
|
||||
fieldInputs.point_formats = formatPlainNumericArray(profile.point_formats)
|
||||
fieldInputs.signature_algorithms = formatNumericArray(profile.signature_algorithms)
|
||||
fieldInputs.alpn_protocols = (profile.alpn_protocols ?? []).join(', ')
|
||||
fieldInputs.supported_versions = formatNumericArray(profile.supported_versions)
|
||||
fieldInputs.key_share_groups = formatPlainNumericArray(profile.key_share_groups)
|
||||
fieldInputs.psk_modes = formatPlainNumericArray(profile.psk_modes)
|
||||
fieldInputs.extensions = formatNumericArray(profile.extensions)
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (profile: TLSFingerprintProfile) => {
|
||||
deletingProfile.value = profile
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
appStore.showError(t('admin.tlsFingerprintProfiles.form.name') + ' ' + t('common.required'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = {
|
||||
name: form.name.trim(),
|
||||
description: form.description?.trim() || null,
|
||||
enable_grease: form.enable_grease,
|
||||
cipher_suites: parseNumericArray(fieldInputs.cipher_suites),
|
||||
curves: parseNumericArray(fieldInputs.curves),
|
||||
point_formats: parseNumericArray(fieldInputs.point_formats),
|
||||
signature_algorithms: parseNumericArray(fieldInputs.signature_algorithms),
|
||||
alpn_protocols: parseStringArray(fieldInputs.alpn_protocols),
|
||||
supported_versions: parseNumericArray(fieldInputs.supported_versions),
|
||||
key_share_groups: parseNumericArray(fieldInputs.key_share_groups),
|
||||
psk_modes: parseNumericArray(fieldInputs.psk_modes),
|
||||
extensions: parseNumericArray(fieldInputs.extensions)
|
||||
}
|
||||
|
||||
if (showEditModal.value && editingProfile.value) {
|
||||
await adminAPI.tlsFingerprintProfiles.update(editingProfile.value.id, data)
|
||||
appStore.showSuccess(t('admin.tlsFingerprintProfiles.updateSuccess'))
|
||||
} else {
|
||||
await adminAPI.tlsFingerprintProfiles.create(data)
|
||||
appStore.showSuccess(t('admin.tlsFingerprintProfiles.createSuccess'))
|
||||
}
|
||||
|
||||
closeFormModal()
|
||||
loadProfiles()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.tlsFingerprintProfiles.saveFailed'))
|
||||
console.error('Error saving TLS fingerprint profile:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingProfile.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.tlsFingerprintProfiles.delete(deletingProfile.value.id)
|
||||
appStore.showSuccess(t('admin.tlsFingerprintProfiles.deleteSuccess'))
|
||||
showDeleteDialog.value = false
|
||||
deletingProfile.value = null
|
||||
loadProfiles()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.tlsFingerprintProfiles.deleteFailed'))
|
||||
console.error('Error deleting TLS fingerprint profile:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
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