Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
b6d46fd5
Unverified
Commit
b6d46fd5
authored
Mar 27, 2026
by
InCerryGit
Committed by
GitHub
Mar 27, 2026
Browse files
Merge branch 'Wei-Shaw:main' into main
parents
fa68cbad
fdd8499f
Changes
107
Hide whitespace changes
Inline
Side-by-side
backend/internal/service/openai_oauth_passthrough_test.go
View file @
b6d46fd5
...
...
@@ -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
,
enableTLSF
ingerprint
bool
)
(
*
http
.
Response
,
error
)
{
func
(
u
*
httpUpstreamRecorder
)
DoWithTLS
(
req
*
http
.
Request
,
proxyURL
string
,
accountID
int64
,
accountConcurrency
int
,
profile
*
tlsf
ingerprint
.
Profile
)
(
*
http
.
Response
,
error
)
{
return
u
.
Do
(
req
,
proxyURL
,
accountID
,
accountConcurrency
)
}
...
...
backend/internal/service/openai_privacy_retry_test.go
0 → 100644
View file @
b6d46fd5
//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
)
})
}
}
backend/internal/service/openai_privacy_service.go
View file @
b6d46fd5
...
...
@@ -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
{
...
...
backend/internal/service/openai_ws_protocol_forward_test.go
View file @
b6d46fd5
...
...
@@ -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
,
enableTLSF
ingerprint
bool
)
(
*
http
.
Response
,
error
)
{
func
(
u
*
httpUpstreamSequenceRecorder
)
DoWithTLS
(
req
*
http
.
Request
,
proxyURL
string
,
accountID
int64
,
accountConcurrency
int
,
profile
*
tlsf
ingerprint
.
Profile
)
(
*
http
.
Response
,
error
)
{
return
u
.
Do
(
req
,
proxyURL
,
accountID
,
accountConcurrency
)
}
...
...
backend/internal/service/ratelimit_service.go
View file @
b6d46fd5
...
...
@@ -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
!=
""
{
...
...
backend/internal/service/setting_service.go
View file @
b6d46fd5
...
...
@@ -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
}
...
...
backend/internal/service/settings_view.go
View file @
b6d46fd5
...
...
@@ -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 返回默认的整流器配置(全部启用)
...
...
backend/internal/service/tls_fingerprint_profile_service.go
0 → 100644
View file @
b6d46fd5
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
)
}
}
}
backend/internal/service/token_refresh_service.go
View file @
b6d46fd5
...
...
@@ -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,7 +448,48 @@ func (s *TokenRefreshService) ensureOpenAIPrivacy(ctx context.Context, account *
if
s
.
privacyClientFactory
==
nil
{
return
}
// 已设置过则跳过
if
shouldSkipOpenAIPrivacyEnsure
(
account
.
Extra
)
{
return
}
token
,
_
:=
account
.
Credentials
[
"access_token"
]
.
(
string
)
if
token
==
""
{
return
}
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
:=
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
{
slog
.
Warn
(
"token_refresh.update_privacy_mode_failed"
,
"account_id"
,
account
.
ID
,
"error"
,
err
,
)
}
else
{
slog
.
Info
(
"token_refresh.privacy_mode_set"
,
"account_id"
,
account
.
ID
,
"privacy_mode"
,
mode
,
)
}
}
// 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
...
...
@@ -453,6 +501,8 @@ func (s *TokenRefreshService) ensureOpenAIPrivacy(ctx context.Context, account *
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
{
...
...
@@ -460,18 +510,19 @@ func (s *TokenRefreshService) ensureOpenAIPrivacy(ctx context.Context, account *
}
}
mode
:=
disableOpenAITraining
(
ctx
,
s
.
privacyClientFactory
,
token
,
proxyURL
)
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_privacy_mode_failed"
,
slog
.
Warn
(
"token_refresh.update_
antigravity_
privacy_mode_failed"
,
"account_id"
,
account
.
ID
,
"error"
,
err
,
)
}
else
{
slog
.
Info
(
"token_refresh.privacy_mode_set"
,
applyAntigravityPrivacyMode
(
account
,
mode
)
slog
.
Info
(
"token_refresh.antigravity_privacy_mode_set"
,
"account_id"
,
account
.
ID
,
"privacy_mode"
,
mode
,
)
...
...
backend/internal/service/wire.go
View file @
b6d46fd5
...
...
@@ -482,6 +482,7 @@ var ProviderSet = wire.NewSet(
NewUsageCache
,
NewTotpService
,
NewErrorPassthroughService
,
NewTLSFingerprintProfileService
,
NewDigestSessionStore
,
ProvideIdempotencyCoordinator
,
ProvideSystemOperationLockService
,
...
...
backend/migrations/080_create_tls_fingerprint_profiles.sql
0 → 100644
View file @
b6d46fd5
-- 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'
;
frontend/src/api/admin/accounts.ts
View file @
b6d46fd5
...
...
@@ -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
frontend/src/api/admin/index.ts
View file @
b6d46fd5
...
...
@@ -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
'
frontend/src/api/admin/settings.ts
View file @
b6d46fd5
...
...
@@ -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
[]
}
/**
...
...
frontend/src/api/admin/tlsFingerprintProfile.ts
0 → 100644
View file @
b6d46fd5
/**
* 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
frontend/src/components/account/BulkEditAccountModal.vue
View file @
b6d46fd5
...
...
@@ -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,66 +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
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
>
<!--
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
"
>
<
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
"
...
...
@@ -157,32 +172,23 @@
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M
13 16h-1v-4h-1m1-4h.01M21 1
2a9 9 0 11-18 0 9 9 0 0118 0z
"
d
=
"
M
9 12l2 2 4-4m6
2a9 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
"
>
{{
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 inline h-4 w-4
"
class
=
"
mr-1
.5
inline h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
...
...
@@ -191,28 +197,124 @@
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M
13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
d
=
"
M
8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.mapRequestModels
'
)
}}
{{
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
>
<
/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
"
<!--
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
"
>
<
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
"
class
=
"
mr-1 inline h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
...
...
@@ -221,66 +323,26 @@
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M1
4 5l7 7m0 0l-7 7m7-7H3
"
d
=
"
M1
2 4v16m8-8H4
"
/>
<
/svg
>
<
input
v
-
model
=
"
mapping.to
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.actualModel')
"
/>
{{
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 p
-2 text-red-500
transition-colors
hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20
"
@
click
=
"
removeModelMapping(index
)
"
:
class
=
"
['
rounded-lg p
x-3 py-1 text-xs
transition-colors
', preset.color]
"
@
click
=
"
addPresetMapping(preset.from, preset.to
)
"
>
<
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
>
+
{{
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
=
[]
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
b6d46fd5
...
...
@@ -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
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
b6d46fd5
...
...
@@ -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
...
...
frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
View file @
b6d46fd5
...
...
@@ -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
'
)
})
})
frontend/src/components/admin/TLSFingerprintProfilesModal.vue
0 → 100644
View file @
b6d46fd5
<
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
>
Prev
1
2
3
4
5
6
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment