Commit b22ab6ad authored by shaw's avatar shaw Committed by 陈曦
Browse files

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

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

代码优化:
- 删除已被 TLSFingerprintProfileService 完全取代的 registry.go 死代码(418 行)
- 提取 3 个 dialer 的重复 TLS 握手逻辑为 performTLSHandshake() 共用函数
- 修复 GetTLSFingerprintProfileID 缺少 json.Number 处理的 bug
- gateway_service.Forward 中 ResolveTLSProfile 从重试循环内重复调用改为预解析局部变量
- 删除冗余的 buildClientHelloSpec() 单行 wrapper 和 int64(e.ID) 无效转换
- tls_fingerprint_profile_cache.go 日志从 log.Printf 改为 slog 结构化日志
- dialer_capture_test.go 添加 //go:build integration 标签,防止 CI 失败
- 去重 TestProfileExpectation 类型至共享 test_types_test.go
- 修复 9 个测试文件缺少 tlsfingerprint import 的编译错误
- 修复 error_policy_integration_test.go 中 handleError 回调签名被错误替换的问题
parent 3909a33b
......@@ -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"
......@@ -243,8 +244,8 @@ type ClaudeUsageResponse struct {
type ClaudeUsageFetchOptions struct {
AccessToken string // OAuth access token
ProxyURL string // 代理 URL(可选)
AccountID int64 // 账号 ID(用于 TLS 指纹选择
EnableTLSFingerprint bool // 是否启用 TLS 指纹伪装
AccountID int64 // 账号 ID(用于连接池隔离
TLSProfile *tlsfingerprint.Profile // TLS 指纹 Profile(nil 表示不启用)
Fingerprint *Fingerprint // 缓存的指纹信息(User-Agent 等)
}
......@@ -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,
}
}
......@@ -1158,7 +1162,7 @@ func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *A
AccessToken: accessToken,
ProxyURL: proxyURL,
AccountID: account.ID,
EnableTLSFingerprint: account.IsTLSFingerprintEnabled(),
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)
}
......
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 指纹伪装
//
// 返回:
// - *http.Response: HTTP 响应,调用方必须关闭 Body
// - error: 请求错误(网络错误、超时等)
//
// TLS 指纹说明:
// - 当 enableTLSFingerprint=true 时,使用 utls 库模拟 Claude CLI 的 TLS 指纹
// - TLS 指纹模板根据 accountID % len(profiles) 自动选择
// - 支持直连、HTTP/HTTPS 代理、SOCKS5 代理三种场景
// - 如果 enableTLSFingerprint=false,行为与 Do 方法相同
// profile 参数:
// - nil: 不启用 TLS 指纹,行为与 Do 方法相同
// - non-nil: 使用指定的 Profile 进行 TLS 指纹伪装
//
// 注意:
// - 调用方必须关闭 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)
}
......
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,
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment