feat(tls-fingerprint): 新增 TLS 指纹 Profile 数据库管理及代码质量优化

新增功能:
- 新增 TLS 指纹 Profile CRUD 管理(Ent schema + 迁移 + Admin API + 前端管理界面)
- 支持账号绑定数据库中的自定义 TLS Profile,或随机选择(profile_id=-1)
- HTTPUpstream.DoWithTLS 接口从 bool 改为 *tlsfingerprint.Profile,支持按账号指定 Profile
- AccountUsageService 注入 TLSFingerprintProfileService,统一 usage 场景与网关的 Profile 解析逻辑

代码优化:
- 删除已被 TLSFingerprintProfileService 完全取代的 registry.go 死代码(418 行)
- 提取 3 个 dialer 的重复 TLS 握手逻辑为 performTLSHandshake() 共用函数
- 修复 GetTLSFingerprintProfileID 缺少 json.Number 处理的 bug
- gateway_service.Forward 中 ResolveTLSProfile 从重试循环内重复调用改为预解析局部变量
- 删除冗余的 buildClientHelloSpec() 单行 wrapper 和 int64(e.ID) 无效转换
- tls_fingerprint_profile_cache.go 日志从 log.Printf 改为 slog 结构化日志
- dialer_capture_test.go 添加 //go:build integration 标签,防止 CI 失败
- 去重 TestProfileExpectation 类型至共享 test_types_test.go
- 修复 9 个测试文件缺少 tlsfingerprint import 的编译错误
- 修复 error_policy_integration_test.go 中 handleError 回调签名被错误替换的问题
This commit is contained in:
shaw
2026-03-27 14:33:05 +08:00
parent ef5c8e6839
commit 1854050df3
70 changed files with 8095 additions and 1037 deletions
+20 -4
View File
@@ -656,17 +656,33 @@ type TLSFingerprintConfig struct {
}
// TLSProfileConfig 单个TLS指纹模板的配置
// 所有列表字段为空时使用内置默认值(Claude CLI 2.x / Node.js 20.x
// 建议通过 TLS 指纹采集工具 (tests/tls-fingerprint-web) 获取完整配置
type TLSProfileConfig struct {
// Name: 模板显示名称
Name string `mapstructure:"name"`
// EnableGREASE: 是否启用GREASE扩展(Chrome使用,Node.js不使用)
EnableGREASE bool `mapstructure:"enable_grease"`
// CipherSuites: TLS加密套件列表(空则使用内置默认值)
// CipherSuites: TLS加密套件列表
CipherSuites []uint16 `mapstructure:"cipher_suites"`
// Curves: 椭圆曲线列表(空则使用内置默认值)
// Curves: 椭圆曲线列表
Curves []uint16 `mapstructure:"curves"`
// PointFormats: 点格式列表(空则使用内置默认值)
PointFormats []uint8 `mapstructure:"point_formats"`
// PointFormats: 点格式列表
PointFormats []uint16 `mapstructure:"point_formats"`
// SignatureAlgorithms: 签名算法列表
SignatureAlgorithms []uint16 `mapstructure:"signature_algorithms"`
// ALPNProtocols: ALPN协议列表(如 ["h2", "http/1.1"]
ALPNProtocols []string `mapstructure:"alpn_protocols"`
// SupportedVersions: 支持的TLS版本列表(如 [0x0304, 0x0303] 即 TLS1.3, TLS1.2
SupportedVersions []uint16 `mapstructure:"supported_versions"`
// KeyShareGroups: Key Share中发送的曲线组(如 [29] 即 X25519
KeyShareGroups []uint16 `mapstructure:"key_share_groups"`
// PSKModes: PSK密钥交换模式(如 [1] 即 psk_dhe_ke
PSKModes []uint16 `mapstructure:"psk_modes"`
// Extensions: TLS扩展类型ID列表,按发送顺序排列
// 空则使用内置默认顺序 [0,11,10,35,16,22,23,13,43,45,51]
// GREASE值(如0x0a0a)会自动插入GREASE扩展
Extensions []uint16 `mapstructure:"extensions"`
}
// GatewaySchedulingConfig accounts scheduling configuration.
@@ -0,0 +1,234 @@
package admin
import (
"strconv"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// TLSFingerprintProfileHandler 处理 TLS 指纹模板的 HTTP 请求
type TLSFingerprintProfileHandler struct {
service *service.TLSFingerprintProfileService
}
// NewTLSFingerprintProfileHandler 创建 TLS 指纹模板处理器
func NewTLSFingerprintProfileHandler(service *service.TLSFingerprintProfileService) *TLSFingerprintProfileHandler {
return &TLSFingerprintProfileHandler{service: service}
}
// CreateTLSFingerprintProfileRequest 创建模板请求
type CreateTLSFingerprintProfileRequest struct {
Name string `json:"name" binding:"required"`
Description *string `json:"description"`
EnableGREASE *bool `json:"enable_grease"`
CipherSuites []uint16 `json:"cipher_suites"`
Curves []uint16 `json:"curves"`
PointFormats []uint16 `json:"point_formats"`
SignatureAlgorithms []uint16 `json:"signature_algorithms"`
ALPNProtocols []string `json:"alpn_protocols"`
SupportedVersions []uint16 `json:"supported_versions"`
KeyShareGroups []uint16 `json:"key_share_groups"`
PSKModes []uint16 `json:"psk_modes"`
Extensions []uint16 `json:"extensions"`
}
// UpdateTLSFingerprintProfileRequest 更新模板请求(部分更新)
type UpdateTLSFingerprintProfileRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
EnableGREASE *bool `json:"enable_grease"`
CipherSuites []uint16 `json:"cipher_suites"`
Curves []uint16 `json:"curves"`
PointFormats []uint16 `json:"point_formats"`
SignatureAlgorithms []uint16 `json:"signature_algorithms"`
ALPNProtocols []string `json:"alpn_protocols"`
SupportedVersions []uint16 `json:"supported_versions"`
KeyShareGroups []uint16 `json:"key_share_groups"`
PSKModes []uint16 `json:"psk_modes"`
Extensions []uint16 `json:"extensions"`
}
// List 获取所有模板
// GET /api/v1/admin/tls-fingerprint-profiles
func (h *TLSFingerprintProfileHandler) List(c *gin.Context) {
profiles, err := h.service.List(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, profiles)
}
// GetByID 根据 ID 获取模板
// GET /api/v1/admin/tls-fingerprint-profiles/:id
func (h *TLSFingerprintProfileHandler) GetByID(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid profile ID")
return
}
profile, err := h.service.GetByID(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
if profile == nil {
response.NotFound(c, "Profile not found")
return
}
response.Success(c, profile)
}
// Create 创建模板
// POST /api/v1/admin/tls-fingerprint-profiles
func (h *TLSFingerprintProfileHandler) Create(c *gin.Context) {
var req CreateTLSFingerprintProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
profile := &model.TLSFingerprintProfile{
Name: req.Name,
Description: req.Description,
CipherSuites: req.CipherSuites,
Curves: req.Curves,
PointFormats: req.PointFormats,
SignatureAlgorithms: req.SignatureAlgorithms,
ALPNProtocols: req.ALPNProtocols,
SupportedVersions: req.SupportedVersions,
KeyShareGroups: req.KeyShareGroups,
PSKModes: req.PSKModes,
Extensions: req.Extensions,
}
if req.EnableGREASE != nil {
profile.EnableGREASE = *req.EnableGREASE
}
created, err := h.service.Create(c.Request.Context(), profile)
if err != nil {
if _, ok := err.(*model.ValidationError); ok {
response.BadRequest(c, err.Error())
return
}
response.ErrorFrom(c, err)
return
}
response.Success(c, created)
}
// Update 更新模板(支持部分更新)
// PUT /api/v1/admin/tls-fingerprint-profiles/:id
func (h *TLSFingerprintProfileHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid profile ID")
return
}
var req UpdateTLSFingerprintProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
existing, err := h.service.GetByID(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
if existing == nil {
response.NotFound(c, "Profile not found")
return
}
// 部分更新
profile := &model.TLSFingerprintProfile{
ID: id,
Name: existing.Name,
Description: existing.Description,
EnableGREASE: existing.EnableGREASE,
CipherSuites: existing.CipherSuites,
Curves: existing.Curves,
PointFormats: existing.PointFormats,
SignatureAlgorithms: existing.SignatureAlgorithms,
ALPNProtocols: existing.ALPNProtocols,
SupportedVersions: existing.SupportedVersions,
KeyShareGroups: existing.KeyShareGroups,
PSKModes: existing.PSKModes,
Extensions: existing.Extensions,
}
if req.Name != nil {
profile.Name = *req.Name
}
if req.Description != nil {
profile.Description = req.Description
}
if req.EnableGREASE != nil {
profile.EnableGREASE = *req.EnableGREASE
}
if req.CipherSuites != nil {
profile.CipherSuites = req.CipherSuites
}
if req.Curves != nil {
profile.Curves = req.Curves
}
if req.PointFormats != nil {
profile.PointFormats = req.PointFormats
}
if req.SignatureAlgorithms != nil {
profile.SignatureAlgorithms = req.SignatureAlgorithms
}
if req.ALPNProtocols != nil {
profile.ALPNProtocols = req.ALPNProtocols
}
if req.SupportedVersions != nil {
profile.SupportedVersions = req.SupportedVersions
}
if req.KeyShareGroups != nil {
profile.KeyShareGroups = req.KeyShareGroups
}
if req.PSKModes != nil {
profile.PSKModes = req.PSKModes
}
if req.Extensions != nil {
profile.Extensions = req.Extensions
}
updated, err := h.service.Update(c.Request.Context(), profile)
if err != nil {
if _, ok := err.(*model.ValidationError); ok {
response.BadRequest(c, err.Error())
return
}
response.ErrorFrom(c, err)
return
}
response.Success(c, updated)
}
// Delete 删除模板
// DELETE /api/v1/admin/tls-fingerprint-profiles/:id
func (h *TLSFingerprintProfileHandler) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid profile ID")
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"message": "Profile deleted successfully"})
}
+4
View File
@@ -252,6 +252,10 @@ func AccountFromServiceShallow(a *service.Account) *Account {
enabled := true
out.EnableTLSFingerprint = &enabled
}
// TLS指纹模板ID
if profileID := a.GetTLSFingerprintProfileID(); profileID > 0 {
out.TLSFingerprintProfileID = &profileID
}
// 会话ID伪装开关
if a.IsSessionIDMaskingEnabled() {
enabled := true
+2 -1
View File
@@ -185,7 +185,8 @@ type Account struct {
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
// 从 extra 字段提取,方便前端显示和编辑
EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"`
EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"`
TLSFingerprintProfileID *int64 `json:"tls_fingerprint_profile_id,omitempty"`
// 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效)
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID
@@ -75,8 +75,10 @@ func (f *fakeGroupRepo) ListActive(context.Context) ([]service.Group, error) { r
func (f *fakeGroupRepo) ListActiveByPlatform(context.Context, string) ([]service.Group, error) {
return nil, nil
}
func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil }
func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, int64, error) { return 0, 0, nil }
func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil }
func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, int64, error) {
return 0, 0, nil
}
func (f *fakeGroupRepo) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
return 0, nil
}
@@ -158,6 +160,7 @@ func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*servi
nil, // rpmCache
nil, // digestStore
nil, // settingService
nil, // tlsFPProfileService
)
// RunModeSimple:跳过计费检查,避免引入 repo/cache 依赖。
+24 -23
View File
@@ -6,29 +6,30 @@ import (
// AdminHandlers contains all admin-related HTTP handlers
type AdminHandlers struct {
Dashboard *admin.DashboardHandler
User *admin.UserHandler
Group *admin.GroupHandler
Account *admin.AccountHandler
Announcement *admin.AnnouncementHandler
DataManagement *admin.DataManagementHandler
Backup *admin.BackupHandler
OAuth *admin.OAuthHandler
OpenAIOAuth *admin.OpenAIOAuthHandler
GeminiOAuth *admin.GeminiOAuthHandler
AntigravityOAuth *admin.AntigravityOAuthHandler
Proxy *admin.ProxyHandler
Redeem *admin.RedeemHandler
Promo *admin.PromoHandler
Setting *admin.SettingHandler
Ops *admin.OpsHandler
System *admin.SystemHandler
Subscription *admin.SubscriptionHandler
Usage *admin.UsageHandler
UserAttribute *admin.UserAttributeHandler
ErrorPassthrough *admin.ErrorPassthroughHandler
APIKey *admin.AdminAPIKeyHandler
ScheduledTest *admin.ScheduledTestHandler
Dashboard *admin.DashboardHandler
User *admin.UserHandler
Group *admin.GroupHandler
Account *admin.AccountHandler
Announcement *admin.AnnouncementHandler
DataManagement *admin.DataManagementHandler
Backup *admin.BackupHandler
OAuth *admin.OAuthHandler
OpenAIOAuth *admin.OpenAIOAuthHandler
GeminiOAuth *admin.GeminiOAuthHandler
AntigravityOAuth *admin.AntigravityOAuthHandler
Proxy *admin.ProxyHandler
Redeem *admin.RedeemHandler
Promo *admin.PromoHandler
Setting *admin.SettingHandler
Ops *admin.OpsHandler
System *admin.SystemHandler
Subscription *admin.SubscriptionHandler
Usage *admin.UsageHandler
UserAttribute *admin.UserAttributeHandler
ErrorPassthrough *admin.ErrorPassthroughHandler
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
APIKey *admin.AdminAPIKeyHandler
ScheduledTest *admin.ScheduledTestHandler
}
// Handlers contains all HTTP handlers
@@ -2224,7 +2224,7 @@ func (s *stubSoraClientForHandler) GetVideoTask(_ context.Context, _ *service.Ac
func newMinimalGatewayService(accountRepo service.AccountRepository) *service.GatewayService {
return service.NewGatewayService(
accountRepo, nil, nil, nil, nil, nil, nil, nil, nil,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
)
}
@@ -464,6 +464,7 @@ func TestSoraGatewayHandler_ChatCompletions(t *testing.T) {
nil, // rpmCache
nil, // digestStore
nil, // settingService
nil, // tlsFPProfileService
)
soraClient := &stubSoraClient{imageURLs: []string{"https://example.com/a.png"}}
+26 -23
View File
@@ -30,33 +30,35 @@ func ProvideAdminHandlers(
usageHandler *admin.UsageHandler,
userAttributeHandler *admin.UserAttributeHandler,
errorPassthroughHandler *admin.ErrorPassthroughHandler,
tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler,
apiKeyHandler *admin.AdminAPIKeyHandler,
scheduledTestHandler *admin.ScheduledTestHandler,
) *AdminHandlers {
return &AdminHandlers{
Dashboard: dashboardHandler,
User: userHandler,
Group: groupHandler,
Account: accountHandler,
Announcement: announcementHandler,
DataManagement: dataManagementHandler,
Backup: backupHandler,
OAuth: oauthHandler,
OpenAIOAuth: openaiOAuthHandler,
GeminiOAuth: geminiOAuthHandler,
AntigravityOAuth: antigravityOAuthHandler,
Proxy: proxyHandler,
Redeem: redeemHandler,
Promo: promoHandler,
Setting: settingHandler,
Ops: opsHandler,
System: systemHandler,
Subscription: subscriptionHandler,
Usage: usageHandler,
UserAttribute: userAttributeHandler,
ErrorPassthrough: errorPassthroughHandler,
APIKey: apiKeyHandler,
ScheduledTest: scheduledTestHandler,
Dashboard: dashboardHandler,
User: userHandler,
Group: groupHandler,
Account: accountHandler,
Announcement: announcementHandler,
DataManagement: dataManagementHandler,
Backup: backupHandler,
OAuth: oauthHandler,
OpenAIOAuth: openaiOAuthHandler,
GeminiOAuth: geminiOAuthHandler,
AntigravityOAuth: antigravityOAuthHandler,
Proxy: proxyHandler,
Redeem: redeemHandler,
Promo: promoHandler,
Setting: settingHandler,
Ops: opsHandler,
System: systemHandler,
Subscription: subscriptionHandler,
Usage: usageHandler,
UserAttribute: userAttributeHandler,
ErrorPassthrough: errorPassthroughHandler,
TLSFingerprintProfile: tlsFingerprintProfileHandler,
APIKey: apiKeyHandler,
ScheduledTest: scheduledTestHandler,
}
}
@@ -145,6 +147,7 @@ var ProviderSet = wire.NewSet(
admin.NewUsageHandler,
admin.NewUserAttributeHandler,
admin.NewErrorPassthroughHandler,
admin.NewTLSFingerprintProfileHandler,
admin.NewAdminAPIKeyHandler,
admin.NewScheduledTestHandler,
@@ -0,0 +1,54 @@
// Package model 定义服务层使用的数据模型。
package model
import (
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
)
// TLSFingerprintProfile TLS 指纹配置模板
// 包含完整的 ClientHello 参数,用于模拟特定客户端的 TLS 握手特征
type TLSFingerprintProfile struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
EnableGREASE bool `json:"enable_grease"`
CipherSuites []uint16 `json:"cipher_suites"`
Curves []uint16 `json:"curves"`
PointFormats []uint16 `json:"point_formats"`
SignatureAlgorithms []uint16 `json:"signature_algorithms"`
ALPNProtocols []string `json:"alpn_protocols"`
SupportedVersions []uint16 `json:"supported_versions"`
KeyShareGroups []uint16 `json:"key_share_groups"`
PSKModes []uint16 `json:"psk_modes"`
Extensions []uint16 `json:"extensions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Validate 验证模板配置的有效性
func (p *TLSFingerprintProfile) Validate() error {
if p.Name == "" {
return &ValidationError{Field: "name", Message: "name is required"}
}
return nil
}
// ToTLSProfile 将领域模型转换为运行时使用的 tlsfingerprint.Profile
// 空切片字段会在 dialer 中 fallback 到内置默认值
func (p *TLSFingerprintProfile) ToTLSProfile() *tlsfingerprint.Profile {
return &tlsfingerprint.Profile{
Name: p.Name,
EnableGREASE: p.EnableGREASE,
CipherSuites: p.CipherSuites,
Curves: p.Curves,
PointFormats: p.PointFormats,
SignatureAlgorithms: p.SignatureAlgorithms,
ALPNProtocols: p.ALPNProtocols,
SupportedVersions: p.SupportedVersions,
KeyShareGroups: p.KeyShareGroups,
PSKModes: p.PSKModes,
Extensions: p.Extensions,
}
}
+181 -283
View File
@@ -17,12 +17,19 @@ import (
)
// Profile contains TLS fingerprint configuration.
// All slice fields use built-in defaults when empty.
type Profile struct {
Name string // Profile name for identification
CipherSuites []uint16
Curves []uint16
PointFormats []uint8
EnableGREASE bool
Name string // Profile name for identification
CipherSuites []uint16
Curves []uint16
PointFormats []uint16
EnableGREASE bool
SignatureAlgorithms []uint16 // Empty uses defaultSignatureAlgorithms
ALPNProtocols []string // Empty uses ["http/1.1"]
SupportedVersions []uint16 // Empty uses [TLS1.3, TLS1.2]
KeyShareGroups []uint16 // Empty uses [X25519]
PSKModes []uint16 // Empty uses [psk_dhe_ke]
Extensions []uint16 // Extension type IDs in order; empty uses default Node.js 24.x order
}
// Dialer creates TLS connections with custom fingerprints.
@@ -45,154 +52,67 @@ type SOCKS5ProxyDialer struct {
proxyURL *url.URL
}
// Default TLS fingerprint values captured from Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)
// Captured using: tshark -i lo -f "tcp port 8443" -Y "tls.handshake.type == 1" -V
// JA3 Hash: 1a28e69016765d92e3b381168d68922c
//
// Note: JA3/JA4 may have slight variations due to:
// - Session ticket presence/absence
// - Extension negotiation state
// Default TLS fingerprint values captured from Claude Code (Node.js 24.x)
// Captured via tls-fingerprint-web capture server
// JA3 Hash: 44f88fca027f27bab4bb08d4af15f23e
// JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
var (
// defaultCipherSuites contains all 59 cipher suites from Claude CLI
// defaultCipherSuites contains the 17 cipher suites from Node.js 24.x
// Order is critical for JA3 fingerprint matching
defaultCipherSuites = []uint16{
// TLS 1.3 cipher suites (MUST be first)
// TLS 1.3 cipher suites
0x1301, // TLS_AES_128_GCM_SHA256
0x1302, // TLS_AES_256_GCM_SHA384
0x1303, // TLS_CHACHA20_POLY1305_SHA256
0x1301, // TLS_AES_128_GCM_SHA256
// ECDHE + AES-GCM
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
0xc02b, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
0xc02c, // TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
// DHE + AES-GCM
0x009e, // TLS_DHE_RSA_WITH_AES_128_GCM_SHA256
// ECDHE/DHE + AES-CBC-SHA256/384
0xc027, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256
0x0067, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA256
0xc028, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384
0x006b, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA256
// DHE-DSS/RSA + AES-GCM
0x00a3, // TLS_DHE_DSS_WITH_AES_256_GCM_SHA384
0x009f, // TLS_DHE_RSA_WITH_AES_256_GCM_SHA384
// ChaCha20-Poly1305
// ECDHE + ChaCha20-Poly1305
0xcca9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
0xcca8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
0xccaa, // TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256
// AES-CCM (256-bit)
0xc0af, // TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8
0xc0ad, // TLS_ECDHE_ECDSA_WITH_AES_256_CCM
0xc0a3, // TLS_DHE_RSA_WITH_AES_256_CCM_8
0xc09f, // TLS_DHE_RSA_WITH_AES_256_CCM
// ARIA (256-bit)
0xc05d, // TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384
0xc061, // TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384
0xc057, // TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384
0xc053, // TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384
// DHE-DSS + AES-GCM (128-bit)
0x00a2, // TLS_DHE_DSS_WITH_AES_128_GCM_SHA256
// AES-CCM (128-bit)
0xc0ae, // TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8
0xc0ac, // TLS_ECDHE_ECDSA_WITH_AES_128_CCM
0xc0a2, // TLS_DHE_RSA_WITH_AES_128_CCM_8
0xc09e, // TLS_DHE_RSA_WITH_AES_128_CCM
// ARIA (128-bit)
0xc05c, // TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256
0xc060, // TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256
0xc056, // TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256
0xc052, // TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256
// ECDHE/DHE + AES-CBC-SHA384/256 (more)
0xc024, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384
0x006a, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA256
0xc023, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
0x0040, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA256
// ECDHE/DHE + AES-CBC-SHA (legacy)
0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
0x0039, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA
0x0038, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA
// ECDHE + AES-CBC-SHA (legacy fallback)
0xc009, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
0xc013, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
0x0033, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA
0x0032, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA
0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
// RSA + AES-GCM/CCM/ARIA (non-PFS, 256-bit)
0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384
0xc0a1, // TLS_RSA_WITH_AES_256_CCM_8
0xc09d, // TLS_RSA_WITH_AES_256_CCM
0xc051, // TLS_RSA_WITH_ARIA_256_GCM_SHA384
// RSA + AES-GCM/CCM/ARIA (non-PFS, 128-bit)
// RSA + AES-GCM (non-PFS)
0x009c, // TLS_RSA_WITH_AES_128_GCM_SHA256
0xc0a0, // TLS_RSA_WITH_AES_128_CCM_8
0xc09c, // TLS_RSA_WITH_AES_128_CCM
0xc050, // TLS_RSA_WITH_ARIA_128_GCM_SHA256
0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384
// RSA + AES-CBC (non-PFS, legacy)
0x003d, // TLS_RSA_WITH_AES_256_CBC_SHA256
0x003c, // TLS_RSA_WITH_AES_128_CBC_SHA256
0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA
// RSA + AES-CBC-SHA (non-PFS, legacy)
0x002f, // TLS_RSA_WITH_AES_128_CBC_SHA
// Renegotiation indication
0x00ff, // TLS_EMPTY_RENEGOTIATION_INFO_SCSV
0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA
}
// defaultCurves contains the 10 supported groups from Claude CLI (including FFDHE)
// defaultCurves contains the 3 supported groups from Node.js 24.x
defaultCurves = []utls.CurveID{
utls.X25519, // 0x001d
utls.CurveP256, // 0x0017 (secp256r1)
utls.CurveID(0x001e), // x448
utls.CurveP521, // 0x0019 (secp521r1)
utls.CurveP384, // 0x0018 (secp384r1)
utls.CurveID(0x0100), // ffdhe2048
utls.CurveID(0x0101), // ffdhe3072
utls.CurveID(0x0102), // ffdhe4096
utls.CurveID(0x0103), // ffdhe6144
utls.CurveID(0x0104), // ffdhe8192
utls.X25519, // 0x001d
utls.CurveP256, // 0x0017 (secp256r1)
utls.CurveP384, // 0x0018 (secp384r1)
}
// defaultPointFormats contains all 3 point formats from Claude CLI
defaultPointFormats = []uint8{
// defaultPointFormats contains point formats from Node.js 24.x
defaultPointFormats = []uint16{
0, // uncompressed
1, // ansiX962_compressed_prime
2, // ansiX962_compressed_char2
}
// defaultSignatureAlgorithms contains the 20 signature algorithms from Claude CLI
// defaultSignatureAlgorithms contains the 9 signature algorithms from Node.js 24.x
defaultSignatureAlgorithms = []utls.SignatureScheme{
0x0403, // ecdsa_secp256r1_sha256
0x0503, // ecdsa_secp384r1_sha384
0x0603, // ecdsa_secp521r1_sha512
0x0807, // ed25519
0x0808, // ed448
0x0809, // rsa_pss_pss_sha256
0x080a, // rsa_pss_pss_sha384
0x080b, // rsa_pss_pss_sha512
0x0804, // rsa_pss_rsae_sha256
0x0805, // rsa_pss_rsae_sha384
0x0806, // rsa_pss_rsae_sha512
0x0401, // rsa_pkcs1_sha256
0x0503, // ecdsa_secp384r1_sha384
0x0805, // rsa_pss_rsae_sha384
0x0501, // rsa_pkcs1_sha384
0x0806, // rsa_pss_rsae_sha512
0x0601, // rsa_pkcs1_sha512
0x0303, // ecdsa_sha224
0x0301, // rsa_pkcs1_sha224
0x0302, // dsa_sha224
0x0402, // dsa_sha256
0x0502, // dsa_sha384
0x0602, // dsa_sha512
0x0201, // rsa_pkcs1_sha1
}
)
@@ -256,49 +176,7 @@ func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr st
slog.Debug("tls_fingerprint_socks5_tunnel_established")
// Step 3: Perform TLS handshake on the tunnel with utls fingerprint
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
slog.Debug("tls_fingerprint_socks5_starting_handshake", "host", host)
// Build ClientHello specification from profile (Node.js/Claude CLI fingerprint)
spec := buildClientHelloSpecFromProfile(d.profile)
slog.Debug("tls_fingerprint_socks5_clienthello_spec",
"cipher_suites", len(spec.CipherSuites),
"extensions", len(spec.Extensions),
"compression_methods", spec.CompressionMethods,
"tls_vers_max", spec.TLSVersMax,
"tls_vers_min", spec.TLSVersMin)
if d.profile != nil {
slog.Debug("tls_fingerprint_socks5_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE)
}
// Create uTLS connection on the tunnel
tlsConn := utls.UClient(conn, &utls.Config{
ServerName: host,
}, utls.HelloCustom)
if err := tlsConn.ApplyPreset(spec); err != nil {
slog.Debug("tls_fingerprint_socks5_apply_preset_failed", "error", err)
_ = conn.Close()
return nil, fmt.Errorf("apply TLS preset: %w", err)
}
if err := tlsConn.HandshakeContext(ctx); err != nil {
slog.Debug("tls_fingerprint_socks5_handshake_failed", "error", err)
_ = conn.Close()
return nil, fmt.Errorf("TLS handshake failed: %w", err)
}
state := tlsConn.ConnectionState()
slog.Debug("tls_fingerprint_socks5_handshake_success",
"version", state.Version,
"cipher_suite", state.CipherSuite,
"alpn", state.NegotiatedProtocol)
return tlsConn, nil
return performTLSHandshake(ctx, conn, d.profile, addr)
}
// DialTLSContext establishes a TLS connection through HTTP proxy with the configured fingerprint.
@@ -358,7 +236,8 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri
slog.Debug("tls_fingerprint_http_proxy_read_response_failed", "error", err)
return nil, fmt.Errorf("read CONNECT response: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// CONNECT response has no body; do not defer resp.Body.Close() as it wraps the
// same conn that will be used for the TLS handshake.
if resp.StatusCode != http.StatusOK {
_ = conn.Close()
@@ -368,47 +247,7 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri
slog.Debug("tls_fingerprint_http_proxy_tunnel_established")
// Step 4: Perform TLS handshake on the tunnel with utls fingerprint
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
slog.Debug("tls_fingerprint_http_proxy_starting_handshake", "host", host)
// Build ClientHello specification (reuse the shared method)
spec := buildClientHelloSpecFromProfile(d.profile)
slog.Debug("tls_fingerprint_http_proxy_clienthello_spec",
"cipher_suites", len(spec.CipherSuites),
"extensions", len(spec.Extensions))
if d.profile != nil {
slog.Debug("tls_fingerprint_http_proxy_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE)
}
// Create uTLS connection on the tunnel
// Note: TLS 1.3 cipher suites are handled automatically by utls when TLS 1.3 is in SupportedVersions
tlsConn := utls.UClient(conn, &utls.Config{
ServerName: host,
}, utls.HelloCustom)
if err := tlsConn.ApplyPreset(spec); err != nil {
slog.Debug("tls_fingerprint_http_proxy_apply_preset_failed", "error", err)
_ = conn.Close()
return nil, fmt.Errorf("apply TLS preset: %w", err)
}
if err := tlsConn.HandshakeContext(ctx); err != nil {
slog.Debug("tls_fingerprint_http_proxy_handshake_failed", "error", err)
_ = conn.Close()
return nil, fmt.Errorf("TLS handshake failed: %w", err)
}
state := tlsConn.ConnectionState()
slog.Debug("tls_fingerprint_http_proxy_handshake_success",
"version", state.Version,
"cipher_suite", state.CipherSuite,
"alpn", state.NegotiatedProtocol)
return tlsConn, nil
return performTLSHandshake(ctx, conn, d.profile, addr)
}
// DialTLSContext establishes a TLS connection with the configured fingerprint.
@@ -423,53 +262,35 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.
}
slog.Debug("tls_fingerprint_tcp_connected", "addr", addr)
// Extract hostname for SNI
// Perform TLS handshake with utls fingerprint
return performTLSHandshake(ctx, conn, d.profile, addr)
}
// performTLSHandshake performs the uTLS handshake on an established connection.
// It builds a ClientHello spec from the profile, applies it, and completes the handshake.
// On failure, conn is closed and an error is returned.
func performTLSHandshake(ctx context.Context, conn net.Conn, profile *Profile, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
slog.Debug("tls_fingerprint_sni_hostname", "host", host)
// Build ClientHello specification
spec := d.buildClientHelloSpec()
slog.Debug("tls_fingerprint_clienthello_spec",
"cipher_suites", len(spec.CipherSuites),
"extensions", len(spec.Extensions))
spec := buildClientHelloSpecFromProfile(profile)
tlsConn := utls.UClient(conn, &utls.Config{ServerName: host}, utls.HelloCustom)
// Log profile info
if d.profile != nil {
slog.Debug("tls_fingerprint_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE)
} else {
slog.Debug("tls_fingerprint_using_default_profile")
}
// Create uTLS connection
// Note: TLS 1.3 cipher suites are handled automatically by utls when TLS 1.3 is in SupportedVersions
tlsConn := utls.UClient(conn, &utls.Config{
ServerName: host,
}, utls.HelloCustom)
// Apply fingerprint
if err := tlsConn.ApplyPreset(spec); err != nil {
slog.Debug("tls_fingerprint_apply_preset_failed", "error", err)
_ = conn.Close()
return nil, err
return nil, fmt.Errorf("apply TLS preset: %w", err)
}
slog.Debug("tls_fingerprint_preset_applied")
// Perform TLS handshake
if err := tlsConn.HandshakeContext(ctx); err != nil {
slog.Debug("tls_fingerprint_handshake_failed",
"error", err,
"local_addr", conn.LocalAddr(),
"remote_addr", conn.RemoteAddr())
_ = conn.Close()
return nil, fmt.Errorf("TLS handshake failed: %w", err)
}
// Log successful handshake details
state := tlsConn.ConnectionState()
slog.Debug("tls_fingerprint_handshake_success",
"host", host,
"version", state.Version,
"cipher_suite", state.CipherSuite,
"alpn", state.NegotiatedProtocol)
@@ -477,11 +298,6 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.
return tlsConn, nil
}
// buildClientHelloSpec constructs the ClientHello specification based on the profile.
func (d *Dialer) buildClientHelloSpec() *utls.ClientHelloSpec {
return buildClientHelloSpecFromProfile(d.profile)
}
// toUTLSCurves converts uint16 slice to utls.CurveID slice.
func toUTLSCurves(curves []uint16) []utls.CurveID {
result := make([]utls.CurveID, len(curves))
@@ -491,70 +307,143 @@ func toUTLSCurves(curves []uint16) []utls.CurveID {
return result
}
// defaultExtensionOrder is the Node.js 24.x extension order.
// Used when Profile.Extensions is empty.
var defaultExtensionOrder = []uint16{
0, // server_name
65037, // encrypted_client_hello
23, // extended_master_secret
65281, // renegotiation_info
10, // supported_groups
11, // ec_point_formats
35, // session_ticket
16, // alpn
5, // status_request
13, // signature_algorithms
18, // signed_certificate_timestamp
51, // key_share
45, // psk_key_exchange_modes
43, // supported_versions
}
// isGREASEValue checks if a uint16 value matches the TLS GREASE pattern (0x?a?a).
func isGREASEValue(v uint16) bool {
return v&0x0f0f == 0x0a0a && v>>8 == v&0xff
}
// buildClientHelloSpecFromProfile constructs ClientHelloSpec from a Profile.
// This is a standalone function that can be used by both Dialer and HTTPProxyDialer.
func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
// Get cipher suites
var cipherSuites []uint16
// Resolve effective values (profile overrides or built-in defaults)
cipherSuites := defaultCipherSuites
if profile != nil && len(profile.CipherSuites) > 0 {
cipherSuites = profile.CipherSuites
} else {
cipherSuites = defaultCipherSuites
}
// Get curves
var curves []utls.CurveID
curves := defaultCurves
if profile != nil && len(profile.Curves) > 0 {
curves = toUTLSCurves(profile.Curves)
} else {
curves = defaultCurves
}
// Get point formats
var pointFormats []uint8
pointFormats := defaultPointFormats
if profile != nil && len(profile.PointFormats) > 0 {
pointFormats = profile.PointFormats
} else {
pointFormats = defaultPointFormats
}
// Check if GREASE is enabled
signatureAlgorithms := defaultSignatureAlgorithms
if profile != nil && len(profile.SignatureAlgorithms) > 0 {
signatureAlgorithms = make([]utls.SignatureScheme, len(profile.SignatureAlgorithms))
for i, s := range profile.SignatureAlgorithms {
signatureAlgorithms[i] = utls.SignatureScheme(s)
}
}
alpnProtocols := []string{"http/1.1"}
if profile != nil && len(profile.ALPNProtocols) > 0 {
alpnProtocols = profile.ALPNProtocols
}
supportedVersions := []uint16{utls.VersionTLS13, utls.VersionTLS12}
if profile != nil && len(profile.SupportedVersions) > 0 {
supportedVersions = profile.SupportedVersions
}
keyShareGroups := []utls.CurveID{utls.X25519}
if profile != nil && len(profile.KeyShareGroups) > 0 {
keyShareGroups = toUTLSCurves(profile.KeyShareGroups)
}
pskModes := []uint16{uint16(utls.PskModeDHE)}
if profile != nil && len(profile.PSKModes) > 0 {
pskModes = profile.PSKModes
}
enableGREASE := profile != nil && profile.EnableGREASE
extensions := make([]utls.TLSExtension, 0, 16)
if enableGREASE {
extensions = append(extensions, &utls.UtlsGREASEExtension{})
// Build key shares
keyShares := make([]utls.KeyShare, len(keyShareGroups))
for i, g := range keyShareGroups {
keyShares[i] = utls.KeyShare{Group: g}
}
// SNI extension - MUST be explicitly added for HelloCustom mode
// utls will populate the server name from Config.ServerName
extensions = append(extensions, &utls.SNIExtension{})
// Determine extension order
extOrder := defaultExtensionOrder
if profile != nil && len(profile.Extensions) > 0 {
extOrder = profile.Extensions
}
// Claude CLI extension order (captured from tshark):
// server_name(0), ec_point_formats(11), supported_groups(10), session_ticket(35),
// alpn(16), encrypt_then_mac(22), extended_master_secret(23),
// signature_algorithms(13), supported_versions(43),
// psk_key_exchange_modes(45), key_share(51)
extensions = append(extensions,
&utls.SupportedPointsExtension{SupportedPoints: pointFormats},
&utls.SupportedCurvesExtension{Curves: curves},
&utls.SessionTicketExtension{},
&utls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}},
&utls.GenericExtension{Id: 22},
&utls.ExtendedMasterSecretExtension{},
&utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: defaultSignatureAlgorithms},
&utls.SupportedVersionsExtension{Versions: []uint16{
utls.VersionTLS13,
utls.VersionTLS12,
}},
&utls.PSKKeyExchangeModesExtension{Modes: []uint8{utls.PskModeDHE}},
&utls.KeyShareExtension{KeyShares: []utls.KeyShare{
{Group: utls.X25519},
}},
)
// Build extensions list from the ordered IDs.
// Parametric extensions (curves, sigalgs, etc.) are populated with resolved profile values.
// Unknown IDs use GenericExtension (sends type ID with empty data).
extensions := make([]utls.TLSExtension, 0, len(extOrder)+2)
for _, id := range extOrder {
if isGREASEValue(id) {
extensions = append(extensions, &utls.UtlsGREASEExtension{})
continue
}
switch id {
case 0: // server_name
extensions = append(extensions, &utls.SNIExtension{})
case 5: // status_request (OCSP)
extensions = append(extensions, &utls.StatusRequestExtension{})
case 10: // supported_groups
extensions = append(extensions, &utls.SupportedCurvesExtension{Curves: curves})
case 11: // ec_point_formats
extensions = append(extensions, &utls.SupportedPointsExtension{SupportedPoints: toUint8s(pointFormats)})
case 13: // signature_algorithms
extensions = append(extensions, &utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: signatureAlgorithms})
case 16: // alpn
extensions = append(extensions, &utls.ALPNExtension{AlpnProtocols: alpnProtocols})
case 18: // signed_certificate_timestamp
extensions = append(extensions, &utls.SCTExtension{})
case 23: // extended_master_secret
extensions = append(extensions, &utls.ExtendedMasterSecretExtension{})
case 35: // session_ticket
extensions = append(extensions, &utls.SessionTicketExtension{})
case 43: // supported_versions
extensions = append(extensions, &utls.SupportedVersionsExtension{Versions: supportedVersions})
case 45: // psk_key_exchange_modes
extensions = append(extensions, &utls.PSKKeyExchangeModesExtension{Modes: toUint8s(pskModes)})
case 50: // signature_algorithms_cert
extensions = append(extensions, &utls.SignatureAlgorithmsCertExtension{SupportedSignatureAlgorithms: signatureAlgorithms})
case 51: // key_share
extensions = append(extensions, &utls.KeyShareExtension{KeyShares: keyShares})
case 0xfe0d: // encrypted_client_hello (ECH, 65037)
// Send GREASE ECH with random payload — mimics Node.js behavior when no real ECHConfig is available.
// An empty GenericExtension causes "error decoding message" from servers that validate ECH format.
extensions = append(extensions, &utls.GREASEEncryptedClientHelloExtension{})
case 0xff01: // renegotiation_info
extensions = append(extensions, &utls.RenegotiationInfoExtension{})
default:
// Unknown extension — send as GenericExtension (type ID + empty data).
// This covers encrypt_then_mac(22) and any future extensions.
extensions = append(extensions, &utls.GenericExtension{Id: id})
}
}
if enableGREASE {
// For default extension order with EnableGREASE, wrap with GREASE bookends
if enableGREASE && (profile == nil || len(profile.Extensions) == 0) {
extensions = append([]utls.TLSExtension{&utls.UtlsGREASEExtension{}}, extensions...)
extensions = append(extensions, &utls.UtlsGREASEExtension{})
}
@@ -566,3 +455,12 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
TLSVersMin: utls.VersionTLS10,
}
}
// toUint8s converts []uint16 to []uint8 (for utls fields that require []uint8).
func toUint8s(vals []uint16) []uint8 {
out := make([]uint8, len(vals))
for i, v := range vals {
out[i] = uint8(v)
}
return out
}
@@ -0,0 +1,368 @@
//go:build integration
package tlsfingerprint
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
utls "github.com/refraction-networking/utls"
)
// CapturedFingerprint mirrors the Fingerprint struct from tls-fingerprint-web.
// Used to deserialize the JSON response from the capture server.
type CapturedFingerprint struct {
JA3Raw string `json:"ja3_raw"`
JA3Hash string `json:"ja3_hash"`
JA4 string `json:"ja4"`
HTTP2 string `json:"http2"`
CipherSuites []int `json:"cipher_suites"`
Curves []int `json:"curves"`
PointFormats []int `json:"point_formats"`
Extensions []int `json:"extensions"`
SignatureAlgorithms []int `json:"signature_algorithms"`
ALPNProtocols []string `json:"alpn_protocols"`
SupportedVersions []int `json:"supported_versions"`
KeyShareGroups []int `json:"key_share_groups"`
PSKModes []int `json:"psk_modes"`
CompressCertAlgos []int `json:"compress_cert_algos"`
EnableGREASE bool `json:"enable_grease"`
}
// TestDialerAgainstCaptureServer connects to the tls-fingerprint-web capture server
// and verifies that the dialer's TLS fingerprint matches the configured Profile.
//
// Default capture server: https://tls.sub2api.org:8090
// Override with env: TLSFINGERPRINT_CAPTURE_URL=https://localhost:8443
//
// Run: go test -v -run TestDialerAgainstCaptureServer ./internal/pkg/tlsfingerprint/...
func TestDialerAgainstCaptureServer(t *testing.T) {
captureURL := os.Getenv("TLSFINGERPRINT_CAPTURE_URL")
if captureURL == "" {
captureURL = "https://tls.sub2api.org:8090"
}
tests := []struct {
name string
profile *Profile
}{
{
name: "default_profile",
profile: &Profile{
Name: "default",
EnableGREASE: false,
// All empty → uses built-in defaults
},
},
{
name: "linux_x64_node_v22171",
profile: &Profile{
Name: "linux_x64_node_v22171",
EnableGREASE: false,
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
PointFormats: []uint16{0, 1, 2},
SignatureAlgorithms: []uint16{0x0403, 0x0503, 0x0603, 0x0807, 0x0808, 0x0809, 0x080a, 0x080b, 0x0804, 0x0805, 0x0806, 0x0401, 0x0501, 0x0601, 0x0303, 0x0301, 0x0302, 0x0402, 0x0502, 0x0602},
ALPNProtocols: []string{"http/1.1"},
SupportedVersions: []uint16{0x0304, 0x0303},
KeyShareGroups: []uint16{29},
PSKModes: []uint16{1},
Extensions: []uint16{0, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51},
},
},
{
name: "macos_arm64_node_v2430",
profile: &Profile{
Name: "MacOS_arm64_node_v2430",
EnableGREASE: false,
CipherSuites: []uint16{4865, 4866, 4867, 49195, 49199, 49196, 49200, 52393, 52392, 49161, 49171, 49162, 49172, 156, 157, 47, 53},
Curves: []uint16{29, 23, 24},
PointFormats: []uint16{0},
SignatureAlgorithms: []uint16{0x0403, 0x0804, 0x0401, 0x0503, 0x0805, 0x0501, 0x0806, 0x0601, 0x0201},
ALPNProtocols: []string{"http/1.1"},
SupportedVersions: []uint16{0x0304, 0x0303},
KeyShareGroups: []uint16{29},
PSKModes: []uint16{1},
Extensions: []uint16{0, 65037, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
captured := fetchCapturedFingerprint(t, captureURL, tc.profile)
if captured == nil {
return
}
t.Logf("JA3 Hash: %s", captured.JA3Hash)
t.Logf("JA4: %s", captured.JA4)
// Resolve effective profile values (what the dialer actually uses)
effectiveCipherSuites := tc.profile.CipherSuites
if len(effectiveCipherSuites) == 0 {
effectiveCipherSuites = defaultCipherSuites
}
effectiveCurves := tc.profile.Curves
if len(effectiveCurves) == 0 {
effectiveCurves = make([]uint16, len(defaultCurves))
for i, c := range defaultCurves {
effectiveCurves[i] = uint16(c)
}
}
effectivePointFormats := tc.profile.PointFormats
if len(effectivePointFormats) == 0 {
effectivePointFormats = defaultPointFormats
}
effectiveSigAlgs := tc.profile.SignatureAlgorithms
if len(effectiveSigAlgs) == 0 {
effectiveSigAlgs = make([]uint16, len(defaultSignatureAlgorithms))
for i, s := range defaultSignatureAlgorithms {
effectiveSigAlgs[i] = uint16(s)
}
}
effectiveALPN := tc.profile.ALPNProtocols
if len(effectiveALPN) == 0 {
effectiveALPN = []string{"http/1.1"}
}
effectiveVersions := tc.profile.SupportedVersions
if len(effectiveVersions) == 0 {
effectiveVersions = []uint16{0x0304, 0x0303}
}
effectiveKeyShare := tc.profile.KeyShareGroups
if len(effectiveKeyShare) == 0 {
effectiveKeyShare = []uint16{29} // X25519
}
effectivePSKModes := tc.profile.PSKModes
if len(effectivePSKModes) == 0 {
effectivePSKModes = []uint16{1} // psk_dhe_ke
}
// Verify each field
assertIntSliceEqual(t, "cipher_suites", uint16sToInts(effectiveCipherSuites), captured.CipherSuites)
assertIntSliceEqual(t, "curves", uint16sToInts(effectiveCurves), captured.Curves)
assertIntSliceEqual(t, "point_formats", uint16sToInts(effectivePointFormats), captured.PointFormats)
assertIntSliceEqual(t, "signature_algorithms", uint16sToInts(effectiveSigAlgs), captured.SignatureAlgorithms)
assertStringSliceEqual(t, "alpn_protocols", effectiveALPN, captured.ALPNProtocols)
assertIntSliceEqual(t, "supported_versions", uint16sToInts(effectiveVersions), captured.SupportedVersions)
assertIntSliceEqual(t, "key_share_groups", uint16sToInts(effectiveKeyShare), captured.KeyShareGroups)
assertIntSliceEqual(t, "psk_modes", uint16sToInts(effectivePSKModes), captured.PSKModes)
if captured.EnableGREASE != tc.profile.EnableGREASE {
t.Errorf("enable_grease: got %v, want %v", captured.EnableGREASE, tc.profile.EnableGREASE)
} else {
t.Logf(" enable_grease: %v OK", captured.EnableGREASE)
}
// Verify extension order
// Use profile.Extensions if set, otherwise the default order (Node.js 24.x)
expectedExtOrder := uint16sToInts(defaultExtensionOrder)
if len(tc.profile.Extensions) > 0 {
expectedExtOrder = uint16sToInts(tc.profile.Extensions)
}
// Strip GREASE values from both expected and captured for comparison
var filteredExpected, filteredActual []int
for _, e := range expectedExtOrder {
if !isGREASEValue(uint16(e)) {
filteredExpected = append(filteredExpected, e)
}
}
for _, e := range captured.Extensions {
if !isGREASEValue(uint16(e)) {
filteredActual = append(filteredActual, e)
}
}
assertIntSliceEqual(t, "extensions (order, non-GREASE)", filteredExpected, filteredActual)
// Print full captured data as JSON for debugging
capturedJSON, _ := json.MarshalIndent(captured, " ", " ")
t.Logf("Full captured fingerprint:\n %s", string(capturedJSON))
})
}
}
func fetchCapturedFingerprint(t *testing.T, captureURL string, profile *Profile) *CapturedFingerprint {
t.Helper()
dialer := NewDialer(profile, nil)
client := &http.Client{
Transport: &http.Transport{
DialTLSContext: dialer.DialTLSContext,
},
Timeout: 10 * time.Second,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", captureURL, strings.NewReader(`{"model":"test"}`))
if err != nil {
t.Fatalf("create request: %v", err)
return nil
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
return nil
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
return nil
}
var fp CapturedFingerprint
if err := json.Unmarshal(body, &fp); err != nil {
t.Logf("Response body: %s", string(body))
t.Fatalf("parse response: %v", err)
return nil
}
return &fp
}
func uint16sToInts(vals []uint16) []int {
result := make([]int, len(vals))
for i, v := range vals {
result[i] = int(v)
}
return result
}
func assertIntSliceEqual(t *testing.T, name string, expected, actual []int) {
t.Helper()
if len(expected) != len(actual) {
t.Errorf("%s: length mismatch: got %d, want %d", name, len(actual), len(expected))
if len(actual) < 20 && len(expected) < 20 {
t.Errorf(" got: %v", actual)
t.Errorf(" want: %v", expected)
}
return
}
mismatches := 0
for i := range expected {
if expected[i] != actual[i] {
if mismatches < 5 {
t.Errorf("%s[%d]: got %d (0x%04x), want %d (0x%04x)", name, i, actual[i], actual[i], expected[i], expected[i])
}
mismatches++
}
}
if mismatches == 0 {
t.Logf(" %s: %d items OK", name, len(expected))
} else if mismatches > 5 {
t.Errorf(" %s: %d/%d mismatches (showing first 5)", name, mismatches, len(expected))
}
}
func assertStringSliceEqual(t *testing.T, name string, expected, actual []string) {
t.Helper()
if len(expected) != len(actual) {
t.Errorf("%s: length mismatch: got %d (%v), want %d (%v)", name, len(actual), actual, len(expected), expected)
return
}
for i := range expected {
if expected[i] != actual[i] {
t.Errorf("%s[%d]: got %q, want %q", name, i, actual[i], expected[i])
return
}
}
t.Logf(" %s: %v OK", name, expected)
}
// TestBuildClientHelloSpecNewFields tests that new Profile fields are correctly applied.
func TestBuildClientHelloSpecNewFields(t *testing.T) {
// Test custom ALPN, versions, key shares, PSK modes
profile := &Profile{
Name: "custom_full",
EnableGREASE: false,
CipherSuites: []uint16{0x1301, 0x1302},
Curves: []uint16{29, 23},
PointFormats: []uint16{0},
SignatureAlgorithms: []uint16{0x0403, 0x0804},
ALPNProtocols: []string{"h2", "http/1.1"},
SupportedVersions: []uint16{0x0304},
KeyShareGroups: []uint16{29, 23},
PSKModes: []uint16{1},
}
spec := buildClientHelloSpecFromProfile(profile)
// Verify cipher suites
if len(spec.CipherSuites) != 2 || spec.CipherSuites[0] != 0x1301 {
t.Errorf("cipher suites: got %v", spec.CipherSuites)
}
// Check extensions for expected values
var foundALPN, foundVersions, foundKeyShare, foundPSK, foundSigAlgs bool
for _, ext := range spec.Extensions {
switch e := ext.(type) {
case *utls.ALPNExtension:
foundALPN = true
if len(e.AlpnProtocols) != 2 || e.AlpnProtocols[0] != "h2" {
t.Errorf("ALPN: got %v, want [h2, http/1.1]", e.AlpnProtocols)
}
case *utls.SupportedVersionsExtension:
foundVersions = true
if len(e.Versions) != 1 || e.Versions[0] != 0x0304 {
t.Errorf("versions: got %v, want [0x0304]", e.Versions)
}
case *utls.KeyShareExtension:
foundKeyShare = true
if len(e.KeyShares) != 2 {
t.Errorf("key shares: got %d entries, want 2", len(e.KeyShares))
}
case *utls.PSKKeyExchangeModesExtension:
foundPSK = true
if len(e.Modes) != 1 || e.Modes[0] != 1 {
t.Errorf("PSK modes: got %v, want [1]", e.Modes)
}
case *utls.SignatureAlgorithmsExtension:
foundSigAlgs = true
if len(e.SupportedSignatureAlgorithms) != 2 {
t.Errorf("sig algs: got %d, want 2", len(e.SupportedSignatureAlgorithms))
}
}
}
for name, found := range map[string]bool{
"ALPN": foundALPN, "Versions": foundVersions, "KeyShare": foundKeyShare,
"PSK": foundPSK, "SigAlgs": foundSigAlgs,
} {
if !found {
t.Errorf("extension %s not found in spec", name)
}
}
// Test nil profile uses all defaults
specDefault := buildClientHelloSpecFromProfile(nil)
for _, ext := range specDefault.Extensions {
switch e := ext.(type) {
case *utls.ALPNExtension:
if len(e.AlpnProtocols) != 1 || e.AlpnProtocols[0] != "http/1.1" {
t.Errorf("default ALPN: got %v, want [http/1.1]", e.AlpnProtocols)
}
case *utls.SupportedVersionsExtension:
if len(e.Versions) != 2 {
t.Errorf("default versions: got %v, want 2 entries", e.Versions)
}
case *utls.KeyShareExtension:
if len(e.KeyShares) != 1 {
t.Errorf("default key shares: got %d, want 1", len(e.KeyShares))
}
}
}
t.Log("TestBuildClientHelloSpecNewFields passed")
}
@@ -40,16 +40,15 @@ func skipIfExternalServiceUnavailable(t *testing.T, err error) {
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
// This test uses tls.peet.ws to verify the fingerprint.
// Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x)
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP)
// Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
// Expected JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
func TestJA3Fingerprint(t *testing.T) {
// Skip if network is unavailable or if running in short mode
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := &Profile{
Name: "Claude CLI Test",
Name: "Default Profile Test",
EnableGREASE: false,
}
dialer := NewDialer(profile, nil)
@@ -61,7 +60,6 @@ func TestJA3Fingerprint(t *testing.T) {
Timeout: 30 * time.Second,
}
// Use tls.peet.ws fingerprint detection API
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -69,7 +67,7 @@ func TestJA3Fingerprint(t *testing.T) {
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0")
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/24.3.0")
resp, err := client.Do(req)
skipIfExternalServiceUnavailable(t, err)
@@ -86,71 +84,23 @@ func TestJA3Fingerprint(t *testing.T) {
t.Fatalf("failed to parse fingerprint response: %v", err)
}
// Log all fingerprint information
t.Logf("JA3: %s", fpResp.TLS.JA3)
t.Logf("JA3 Hash: %s", fpResp.TLS.JA3Hash)
t.Logf("JA4: %s", fpResp.TLS.JA4)
t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint)
t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash)
// Verify JA3 hash matches expected value
expectedJA3Hash := "1a28e69016765d92e3b381168d68922c"
expectedJA3Hash := "44f88fca027f27bab4bb08d4af15f23e"
if fpResp.TLS.JA3Hash == expectedJA3Hash {
t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash)
t.Logf("✓ JA3 hash matches: %s", expectedJA3Hash)
} else {
t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash)
}
// Verify JA4 fingerprint
// JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash]
// Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP)
// The suffix _a33745022dd6_1f22a2ca17c4 should match
expectedJA4Suffix := "_a33745022dd6_1f22a2ca17c4"
if strings.HasSuffix(fpResp.TLS.JA4, expectedJA4Suffix) {
t.Logf("✓ JA4 suffix matches expected value: %s", expectedJA4Suffix)
expectedJA4CipherHash := "_5b57614c22b0_"
if strings.Contains(fpResp.TLS.JA4, expectedJA4CipherHash) {
t.Logf("✓ JA4 cipher hash matches: %s", expectedJA4CipherHash)
} else {
t.Errorf("✗ JA4 suffix mismatch: got %s, expected suffix %s", fpResp.TLS.JA4, expectedJA4Suffix)
t.Errorf("✗ JA4 cipher hash mismatch: got %s, expected containing %s", fpResp.TLS.JA4, expectedJA4CipherHash)
}
// Verify JA4 prefix (t13d5911h1 or t13i5911h1)
// d = domain (SNI present), i = IP (no SNI)
// Since we connect to tls.peet.ws (domain), we expect 'd'
expectedJA4Prefix := "t13d5911h1"
if strings.HasPrefix(fpResp.TLS.JA4, expectedJA4Prefix) {
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 59=ciphers, 11=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
} else {
// Also accept 'i' variant for IP connections
altPrefix := "t13i5911h1"
if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) {
t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix)
} else {
t.Errorf("✗ JA4 prefix mismatch: got %s, expected %s or %s", fpResp.TLS.JA4, expectedJA4Prefix, altPrefix)
}
}
// Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning)
if strings.Contains(fpResp.TLS.JA3, "4866-4867-4865") {
t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites")
} else {
t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites")
}
// Verify extension list (should be 11 extensions including SNI)
// Expected: 0-11-10-35-16-22-23-13-43-45-51
expectedExtensions := "0-11-10-35-16-22-23-13-43-45-51"
if strings.Contains(fpResp.TLS.JA3, expectedExtensions) {
t.Logf("✓ JA3 contains expected extension list: %s", expectedExtensions)
} else {
t.Logf("Warning: JA3 extension list may differ")
}
}
// TestProfileExpectation defines expected fingerprint values for a profile.
type TestProfileExpectation struct {
Profile *Profile
ExpectedJA3 string // Expected JA3 hash (empty = don't check)
ExpectedJA4 string // Expected full JA4 (empty = don't check)
JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check)
}
// TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
@@ -164,30 +114,24 @@ func TestAllProfiles(t *testing.T) {
// These profiles are from config.yaml gateway.tls_fingerprint.profiles
profiles := []TestProfileExpectation{
{
// Linux x64 Node.js v22.17.1
// Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4
// Default profile (Node.js 24.x)
Profile: &Profile{
Name: "default_node_v24",
EnableGREASE: false,
},
JA4CipherHash: "5b57614c22b0",
},
{
// Linux x64 Node.js v22.17.1 (explicit profile with v22 extensions)
Profile: &Profile{
Name: "linux_x64_node_v22171",
EnableGREASE: false,
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
PointFormats: []uint8{0, 1, 2},
PointFormats: []uint16{0, 1, 2},
Extensions: []uint16{0, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51},
},
JA4CipherHash: "a33745022dd6", // stable part
},
{
// MacOS arm64 Node.js v22.18.0
// Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea
// Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406
Profile: &Profile{
Name: "macos_arm64_node_v22180",
EnableGREASE: false,
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
PointFormats: []uint8{0, 1, 2},
},
JA4CipherHash: "a33745022dd6", // stable part (same cipher suites)
JA4CipherHash: "a33745022dd6",
},
}
@@ -55,13 +55,13 @@ func TestDialerBasicConnection(t *testing.T) {
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
// This test uses tls.peet.ws to verify the fingerprint.
// Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x)
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP)
// Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
// Expected JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
func TestJA3Fingerprint(t *testing.T) {
skipNetworkTest(t)
profile := &Profile{
Name: "Claude CLI Test",
Name: "Default Profile Test",
EnableGREASE: false,
}
dialer := NewDialer(profile, nil)
@@ -81,7 +81,7 @@ func TestJA3Fingerprint(t *testing.T) {
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0")
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/24.3.0")
resp, err := client.Do(req)
if err != nil {
@@ -107,34 +107,28 @@ func TestJA3Fingerprint(t *testing.T) {
t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint)
t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash)
// Verify JA3 hash matches expected value
expectedJA3Hash := "1a28e69016765d92e3b381168d68922c"
// Verify JA3 hash matches expected value (Node.js 24.x default)
expectedJA3Hash := "44f88fca027f27bab4bb08d4af15f23e"
if fpResp.TLS.JA3Hash == expectedJA3Hash {
t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash)
} else {
t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash)
}
// Verify JA4 fingerprint
// JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash]
// Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP)
// The suffix _a33745022dd6_1f22a2ca17c4 should match
expectedJA4Suffix := "_a33745022dd6_1f22a2ca17c4"
if strings.HasSuffix(fpResp.TLS.JA4, expectedJA4Suffix) {
t.Logf("✓ JA4 suffix matches expected value: %s", expectedJA4Suffix)
// Verify JA4 cipher hash (stable middle part)
expectedJA4CipherHash := "_5b57614c22b0_"
if strings.Contains(fpResp.TLS.JA4, expectedJA4CipherHash) {
t.Logf("✓ JA4 cipher hash matches: %s", expectedJA4CipherHash)
} else {
t.Errorf("✗ JA4 suffix mismatch: got %s, expected suffix %s", fpResp.TLS.JA4, expectedJA4Suffix)
t.Errorf("✗ JA4 cipher hash mismatch: got %s, expected containing %s", fpResp.TLS.JA4, expectedJA4CipherHash)
}
// Verify JA4 prefix (t13d5911h1 or t13i5911h1)
// d = domain (SNI present), i = IP (no SNI)
// Since we connect to tls.peet.ws (domain), we expect 'd'
expectedJA4Prefix := "t13d5911h1"
// Verify JA4 prefix (t13d1714h1 or t13i1714h1)
expectedJA4Prefix := "t13d1714h1"
if strings.HasPrefix(fpResp.TLS.JA4, expectedJA4Prefix) {
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 59=ciphers, 11=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 17=ciphers, 14=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
} else {
// Also accept 'i' variant for IP connections
altPrefix := "t13i5911h1"
altPrefix := "t13i1714h1"
if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) {
t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix)
} else {
@@ -142,16 +136,15 @@ func TestJA3Fingerprint(t *testing.T) {
}
}
// Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning)
if strings.Contains(fpResp.TLS.JA3, "4866-4867-4865") {
// Verify JA3 contains expected TLS 1.3 cipher suites
if strings.Contains(fpResp.TLS.JA3, "4865-4866-4867") {
t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites")
} else {
t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites")
}
// Verify extension list (should be 11 extensions including SNI)
// Expected: 0-11-10-35-16-22-23-13-43-45-51
expectedExtensions := "0-11-10-35-16-22-23-13-43-45-51"
// Verify extension list (14 extensions, Node.js 24.x order)
expectedExtensions := "0-65037-23-65281-10-11-35-16-5-13-18-51-45-43"
if strings.Contains(fpResp.TLS.JA3, expectedExtensions) {
t.Logf("✓ JA3 contains expected extension list: %s", expectedExtensions)
} else {
@@ -186,8 +179,8 @@ func TestDialerWithProfile(t *testing.T) {
// Build specs and compare
// Note: We can't directly compare JA3 without making network requests
// but we can verify the specs are different
spec1 := dialer1.buildClientHelloSpec()
spec2 := dialer2.buildClientHelloSpec()
spec1 := buildClientHelloSpecFromProfile(dialer1.profile)
spec2 := buildClientHelloSpecFromProfile(dialer2.profile)
// Profile with GREASE should have more extensions
if len(spec2.Extensions) <= len(spec1.Extensions) {
@@ -296,47 +289,33 @@ func mustParseURL(rawURL string) *url.URL {
return u
}
// TestProfileExpectation defines expected fingerprint values for a profile.
type TestProfileExpectation struct {
Profile *Profile
ExpectedJA3 string // Expected JA3 hash (empty = don't check)
ExpectedJA4 string // Expected full JA4 (empty = don't check)
JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check)
}
// TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
// Run with: go test -v -run TestAllProfiles ./internal/pkg/tlsfingerprint/...
func TestAllProfiles(t *testing.T) {
skipNetworkTest(t)
// Define all profiles to test with their expected fingerprints
// These profiles are from config.yaml gateway.tls_fingerprint.profiles
profiles := []TestProfileExpectation{
{
// Linux x64 Node.js v22.17.1
// Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4
// Default profile (Node.js 24.x)
// JA3 Hash: 44f88fca027f27bab4bb08d4af15f23e
// JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
Profile: &Profile{
Name: "default_node_v24",
EnableGREASE: false,
},
JA4CipherHash: "5b57614c22b0",
},
{
// Linux x64 Node.js v22.17.1 (explicit profile)
Profile: &Profile{
Name: "linux_x64_node_v22171",
EnableGREASE: false,
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
PointFormats: []uint8{0, 1, 2},
PointFormats: []uint16{0, 1, 2},
Extensions: []uint16{0, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51},
},
JA4CipherHash: "a33745022dd6", // stable part
},
{
// MacOS arm64 Node.js v22.18.0
// Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea
// Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406
Profile: &Profile{
Name: "macos_arm64_node_v22180",
EnableGREASE: false,
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
PointFormats: []uint8{0, 1, 2},
},
JA4CipherHash: "a33745022dd6", // stable part (same cipher suites)
JA4CipherHash: "a33745022dd6",
},
}
@@ -1,171 +0,0 @@
// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients.
package tlsfingerprint
import (
"log/slog"
"sort"
"sync"
"github.com/Wei-Shaw/sub2api/internal/config"
)
// DefaultProfileName is the name of the built-in Claude CLI profile.
const DefaultProfileName = "claude_cli_v2"
// Registry manages TLS fingerprint profiles.
// It holds a collection of profiles that can be used for TLS fingerprint simulation.
// Profiles are selected based on account ID using modulo operation.
type Registry struct {
mu sync.RWMutex
profiles map[string]*Profile
profileNames []string // Sorted list of profile names for deterministic selection
}
// NewRegistry creates a new TLS fingerprint profile registry.
// It initializes with the built-in default profile.
func NewRegistry() *Registry {
r := &Registry{
profiles: make(map[string]*Profile),
profileNames: make([]string, 0),
}
// Register the built-in default profile
r.registerBuiltinProfile()
return r
}
// NewRegistryFromConfig creates a new registry and loads profiles from config.
// If the config has custom profiles defined, they will be merged with the built-in default.
func NewRegistryFromConfig(cfg *config.TLSFingerprintConfig) *Registry {
r := NewRegistry()
if cfg == nil || !cfg.Enabled {
slog.Debug("tls_registry_disabled", "reason", "disabled or no config")
return r
}
// Load custom profiles from config
for name, profileCfg := range cfg.Profiles {
profile := &Profile{
Name: profileCfg.Name,
EnableGREASE: profileCfg.EnableGREASE,
CipherSuites: profileCfg.CipherSuites,
Curves: profileCfg.Curves,
PointFormats: profileCfg.PointFormats,
}
// If the profile has empty values, they will use defaults in dialer
r.RegisterProfile(name, profile)
slog.Debug("tls_registry_loaded_profile", "key", name, "name", profileCfg.Name)
}
slog.Debug("tls_registry_initialized", "profile_count", len(r.profileNames), "profiles", r.profileNames)
return r
}
// registerBuiltinProfile adds the default Claude CLI profile to the registry.
func (r *Registry) registerBuiltinProfile() {
defaultProfile := &Profile{
Name: "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)",
EnableGREASE: false, // Node.js does not use GREASE
// Empty slices will cause dialer to use built-in defaults
CipherSuites: nil,
Curves: nil,
PointFormats: nil,
}
r.RegisterProfile(DefaultProfileName, defaultProfile)
}
// RegisterProfile adds or updates a profile in the registry.
func (r *Registry) RegisterProfile(name string, profile *Profile) {
r.mu.Lock()
defer r.mu.Unlock()
// Check if this is a new profile
_, exists := r.profiles[name]
r.profiles[name] = profile
if !exists {
r.profileNames = append(r.profileNames, name)
// Keep names sorted for deterministic selection
sort.Strings(r.profileNames)
}
}
// GetProfile returns a profile by name.
// Returns nil if the profile does not exist.
func (r *Registry) GetProfile(name string) *Profile {
r.mu.RLock()
defer r.mu.RUnlock()
return r.profiles[name]
}
// GetDefaultProfile returns the built-in default profile.
func (r *Registry) GetDefaultProfile() *Profile {
return r.GetProfile(DefaultProfileName)
}
// GetProfileByAccountID returns a profile for the given account ID.
// The profile is selected using: profileNames[accountID % len(profiles)]
// This ensures deterministic profile assignment for each account.
func (r *Registry) GetProfileByAccountID(accountID int64) *Profile {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.profileNames) == 0 {
return nil
}
// Use modulo to select profile index
// Use absolute value to handle negative IDs (though unlikely)
idx := accountID
if idx < 0 {
idx = -idx
}
selectedIndex := int(idx % int64(len(r.profileNames)))
selectedName := r.profileNames[selectedIndex]
return r.profiles[selectedName]
}
// ProfileCount returns the number of registered profiles.
func (r *Registry) ProfileCount() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.profiles)
}
// ProfileNames returns a sorted list of all registered profile names.
func (r *Registry) ProfileNames() []string {
r.mu.RLock()
defer r.mu.RUnlock()
// Return a copy to prevent modification
names := make([]string, len(r.profileNames))
copy(names, r.profileNames)
return names
}
// Global registry instance for convenience
var globalRegistry *Registry
var globalRegistryOnce sync.Once
// GlobalRegistry returns the global TLS fingerprint registry.
// The registry is lazily initialized with the default profile.
func GlobalRegistry() *Registry {
globalRegistryOnce.Do(func() {
globalRegistry = NewRegistry()
})
return globalRegistry
}
// InitGlobalRegistry initializes the global registry with configuration.
// This should be called during application startup.
// It is safe to call multiple times; subsequent calls will update the registry.
func InitGlobalRegistry(cfg *config.TLSFingerprintConfig) *Registry {
globalRegistryOnce.Do(func() {
globalRegistry = NewRegistryFromConfig(cfg)
})
return globalRegistry
}
@@ -1,243 +0,0 @@
package tlsfingerprint
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
)
func TestNewRegistry(t *testing.T) {
r := NewRegistry()
// Should have exactly one profile (the default)
if r.ProfileCount() != 1 {
t.Errorf("expected 1 profile, got %d", r.ProfileCount())
}
// Should have the default profile
profile := r.GetDefaultProfile()
if profile == nil {
t.Error("expected default profile to exist")
}
// Default profile name should be in the list
names := r.ProfileNames()
if len(names) != 1 || names[0] != DefaultProfileName {
t.Errorf("expected profile names to be [%s], got %v", DefaultProfileName, names)
}
}
func TestRegisterProfile(t *testing.T) {
r := NewRegistry()
// Register a new profile
customProfile := &Profile{
Name: "Custom Profile",
EnableGREASE: true,
}
r.RegisterProfile("custom", customProfile)
// Should now have 2 profiles
if r.ProfileCount() != 2 {
t.Errorf("expected 2 profiles, got %d", r.ProfileCount())
}
// Should be able to retrieve the custom profile
retrieved := r.GetProfile("custom")
if retrieved == nil {
t.Fatal("expected custom profile to exist")
}
if retrieved.Name != "Custom Profile" {
t.Errorf("expected profile name 'Custom Profile', got '%s'", retrieved.Name)
}
if !retrieved.EnableGREASE {
t.Error("expected EnableGREASE to be true")
}
}
func TestGetProfile(t *testing.T) {
r := NewRegistry()
// Get existing profile
profile := r.GetProfile(DefaultProfileName)
if profile == nil {
t.Error("expected default profile to exist")
}
// Get non-existing profile
nonExistent := r.GetProfile("nonexistent")
if nonExistent != nil {
t.Error("expected nil for non-existent profile")
}
}
func TestGetProfileByAccountID(t *testing.T) {
r := NewRegistry()
// With only default profile, all account IDs should return the same profile
for i := int64(0); i < 10; i++ {
profile := r.GetProfileByAccountID(i)
if profile == nil {
t.Errorf("expected profile for account %d, got nil", i)
}
}
// Add more profiles
r.RegisterProfile("profile_a", &Profile{Name: "Profile A"})
r.RegisterProfile("profile_b", &Profile{Name: "Profile B"})
// Now we have 3 profiles: claude_cli_v2, profile_a, profile_b
// Names are sorted, so order is: claude_cli_v2, profile_a, profile_b
expectedOrder := []string{DefaultProfileName, "profile_a", "profile_b"}
names := r.ProfileNames()
for i, name := range expectedOrder {
if names[i] != name {
t.Errorf("expected name at index %d to be %s, got %s", i, name, names[i])
}
}
// Test modulo selection
// Account ID 0 % 3 = 0 -> claude_cli_v2
// Account ID 1 % 3 = 1 -> profile_a
// Account ID 2 % 3 = 2 -> profile_b
// Account ID 3 % 3 = 0 -> claude_cli_v2
testCases := []struct {
accountID int64
expectedName string
}{
{0, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"},
{1, "Profile A"},
{2, "Profile B"},
{3, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"},
{4, "Profile A"},
{5, "Profile B"},
{100, "Profile A"}, // 100 % 3 = 1
{-1, "Profile A"}, // |-1| % 3 = 1
{-3, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"}, // |-3| % 3 = 0
}
for _, tc := range testCases {
profile := r.GetProfileByAccountID(tc.accountID)
if profile == nil {
t.Errorf("expected profile for account %d, got nil", tc.accountID)
continue
}
if profile.Name != tc.expectedName {
t.Errorf("account %d: expected profile name '%s', got '%s'", tc.accountID, tc.expectedName, profile.Name)
}
}
}
func TestNewRegistryFromConfig(t *testing.T) {
// Test with nil config
r := NewRegistryFromConfig(nil)
if r.ProfileCount() != 1 {
t.Errorf("expected 1 profile with nil config, got %d", r.ProfileCount())
}
// Test with disabled config
disabledCfg := &config.TLSFingerprintConfig{
Enabled: false,
}
r = NewRegistryFromConfig(disabledCfg)
if r.ProfileCount() != 1 {
t.Errorf("expected 1 profile with disabled config, got %d", r.ProfileCount())
}
// Test with enabled config and custom profiles
enabledCfg := &config.TLSFingerprintConfig{
Enabled: true,
Profiles: map[string]config.TLSProfileConfig{
"custom1": {
Name: "Custom Profile 1",
EnableGREASE: true,
},
"custom2": {
Name: "Custom Profile 2",
EnableGREASE: false,
},
},
}
r = NewRegistryFromConfig(enabledCfg)
// Should have 3 profiles: default + 2 custom
if r.ProfileCount() != 3 {
t.Errorf("expected 3 profiles, got %d", r.ProfileCount())
}
// Check custom profiles exist
custom1 := r.GetProfile("custom1")
if custom1 == nil || custom1.Name != "Custom Profile 1" {
t.Error("expected custom1 profile to exist with correct name")
}
custom2 := r.GetProfile("custom2")
if custom2 == nil || custom2.Name != "Custom Profile 2" {
t.Error("expected custom2 profile to exist with correct name")
}
}
func TestProfileNames(t *testing.T) {
r := NewRegistry()
// Add profiles in non-alphabetical order
r.RegisterProfile("zebra", &Profile{Name: "Zebra"})
r.RegisterProfile("alpha", &Profile{Name: "Alpha"})
r.RegisterProfile("beta", &Profile{Name: "Beta"})
names := r.ProfileNames()
// Should be sorted alphabetically
expected := []string{"alpha", "beta", DefaultProfileName, "zebra"}
if len(names) != len(expected) {
t.Errorf("expected %d names, got %d", len(expected), len(names))
}
for i, name := range expected {
if names[i] != name {
t.Errorf("expected name at index %d to be %s, got %s", i, name, names[i])
}
}
// Test that returned slice is a copy (modifying it shouldn't affect registry)
names[0] = "modified"
originalNames := r.ProfileNames()
if originalNames[0] == "modified" {
t.Error("modifying returned slice should not affect registry")
}
}
func TestConcurrentAccess(t *testing.T) {
r := NewRegistry()
// Run concurrent reads and writes
done := make(chan bool)
// Writers
for i := 0; i < 10; i++ {
go func(id int) {
for j := 0; j < 100; j++ {
r.RegisterProfile("concurrent"+string(rune('0'+id)), &Profile{Name: "Concurrent"})
}
done <- true
}(i)
}
// Readers
for i := 0; i < 10; i++ {
go func(id int) {
for j := 0; j < 100; j++ {
_ = r.ProfileCount()
_ = r.ProfileNames()
_ = r.GetProfileByAccountID(int64(id * j))
_ = r.GetProfile(DefaultProfileName)
}
done <- true
}(i)
}
// Wait for all goroutines
for i := 0; i < 20; i++ {
<-done
}
// Test should pass without data races (run with -race flag)
}
@@ -8,6 +8,14 @@ type FingerprintResponse struct {
HTTP2 any `json:"http2"`
}
// TestProfileExpectation defines expected fingerprint values for a profile.
type TestProfileExpectation struct {
Profile *Profile
ExpectedJA3 string // Expected JA3 hash (empty = don't check)
ExpectedJA4 string // Expected full JA4 (empty = don't check)
JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check)
}
// TLSInfo contains TLS fingerprint details.
type TLSInfo struct {
JA3 string `json:"ja3"`
@@ -68,10 +68,9 @@ func (s *claudeUsageService) FetchUsageWithOptions(ctx context.Context, opts *se
var resp *http.Response
// 如果启用 TLS 指纹且有 HTTPUpstream,使用 DoWithTLS
if opts.EnableTLSFingerprint && s.httpUpstream != nil {
// accountConcurrency 传 0 使用默认连接池配置,usage 请求不需要特殊的并发设置
resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, true)
// 如果 TLS Profile 且有 HTTPUpstream,使用 DoWithTLS
if opts.TLSProfile != nil && s.httpUpstream != nil {
resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, opts.TLSProfile)
if err != nil {
return nil, fmt.Errorf("request with TLS fingerprint failed: %w", err)
}
+5 -35
View File
@@ -161,26 +161,14 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
}
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
// 根据 enableTLSFingerprint 参数决定是否使用 TLS 指纹
//
// 参数:
// - req: HTTP 请求对象
// - proxyURL: 代理地址,空字符串表示直连
// - accountID: 账户 ID,用于账户级隔离和 TLS 指纹模板选择
// - accountConcurrency: 账户并发限制,用于动态调整连接池大小
// - enableTLSFingerprint: 是否启用 TLS 指纹伪装
//
// TLS 指纹说明:
// - 当 enableTLSFingerprint=true 时,使用 utls 库模拟 Claude CLI 的 TLS 指纹
// - 指纹模板根据 accountID % len(profiles) 自动选择
// - 支持直连、HTTP/HTTPS 代理、SOCKS5 代理三种场景
func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
// 如果未启用 TLS 指纹,直接使用标准请求路径
if !enableTLSFingerprint {
// profile 为 nil 时不启用 TLS 指纹,行为与 Do 方法相同。
// profile 非 nil 时使用指定的 Profile 进行 TLS 指纹伪装。
func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
if profile == nil {
return s.Do(req, proxyURL, accountID, accountConcurrency)
}
// TLS 指纹已启用,记录调试日志
targetHost := ""
if req != nil && req.URL != nil {
targetHost = req.URL.Host
@@ -189,46 +177,28 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
if proxyURL != "" {
proxyInfo = proxyURL
}
slog.Debug("tls_fingerprint_enabled", "account_id", accountID, "target", targetHost, "proxy", proxyInfo)
slog.Debug("tls_fingerprint_enabled", "account_id", accountID, "target", targetHost, "proxy", proxyInfo, "profile", profile.Name)
if err := s.validateRequestHost(req); err != nil {
return nil, err
}
// 获取 TLS 指纹 Profile
registry := tlsfingerprint.GlobalRegistry()
profile := registry.GetProfileByAccountID(accountID)
if profile == nil {
// 如果获取不到 profile,回退到普通请求
slog.Debug("tls_fingerprint_no_profile", "account_id", accountID, "fallback", "standard_request")
return s.Do(req, proxyURL, accountID, accountConcurrency)
}
slog.Debug("tls_fingerprint_using_profile", "account_id", accountID, "profile", profile.Name, "grease", profile.EnableGREASE)
// 获取或创建带 TLS 指纹的客户端
entry, err := s.acquireClientWithTLS(proxyURL, accountID, accountConcurrency, profile)
if err != nil {
slog.Debug("tls_fingerprint_acquire_client_failed", "account_id", accountID, "error", err)
return nil, err
}
// 执行请求
resp, err := entry.client.Do(req)
if err != nil {
// 请求失败,立即减少计数
atomic.AddInt64(&entry.inFlight, -1)
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
slog.Debug("tls_fingerprint_request_failed", "account_id", accountID, "error", err)
return nil, err
}
slog.Debug("tls_fingerprint_request_success", "account_id", accountID, "status", resp.StatusCode)
// 如果上游返回了压缩内容,解压后再交给业务层
decompressResponseBody(resp)
// 包装响应体,在关闭时自动减少计数并更新时间戳
resp.Body = wrapTrackedBody(resp.Body, func() {
atomic.AddInt64(&entry.inFlight, -1)
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
@@ -0,0 +1,122 @@
package repository
import (
"context"
"encoding/json"
"log/slog"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
)
const (
tlsFPProfileCacheKey = "tls_fingerprint_profiles"
tlsFPProfilePubSubKey = "tls_fingerprint_profiles_updated"
tlsFPProfileCacheTTL = 24 * time.Hour
)
type tlsFingerprintProfileCache struct {
rdb *redis.Client
localCache []*model.TLSFingerprintProfile
localMu sync.RWMutex
}
// NewTLSFingerprintProfileCache 创建 TLS 指纹模板缓存
func NewTLSFingerprintProfileCache(rdb *redis.Client) service.TLSFingerprintProfileCache {
return &tlsFingerprintProfileCache{
rdb: rdb,
}
}
// Get 从缓存获取模板列表
func (c *tlsFingerprintProfileCache) Get(ctx context.Context) ([]*model.TLSFingerprintProfile, bool) {
c.localMu.RLock()
if c.localCache != nil {
profiles := c.localCache
c.localMu.RUnlock()
return profiles, true
}
c.localMu.RUnlock()
data, err := c.rdb.Get(ctx, tlsFPProfileCacheKey).Bytes()
if err != nil {
if err != redis.Nil {
slog.Warn("tls_fp_profile_cache_get_failed", "error", err)
}
return nil, false
}
var profiles []*model.TLSFingerprintProfile
if err := json.Unmarshal(data, &profiles); err != nil {
slog.Warn("tls_fp_profile_cache_unmarshal_failed", "error", err)
return nil, false
}
c.localMu.Lock()
c.localCache = profiles
c.localMu.Unlock()
return profiles, true
}
// Set 设置缓存
func (c *tlsFingerprintProfileCache) Set(ctx context.Context, profiles []*model.TLSFingerprintProfile) error {
data, err := json.Marshal(profiles)
if err != nil {
return err
}
if err := c.rdb.Set(ctx, tlsFPProfileCacheKey, data, tlsFPProfileCacheTTL).Err(); err != nil {
return err
}
c.localMu.Lock()
c.localCache = profiles
c.localMu.Unlock()
return nil
}
// Invalidate 使缓存失效
func (c *tlsFingerprintProfileCache) Invalidate(ctx context.Context) error {
c.localMu.Lock()
c.localCache = nil
c.localMu.Unlock()
return c.rdb.Del(ctx, tlsFPProfileCacheKey).Err()
}
// NotifyUpdate 通知其他实例刷新缓存
func (c *tlsFingerprintProfileCache) NotifyUpdate(ctx context.Context) error {
return c.rdb.Publish(ctx, tlsFPProfilePubSubKey, "refresh").Err()
}
// SubscribeUpdates 订阅缓存更新通知
func (c *tlsFingerprintProfileCache) SubscribeUpdates(ctx context.Context, handler func()) {
go func() {
sub := c.rdb.Subscribe(ctx, tlsFPProfilePubSubKey)
defer func() { _ = sub.Close() }()
ch := sub.Channel()
for {
select {
case <-ctx.Done():
slog.Debug("tls_fp_profile_cache_subscriber_stopped", "reason", "context_done")
return
case msg := <-ch:
if msg == nil {
slog.Warn("tls_fp_profile_cache_subscriber_stopped", "reason", "channel_closed")
return
}
c.localMu.Lock()
c.localCache = nil
c.localMu.Unlock()
handler()
}
}
}()
}
@@ -0,0 +1,213 @@
package repository
import (
"context"
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type tlsFingerprintProfileRepository struct {
client *ent.Client
}
// NewTLSFingerprintProfileRepository 创建 TLS 指纹模板仓库
func NewTLSFingerprintProfileRepository(client *ent.Client) service.TLSFingerprintProfileRepository {
return &tlsFingerprintProfileRepository{client: client}
}
// List 获取所有模板
func (r *tlsFingerprintProfileRepository) List(ctx context.Context) ([]*model.TLSFingerprintProfile, error) {
profiles, err := r.client.TLSFingerprintProfile.Query().
Order(ent.Asc(tlsfingerprintprofile.FieldName)).
All(ctx)
if err != nil {
return nil, err
}
result := make([]*model.TLSFingerprintProfile, len(profiles))
for i, p := range profiles {
result[i] = r.toModel(p)
}
return result, nil
}
// GetByID 根据 ID 获取模板
func (r *tlsFingerprintProfileRepository) GetByID(ctx context.Context, id int64) (*model.TLSFingerprintProfile, error) {
p, err := r.client.TLSFingerprintProfile.Get(ctx, id)
if err != nil {
if ent.IsNotFound(err) {
return nil, nil
}
return nil, err
}
return r.toModel(p), nil
}
// Create 创建模板
func (r *tlsFingerprintProfileRepository) Create(ctx context.Context, p *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
builder := r.client.TLSFingerprintProfile.Create().
SetName(p.Name).
SetEnableGrease(p.EnableGREASE)
if p.Description != nil {
builder.SetDescription(*p.Description)
}
if len(p.CipherSuites) > 0 {
builder.SetCipherSuites(p.CipherSuites)
}
if len(p.Curves) > 0 {
builder.SetCurves(p.Curves)
}
if len(p.PointFormats) > 0 {
builder.SetPointFormats(p.PointFormats)
}
if len(p.SignatureAlgorithms) > 0 {
builder.SetSignatureAlgorithms(p.SignatureAlgorithms)
}
if len(p.ALPNProtocols) > 0 {
builder.SetAlpnProtocols(p.ALPNProtocols)
}
if len(p.SupportedVersions) > 0 {
builder.SetSupportedVersions(p.SupportedVersions)
}
if len(p.KeyShareGroups) > 0 {
builder.SetKeyShareGroups(p.KeyShareGroups)
}
if len(p.PSKModes) > 0 {
builder.SetPskModes(p.PSKModes)
}
if len(p.Extensions) > 0 {
builder.SetExtensions(p.Extensions)
}
created, err := builder.Save(ctx)
if err != nil {
return nil, err
}
return r.toModel(created), nil
}
// Update 更新模板
func (r *tlsFingerprintProfileRepository) Update(ctx context.Context, p *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
builder := r.client.TLSFingerprintProfile.UpdateOneID(p.ID).
SetName(p.Name).
SetEnableGrease(p.EnableGREASE)
if p.Description != nil {
builder.SetDescription(*p.Description)
} else {
builder.ClearDescription()
}
if len(p.CipherSuites) > 0 {
builder.SetCipherSuites(p.CipherSuites)
} else {
builder.ClearCipherSuites()
}
if len(p.Curves) > 0 {
builder.SetCurves(p.Curves)
} else {
builder.ClearCurves()
}
if len(p.PointFormats) > 0 {
builder.SetPointFormats(p.PointFormats)
} else {
builder.ClearPointFormats()
}
if len(p.SignatureAlgorithms) > 0 {
builder.SetSignatureAlgorithms(p.SignatureAlgorithms)
} else {
builder.ClearSignatureAlgorithms()
}
if len(p.ALPNProtocols) > 0 {
builder.SetAlpnProtocols(p.ALPNProtocols)
} else {
builder.ClearAlpnProtocols()
}
if len(p.SupportedVersions) > 0 {
builder.SetSupportedVersions(p.SupportedVersions)
} else {
builder.ClearSupportedVersions()
}
if len(p.KeyShareGroups) > 0 {
builder.SetKeyShareGroups(p.KeyShareGroups)
} else {
builder.ClearKeyShareGroups()
}
if len(p.PSKModes) > 0 {
builder.SetPskModes(p.PSKModes)
} else {
builder.ClearPskModes()
}
if len(p.Extensions) > 0 {
builder.SetExtensions(p.Extensions)
} else {
builder.ClearExtensions()
}
updated, err := builder.Save(ctx)
if err != nil {
return nil, err
}
return r.toModel(updated), nil
}
// Delete 删除模板
func (r *tlsFingerprintProfileRepository) Delete(ctx context.Context, id int64) error {
return r.client.TLSFingerprintProfile.DeleteOneID(id).Exec(ctx)
}
// toModel 将 Ent 实体转换为服务模型
func (r *tlsFingerprintProfileRepository) toModel(e *ent.TLSFingerprintProfile) *model.TLSFingerprintProfile {
p := &model.TLSFingerprintProfile{
ID: e.ID,
Name: e.Name,
Description: e.Description,
EnableGREASE: e.EnableGrease,
CipherSuites: e.CipherSuites,
Curves: e.Curves,
PointFormats: e.PointFormats,
SignatureAlgorithms: e.SignatureAlgorithms,
ALPNProtocols: e.AlpnProtocols,
SupportedVersions: e.SupportedVersions,
KeyShareGroups: e.KeyShareGroups,
PSKModes: e.PskModes,
Extensions: e.Extensions,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
// 确保切片不为 nil
if p.CipherSuites == nil {
p.CipherSuites = []uint16{}
}
if p.Curves == nil {
p.Curves = []uint16{}
}
if p.PointFormats == nil {
p.PointFormats = []uint16{}
}
if p.SignatureAlgorithms == nil {
p.SignatureAlgorithms = []uint16{}
}
if p.ALPNProtocols == nil {
p.ALPNProtocols = []string{}
}
if p.SupportedVersions == nil {
p.SupportedVersions = []uint16{}
}
if p.KeyShareGroups == nil {
p.KeyShareGroups = []uint16{}
}
if p.PSKModes == nil {
p.PSKModes = []uint16{}
}
if p.Extensions == nil {
p.Extensions = []uint16{}
}
return p
}
+2
View File
@@ -73,6 +73,7 @@ var ProviderSet = wire.NewSet(
NewUserAttributeValueRepository,
NewUserGroupRateRepository,
NewErrorPassthroughRepository,
NewTLSFingerprintProfileRepository,
// Cache implementations
NewGatewayCache,
@@ -96,6 +97,7 @@ var ProviderSet = wire.NewSet(
NewTotpCache,
NewRefreshTokenCache,
NewErrorPassthroughCache,
NewTLSFingerprintProfileCache,
// Encryptors
NewAESEncryptor,
+14
View File
@@ -79,6 +79,9 @@ func RegisterAdminRoutes(
// 错误透传规则管理
registerErrorPassthroughRoutes(admin, h)
// TLS 指纹模板管理
registerTLSFingerprintProfileRoutes(admin, h)
// API Key 管理
registerAdminAPIKeyRoutes(admin, h)
@@ -553,3 +556,14 @@ func registerErrorPassthroughRoutes(admin *gin.RouterGroup, h *handler.Handlers)
rules.DELETE("/:id", h.Admin.ErrorPassthrough.Delete)
}
}
func registerTLSFingerprintProfileRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
profiles := admin.Group("/tls-fingerprint-profiles")
{
profiles.GET("", h.Admin.TLSFingerprintProfile.List)
profiles.GET("/:id", h.Admin.TLSFingerprintProfile.GetByID)
profiles.POST("", h.Admin.TLSFingerprintProfile.Create)
profiles.PUT("/:id", h.Admin.TLSFingerprintProfile.Update)
profiles.DELETE("/:id", h.Admin.TLSFingerprintProfile.Delete)
}
}
+25
View File
@@ -1165,6 +1165,31 @@ func (a *Account) IsTLSFingerprintEnabled() bool {
return false
}
// GetTLSFingerprintProfileID 获取账号绑定的 TLS 指纹模板 ID
// 返回 0 表示未绑定(使用内置默认 profile)
func (a *Account) GetTLSFingerprintProfileID() int64 {
if a.Extra == nil {
return 0
}
v, ok := a.Extra["tls_fingerprint_profile_id"]
if !ok {
return 0
}
switch id := v.(type) {
case float64:
return int64(id)
case int64:
return id
case int:
return int64(id)
case json.Number:
if i, err := id.Int64(); err == nil {
return i
}
}
return 0
}
// GetUserMsgQueueMode 获取用户消息队列模式
// "serialize" = 串行队列, "throttle" = 软性限速, "" = 未设置(使用全局配置)
func (a *Account) GetUserMsgQueueMode() string {
@@ -23,6 +23,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/util/soraerror"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/gin-gonic/gin"
@@ -69,6 +70,7 @@ type AccountTestService struct {
antigravityGatewayService *AntigravityGatewayService
httpUpstream HTTPUpstream
cfg *config.Config
tlsFPProfileService *TLSFingerprintProfileService
soraTestGuardMu sync.Mutex
soraTestLastRun map[int64]time.Time
soraTestCooldown time.Duration
@@ -83,6 +85,7 @@ func NewAccountTestService(
antigravityGatewayService *AntigravityGatewayService,
httpUpstream HTTPUpstream,
cfg *config.Config,
tlsFPProfileService *TLSFingerprintProfileService,
) *AccountTestService {
return &AccountTestService{
accountRepo: accountRepo,
@@ -90,6 +93,7 @@ func NewAccountTestService(
antigravityGatewayService: antigravityGatewayService,
httpUpstream: httpUpstream,
cfg: cfg,
tlsFPProfileService: tlsFPProfileService,
soraTestLastRun: make(map[int64]time.Time),
soraTestCooldown: defaultSoraTestCooldown,
}
@@ -300,7 +304,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
}
@@ -390,7 +394,7 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, false)
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, nil)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
}
@@ -520,7 +524,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
}
@@ -610,7 +614,7 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
}
@@ -881,9 +885,9 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
enableSoraTLSFingerprint := s.shouldEnableSoraTLSFingerprint()
soraTLSProfile := s.resolveSoraTLSProfile()
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, enableSoraTLSFingerprint)
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, soraTLSProfile)
if err != nil {
recorder.addStep("me", "failed", 0, "network_error", err.Error())
s.emitSoraProbeSummary(c, recorder)
@@ -948,7 +952,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
subReq.Header.Set("Origin", "https://sora.chatgpt.com")
subReq.Header.Set("Referer", "https://sora.chatgpt.com/")
subResp, subErr := s.httpUpstream.DoWithTLS(subReq, proxyURL, account.ID, account.Concurrency, enableSoraTLSFingerprint)
subResp, subErr := s.httpUpstream.DoWithTLS(subReq, proxyURL, account.ID, account.Concurrency, soraTLSProfile)
if subErr != nil {
recorder.addStep("subscription", "failed", 0, "network_error", subErr.Error())
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Subscription check skipped: %s", subErr.Error())})
@@ -977,7 +981,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
}
// 追加 Sora2 能力探测(对齐 sora2api 的测试思路):邀请码 + 剩余额度。
s.testSora2Capabilities(c, ctx, account, authToken, proxyURL, enableSoraTLSFingerprint, recorder)
s.testSora2Capabilities(c, ctx, account, authToken, proxyURL, soraTLSProfile, recorder)
s.emitSoraProbeSummary(c, recorder)
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
@@ -990,7 +994,7 @@ func (s *AccountTestService) testSora2Capabilities(
account *Account,
authToken string,
proxyURL string,
enableTLSFingerprint bool,
tlsProfile *tlsfingerprint.Profile,
recorder *soraProbeRecorder,
) {
inviteStatus, inviteHeader, inviteBody, err := s.fetchSoraTestEndpoint(
@@ -999,7 +1003,7 @@ func (s *AccountTestService) testSora2Capabilities(
authToken,
soraInviteMineURL,
proxyURL,
enableTLSFingerprint,
tlsProfile,
)
if err != nil {
if recorder != nil {
@@ -1016,7 +1020,7 @@ func (s *AccountTestService) testSora2Capabilities(
authToken,
soraBootstrapURL,
proxyURL,
enableTLSFingerprint,
tlsProfile,
)
if bootstrapErr == nil && bootstrapStatus == http.StatusOK {
if recorder != nil {
@@ -1029,7 +1033,7 @@ func (s *AccountTestService) testSora2Capabilities(
authToken,
soraInviteMineURL,
proxyURL,
enableTLSFingerprint,
tlsProfile,
)
if err != nil {
if recorder != nil {
@@ -1081,7 +1085,7 @@ func (s *AccountTestService) testSora2Capabilities(
authToken,
soraRemainingURL,
proxyURL,
enableTLSFingerprint,
tlsProfile,
)
if remainingErr != nil {
if recorder != nil {
@@ -1122,7 +1126,7 @@ func (s *AccountTestService) fetchSoraTestEndpoint(
authToken string,
url string,
proxyURL string,
enableTLSFingerprint bool,
tlsProfile *tlsfingerprint.Profile,
) (int, http.Header, []byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
@@ -1135,7 +1139,7 @@ func (s *AccountTestService) fetchSoraTestEndpoint(
req.Header.Set("Origin", "https://sora.chatgpt.com")
req.Header.Set("Referer", "https://sora.chatgpt.com/")
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, enableTLSFingerprint)
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, tlsProfile)
if err != nil {
return 0, nil, nil, err
}
@@ -1224,11 +1228,12 @@ func parseSoraRemainingSummary(body []byte) string {
return strings.Join(parts, " | ")
}
func (s *AccountTestService) shouldEnableSoraTLSFingerprint() bool {
if s == nil || s.cfg == nil {
return true
func (s *AccountTestService) resolveSoraTLSProfile() *tlsfingerprint.Profile {
if s == nil || s.cfg == nil || !s.cfg.Sora.Client.DisableTLSFingerprint {
// Sora TLS fingerprint enabled — use built-in default profile
return &tlsfingerprint.Profile{Name: "Built-in Default (Sora)"}
}
return !s.cfg.Sora.Client.DisableTLSFingerprint
return nil // disabled
}
func isCloudflareChallengeResponse(statusCode int, headers http.Header, body []byte) bool {
@@ -10,6 +10,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -24,9 +25,9 @@ func (u *queuedHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*htt
return nil, fmt.Errorf("unexpected Do call")
}
func (u *queuedHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, enableTLSFingerprint bool) (*http.Response, error) {
func (u *queuedHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, profile *tlsfingerprint.Profile) (*http.Response, error) {
u.requests = append(u.requests, req)
u.tlsFlags = append(u.tlsFlags, enableTLSFingerprint)
u.tlsFlags = append(u.tlsFlags, profile != nil)
if len(u.responses) == 0 {
return nil, fmt.Errorf("no mocked response")
}
@@ -17,6 +17,7 @@ import (
openaipkg "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/singleflight"
@@ -241,11 +242,11 @@ type ClaudeUsageResponse struct {
// ClaudeUsageFetchOptions 包含获取 Claude 用量数据所需的所有选项
type ClaudeUsageFetchOptions struct {
AccessToken string // OAuth access token
ProxyURL string // 代理 URL(可选)
AccountID int64 // 账号 ID(用于 TLS 指纹选择
EnableTLSFingerprint bool // 是否启用 TLS 指纹伪装
Fingerprint *Fingerprint // 缓存的指纹信息(User-Agent 等)
AccessToken string // OAuth access token
ProxyURL string // 代理 URL(可选)
AccountID int64 // 账号 ID(用于连接池隔离
TLSProfile *tlsfingerprint.Profile // TLS 指纹 Profilenil 表示不启用)
Fingerprint *Fingerprint // 缓存的指纹信息(User-Agent 等)
}
// ClaudeUsageFetcher fetches usage data from Anthropic OAuth API
@@ -264,6 +265,7 @@ type AccountUsageService struct {
antigravityQuotaFetcher *AntigravityQuotaFetcher
cache *UsageCache
identityCache IdentityCache
tlsFPProfileService *TLSFingerprintProfileService
}
// NewAccountUsageService 创建AccountUsageService实例
@@ -275,6 +277,7 @@ func NewAccountUsageService(
antigravityQuotaFetcher *AntigravityQuotaFetcher,
cache *UsageCache,
identityCache IdentityCache,
tlsFPProfileService *TLSFingerprintProfileService,
) *AccountUsageService {
return &AccountUsageService{
accountRepo: accountRepo,
@@ -284,6 +287,7 @@ func NewAccountUsageService(
antigravityQuotaFetcher: antigravityQuotaFetcher,
cache: cache,
identityCache: identityCache,
tlsFPProfileService: tlsFPProfileService,
}
}
@@ -1155,10 +1159,10 @@ func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *A
// 构建完整的选项
opts := &ClaudeUsageFetchOptions{
AccessToken: accessToken,
ProxyURL: proxyURL,
AccountID: account.ID,
EnableTLSFingerprint: account.IsTLSFingerprintEnabled(),
AccessToken: accessToken,
ProxyURL: proxyURL,
AccountID: account.ID,
TLSProfile: s.tlsFPProfileService.ResolveTLSProfile(account),
}
// 尝试获取缓存的 Fingerprint(包含 User-Agent 等信息)
@@ -15,6 +15,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -130,7 +131,7 @@ func (s *httpUpstreamStub) Do(_ *http.Request, _ string, _ int64, _ int) (*http.
return s.resp, s.err
}
func (s *httpUpstreamStub) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ bool) (*http.Response, error) {
func (s *httpUpstreamStub) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return s.resp, s.err
}
@@ -171,7 +172,7 @@ func (s *queuedHTTPUpstreamStub) Do(req *http.Request, _ string, _ int64, _ int)
return resp, err
}
func (s *queuedHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ bool) (*http.Response, error) {
func (s *queuedHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return s.Do(req, proxyURL, accountID, concurrency)
}
@@ -12,6 +12,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/stretchr/testify/require"
)
@@ -40,7 +41,7 @@ func (r *recordingOKUpstream) Do(req *http.Request, proxyURL string, accountID i
}, nil
}
func (r *recordingOKUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
func (r *recordingOKUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
return r.Do(req, proxyURL, accountID, accountConcurrency)
}
@@ -61,7 +62,7 @@ func (s *stubAntigravityUpstream) Do(req *http.Request, proxyURL string, account
}, nil
}
func (s *stubAntigravityUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
func (s *stubAntigravityUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
return s.Do(req, proxyURL, accountID, accountConcurrency)
}
@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/stretchr/testify/require"
)
@@ -93,7 +94,7 @@ func (m *mockSmartRetryUpstream) Do(req *http.Request, proxyURL string, accountI
}, respErr
}
func (m *mockSmartRetryUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
func (m *mockSmartRetryUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
return m.Do(req, proxyURL, accountID, accountConcurrency)
}
@@ -12,6 +12,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/stretchr/testify/require"
)
@@ -35,7 +36,7 @@ func (u *epFixedUpstream) Do(req *http.Request, proxyURL string, accountID int64
}, nil
}
func (u *epFixedUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
func (u *epFixedUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
return u.Do(req, proxyURL, accountID, accountConcurrency)
}
@@ -15,6 +15,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
@@ -60,7 +61,7 @@ func (u *anthropicHTTPUpstreamRecorder) Do(req *http.Request, proxyURL string, a
return u.resp, nil
}
func (u *anthropicHTTPUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
func (u *anthropicHTTPUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
return u.Do(req, proxyURL, accountID, accountConcurrency)
}
@@ -120,7 +120,7 @@ func (s *GatewayService) ForwardAsChatCompletions(
}
// 11. Send request
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
@@ -117,7 +117,7 @@ func (s *GatewayService) ForwardAsResponses(
}
// 11. Send request
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
@@ -40,6 +40,7 @@ func newGatewayRecordUsageServiceForTest(usageRepo UsageLogRepository, userRepo
nil,
nil,
nil,
nil,
)
}
+16 -10
View File
@@ -566,6 +566,7 @@ type GatewayService struct {
debugModelRouting atomic.Bool
debugClaudeMimic atomic.Bool
debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set
tlsFPProfileService *TLSFingerprintProfileService
}
// NewGatewayService creates a new GatewayService
@@ -592,6 +593,7 @@ func NewGatewayService(
rpmCache RPMCache,
digestStore *DigestSessionStore,
settingService *SettingService,
tlsFPProfileService *TLSFingerprintProfileService,
) *GatewayService {
userGroupRateTTL := resolveUserGroupRateCacheTTL(cfg)
modelsListTTL := resolveModelsListCacheTTL(cfg)
@@ -623,6 +625,7 @@ func NewGatewayService(
modelsListCache: gocache.New(modelsListTTL, time.Minute),
modelsListCacheTTL: modelsListTTL,
responseHeaderFilter: compileResponseHeaderFilter(cfg),
tlsFPProfileService: tlsFPProfileService,
}
svc.userGroupRateResolver = newUserGroupRateResolver(
userGroupRateRepo,
@@ -4133,9 +4136,12 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
proxyURL = account.Proxy.URL()
}
// 解析 TLS 指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析)
tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account)
// 调试日志:记录即将转发的账号信息
logger.LegacyPrintf("service.gateway", "[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s",
account.ID, account.Name, account.Platform, account.Type, account.IsTLSFingerprintEnabled(), proxyURL)
account.ID, account.Name, account.Platform, account.Type, tlsProfile, proxyURL)
// Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400.
body = StripEmptyTextBlocks(body)
@@ -4155,7 +4161,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
// 发送请求
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
@@ -4233,7 +4239,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
retryReq, buildErr := s.buildUpstreamRequest(retryCtx, c, account, filteredBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
releaseRetryCtx()
if buildErr == nil {
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
if retryErr == nil {
if retryResp.StatusCode < 400 {
logger.LegacyPrintf("service.gateway", "Account %d: thinking block retry succeeded (blocks downgraded)", account.ID)
@@ -4268,7 +4274,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
retryReq2, buildErr2 := s.buildUpstreamRequest(retryCtx2, c, account, filteredBody2, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
releaseRetryCtx2()
if buildErr2 == nil {
retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsProfile)
if retryErr2 == nil {
resp = retryResp2
break
@@ -4339,7 +4345,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
budgetRetryReq, buildErr := s.buildUpstreamRequest(budgetRetryCtx, c, account, rectifiedBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
releaseBudgetRetryCtx()
if buildErr == nil {
budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
if retryErr == nil {
resp = budgetRetryResp
break
@@ -4645,7 +4651,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
return nil, err
}
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
@@ -5364,7 +5370,7 @@ func (s *GatewayService) executeBedrockUpstream(
return nil, err
}
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, false)
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, nil)
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
@@ -8044,7 +8050,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
}
// 发送请求
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed")
@@ -8072,7 +8078,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
filteredBody := FilterThinkingBlocksForRetry(body)
retryReq, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel, shouldMimicClaudeCode)
if buildErr == nil {
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if retryErr == nil {
resp = retryResp
respBody, err = readUpstreamResponseBodyLimited(resp.Body, maxReadBytes)
@@ -8161,7 +8167,7 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
@@ -12,6 +12,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -36,7 +37,7 @@ func (s *geminiCompatHTTPUpstreamStub) Do(req *http.Request, proxyURL string, ac
return &resp, nil
}
func (s *geminiCompatHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
func (s *geminiCompatHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
return s.Do(req, proxyURL, accountID, accountConcurrency)
}
+12 -43
View File
@@ -1,55 +1,24 @@
package service
import "net/http"
import (
"net/http"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
)
// HTTPUpstream 上游 HTTP 请求接口
// 用于向上游 APIClaude、OpenAI、Gemini 等)发送请求
// 这是一个通用接口,可用于任何基于 HTTP 的上游服务
//
// 设计说明:
// - 支持可选代理配置
// - 支持账户级连接池隔离
// - 实现类负责连接池管理和复用
// - 支持可选的 TLS 指纹伪装
type HTTPUpstream interface {
// Do 执行 HTTP 请求
//
// 参数:
// - req: HTTP 请求对象,由调用方构建
// - proxyURL: 代理服务器地址,空字符串表示直连
// - accountID: 账户 ID,用于连接池隔离(隔离策略为 account 或 account_proxy 时生效)
// - accountConcurrency: 账户并发限制,用于动态调整连接池大小
//
// 返回:
// - *http.Response: HTTP 响应,调用方必须关闭 Body
// - error: 请求错误(网络错误、超时等)
//
// 注意:
// - 调用方必须关闭 resp.Body,否则会导致连接泄漏
// - 响应体可能已被包装以跟踪请求生命周期
// Do 执行 HTTP 请求(不启用 TLS 指纹)
Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error)
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
//
// 参数:
// - req: HTTP 请求对象,由调用方构建
// - proxyURL: 代理服务器地址,空字符串表示直连
// - accountID: 账户 ID,用于连接池隔离和 TLS 指纹模板选择
// - accountConcurrency: 账户并发限制,用于动态调整连接池大小
// - enableTLSFingerprint: 是否启用 TLS 指纹伪装
// profile 参数:
// - nil: 不启用 TLS 指纹,行为与 Do 方法相同
// - non-nil: 使用指定的 Profile 进行 TLS 指纹伪装
//
// 返回:
// - *http.Response: HTTP 响应,调用方必须关闭 Body
// - error: 请求错误(网络错误、超时等)
//
// TLS 指纹说明:
// - 当 enableTLSFingerprint=true 时,使用 utls 库模拟 Claude CLI 的 TLS 指纹
// - TLS 指纹模板根据 accountID % len(profiles) 自动选择
// - 支持直连、HTTP/HTTPS 代理、SOCKS5 代理三种场景
// - 如果 enableTLSFingerprint=false,行为与 Do 方法相同
//
// 注意:
// - 调用方必须关闭 resp.Body,否则会导致连接泄漏
// - TLS 指纹客户端与普通客户端使用不同的缓存键,互不影响
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error)
// Profile 由调用方通过 TLSFingerprintProfileService 解析后传入,
// 支持按账号绑定的数据库 profile 或内置默认 profile。
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error)
}
@@ -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)
}
@@ -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)
}
@@ -0,0 +1,259 @@
package service
import (
"context"
"math/rand/v2"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
)
// TLSFingerprintProfileRepository 定义 TLS 指纹模板的数据访问接口
type TLSFingerprintProfileRepository interface {
List(ctx context.Context) ([]*model.TLSFingerprintProfile, error)
GetByID(ctx context.Context, id int64) (*model.TLSFingerprintProfile, error)
Create(ctx context.Context, profile *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error)
Update(ctx context.Context, profile *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error)
Delete(ctx context.Context, id int64) error
}
// TLSFingerprintProfileCache 定义 TLS 指纹模板的缓存接口
type TLSFingerprintProfileCache interface {
Get(ctx context.Context) ([]*model.TLSFingerprintProfile, bool)
Set(ctx context.Context, profiles []*model.TLSFingerprintProfile) error
Invalidate(ctx context.Context) error
NotifyUpdate(ctx context.Context) error
SubscribeUpdates(ctx context.Context, handler func())
}
// TLSFingerprintProfileService TLS 指纹模板管理服务
type TLSFingerprintProfileService struct {
repo TLSFingerprintProfileRepository
cache TLSFingerprintProfileCache
// 本地 ID→Profile 映射缓存,用于 DoWithTLS 热路径快速查找
localCache map[int64]*model.TLSFingerprintProfile
localMu sync.RWMutex
}
// NewTLSFingerprintProfileService 创建 TLS 指纹模板服务
func NewTLSFingerprintProfileService(
repo TLSFingerprintProfileRepository,
cache TLSFingerprintProfileCache,
) *TLSFingerprintProfileService {
svc := &TLSFingerprintProfileService{
repo: repo,
cache: cache,
localCache: make(map[int64]*model.TLSFingerprintProfile),
}
ctx := context.Background()
if err := svc.reloadFromDB(ctx); err != nil {
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to load profiles from DB on startup: %v", err)
if fallbackErr := svc.refreshLocalCache(ctx); fallbackErr != nil {
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to load profiles from cache fallback on startup: %v", fallbackErr)
}
}
if cache != nil {
cache.SubscribeUpdates(ctx, func() {
if err := svc.refreshLocalCache(context.Background()); err != nil {
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to refresh cache on notification: %v", err)
}
})
}
return svc
}
// --- CRUD ---
// List 获取所有模板
func (s *TLSFingerprintProfileService) List(ctx context.Context) ([]*model.TLSFingerprintProfile, error) {
return s.repo.List(ctx)
}
// GetByID 根据 ID 获取模板
func (s *TLSFingerprintProfileService) GetByID(ctx context.Context, id int64) (*model.TLSFingerprintProfile, error) {
return s.repo.GetByID(ctx, id)
}
// Create 创建模板
func (s *TLSFingerprintProfileService) Create(ctx context.Context, profile *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
if err := profile.Validate(); err != nil {
return nil, err
}
created, err := s.repo.Create(ctx, profile)
if err != nil {
return nil, err
}
refreshCtx, cancel := s.newCacheRefreshContext()
defer cancel()
s.invalidateAndNotify(refreshCtx)
return created, nil
}
// Update 更新模板
func (s *TLSFingerprintProfileService) Update(ctx context.Context, profile *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
if err := profile.Validate(); err != nil {
return nil, err
}
updated, err := s.repo.Update(ctx, profile)
if err != nil {
return nil, err
}
refreshCtx, cancel := s.newCacheRefreshContext()
defer cancel()
s.invalidateAndNotify(refreshCtx)
return updated, nil
}
// Delete 删除模板
func (s *TLSFingerprintProfileService) Delete(ctx context.Context, id int64) error {
if err := s.repo.Delete(ctx, id); err != nil {
return err
}
refreshCtx, cancel := s.newCacheRefreshContext()
defer cancel()
s.invalidateAndNotify(refreshCtx)
return nil
}
// --- 热路径:运行时 Profile 查找 ---
// GetProfileByID 根据 ID 从本地缓存获取 Profile(用于 DoWithTLS 热路径)
// 返回 nil 表示未找到,调用方应 fallback 到内置默认 Profile
func (s *TLSFingerprintProfileService) GetProfileByID(id int64) *tlsfingerprint.Profile {
s.localMu.RLock()
p, ok := s.localCache[id]
s.localMu.RUnlock()
if ok && p != nil {
return p.ToTLSProfile()
}
return nil
}
// getRandomProfile 从本地缓存中随机选择一个 Profile
func (s *TLSFingerprintProfileService) getRandomProfile() *tlsfingerprint.Profile {
s.localMu.RLock()
defer s.localMu.RUnlock()
if len(s.localCache) == 0 {
return nil
}
// 收集所有 profile
profiles := make([]*model.TLSFingerprintProfile, 0, len(s.localCache))
for _, p := range s.localCache {
if p != nil {
profiles = append(profiles, p)
}
}
if len(profiles) == 0 {
return nil
}
return profiles[rand.IntN(len(profiles))].ToTLSProfile()
}
// ResolveTLSProfile 根据 Account 的配置解析出运行时 TLS Profile
//
// 逻辑:
// 1. 未启用 TLS 指纹 → 返回 nil(不伪装)
// 2. 启用 + 绑定了 profile_id → 从缓存查找对应 profile
// 3. 启用 + 未绑定或找不到 → 返回空 Profile(使用代码内置默认值)
func (s *TLSFingerprintProfileService) ResolveTLSProfile(account *Account) *tlsfingerprint.Profile {
if account == nil || !account.IsTLSFingerprintEnabled() {
return nil
}
id := account.GetTLSFingerprintProfileID()
if id > 0 {
if p := s.GetProfileByID(id); p != nil {
return p
}
}
if id == -1 {
// 随机选择一个 profile
if p := s.getRandomProfile(); p != nil {
return p
}
}
// TLS 启用但无绑定 profile → 空 Profile → dialer 使用内置默认值
return &tlsfingerprint.Profile{Name: "Built-in Default (Node.js 24.x)"}
}
// --- 缓存管理 ---
func (s *TLSFingerprintProfileService) refreshLocalCache(ctx context.Context) error {
if s.cache != nil {
if profiles, ok := s.cache.Get(ctx); ok {
s.setLocalCache(profiles)
return nil
}
}
return s.reloadFromDB(ctx)
}
func (s *TLSFingerprintProfileService) reloadFromDB(ctx context.Context) error {
profiles, err := s.repo.List(ctx)
if err != nil {
return err
}
if s.cache != nil {
if err := s.cache.Set(ctx, profiles); err != nil {
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to set cache: %v", err)
}
}
s.setLocalCache(profiles)
return nil
}
func (s *TLSFingerprintProfileService) setLocalCache(profiles []*model.TLSFingerprintProfile) {
m := make(map[int64]*model.TLSFingerprintProfile, len(profiles))
for _, p := range profiles {
m[p.ID] = p
}
s.localMu.Lock()
s.localCache = m
s.localMu.Unlock()
}
func (s *TLSFingerprintProfileService) newCacheRefreshContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 3*time.Second)
}
func (s *TLSFingerprintProfileService) invalidateAndNotify(ctx context.Context) {
if s.cache != nil {
if err := s.cache.Invalidate(ctx); err != nil {
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to invalidate cache: %v", err)
}
}
if err := s.reloadFromDB(ctx); err != nil {
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to refresh local cache: %v", err)
s.localMu.Lock()
s.localCache = make(map[int64]*model.TLSFingerprintProfile)
s.localMu.Unlock()
}
if s.cache != nil {
if err := s.cache.NotifyUpdate(ctx); err != nil {
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to notify cache update: %v", err)
}
}
}
+1
View File
@@ -482,6 +482,7 @@ var ProviderSet = wire.NewSet(
NewUsageCache,
NewTotpService,
NewErrorPassthroughService,
NewTLSFingerprintProfileService,
NewDigestSessionStore,
ProvideIdempotencyCoordinator,
ProvideSystemOperationLockService,