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:
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 依赖。
|
||||
|
||||
@@ -6,29 +6,30 @@ import (
|
||||
|
||||
// AdminHandlers contains all admin-related HTTP handlers
|
||||
type AdminHandlers struct {
|
||||
Dashboard *admin.DashboardHandler
|
||||
User *admin.UserHandler
|
||||
Group *admin.GroupHandler
|
||||
Account *admin.AccountHandler
|
||||
Announcement *admin.AnnouncementHandler
|
||||
DataManagement *admin.DataManagementHandler
|
||||
Backup *admin.BackupHandler
|
||||
OAuth *admin.OAuthHandler
|
||||
OpenAIOAuth *admin.OpenAIOAuthHandler
|
||||
GeminiOAuth *admin.GeminiOAuthHandler
|
||||
AntigravityOAuth *admin.AntigravityOAuthHandler
|
||||
Proxy *admin.ProxyHandler
|
||||
Redeem *admin.RedeemHandler
|
||||
Promo *admin.PromoHandler
|
||||
Setting *admin.SettingHandler
|
||||
Ops *admin.OpsHandler
|
||||
System *admin.SystemHandler
|
||||
Subscription *admin.SubscriptionHandler
|
||||
Usage *admin.UsageHandler
|
||||
UserAttribute *admin.UserAttributeHandler
|
||||
ErrorPassthrough *admin.ErrorPassthroughHandler
|
||||
APIKey *admin.AdminAPIKeyHandler
|
||||
ScheduledTest *admin.ScheduledTestHandler
|
||||
Dashboard *admin.DashboardHandler
|
||||
User *admin.UserHandler
|
||||
Group *admin.GroupHandler
|
||||
Account *admin.AccountHandler
|
||||
Announcement *admin.AnnouncementHandler
|
||||
DataManagement *admin.DataManagementHandler
|
||||
Backup *admin.BackupHandler
|
||||
OAuth *admin.OAuthHandler
|
||||
OpenAIOAuth *admin.OpenAIOAuthHandler
|
||||
GeminiOAuth *admin.GeminiOAuthHandler
|
||||
AntigravityOAuth *admin.AntigravityOAuthHandler
|
||||
Proxy *admin.ProxyHandler
|
||||
Redeem *admin.RedeemHandler
|
||||
Promo *admin.PromoHandler
|
||||
Setting *admin.SettingHandler
|
||||
Ops *admin.OpsHandler
|
||||
System *admin.SystemHandler
|
||||
Subscription *admin.SubscriptionHandler
|
||||
Usage *admin.UsageHandler
|
||||
UserAttribute *admin.UserAttributeHandler
|
||||
ErrorPassthrough *admin.ErrorPassthroughHandler
|
||||
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
|
||||
APIKey *admin.AdminAPIKeyHandler
|
||||
ScheduledTest *admin.ScheduledTestHandler
|
||||
}
|
||||
|
||||
// Handlers contains all HTTP handlers
|
||||
|
||||
@@ -2224,7 +2224,7 @@ func (s *stubSoraClientForHandler) GetVideoTask(_ context.Context, _ *service.Ac
|
||||
func newMinimalGatewayService(accountRepo service.AccountRepository) *service.GatewayService {
|
||||
return service.NewGatewayService(
|
||||
accountRepo, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -464,6 +464,7 @@ func TestSoraGatewayHandler_ChatCompletions(t *testing.T) {
|
||||
nil, // rpmCache
|
||||
nil, // digestStore
|
||||
nil, // settingService
|
||||
nil, // tlsFPProfileService
|
||||
)
|
||||
|
||||
soraClient := &stubSoraClient{imageURLs: []string{"https://example.com/a.png"}}
|
||||
|
||||
@@ -30,33 +30,35 @@ func ProvideAdminHandlers(
|
||||
usageHandler *admin.UsageHandler,
|
||||
userAttributeHandler *admin.UserAttributeHandler,
|
||||
errorPassthroughHandler *admin.ErrorPassthroughHandler,
|
||||
tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler,
|
||||
apiKeyHandler *admin.AdminAPIKeyHandler,
|
||||
scheduledTestHandler *admin.ScheduledTestHandler,
|
||||
) *AdminHandlers {
|
||||
return &AdminHandlers{
|
||||
Dashboard: dashboardHandler,
|
||||
User: userHandler,
|
||||
Group: groupHandler,
|
||||
Account: accountHandler,
|
||||
Announcement: announcementHandler,
|
||||
DataManagement: dataManagementHandler,
|
||||
Backup: backupHandler,
|
||||
OAuth: oauthHandler,
|
||||
OpenAIOAuth: openaiOAuthHandler,
|
||||
GeminiOAuth: geminiOAuthHandler,
|
||||
AntigravityOAuth: antigravityOAuthHandler,
|
||||
Proxy: proxyHandler,
|
||||
Redeem: redeemHandler,
|
||||
Promo: promoHandler,
|
||||
Setting: settingHandler,
|
||||
Ops: opsHandler,
|
||||
System: systemHandler,
|
||||
Subscription: subscriptionHandler,
|
||||
Usage: usageHandler,
|
||||
UserAttribute: userAttributeHandler,
|
||||
ErrorPassthrough: errorPassthroughHandler,
|
||||
APIKey: apiKeyHandler,
|
||||
ScheduledTest: scheduledTestHandler,
|
||||
Dashboard: dashboardHandler,
|
||||
User: userHandler,
|
||||
Group: groupHandler,
|
||||
Account: accountHandler,
|
||||
Announcement: announcementHandler,
|
||||
DataManagement: dataManagementHandler,
|
||||
Backup: backupHandler,
|
||||
OAuth: oauthHandler,
|
||||
OpenAIOAuth: openaiOAuthHandler,
|
||||
GeminiOAuth: geminiOAuthHandler,
|
||||
AntigravityOAuth: antigravityOAuthHandler,
|
||||
Proxy: proxyHandler,
|
||||
Redeem: redeemHandler,
|
||||
Promo: promoHandler,
|
||||
Setting: settingHandler,
|
||||
Ops: opsHandler,
|
||||
System: systemHandler,
|
||||
Subscription: subscriptionHandler,
|
||||
Usage: usageHandler,
|
||||
UserAttribute: userAttributeHandler,
|
||||
ErrorPassthrough: errorPassthroughHandler,
|
||||
TLSFingerprintProfile: tlsFingerprintProfileHandler,
|
||||
APIKey: apiKeyHandler,
|
||||
ScheduledTest: scheduledTestHandler,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +147,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewUsageHandler,
|
||||
admin.NewUserAttributeHandler,
|
||||
admin.NewErrorPassthroughHandler,
|
||||
admin.NewTLSFingerprintProfileHandler,
|
||||
admin.NewAdminAPIKeyHandler,
|
||||
admin.NewScheduledTestHandler,
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1165,6 +1165,31 @@ func (a *Account) IsTLSFingerprintEnabled() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// GetTLSFingerprintProfileID 获取账号绑定的 TLS 指纹模板 ID
|
||||
// 返回 0 表示未绑定(使用内置默认 profile)
|
||||
func (a *Account) GetTLSFingerprintProfileID() int64 {
|
||||
if a.Extra == nil {
|
||||
return 0
|
||||
}
|
||||
v, ok := a.Extra["tls_fingerprint_profile_id"]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch id := v.(type) {
|
||||
case float64:
|
||||
return int64(id)
|
||||
case int64:
|
||||
return id
|
||||
case int:
|
||||
return int64(id)
|
||||
case json.Number:
|
||||
if i, err := id.Int64(); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetUserMsgQueueMode 获取用户消息队列模式
|
||||
// "serialize" = 串行队列, "throttle" = 软性限速, "" = 未设置(使用全局配置)
|
||||
func (a *Account) GetUserMsgQueueMode() string {
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/soraerror"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -69,6 +70,7 @@ type AccountTestService struct {
|
||||
antigravityGatewayService *AntigravityGatewayService
|
||||
httpUpstream HTTPUpstream
|
||||
cfg *config.Config
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
soraTestGuardMu sync.Mutex
|
||||
soraTestLastRun map[int64]time.Time
|
||||
soraTestCooldown time.Duration
|
||||
@@ -83,6 +85,7 @@ func NewAccountTestService(
|
||||
antigravityGatewayService *AntigravityGatewayService,
|
||||
httpUpstream HTTPUpstream,
|
||||
cfg *config.Config,
|
||||
tlsFPProfileService *TLSFingerprintProfileService,
|
||||
) *AccountTestService {
|
||||
return &AccountTestService{
|
||||
accountRepo: accountRepo,
|
||||
@@ -90,6 +93,7 @@ func NewAccountTestService(
|
||||
antigravityGatewayService: antigravityGatewayService,
|
||||
httpUpstream: httpUpstream,
|
||||
cfg: cfg,
|
||||
tlsFPProfileService: tlsFPProfileService,
|
||||
soraTestLastRun: make(map[int64]time.Time),
|
||||
soraTestCooldown: defaultSoraTestCooldown,
|
||||
}
|
||||
@@ -300,7 +304,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@@ -390,7 +394,7 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, false)
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, nil)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@@ -520,7 +524,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@@ -610,7 +614,7 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@@ -881,9 +885,9 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
enableSoraTLSFingerprint := s.shouldEnableSoraTLSFingerprint()
|
||||
soraTLSProfile := s.resolveSoraTLSProfile()
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, enableSoraTLSFingerprint)
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, soraTLSProfile)
|
||||
if err != nil {
|
||||
recorder.addStep("me", "failed", 0, "network_error", err.Error())
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
@@ -948,7 +952,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
|
||||
subReq.Header.Set("Origin", "https://sora.chatgpt.com")
|
||||
subReq.Header.Set("Referer", "https://sora.chatgpt.com/")
|
||||
|
||||
subResp, subErr := s.httpUpstream.DoWithTLS(subReq, proxyURL, account.ID, account.Concurrency, enableSoraTLSFingerprint)
|
||||
subResp, subErr := s.httpUpstream.DoWithTLS(subReq, proxyURL, account.ID, account.Concurrency, soraTLSProfile)
|
||||
if subErr != nil {
|
||||
recorder.addStep("subscription", "failed", 0, "network_error", subErr.Error())
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Subscription check skipped: %s", subErr.Error())})
|
||||
@@ -977,7 +981,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
|
||||
}
|
||||
|
||||
// 追加 Sora2 能力探测(对齐 sora2api 的测试思路):邀请码 + 剩余额度。
|
||||
s.testSora2Capabilities(c, ctx, account, authToken, proxyURL, enableSoraTLSFingerprint, recorder)
|
||||
s.testSora2Capabilities(c, ctx, account, authToken, proxyURL, soraTLSProfile, recorder)
|
||||
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
@@ -990,7 +994,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
account *Account,
|
||||
authToken string,
|
||||
proxyURL string,
|
||||
enableTLSFingerprint bool,
|
||||
tlsProfile *tlsfingerprint.Profile,
|
||||
recorder *soraProbeRecorder,
|
||||
) {
|
||||
inviteStatus, inviteHeader, inviteBody, err := s.fetchSoraTestEndpoint(
|
||||
@@ -999,7 +1003,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
authToken,
|
||||
soraInviteMineURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
tlsProfile,
|
||||
)
|
||||
if err != nil {
|
||||
if recorder != nil {
|
||||
@@ -1016,7 +1020,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
authToken,
|
||||
soraBootstrapURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
tlsProfile,
|
||||
)
|
||||
if bootstrapErr == nil && bootstrapStatus == http.StatusOK {
|
||||
if recorder != nil {
|
||||
@@ -1029,7 +1033,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
authToken,
|
||||
soraInviteMineURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
tlsProfile,
|
||||
)
|
||||
if err != nil {
|
||||
if recorder != nil {
|
||||
@@ -1081,7 +1085,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
authToken,
|
||||
soraRemainingURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
tlsProfile,
|
||||
)
|
||||
if remainingErr != nil {
|
||||
if recorder != nil {
|
||||
@@ -1122,7 +1126,7 @@ func (s *AccountTestService) fetchSoraTestEndpoint(
|
||||
authToken string,
|
||||
url string,
|
||||
proxyURL string,
|
||||
enableTLSFingerprint bool,
|
||||
tlsProfile *tlsfingerprint.Profile,
|
||||
) (int, http.Header, []byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
@@ -1135,7 +1139,7 @@ func (s *AccountTestService) fetchSoraTestEndpoint(
|
||||
req.Header.Set("Origin", "https://sora.chatgpt.com")
|
||||
req.Header.Set("Referer", "https://sora.chatgpt.com/")
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, enableTLSFingerprint)
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
@@ -1224,11 +1228,12 @@ func parseSoraRemainingSummary(body []byte) string {
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func (s *AccountTestService) shouldEnableSoraTLSFingerprint() bool {
|
||||
if s == nil || s.cfg == nil {
|
||||
return true
|
||||
func (s *AccountTestService) resolveSoraTLSProfile() *tlsfingerprint.Profile {
|
||||
if s == nil || s.cfg == nil || !s.cfg.Sora.Client.DisableTLSFingerprint {
|
||||
// Sora TLS fingerprint enabled — use built-in default profile
|
||||
return &tlsfingerprint.Profile{Name: "Built-in Default (Sora)"}
|
||||
}
|
||||
return !s.cfg.Sora.Client.DisableTLSFingerprint
|
||||
return nil // disabled
|
||||
}
|
||||
|
||||
func isCloudflareChallengeResponse(statusCode int, headers http.Header, body []byte) bool {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -24,9 +25,9 @@ func (u *queuedHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*htt
|
||||
return nil, fmt.Errorf("unexpected Do call")
|
||||
}
|
||||
|
||||
func (u *queuedHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (u *queuedHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
u.requests = append(u.requests, req)
|
||||
u.tlsFlags = append(u.tlsFlags, enableTLSFingerprint)
|
||||
u.tlsFlags = append(u.tlsFlags, profile != nil)
|
||||
if len(u.responses) == 0 {
|
||||
return nil, fmt.Errorf("no mocked response")
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
openaipkg "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/sync/singleflight"
|
||||
@@ -241,11 +242,11 @@ type ClaudeUsageResponse struct {
|
||||
|
||||
// ClaudeUsageFetchOptions 包含获取 Claude 用量数据所需的所有选项
|
||||
type ClaudeUsageFetchOptions struct {
|
||||
AccessToken string // OAuth access token
|
||||
ProxyURL string // 代理 URL(可选)
|
||||
AccountID int64 // 账号 ID(用于 TLS 指纹选择)
|
||||
EnableTLSFingerprint bool // 是否启用 TLS 指纹伪装
|
||||
Fingerprint *Fingerprint // 缓存的指纹信息(User-Agent 等)
|
||||
AccessToken string // OAuth access token
|
||||
ProxyURL string // 代理 URL(可选)
|
||||
AccountID int64 // 账号 ID(用于连接池隔离)
|
||||
TLSProfile *tlsfingerprint.Profile // TLS 指纹 Profile(nil 表示不启用)
|
||||
Fingerprint *Fingerprint // 缓存的指纹信息(User-Agent 等)
|
||||
}
|
||||
|
||||
// ClaudeUsageFetcher fetches usage data from Anthropic OAuth API
|
||||
@@ -264,6 +265,7 @@ type AccountUsageService struct {
|
||||
antigravityQuotaFetcher *AntigravityQuotaFetcher
|
||||
cache *UsageCache
|
||||
identityCache IdentityCache
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
}
|
||||
|
||||
// NewAccountUsageService 创建AccountUsageService实例
|
||||
@@ -275,6 +277,7 @@ func NewAccountUsageService(
|
||||
antigravityQuotaFetcher *AntigravityQuotaFetcher,
|
||||
cache *UsageCache,
|
||||
identityCache IdentityCache,
|
||||
tlsFPProfileService *TLSFingerprintProfileService,
|
||||
) *AccountUsageService {
|
||||
return &AccountUsageService{
|
||||
accountRepo: accountRepo,
|
||||
@@ -284,6 +287,7 @@ func NewAccountUsageService(
|
||||
antigravityQuotaFetcher: antigravityQuotaFetcher,
|
||||
cache: cache,
|
||||
identityCache: identityCache,
|
||||
tlsFPProfileService: tlsFPProfileService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1155,10 +1159,10 @@ func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *A
|
||||
|
||||
// 构建完整的选项
|
||||
opts := &ClaudeUsageFetchOptions{
|
||||
AccessToken: accessToken,
|
||||
ProxyURL: proxyURL,
|
||||
AccountID: account.ID,
|
||||
EnableTLSFingerprint: account.IsTLSFingerprintEnabled(),
|
||||
AccessToken: accessToken,
|
||||
ProxyURL: proxyURL,
|
||||
AccountID: account.ID,
|
||||
TLSProfile: s.tlsFPProfileService.ResolveTLSProfile(account),
|
||||
}
|
||||
|
||||
// 尝试获取缓存的 Fingerprint(包含 User-Agent 等信息)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +1,24 @@
|
||||
package service
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
)
|
||||
|
||||
// HTTPUpstream 上游 HTTP 请求接口
|
||||
// 用于向上游 API(Claude、OpenAI、Gemini 等)发送请求
|
||||
// 这是一个通用接口,可用于任何基于 HTTP 的上游服务
|
||||
//
|
||||
// 设计说明:
|
||||
// - 支持可选代理配置
|
||||
// - 支持账户级连接池隔离
|
||||
// - 实现类负责连接池管理和复用
|
||||
// - 支持可选的 TLS 指纹伪装
|
||||
type HTTPUpstream interface {
|
||||
// Do 执行 HTTP 请求
|
||||
//
|
||||
// 参数:
|
||||
// - req: HTTP 请求对象,由调用方构建
|
||||
// - proxyURL: 代理服务器地址,空字符串表示直连
|
||||
// - accountID: 账户 ID,用于连接池隔离(隔离策略为 account 或 account_proxy 时生效)
|
||||
// - accountConcurrency: 账户并发限制,用于动态调整连接池大小
|
||||
//
|
||||
// 返回:
|
||||
// - *http.Response: HTTP 响应,调用方必须关闭 Body
|
||||
// - error: 请求错误(网络错误、超时等)
|
||||
//
|
||||
// 注意:
|
||||
// - 调用方必须关闭 resp.Body,否则会导致连接泄漏
|
||||
// - 响应体可能已被包装以跟踪请求生命周期
|
||||
// Do 执行 HTTP 请求(不启用 TLS 指纹)
|
||||
Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error)
|
||||
|
||||
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
|
||||
//
|
||||
// 参数:
|
||||
// - req: HTTP 请求对象,由调用方构建
|
||||
// - proxyURL: 代理服务器地址,空字符串表示直连
|
||||
// - accountID: 账户 ID,用于连接池隔离和 TLS 指纹模板选择
|
||||
// - accountConcurrency: 账户并发限制,用于动态调整连接池大小
|
||||
// - enableTLSFingerprint: 是否启用 TLS 指纹伪装
|
||||
// profile 参数:
|
||||
// - nil: 不启用 TLS 指纹,行为与 Do 方法相同
|
||||
// - non-nil: 使用指定的 Profile 进行 TLS 指纹伪装
|
||||
//
|
||||
// 返回:
|
||||
// - *http.Response: HTTP 响应,调用方必须关闭 Body
|
||||
// - error: 请求错误(网络错误、超时等)
|
||||
//
|
||||
// TLS 指纹说明:
|
||||
// - 当 enableTLSFingerprint=true 时,使用 utls 库模拟 Claude CLI 的 TLS 指纹
|
||||
// - TLS 指纹模板根据 accountID % len(profiles) 自动选择
|
||||
// - 支持直连、HTTP/HTTPS 代理、SOCKS5 代理三种场景
|
||||
// - 如果 enableTLSFingerprint=false,行为与 Do 方法相同
|
||||
//
|
||||
// 注意:
|
||||
// - 调用方必须关闭 resp.Body,否则会导致连接泄漏
|
||||
// - TLS 指纹客户端与普通客户端使用不同的缓存键,互不影响
|
||||
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error)
|
||||
// Profile 由调用方通过 TLSFingerprintProfileService 解析后传入,
|
||||
// 支持按账号绑定的数据库 profile 或内置默认 profile。
|
||||
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -482,6 +482,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewUsageCache,
|
||||
NewTotpService,
|
||||
NewErrorPassthroughService,
|
||||
NewTLSFingerprintProfileService,
|
||||
NewDigestSessionStore,
|
||||
ProvideIdempotencyCoordinator,
|
||||
ProvideSystemOperationLockService,
|
||||
|
||||
Reference in New Issue
Block a user