Unverified Commit b6d46fd5 authored by InCerryGit's avatar InCerryGit Committed by GitHub
Browse files

Merge branch 'Wei-Shaw:main' into main

parents fa68cbad fdd8499f
......@@ -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)
}
......
//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 {
......@@ -189,6 +193,8 @@ type RectifierSettings struct {
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 返回默认的整流器配置(全部启用)
......
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,12 +448,9 @@ func (s *TokenRefreshService) ensureOpenAIPrivacy(ctx context.Context, account *
if s.privacyClientFactory == nil {
return
}
// 已设置过则跳过
if account.Extra != nil {
if _, ok := account.Extra["privacy_mode"]; ok {
if shouldSkipOpenAIPrivacyEnsure(account.Extra) {
return
}
}
token, _ := account.Credentials["access_token"].(string)
if token == "" {
......@@ -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,
......
-- 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[]
}
/**
......
/**
* 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,6 +140,16 @@
role="group"
aria-labelledby="bulk-edit-model-restriction-label"
>
<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>
<template v-else>
<!-- Mode Toggle -->
<div class="mb-4 flex gap-2">
<button
......@@ -281,6 +342,7 @@
</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')
})
})
<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>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment