Commit 1854050d authored by shaw's avatar shaw
Browse files

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 回调签名被错误替换的问题
parent ef5c8e68
...@@ -252,6 +252,10 @@ func AccountFromServiceShallow(a *service.Account) *Account { ...@@ -252,6 +252,10 @@ func AccountFromServiceShallow(a *service.Account) *Account {
enabled := true enabled := true
out.EnableTLSFingerprint = &enabled out.EnableTLSFingerprint = &enabled
} }
// TLS指纹模板ID
if profileID := a.GetTLSFingerprintProfileID(); profileID > 0 {
out.TLSFingerprintProfileID = &profileID
}
// 会话ID伪装开关 // 会话ID伪装开关
if a.IsSessionIDMaskingEnabled() { if a.IsSessionIDMaskingEnabled() {
enabled := true enabled := true
......
...@@ -185,7 +185,8 @@ type Account struct { ...@@ -185,7 +185,8 @@ type Account struct {
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效) // TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
// 从 extra 字段提取,方便前端显示和编辑 // 从 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 账号有效) // 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效)
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID // 启用后将在15分钟内固定 metadata.user_id 中的 session ID
......
...@@ -75,8 +75,10 @@ func (f *fakeGroupRepo) ListActive(context.Context) ([]service.Group, error) { r ...@@ -75,8 +75,10 @@ func (f *fakeGroupRepo) ListActive(context.Context) ([]service.Group, error) { r
func (f *fakeGroupRepo) ListActiveByPlatform(context.Context, string) ([]service.Group, error) { func (f *fakeGroupRepo) ListActiveByPlatform(context.Context, string) ([]service.Group, error) {
return nil, nil return nil, nil
} }
func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, 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) GetAccountCount(context.Context, int64) (int64, int64, error) {
return 0, 0, nil
}
func (f *fakeGroupRepo) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { func (f *fakeGroupRepo) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
return 0, nil return 0, nil
} }
...@@ -158,6 +160,7 @@ func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*servi ...@@ -158,6 +160,7 @@ func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*servi
nil, // rpmCache nil, // rpmCache
nil, // digestStore nil, // digestStore
nil, // settingService nil, // settingService
nil, // tlsFPProfileService
) )
// RunModeSimple:跳过计费检查,避免引入 repo/cache 依赖。 // RunModeSimple:跳过计费检查,避免引入 repo/cache 依赖。
......
...@@ -6,29 +6,30 @@ import ( ...@@ -6,29 +6,30 @@ import (
// AdminHandlers contains all admin-related HTTP handlers // AdminHandlers contains all admin-related HTTP handlers
type AdminHandlers struct { type AdminHandlers struct {
Dashboard *admin.DashboardHandler Dashboard *admin.DashboardHandler
User *admin.UserHandler User *admin.UserHandler
Group *admin.GroupHandler Group *admin.GroupHandler
Account *admin.AccountHandler Account *admin.AccountHandler
Announcement *admin.AnnouncementHandler Announcement *admin.AnnouncementHandler
DataManagement *admin.DataManagementHandler DataManagement *admin.DataManagementHandler
Backup *admin.BackupHandler Backup *admin.BackupHandler
OAuth *admin.OAuthHandler OAuth *admin.OAuthHandler
OpenAIOAuth *admin.OpenAIOAuthHandler OpenAIOAuth *admin.OpenAIOAuthHandler
GeminiOAuth *admin.GeminiOAuthHandler GeminiOAuth *admin.GeminiOAuthHandler
AntigravityOAuth *admin.AntigravityOAuthHandler AntigravityOAuth *admin.AntigravityOAuthHandler
Proxy *admin.ProxyHandler Proxy *admin.ProxyHandler
Redeem *admin.RedeemHandler Redeem *admin.RedeemHandler
Promo *admin.PromoHandler Promo *admin.PromoHandler
Setting *admin.SettingHandler Setting *admin.SettingHandler
Ops *admin.OpsHandler Ops *admin.OpsHandler
System *admin.SystemHandler System *admin.SystemHandler
Subscription *admin.SubscriptionHandler Subscription *admin.SubscriptionHandler
Usage *admin.UsageHandler Usage *admin.UsageHandler
UserAttribute *admin.UserAttributeHandler UserAttribute *admin.UserAttributeHandler
ErrorPassthrough *admin.ErrorPassthroughHandler ErrorPassthrough *admin.ErrorPassthroughHandler
APIKey *admin.AdminAPIKeyHandler TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
ScheduledTest *admin.ScheduledTestHandler APIKey *admin.AdminAPIKeyHandler
ScheduledTest *admin.ScheduledTestHandler
} }
// Handlers contains all HTTP handlers // Handlers contains all HTTP handlers
......
...@@ -2224,7 +2224,7 @@ func (s *stubSoraClientForHandler) GetVideoTask(_ context.Context, _ *service.Ac ...@@ -2224,7 +2224,7 @@ func (s *stubSoraClientForHandler) GetVideoTask(_ context.Context, _ *service.Ac
func newMinimalGatewayService(accountRepo service.AccountRepository) *service.GatewayService { func newMinimalGatewayService(accountRepo service.AccountRepository) *service.GatewayService {
return service.NewGatewayService( return service.NewGatewayService(
accountRepo, nil, nil, nil, nil, nil, nil, nil, nil, 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) { ...@@ -464,6 +464,7 @@ func TestSoraGatewayHandler_ChatCompletions(t *testing.T) {
nil, // rpmCache nil, // rpmCache
nil, // digestStore nil, // digestStore
nil, // settingService nil, // settingService
nil, // tlsFPProfileService
) )
soraClient := &stubSoraClient{imageURLs: []string{"https://example.com/a.png"}} soraClient := &stubSoraClient{imageURLs: []string{"https://example.com/a.png"}}
......
...@@ -30,33 +30,35 @@ func ProvideAdminHandlers( ...@@ -30,33 +30,35 @@ func ProvideAdminHandlers(
usageHandler *admin.UsageHandler, usageHandler *admin.UsageHandler,
userAttributeHandler *admin.UserAttributeHandler, userAttributeHandler *admin.UserAttributeHandler,
errorPassthroughHandler *admin.ErrorPassthroughHandler, errorPassthroughHandler *admin.ErrorPassthroughHandler,
tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler,
apiKeyHandler *admin.AdminAPIKeyHandler, apiKeyHandler *admin.AdminAPIKeyHandler,
scheduledTestHandler *admin.ScheduledTestHandler, scheduledTestHandler *admin.ScheduledTestHandler,
) *AdminHandlers { ) *AdminHandlers {
return &AdminHandlers{ return &AdminHandlers{
Dashboard: dashboardHandler, Dashboard: dashboardHandler,
User: userHandler, User: userHandler,
Group: groupHandler, Group: groupHandler,
Account: accountHandler, Account: accountHandler,
Announcement: announcementHandler, Announcement: announcementHandler,
DataManagement: dataManagementHandler, DataManagement: dataManagementHandler,
Backup: backupHandler, Backup: backupHandler,
OAuth: oauthHandler, OAuth: oauthHandler,
OpenAIOAuth: openaiOAuthHandler, OpenAIOAuth: openaiOAuthHandler,
GeminiOAuth: geminiOAuthHandler, GeminiOAuth: geminiOAuthHandler,
AntigravityOAuth: antigravityOAuthHandler, AntigravityOAuth: antigravityOAuthHandler,
Proxy: proxyHandler, Proxy: proxyHandler,
Redeem: redeemHandler, Redeem: redeemHandler,
Promo: promoHandler, Promo: promoHandler,
Setting: settingHandler, Setting: settingHandler,
Ops: opsHandler, Ops: opsHandler,
System: systemHandler, System: systemHandler,
Subscription: subscriptionHandler, Subscription: subscriptionHandler,
Usage: usageHandler, Usage: usageHandler,
UserAttribute: userAttributeHandler, UserAttribute: userAttributeHandler,
ErrorPassthrough: errorPassthroughHandler, ErrorPassthrough: errorPassthroughHandler,
APIKey: apiKeyHandler, TLSFingerprintProfile: tlsFingerprintProfileHandler,
ScheduledTest: scheduledTestHandler, APIKey: apiKeyHandler,
ScheduledTest: scheduledTestHandler,
} }
} }
...@@ -145,6 +147,7 @@ var ProviderSet = wire.NewSet( ...@@ -145,6 +147,7 @@ var ProviderSet = wire.NewSet(
admin.NewUsageHandler, admin.NewUsageHandler,
admin.NewUserAttributeHandler, admin.NewUserAttributeHandler,
admin.NewErrorPassthroughHandler, admin.NewErrorPassthroughHandler,
admin.NewTLSFingerprintProfileHandler,
admin.NewAdminAPIKeyHandler, admin.NewAdminAPIKeyHandler,
admin.NewScheduledTestHandler, admin.NewScheduledTestHandler,
......
// 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,
}
}
...@@ -17,12 +17,19 @@ import ( ...@@ -17,12 +17,19 @@ import (
) )
// Profile contains TLS fingerprint configuration. // Profile contains TLS fingerprint configuration.
// All slice fields use built-in defaults when empty.
type Profile struct { type Profile struct {
Name string // Profile name for identification Name string // Profile name for identification
CipherSuites []uint16 CipherSuites []uint16
Curves []uint16 Curves []uint16
PointFormats []uint8 PointFormats []uint16
EnableGREASE bool 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. // Dialer creates TLS connections with custom fingerprints.
...@@ -45,154 +52,67 @@ type SOCKS5ProxyDialer struct { ...@@ -45,154 +52,67 @@ type SOCKS5ProxyDialer struct {
proxyURL *url.URL proxyURL *url.URL
} }
// Default TLS fingerprint values captured from Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x) // Default TLS fingerprint values captured from Claude Code (Node.js 24.x)
// Captured using: tshark -i lo -f "tcp port 8443" -Y "tls.handshake.type == 1" -V // Captured via tls-fingerprint-web capture server
// JA3 Hash: 1a28e69016765d92e3b381168d68922c // JA3 Hash: 44f88fca027f27bab4bb08d4af15f23e
// // JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
// Note: JA3/JA4 may have slight variations due to:
// - Session ticket presence/absence
// - Extension negotiation state
var ( 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 // Order is critical for JA3 fingerprint matching
defaultCipherSuites = []uint16{ 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 0x1302, // TLS_AES_256_GCM_SHA384
0x1303, // TLS_CHACHA20_POLY1305_SHA256 0x1303, // TLS_CHACHA20_POLY1305_SHA256
0x1301, // TLS_AES_128_GCM_SHA256
// ECDHE + AES-GCM // ECDHE + AES-GCM
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
0xc02b, // TLS_ECDHE_ECDSA_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 0xc02c, // TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
// DHE + AES-GCM // ECDHE + ChaCha20-Poly1305
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
0xcca9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 0xcca9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
0xcca8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 0xcca8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
0xccaa, // TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256
// ECDHE + AES-CBC-SHA (legacy fallback)
// 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
0xc009, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA 0xc009, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
0xc013, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA 0xc013, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
0x0033, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA 0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
0x0032, // TLS_DHE_DSS_WITH_AES_128_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 0x009c, // TLS_RSA_WITH_AES_128_GCM_SHA256
0xc0a0, // TLS_RSA_WITH_AES_128_CCM_8 0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384
0xc09c, // TLS_RSA_WITH_AES_128_CCM
0xc050, // TLS_RSA_WITH_ARIA_128_GCM_SHA256
// RSA + AES-CBC (non-PFS, legacy) // RSA + AES-CBC-SHA (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
0x002f, // TLS_RSA_WITH_AES_128_CBC_SHA 0x002f, // TLS_RSA_WITH_AES_128_CBC_SHA
0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA
// Renegotiation indication
0x00ff, // TLS_EMPTY_RENEGOTIATION_INFO_SCSV
} }
// 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{ defaultCurves = []utls.CurveID{
utls.X25519, // 0x001d utls.X25519, // 0x001d
utls.CurveP256, // 0x0017 (secp256r1) utls.CurveP256, // 0x0017 (secp256r1)
utls.CurveID(0x001e), // x448 utls.CurveP384, // 0x0018 (secp384r1)
utls.CurveP521, // 0x0019 (secp521r1) }
utls.CurveP384, // 0x0018 (secp384r1)
utls.CurveID(0x0100), // ffdhe2048 // defaultPointFormats contains point formats from Node.js 24.x
utls.CurveID(0x0101), // ffdhe3072 defaultPointFormats = []uint16{
utls.CurveID(0x0102), // ffdhe4096
utls.CurveID(0x0103), // ffdhe6144
utls.CurveID(0x0104), // ffdhe8192
}
// defaultPointFormats contains all 3 point formats from Claude CLI
defaultPointFormats = []uint8{
0, // uncompressed 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{ defaultSignatureAlgorithms = []utls.SignatureScheme{
0x0403, // ecdsa_secp256r1_sha256 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 0x0804, // rsa_pss_rsae_sha256
0x0805, // rsa_pss_rsae_sha384
0x0806, // rsa_pss_rsae_sha512
0x0401, // rsa_pkcs1_sha256 0x0401, // rsa_pkcs1_sha256
0x0503, // ecdsa_secp384r1_sha384
0x0805, // rsa_pss_rsae_sha384
0x0501, // rsa_pkcs1_sha384 0x0501, // rsa_pkcs1_sha384
0x0806, // rsa_pss_rsae_sha512
0x0601, // rsa_pkcs1_sha512 0x0601, // rsa_pkcs1_sha512
0x0303, // ecdsa_sha224 0x0201, // rsa_pkcs1_sha1
0x0301, // rsa_pkcs1_sha224
0x0302, // dsa_sha224
0x0402, // dsa_sha256
0x0502, // dsa_sha384
0x0602, // dsa_sha512
} }
) )
...@@ -256,49 +176,7 @@ func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr st ...@@ -256,49 +176,7 @@ func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr st
slog.Debug("tls_fingerprint_socks5_tunnel_established") slog.Debug("tls_fingerprint_socks5_tunnel_established")
// Step 3: Perform TLS handshake on the tunnel with utls fingerprint // Step 3: Perform TLS handshake on the tunnel with utls fingerprint
host, _, err := net.SplitHostPort(addr) return performTLSHandshake(ctx, conn, d.profile, 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
} }
// DialTLSContext establishes a TLS connection through HTTP proxy with the configured fingerprint. // 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 ...@@ -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) slog.Debug("tls_fingerprint_http_proxy_read_response_failed", "error", err)
return nil, fmt.Errorf("read CONNECT response: %w", 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 { if resp.StatusCode != http.StatusOK {
_ = conn.Close() _ = conn.Close()
...@@ -368,47 +247,7 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri ...@@ -368,47 +247,7 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri
slog.Debug("tls_fingerprint_http_proxy_tunnel_established") slog.Debug("tls_fingerprint_http_proxy_tunnel_established")
// Step 4: Perform TLS handshake on the tunnel with utls fingerprint // Step 4: Perform TLS handshake on the tunnel with utls fingerprint
host, _, err := net.SplitHostPort(addr) return performTLSHandshake(ctx, conn, d.profile, 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
} }
// DialTLSContext establishes a TLS connection with the configured fingerprint. // DialTLSContext establishes a TLS connection with the configured fingerprint.
...@@ -423,53 +262,35 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net. ...@@ -423,53 +262,35 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.
} }
slog.Debug("tls_fingerprint_tcp_connected", "addr", addr) 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) host, _, err := net.SplitHostPort(addr)
if err != nil { if err != nil {
host = addr host = addr
} }
slog.Debug("tls_fingerprint_sni_hostname", "host", host)
// Build ClientHello specification spec := buildClientHelloSpecFromProfile(profile)
spec := d.buildClientHelloSpec() tlsConn := utls.UClient(conn, &utls.Config{ServerName: host}, utls.HelloCustom)
slog.Debug("tls_fingerprint_clienthello_spec",
"cipher_suites", len(spec.CipherSuites),
"extensions", len(spec.Extensions))
// 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 { if err := tlsConn.ApplyPreset(spec); err != nil {
slog.Debug("tls_fingerprint_apply_preset_failed", "error", err)
_ = conn.Close() _ = 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 { 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() _ = conn.Close()
return nil, fmt.Errorf("TLS handshake failed: %w", err) return nil, fmt.Errorf("TLS handshake failed: %w", err)
} }
// Log successful handshake details
state := tlsConn.ConnectionState() state := tlsConn.ConnectionState()
slog.Debug("tls_fingerprint_handshake_success", slog.Debug("tls_fingerprint_handshake_success",
"host", host,
"version", state.Version, "version", state.Version,
"cipher_suite", state.CipherSuite, "cipher_suite", state.CipherSuite,
"alpn", state.NegotiatedProtocol) "alpn", state.NegotiatedProtocol)
...@@ -477,11 +298,6 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net. ...@@ -477,11 +298,6 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.
return tlsConn, nil 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. // toUTLSCurves converts uint16 slice to utls.CurveID slice.
func toUTLSCurves(curves []uint16) []utls.CurveID { func toUTLSCurves(curves []uint16) []utls.CurveID {
result := make([]utls.CurveID, len(curves)) result := make([]utls.CurveID, len(curves))
...@@ -491,70 +307,143 @@ func toUTLSCurves(curves []uint16) []utls.CurveID { ...@@ -491,70 +307,143 @@ func toUTLSCurves(curves []uint16) []utls.CurveID {
return result 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. // buildClientHelloSpecFromProfile constructs ClientHelloSpec from a Profile.
// This is a standalone function that can be used by both Dialer and HTTPProxyDialer. // This is a standalone function that can be used by both Dialer and HTTPProxyDialer.
func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec { func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
// Get cipher suites // Resolve effective values (profile overrides or built-in defaults)
var cipherSuites []uint16 cipherSuites := defaultCipherSuites
if profile != nil && len(profile.CipherSuites) > 0 { if profile != nil && len(profile.CipherSuites) > 0 {
cipherSuites = profile.CipherSuites cipherSuites = profile.CipherSuites
} else {
cipherSuites = defaultCipherSuites
} }
// Get curves curves := defaultCurves
var curves []utls.CurveID
if profile != nil && len(profile.Curves) > 0 { if profile != nil && len(profile.Curves) > 0 {
curves = toUTLSCurves(profile.Curves) curves = toUTLSCurves(profile.Curves)
} else {
curves = defaultCurves
} }
// Get point formats pointFormats := defaultPointFormats
var pointFormats []uint8
if profile != nil && len(profile.PointFormats) > 0 { if profile != nil && len(profile.PointFormats) > 0 {
pointFormats = profile.PointFormats 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 enableGREASE := profile != nil && profile.EnableGREASE
extensions := make([]utls.TLSExtension, 0, 16) // Build key shares
keyShares := make([]utls.KeyShare, len(keyShareGroups))
for i, g := range keyShareGroups {
keyShares[i] = utls.KeyShare{Group: g}
}
if enableGREASE { // Determine extension order
extensions = append(extensions, &utls.UtlsGREASEExtension{}) extOrder := defaultExtensionOrder
if profile != nil && len(profile.Extensions) > 0 {
extOrder = profile.Extensions
} }
// SNI extension - MUST be explicitly added for HelloCustom mode // Build extensions list from the ordered IDs.
// utls will populate the server name from Config.ServerName // Parametric extensions (curves, sigalgs, etc.) are populated with resolved profile values.
extensions = append(extensions, &utls.SNIExtension{}) // Unknown IDs use GenericExtension (sends type ID with empty data).
extensions := make([]utls.TLSExtension, 0, len(extOrder)+2)
// Claude CLI extension order (captured from tshark): for _, id := range extOrder {
// server_name(0), ec_point_formats(11), supported_groups(10), session_ticket(35), if isGREASEValue(id) {
// alpn(16), encrypt_then_mac(22), extended_master_secret(23), extensions = append(extensions, &utls.UtlsGREASEExtension{})
// signature_algorithms(13), supported_versions(43), continue
// psk_key_exchange_modes(45), key_share(51) }
extensions = append(extensions, switch id {
&utls.SupportedPointsExtension{SupportedPoints: pointFormats}, case 0: // server_name
&utls.SupportedCurvesExtension{Curves: curves}, extensions = append(extensions, &utls.SNIExtension{})
&utls.SessionTicketExtension{}, case 5: // status_request (OCSP)
&utls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}}, extensions = append(extensions, &utls.StatusRequestExtension{})
&utls.GenericExtension{Id: 22}, case 10: // supported_groups
&utls.ExtendedMasterSecretExtension{}, extensions = append(extensions, &utls.SupportedCurvesExtension{Curves: curves})
&utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: defaultSignatureAlgorithms}, case 11: // ec_point_formats
&utls.SupportedVersionsExtension{Versions: []uint16{ extensions = append(extensions, &utls.SupportedPointsExtension{SupportedPoints: toUint8s(pointFormats)})
utls.VersionTLS13, case 13: // signature_algorithms
utls.VersionTLS12, extensions = append(extensions, &utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: signatureAlgorithms})
}}, case 16: // alpn
&utls.PSKKeyExchangeModesExtension{Modes: []uint8{utls.PskModeDHE}}, extensions = append(extensions, &utls.ALPNExtension{AlpnProtocols: alpnProtocols})
&utls.KeyShareExtension{KeyShares: []utls.KeyShare{ case 18: // signed_certificate_timestamp
{Group: utls.X25519}, extensions = append(extensions, &utls.SCTExtension{})
}}, case 23: // extended_master_secret
) extensions = append(extensions, &utls.ExtendedMasterSecretExtension{})
case 35: // session_ticket
if enableGREASE { 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})
}
}
// 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{}) extensions = append(extensions, &utls.UtlsGREASEExtension{})
} }
...@@ -566,3 +455,12 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec { ...@@ -566,3 +455,12 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
TLSVersMin: utls.VersionTLS10, 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
}
//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) { ...@@ -40,16 +40,15 @@ func skipIfExternalServiceUnavailable(t *testing.T, err error) {
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value. // TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
// This test uses tls.peet.ws to verify the fingerprint. // This test uses tls.peet.ws to verify the fingerprint.
// Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x) // Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP) // Expected JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
func TestJA3Fingerprint(t *testing.T) { func TestJA3Fingerprint(t *testing.T) {
// Skip if network is unavailable or if running in short mode
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test in short mode") t.Skip("skipping integration test in short mode")
} }
profile := &Profile{ profile := &Profile{
Name: "Claude CLI Test", Name: "Default Profile Test",
EnableGREASE: false, EnableGREASE: false,
} }
dialer := NewDialer(profile, nil) dialer := NewDialer(profile, nil)
...@@ -61,7 +60,6 @@ func TestJA3Fingerprint(t *testing.T) { ...@@ -61,7 +60,6 @@ func TestJA3Fingerprint(t *testing.T) {
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
} }
// Use tls.peet.ws fingerprint detection API
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
...@@ -69,7 +67,7 @@ func TestJA3Fingerprint(t *testing.T) { ...@@ -69,7 +67,7 @@ func TestJA3Fingerprint(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to create request: %v", err) 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) resp, err := client.Do(req)
skipIfExternalServiceUnavailable(t, err) skipIfExternalServiceUnavailable(t, err)
...@@ -86,71 +84,23 @@ func TestJA3Fingerprint(t *testing.T) { ...@@ -86,71 +84,23 @@ func TestJA3Fingerprint(t *testing.T) {
t.Fatalf("failed to parse fingerprint response: %v", err) t.Fatalf("failed to parse fingerprint response: %v", err)
} }
// Log all fingerprint information
t.Logf("JA3: %s", fpResp.TLS.JA3) t.Logf("JA3: %s", fpResp.TLS.JA3)
t.Logf("JA3 Hash: %s", fpResp.TLS.JA3Hash) t.Logf("JA3 Hash: %s", fpResp.TLS.JA3Hash)
t.Logf("JA4: %s", fpResp.TLS.JA4) 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 := "44f88fca027f27bab4bb08d4af15f23e"
expectedJA3Hash := "1a28e69016765d92e3b381168d68922c"
if fpResp.TLS.JA3Hash == expectedJA3Hash { if fpResp.TLS.JA3Hash == expectedJA3Hash {
t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash) t.Logf("✓ JA3 hash matches: %s", expectedJA3Hash)
} else { } else {
t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash) t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash)
} }
// Verify JA4 fingerprint expectedJA4CipherHash := "_5b57614c22b0_"
// JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash] if strings.Contains(fpResp.TLS.JA4, expectedJA4CipherHash) {
// Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP) t.Logf("✓ JA4 cipher hash matches: %s", expectedJA4CipherHash)
// 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)
} else { } 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. // TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
...@@ -164,30 +114,24 @@ func TestAllProfiles(t *testing.T) { ...@@ -164,30 +114,24 @@ func TestAllProfiles(t *testing.T) {
// These profiles are from config.yaml gateway.tls_fingerprint.profiles // These profiles are from config.yaml gateway.tls_fingerprint.profiles
profiles := []TestProfileExpectation{ profiles := []TestProfileExpectation{
{ {
// Linux x64 Node.js v22.17.1 // Default profile (Node.js 24.x)
// Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4
Profile: &Profile{ Profile: &Profile{
Name: "linux_x64_node_v22171", Name: "default_node_v24",
EnableGREASE: false, 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 JA4CipherHash: "5b57614c22b0",
}, },
{ {
// MacOS arm64 Node.js v22.18.0 // Linux x64 Node.js v22.17.1 (explicit profile with v22 extensions)
// Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea
// Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406
Profile: &Profile{ Profile: &Profile{
Name: "macos_arm64_node_v22180", Name: "linux_x64_node_v22171",
EnableGREASE: false, 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}, 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}, 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 (same cipher suites) JA4CipherHash: "a33745022dd6",
}, },
} }
......
...@@ -55,13 +55,13 @@ func TestDialerBasicConnection(t *testing.T) { ...@@ -55,13 +55,13 @@ func TestDialerBasicConnection(t *testing.T) {
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value. // TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
// This test uses tls.peet.ws to verify the fingerprint. // This test uses tls.peet.ws to verify the fingerprint.
// Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x) // Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP) // Expected JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
func TestJA3Fingerprint(t *testing.T) { func TestJA3Fingerprint(t *testing.T) {
skipNetworkTest(t) skipNetworkTest(t)
profile := &Profile{ profile := &Profile{
Name: "Claude CLI Test", Name: "Default Profile Test",
EnableGREASE: false, EnableGREASE: false,
} }
dialer := NewDialer(profile, nil) dialer := NewDialer(profile, nil)
...@@ -81,7 +81,7 @@ func TestJA3Fingerprint(t *testing.T) { ...@@ -81,7 +81,7 @@ func TestJA3Fingerprint(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to create request: %v", err) 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) resp, err := client.Do(req)
if err != nil { if err != nil {
...@@ -107,34 +107,28 @@ func TestJA3Fingerprint(t *testing.T) { ...@@ -107,34 +107,28 @@ func TestJA3Fingerprint(t *testing.T) {
t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint) t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint)
t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash) t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash)
// Verify JA3 hash matches expected value // Verify JA3 hash matches expected value (Node.js 24.x default)
expectedJA3Hash := "1a28e69016765d92e3b381168d68922c" expectedJA3Hash := "44f88fca027f27bab4bb08d4af15f23e"
if fpResp.TLS.JA3Hash == expectedJA3Hash { if fpResp.TLS.JA3Hash == expectedJA3Hash {
t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash) t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash)
} else { } else {
t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash) t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash)
} }
// Verify JA4 fingerprint // Verify JA4 cipher hash (stable middle part)
// JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash] expectedJA4CipherHash := "_5b57614c22b0_"
// Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP) if strings.Contains(fpResp.TLS.JA4, expectedJA4CipherHash) {
// The suffix _a33745022dd6_1f22a2ca17c4 should match t.Logf("✓ JA4 cipher hash matches: %s", expectedJA4CipherHash)
expectedJA4Suffix := "_a33745022dd6_1f22a2ca17c4"
if strings.HasSuffix(fpResp.TLS.JA4, expectedJA4Suffix) {
t.Logf("✓ JA4 suffix matches expected value: %s", expectedJA4Suffix)
} else { } 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) // Verify JA4 prefix (t13d1714h1 or t13i1714h1)
// d = domain (SNI present), i = IP (no SNI) expectedJA4Prefix := "t13d1714h1"
// Since we connect to tls.peet.ws (domain), we expect 'd'
expectedJA4Prefix := "t13d5911h1"
if strings.HasPrefix(fpResp.TLS.JA4, expectedJA4Prefix) { 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 { } else {
// Also accept 'i' variant for IP connections altPrefix := "t13i1714h1"
altPrefix := "t13i5911h1"
if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) { if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) {
t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix) t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix)
} else { } else {
...@@ -142,16 +136,15 @@ func TestJA3Fingerprint(t *testing.T) { ...@@ -142,16 +136,15 @@ func TestJA3Fingerprint(t *testing.T) {
} }
} }
// Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning) // Verify JA3 contains expected TLS 1.3 cipher suites
if strings.Contains(fpResp.TLS.JA3, "4866-4867-4865") { if strings.Contains(fpResp.TLS.JA3, "4865-4866-4867") {
t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites") t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites")
} else { } else {
t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites") t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites")
} }
// Verify extension list (should be 11 extensions including SNI) // Verify extension list (14 extensions, Node.js 24.x order)
// Expected: 0-11-10-35-16-22-23-13-43-45-51 expectedExtensions := "0-65037-23-65281-10-11-35-16-5-13-18-51-45-43"
expectedExtensions := "0-11-10-35-16-22-23-13-43-45-51"
if strings.Contains(fpResp.TLS.JA3, expectedExtensions) { if strings.Contains(fpResp.TLS.JA3, expectedExtensions) {
t.Logf("✓ JA3 contains expected extension list: %s", expectedExtensions) t.Logf("✓ JA3 contains expected extension list: %s", expectedExtensions)
} else { } else {
...@@ -186,8 +179,8 @@ func TestDialerWithProfile(t *testing.T) { ...@@ -186,8 +179,8 @@ func TestDialerWithProfile(t *testing.T) {
// Build specs and compare // Build specs and compare
// Note: We can't directly compare JA3 without making network requests // Note: We can't directly compare JA3 without making network requests
// but we can verify the specs are different // but we can verify the specs are different
spec1 := dialer1.buildClientHelloSpec() spec1 := buildClientHelloSpecFromProfile(dialer1.profile)
spec2 := dialer2.buildClientHelloSpec() spec2 := buildClientHelloSpecFromProfile(dialer2.profile)
// Profile with GREASE should have more extensions // Profile with GREASE should have more extensions
if len(spec2.Extensions) <= len(spec1.Extensions) { if len(spec2.Extensions) <= len(spec1.Extensions) {
...@@ -296,47 +289,33 @@ func mustParseURL(rawURL string) *url.URL { ...@@ -296,47 +289,33 @@ func mustParseURL(rawURL string) *url.URL {
return u 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. // TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
// Run with: go test -v -run TestAllProfiles ./internal/pkg/tlsfingerprint/... // Run with: go test -v -run TestAllProfiles ./internal/pkg/tlsfingerprint/...
func TestAllProfiles(t *testing.T) { func TestAllProfiles(t *testing.T) {
skipNetworkTest(t) skipNetworkTest(t)
// Define all profiles to test with their expected fingerprints
// These profiles are from config.yaml gateway.tls_fingerprint.profiles
profiles := []TestProfileExpectation{ profiles := []TestProfileExpectation{
{ {
// Linux x64 Node.js v22.17.1 // Default profile (Node.js 24.x)
// Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c // JA3 Hash: 44f88fca027f27bab4bb08d4af15f23e
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 // JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
Profile: &Profile{ Profile: &Profile{
Name: "linux_x64_node_v22171", Name: "default_node_v24",
EnableGREASE: false, 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 JA4CipherHash: "5b57614c22b0",
}, },
{ {
// MacOS arm64 Node.js v22.18.0 // Linux x64 Node.js v22.17.1 (explicit profile)
// Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea
// Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406
Profile: &Profile{ Profile: &Profile{
Name: "macos_arm64_node_v22180", Name: "linux_x64_node_v22171",
EnableGREASE: false, 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}, 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}, 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 (same cipher suites) JA4CipherHash: "a33745022dd6",
}, },
} }
......
// 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
}
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 { ...@@ -8,6 +8,14 @@ type FingerprintResponse struct {
HTTP2 any `json:"http2"` 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. // TLSInfo contains TLS fingerprint details.
type TLSInfo struct { type TLSInfo struct {
JA3 string `json:"ja3"` JA3 string `json:"ja3"`
......
...@@ -68,10 +68,9 @@ func (s *claudeUsageService) FetchUsageWithOptions(ctx context.Context, opts *se ...@@ -68,10 +68,9 @@ func (s *claudeUsageService) FetchUsageWithOptions(ctx context.Context, opts *se
var resp *http.Response var resp *http.Response
// 如果启用 TLS 指纹且有 HTTPUpstream,使用 DoWithTLS // 如果有 TLS Profile 且有 HTTPUpstream,使用 DoWithTLS
if opts.EnableTLSFingerprint && s.httpUpstream != nil { if opts.TLSProfile != nil && s.httpUpstream != nil {
// accountConcurrency 传 0 使用默认连接池配置,usage 请求不需要特殊的并发设置 resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, opts.TLSProfile)
resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, true)
if err != nil { if err != nil {
return nil, fmt.Errorf("request with TLS fingerprint failed: %w", err) return nil, fmt.Errorf("request with TLS fingerprint failed: %w", err)
} }
......
...@@ -161,26 +161,14 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i ...@@ -161,26 +161,14 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
} }
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求 // DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
// 根据 enableTLSFingerprint 参数决定是否使用 TLS 指纹
// //
// 参数: // profile 为 nil 时不启用 TLS 指纹,行为与 Do 方法相同。
// - req: HTTP 请求对象 // profile 非 nil 时使用指定的 Profile 进行 TLS 指纹伪装。
// - proxyURL: 代理地址,空字符串表示直连 func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
// - accountID: 账户 ID,用于账户级隔离和 TLS 指纹模板选择 if profile == nil {
// - 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 {
return s.Do(req, proxyURL, accountID, accountConcurrency) return s.Do(req, proxyURL, accountID, accountConcurrency)
} }
// TLS 指纹已启用,记录调试日志
targetHost := "" targetHost := ""
if req != nil && req.URL != nil { if req != nil && req.URL != nil {
targetHost = req.URL.Host targetHost = req.URL.Host
...@@ -189,46 +177,28 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco ...@@ -189,46 +177,28 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
if proxyURL != "" { if proxyURL != "" {
proxyInfo = 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 { if err := s.validateRequestHost(req); err != nil {
return nil, err 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) entry, err := s.acquireClientWithTLS(proxyURL, accountID, accountConcurrency, profile)
if err != nil { if err != nil {
slog.Debug("tls_fingerprint_acquire_client_failed", "account_id", accountID, "error", err) slog.Debug("tls_fingerprint_acquire_client_failed", "account_id", accountID, "error", err)
return nil, err return nil, err
} }
// 执行请求
resp, err := entry.client.Do(req) resp, err := entry.client.Do(req)
if err != nil { if err != nil {
// 请求失败,立即减少计数
atomic.AddInt64(&entry.inFlight, -1) atomic.AddInt64(&entry.inFlight, -1)
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano()) atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
slog.Debug("tls_fingerprint_request_failed", "account_id", accountID, "error", err) slog.Debug("tls_fingerprint_request_failed", "account_id", accountID, "error", err)
return nil, err return nil, err
} }
slog.Debug("tls_fingerprint_request_success", "account_id", accountID, "status", resp.StatusCode)
// 如果上游返回了压缩内容,解压后再交给业务层
decompressResponseBody(resp) decompressResponseBody(resp)
// 包装响应体,在关闭时自动减少计数并更新时间戳
resp.Body = wrapTrackedBody(resp.Body, func() { resp.Body = wrapTrackedBody(resp.Body, func() {
atomic.AddInt64(&entry.inFlight, -1) atomic.AddInt64(&entry.inFlight, -1)
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano()) atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
......
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()
}
}
}()
}
package repository
import (
"context"
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type tlsFingerprintProfileRepository struct {
client *ent.Client
}
// NewTLSFingerprintProfileRepository 创建 TLS 指纹模板仓库
func NewTLSFingerprintProfileRepository(client *ent.Client) service.TLSFingerprintProfileRepository {
return &tlsFingerprintProfileRepository{client: client}
}
// List 获取所有模板
func (r *tlsFingerprintProfileRepository) List(ctx context.Context) ([]*model.TLSFingerprintProfile, error) {
profiles, err := r.client.TLSFingerprintProfile.Query().
Order(ent.Asc(tlsfingerprintprofile.FieldName)).
All(ctx)
if err != nil {
return nil, err
}
result := make([]*model.TLSFingerprintProfile, len(profiles))
for i, p := range profiles {
result[i] = r.toModel(p)
}
return result, nil
}
// GetByID 根据 ID 获取模板
func (r *tlsFingerprintProfileRepository) GetByID(ctx context.Context, id int64) (*model.TLSFingerprintProfile, error) {
p, err := r.client.TLSFingerprintProfile.Get(ctx, id)
if err != nil {
if ent.IsNotFound(err) {
return nil, nil
}
return nil, err
}
return r.toModel(p), nil
}
// Create 创建模板
func (r *tlsFingerprintProfileRepository) Create(ctx context.Context, p *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
builder := r.client.TLSFingerprintProfile.Create().
SetName(p.Name).
SetEnableGrease(p.EnableGREASE)
if p.Description != nil {
builder.SetDescription(*p.Description)
}
if len(p.CipherSuites) > 0 {
builder.SetCipherSuites(p.CipherSuites)
}
if len(p.Curves) > 0 {
builder.SetCurves(p.Curves)
}
if len(p.PointFormats) > 0 {
builder.SetPointFormats(p.PointFormats)
}
if len(p.SignatureAlgorithms) > 0 {
builder.SetSignatureAlgorithms(p.SignatureAlgorithms)
}
if len(p.ALPNProtocols) > 0 {
builder.SetAlpnProtocols(p.ALPNProtocols)
}
if len(p.SupportedVersions) > 0 {
builder.SetSupportedVersions(p.SupportedVersions)
}
if len(p.KeyShareGroups) > 0 {
builder.SetKeyShareGroups(p.KeyShareGroups)
}
if len(p.PSKModes) > 0 {
builder.SetPskModes(p.PSKModes)
}
if len(p.Extensions) > 0 {
builder.SetExtensions(p.Extensions)
}
created, err := builder.Save(ctx)
if err != nil {
return nil, err
}
return r.toModel(created), nil
}
// Update 更新模板
func (r *tlsFingerprintProfileRepository) Update(ctx context.Context, p *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
builder := r.client.TLSFingerprintProfile.UpdateOneID(p.ID).
SetName(p.Name).
SetEnableGrease(p.EnableGREASE)
if p.Description != nil {
builder.SetDescription(*p.Description)
} else {
builder.ClearDescription()
}
if len(p.CipherSuites) > 0 {
builder.SetCipherSuites(p.CipherSuites)
} else {
builder.ClearCipherSuites()
}
if len(p.Curves) > 0 {
builder.SetCurves(p.Curves)
} else {
builder.ClearCurves()
}
if len(p.PointFormats) > 0 {
builder.SetPointFormats(p.PointFormats)
} else {
builder.ClearPointFormats()
}
if len(p.SignatureAlgorithms) > 0 {
builder.SetSignatureAlgorithms(p.SignatureAlgorithms)
} else {
builder.ClearSignatureAlgorithms()
}
if len(p.ALPNProtocols) > 0 {
builder.SetAlpnProtocols(p.ALPNProtocols)
} else {
builder.ClearAlpnProtocols()
}
if len(p.SupportedVersions) > 0 {
builder.SetSupportedVersions(p.SupportedVersions)
} else {
builder.ClearSupportedVersions()
}
if len(p.KeyShareGroups) > 0 {
builder.SetKeyShareGroups(p.KeyShareGroups)
} else {
builder.ClearKeyShareGroups()
}
if len(p.PSKModes) > 0 {
builder.SetPskModes(p.PSKModes)
} else {
builder.ClearPskModes()
}
if len(p.Extensions) > 0 {
builder.SetExtensions(p.Extensions)
} else {
builder.ClearExtensions()
}
updated, err := builder.Save(ctx)
if err != nil {
return nil, err
}
return r.toModel(updated), nil
}
// Delete 删除模板
func (r *tlsFingerprintProfileRepository) Delete(ctx context.Context, id int64) error {
return r.client.TLSFingerprintProfile.DeleteOneID(id).Exec(ctx)
}
// toModel 将 Ent 实体转换为服务模型
func (r *tlsFingerprintProfileRepository) toModel(e *ent.TLSFingerprintProfile) *model.TLSFingerprintProfile {
p := &model.TLSFingerprintProfile{
ID: e.ID,
Name: e.Name,
Description: e.Description,
EnableGREASE: e.EnableGrease,
CipherSuites: e.CipherSuites,
Curves: e.Curves,
PointFormats: e.PointFormats,
SignatureAlgorithms: e.SignatureAlgorithms,
ALPNProtocols: e.AlpnProtocols,
SupportedVersions: e.SupportedVersions,
KeyShareGroups: e.KeyShareGroups,
PSKModes: e.PskModes,
Extensions: e.Extensions,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
// 确保切片不为 nil
if p.CipherSuites == nil {
p.CipherSuites = []uint16{}
}
if p.Curves == nil {
p.Curves = []uint16{}
}
if p.PointFormats == nil {
p.PointFormats = []uint16{}
}
if p.SignatureAlgorithms == nil {
p.SignatureAlgorithms = []uint16{}
}
if p.ALPNProtocols == nil {
p.ALPNProtocols = []string{}
}
if p.SupportedVersions == nil {
p.SupportedVersions = []uint16{}
}
if p.KeyShareGroups == nil {
p.KeyShareGroups = []uint16{}
}
if p.PSKModes == nil {
p.PSKModes = []uint16{}
}
if p.Extensions == nil {
p.Extensions = []uint16{}
}
return p
}
...@@ -73,6 +73,7 @@ var ProviderSet = wire.NewSet( ...@@ -73,6 +73,7 @@ var ProviderSet = wire.NewSet(
NewUserAttributeValueRepository, NewUserAttributeValueRepository,
NewUserGroupRateRepository, NewUserGroupRateRepository,
NewErrorPassthroughRepository, NewErrorPassthroughRepository,
NewTLSFingerprintProfileRepository,
// Cache implementations // Cache implementations
NewGatewayCache, NewGatewayCache,
...@@ -96,6 +97,7 @@ var ProviderSet = wire.NewSet( ...@@ -96,6 +97,7 @@ var ProviderSet = wire.NewSet(
NewTotpCache, NewTotpCache,
NewRefreshTokenCache, NewRefreshTokenCache,
NewErrorPassthroughCache, NewErrorPassthroughCache,
NewTLSFingerprintProfileCache,
// Encryptors // Encryptors
NewAESEncryptor, NewAESEncryptor,
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment