diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index b2774d723269..d8c86b552285 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -291,7 +291,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService) coordinator := securityaudit.NewCoordinator(legacyEngine, promptService) gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator) - openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator) + openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, tlsFingerprintProfileService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService) totpHandler := handler.NewTotpHandler(totpService) passkeyRepository := repository.NewPasskeyRepository(db) diff --git a/backend/internal/handler/openai_gateway_credential_failover_loop_test.go b/backend/internal/handler/openai_gateway_credential_failover_loop_test.go index 093a00e09582..e5b28c2708d0 100644 --- a/backend/internal/handler/openai_gateway_credential_failover_loop_test.go +++ b/backend/internal/handler/openai_gateway_credential_failover_loop_test.go @@ -17,6 +17,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -390,6 +391,13 @@ func (u *grokCredentialHandlerUpstream) Do(req *http.Request, _ string, accountI }, nil } +// DoWithTLS forwards to Do: doOpenAIUpstream now always calls DoWithTLS (nil profile for +// these test accounts degrades to Do in the real httpUpstreamService, so the fake must +// mirror that instead of falling through to the embedded nil service.HTTPUpstream). +func (u *grokCredentialHandlerUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, accountConcurrency) +} + func (u *grokCredentialHandlerUpstream) accountHits() []int64 { u.mu.Lock() defer u.mu.Unlock() diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 86c56d490176..cb18266e72bc 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -17,6 +17,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -1901,6 +1902,13 @@ func (u *openAIHTTPPassthroughFailoverUpstream) Do(_ *http.Request, _ string, ac }, nil } +// DoWithTLS forwards to Do: doOpenAIUpstream now always calls DoWithTLS (nil profile for +// these API-key test accounts degrades to Do in the real httpUpstreamService, so the fake +// must mirror that instead of falling through to the embedded nil service.HTTPUpstream). +func (u *openAIHTTPPassthroughFailoverUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, accountConcurrency) +} + func (u *openAIHTTPPassthroughFailoverUpstream) calls() []int64 { u.mu.Lock() defer u.mu.Unlock() @@ -1932,6 +1940,11 @@ func (u *openAIHTTPPassthroughAuthFailoverUpstream) Do(_ *http.Request, _ string }, nil } +// DoWithTLS forwards to Do; see openAIHTTPPassthroughFailoverUpstream.DoWithTLS for why. +func (u *openAIHTTPPassthroughAuthFailoverUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, accountConcurrency) +} + func (u *openAIHTTPPassthroughAuthFailoverUpstream) calls() []int64 { u.mu.Lock() defer u.mu.Unlock() @@ -1966,6 +1979,11 @@ func (u *openAIHTTPPassthroughSSERateLimitUpstream) Do(_ *http.Request, _ string }, nil } +// DoWithTLS forwards to Do; see openAIHTTPPassthroughFailoverUpstream.DoWithTLS for why. +func (u *openAIHTTPPassthroughSSERateLimitUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, accountConcurrency) +} + func (u *openAIHTTPPassthroughSSERateLimitUpstream) calls() []int64 { u.mu.Lock() defer u.mu.Unlock() diff --git a/backend/internal/handler/openai_images_failover_test.go b/backend/internal/handler/openai_images_failover_test.go index 8b07cb9a52a4..a859597491ab 100644 --- a/backend/internal/handler/openai_images_failover_test.go +++ b/backend/internal/handler/openai_images_failover_test.go @@ -13,6 +13,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" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -81,6 +82,13 @@ func (u *openAIImagesFailoverHTTPUpstream) Do(_ *http.Request, _ string, account }, nil } +// DoWithTLS forwards to Do: doOpenAIUpstream now always calls DoWithTLS (nil profile for +// these test accounts degrades to Do in the real httpUpstreamService, so the fake must +// mirror that instead of falling through to the embedded nil service.HTTPUpstream). +func (u *openAIImagesFailoverHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, accountConcurrency) +} + func (u *openAIImagesFailoverHTTPUpstream) calls() []int64 { u.mu.Lock() defer u.mu.Unlock() diff --git a/backend/internal/handler/openai_responses_failover_cancel_test.go b/backend/internal/handler/openai_responses_failover_cancel_test.go index 0d806e19d0a3..3533a741ea4a 100644 --- a/backend/internal/handler/openai_responses_failover_cancel_test.go +++ b/backend/internal/handler/openai_responses_failover_cancel_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -43,6 +44,13 @@ func (u *openAIResponsesFailoverCancelUpstream) Do(_ *http.Request, _ string, ac }, nil } +// DoWithTLS forwards to Do: doOpenAIUpstream now always calls DoWithTLS (nil profile for +// these test accounts degrades to Do in the real httpUpstreamService, so the fake must +// mirror that instead of falling through to the embedded nil service.HTTPUpstream). +func (u *openAIResponsesFailoverCancelUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, accountConcurrency) +} + func (u *openAIResponsesFailoverCancelUpstream) calls() []int64 { u.mu.Lock() defer u.mu.Unlock() diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 0e75dc9258f7..4716ae4b7244 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -120,6 +120,7 @@ func ProvideGatewayHandler( func ProvideOpenAIGatewayHandler( gatewayService *service.OpenAIGatewayService, pluginManager *service.PluginManager, + tlsFPProfileService *service.TLSFingerprintProfileService, concurrencyService *service.ConcurrencyService, billingCacheService *service.BillingCacheService, apiKeyService *service.APIKeyService, @@ -132,6 +133,7 @@ func ProvideOpenAIGatewayHandler( coordinator *securityaudit.Coordinator, ) *OpenAIGatewayHandler { gatewayService.SetPluginManager(pluginManager) + gatewayService.SetTLSFingerprintProfileService(tlsFPProfileService) h := NewOpenAIGatewayHandler(gatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, cfg) h.securityAuditCoordinator = coordinator diff --git a/backend/internal/pkg/tlsfingerprint/dialer.go b/backend/internal/pkg/tlsfingerprint/dialer.go index c8d8369ff892..608034cda554 100644 --- a/backend/internal/pkg/tlsfingerprint/dialer.go +++ b/backend/internal/pkg/tlsfingerprint/dialer.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "fmt" "log/slog" + "math/rand/v2" "net" "net/http" "net/url" @@ -30,6 +31,14 @@ type Profile struct { 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 + + // RandomizeExtensionOrder, when true, shuffles the constructed extension list once per + // TLS connection (in buildClientHelloSpecFromProfile) instead of using a fixed order. + // Real rustls clients reshuffle their ClientHello extension order on every connection as + // an anti-fingerprinting measure; a Profile with a permanently fixed order is itself a + // distinguishing signal for such clients. Defaults to false so existing Profiles (e.g. + // the Node.js/Claude Code default) keep their current fixed-order behavior unchanged. + RandomizeExtensionOrder bool } // Dialer creates TLS connections with custom fingerprints. @@ -391,6 +400,9 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec { if profile != nil && len(profile.Extensions) > 0 { extOrder = profile.Extensions } + if profile != nil && profile.RandomizeExtensionOrder { + extOrder = shuffleExtensionOrder(extOrder) + } // Build extensions list from the ordered IDs. // Parametric extensions (curves, sigalgs, etc.) are populated with resolved profile values. @@ -456,6 +468,24 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec { } } +// shuffleExtensionOrder returns a new slice holding a random permutation of ids. It never +// mutates ids: buildClientHelloSpecFromProfile may be called concurrently for many new TLS +// connections sharing the same package-level Profile, and shuffling its Extensions slice in +// place would both race and corrupt the base order seen by other concurrent callers. +// +// This does not need cryptographic randomness — it exists purely to keep the outbound +// ClientHello's extension order from being identical on every connection, mirroring the +// anti-fingerprinting behavior real rustls clients exhibit (see +// specs/002-codex-tls-fingerprint/research.md). +func shuffleExtensionOrder(ids []uint16) []uint16 { + shuffled := make([]uint16, len(ids)) + copy(shuffled, ids) + rand.Shuffle(len(shuffled), func(i, j int) { + shuffled[i], shuffled[j] = shuffled[j], shuffled[i] + }) + return shuffled +} + // toUint8s converts []uint16 to []uint8 (for utls fields that require []uint8). func toUint8s(vals []uint16) []uint8 { out := make([]uint8, len(vals)) diff --git a/backend/internal/pkg/tlsfingerprint/dialer_randomize_test.go b/backend/internal/pkg/tlsfingerprint/dialer_randomize_test.go new file mode 100644 index 000000000000..30b900a78722 --- /dev/null +++ b/backend/internal/pkg/tlsfingerprint/dialer_randomize_test.go @@ -0,0 +1,114 @@ +package tlsfingerprint + +import "testing" + +// sameUint16Set reports whether a and b contain the same multiset of values, ignoring order. +func sameUint16Set(a, b []uint16) bool { + if len(a) != len(b) { + return false + } + counts := make(map[uint16]int, len(a)) + for _, v := range a { + counts[v]++ + } + for _, v := range b { + counts[v]-- + } + for _, c := range counts { + if c != 0 { + return false + } + } + return true +} + +func sameUint16Order(a, b []uint16) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func uint16sToBytes(ids []uint16) []byte { + buf := make([]byte, len(ids)*2) + for i, id := range ids { + buf[i*2] = byte(id >> 8) + buf[i*2+1] = byte(id) + } + return buf +} + +// TestShuffleExtensionOrderPreservesSetButVariesOrder 覆盖 spec User Story 2:重复打乱 +// 同一组扩展类型 ID,集合必须每次不变,但排列顺序在多次采样里至少出现 2 种——镜像真实 +// rustls 客户端每次连接重新打乱 ClientHello 扩展顺序的行为。 +func TestShuffleExtensionOrderPreservesSetButVariesOrder(t *testing.T) { + original := []uint16{0, 5, 10, 11, 13, 23, 35, 43, 45, 51} + + seenOrders := make(map[string]bool) + for i := 0; i < 20; i++ { + got := shuffleExtensionOrder(original) + if !sameUint16Set(original, got) { + t.Fatalf("iteration %d: extension set changed, want set %v got %v", i, original, got) + } + seenOrders[string(uint16sToBytes(got))] = true + } + if len(seenOrders) < 2 { + t.Fatalf("20 次采样只观察到 %d 种排列,期望 >= 2 种", len(seenOrders)) + } +} + +// TestShuffleExtensionOrderDoesNotMutateInput 覆盖并发安全:多个 goroutine 可能并发用 +// 同一个包级 Profile 变量构造连接(每次新建 TLS 连接都会调用一次),原地打乱调用方传入的 +// 切片会造成数据竞争,也会让后续调用的"打乱前基准顺序"被污染。 +func TestShuffleExtensionOrderDoesNotMutateInput(t *testing.T) { + original := []uint16{0, 5, 10, 11, 13, 23, 35, 43, 45, 51} + input := append([]uint16(nil), original...) + + for i := 0; i < 20; i++ { + shuffleExtensionOrder(input) + } + + if !sameUint16Order(input, original) { + t.Fatalf("输入切片被就地修改:want %v got %v", original, input) + } +} + +// TestBuildClientHelloSpecRandomizesExtensionOrderWhenEnabled 覆盖集成层:Profile 开启 +// RandomizeExtensionOrder 后,buildClientHelloSpecFromProfile 产出的扩展数量与集合关系 +// 应保持不变(打乱只影响顺序,不影响内容),且不改变传入 Profile.Extensions 本身。 +func TestBuildClientHelloSpecRandomizesExtensionOrderWhenEnabled(t *testing.T) { + original := []uint16{0, 5, 10, 11, 13, 23, 35, 43, 45, 51} + profile := &Profile{ + Name: "randomize-test", + Extensions: append([]uint16(nil), original...), + RandomizeExtensionOrder: true, + } + + spec := buildClientHelloSpecFromProfile(profile) + if len(spec.Extensions) != len(original) { + t.Fatalf("got %d extensions, want %d", len(spec.Extensions), len(original)) + } + if !sameUint16Order(profile.Extensions, original) { + t.Fatalf("profile.Extensions 被就地修改:want %v got %v", original, profile.Extensions) + } +} + +// TestBuildClientHelloSpecKeepsFixedOrderWhenDisabled 覆盖既有 Profile +// (RandomizeExtensionOrder 零值 false)行为不变——这是宪法原则 IV 明确要求不能破坏的边界。 +func TestBuildClientHelloSpecKeepsFixedOrderWhenDisabled(t *testing.T) { + original := []uint16{0, 5, 10, 11, 13, 23, 35, 43, 45, 51} + profile := &Profile{ + Name: "fixed-order-test", + Extensions: append([]uint16(nil), original...), + } + + spec := buildClientHelloSpecFromProfile(profile) + if len(spec.Extensions) != len(original) { + t.Fatalf("got %d extensions, want %d", len(spec.Extensions), len(original)) + } +} diff --git a/backend/internal/service/openai_codex_tls_profile.go b/backend/internal/service/openai_codex_tls_profile.go new file mode 100644 index 000000000000..3b326814bfb1 --- /dev/null +++ b/backend/internal/service/openai_codex_tls_profile.go @@ -0,0 +1,69 @@ +package service + +import "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + +// codexTLSProfile 是 OpenAI Codex OAuth 出站请求的 TLS 指纹画像,字段取值逐项对应真实 +// Codex CLI(reqwest 0.12 + rustls 0.23,aws_lc_rs crypto provider,编译时未启用 http2 +// feature)的默认握手行为。取值依据:官方 github.com/openai/codex 仓库源码 +// (codex-rs/http-client、codex-rs/utils/rustls-provider)+ 三次独立真实抓包交叉验证, +// 记录在 specs/002-codex-tls-fingerprint/research.md 与 contracts/tls-profile-values.md, +// 不是凭空指定的值。 +// +// Extensions 顺序开启逐连接随机打乱(RandomizeExtensionOrder),镜像 rustls 实测的反指纹 +// 行为——三次抓包里密码套件/分组/点格式三次一致,但扩展排列顺序三次均不同。 +var codexTLSProfile = &tlsfingerprint.Profile{ + Name: "Codex CLI (reqwest+rustls, aws_lc_rs)", + CipherSuites: []uint16{ + 0x1302, // TLS_AES_256_GCM_SHA384 + 0x1301, // TLS_AES_128_GCM_SHA256 + 0x1303, // TLS_CHACHA20_POLY1305_SHA256 + 0xc02c, // TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 + 0xc02b, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + 0xcca9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 + 0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + 0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + 0xcca8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 + 0x00ff, // TLS_EMPTY_RENEGOTIATION_INFO_SCSV + }, + // Curves 对应 supported_groups 扩展,KeyShareGroups 对应 key_share 扩展;真实客户端 + // 两者列出同一组分组,X25519MLKEM768(0x11ec)是后量子混合密钥交换,rustls 默认优先。 + Curves: []uint16{0x11ec, 0x001d, 0x0017, 0x0018}, + KeyShareGroups: []uint16{0x11ec, 0x001d, 0x0017, 0x0018}, + PointFormats: []uint16{0}, // uncompressed + // ALPNProtocols 留空且 Extensions 不含类型 16:reqwest 编译时未启用 http2 feature, + // 从不发送 ALPN 扩展。 + ALPNProtocols: nil, + Extensions: []uint16{ + 0, // server_name + 5, // status_request + 10, // supported_groups + 11, // ec_point_formats + 13, // signature_algorithms + 23, // extended_master_secret + 35, // session_ticket + 43, // supported_versions + 45, // psk_key_exchange_modes + 51, // key_share + }, + EnableGREASE: false, // rustls 不做 GREASE + // 三次真实抓包(research.md §2)显示密码套件/分组/点格式三次一致,但扩展排列顺序三次 + // 均不同——镜像 rustls 的反指纹行为,逐连接重新打乱,不写死固定顺序。 + RandomizeExtensionOrder: true, +} + +// resolveOpenAICodexTLSProfile 决定一次 OpenAI 出站请求应使用的 TLS Profile。 +// +// explicitProfile 是账号已显式配置的 TLS 指纹(由调用方通过 +// TLSFingerprintProfileService.ResolveTLSProfile 解析得到;nil 表示账号未配置该项, +// 或该账号类型当前不支持配置)。account 用于在没有显式配置时判断是否属于 OpenAI Codex +// OAuth——是则自动套用 codexTLSProfile,不需要管理员逐账号手动开关;否则不启用 TLS 指纹, +// 与改动前的行为一致。 +func resolveOpenAICodexTLSProfile(explicitProfile *tlsfingerprint.Profile, account *Account) *tlsfingerprint.Profile { + if explicitProfile != nil { + return explicitProfile + } + if account != nil && account.IsOpenAIOAuth() { + return codexTLSProfile + } + return nil +} diff --git a/backend/internal/service/openai_codex_tls_profile_test.go b/backend/internal/service/openai_codex_tls_profile_test.go new file mode 100644 index 000000000000..7b5bffd6b011 --- /dev/null +++ b/backend/internal/service/openai_codex_tls_profile_test.go @@ -0,0 +1,82 @@ +package service + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/stretchr/testify/require" +) + +// TestCodexTLSProfileCipherSuites 覆盖 contracts/tls-profile-values.md 记录的密码套件表: +// 顺序和内容必须逐项等于三次真实抓包 + 官方 openai/codex 源码交叉验证过的值。 +func TestCodexTLSProfileCipherSuites(t *testing.T) { + want := []uint16{ + 0x1302, 0x1301, 0x1303, + 0xc02c, 0xc02b, 0xcca9, + 0xc030, 0xc02f, 0xcca8, + 0x00ff, + } + require.Equal(t, want, codexTLSProfile.CipherSuites) +} + +// TestCodexTLSProfileGroups 覆盖 supported_groups 与 key_share 的分组集合:两者必须一致 +// (真实客户端里 key_share 携带的分组就是 supported_groups 声明的分组,见 data-model.md)。 +func TestCodexTLSProfileGroups(t *testing.T) { + want := []uint16{0x11ec, 0x001d, 0x0017, 0x0018} + require.Equal(t, want, codexTLSProfile.Curves) + require.Equal(t, want, codexTLSProfile.KeyShareGroups) +} + +// TestCodexTLSProfilePointFormatsAndALPN 覆盖 ec_point_formats 固定值,以及真实 Codex CLI +// (reqwest 编译时未启用 http2 feature)从不发送 ALPN 这一行为。 +func TestCodexTLSProfilePointFormatsAndALPN(t *testing.T) { + require.Equal(t, []uint16{0}, codexTLSProfile.PointFormats) + require.Empty(t, codexTLSProfile.ALPNProtocols) + require.NotContains(t, codexTLSProfile.Extensions, uint16(16), "不应声明 ALPN 扩展类型 ID") +} + +// TestCodexTLSProfileExtensionSet 覆盖扩展类型集合:必须恰好是 contract 文档的 10 项, +// 不多不少;顺序由 US2 的随机打乱负责,这里只比较集合。 +func TestCodexTLSProfileExtensionSet(t *testing.T) { + want := map[uint16]bool{ + 0: true, 5: true, 10: true, 11: true, 13: true, + 23: true, 35: true, 43: true, 45: true, 51: true, + } + require.Len(t, codexTLSProfile.Extensions, len(want)) + for _, id := range codexTLSProfile.Extensions { + require.True(t, want[id], "扩展类型 %d 不在预期集合内", id) + } + for _, forbidden := range []uint16{18, 65281, 65037} { + require.NotContains(t, codexTLSProfile.Extensions, forbidden) + } +} + +// TestCodexTLSProfileRandomizesExtensionOrder 覆盖 US2 的验收标准:codexTLSProfile 必须 +// 开启扩展顺序随机化开关。 +func TestCodexTLSProfileRandomizesExtensionOrder(t *testing.T) { + require.True(t, codexTLSProfile.RandomizeExtensionOrder) +} + +// TestResolveOpenAICodexTLSProfile 覆盖 research.md §6 的三层解析规则: +// 账号已显式配置的 TLS 指纹优先;否则 OpenAI Codex OAuth 账号自动套用 codexTLSProfile; +// 其它情况不启用 TLS 指纹。 +func TestResolveOpenAICodexTLSProfile(t *testing.T) { + codexOAuthAccount := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + apiKeyAccount := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + + t.Run("账号已显式配置时使用账号自己的 Profile", func(t *testing.T) { + explicit := &tlsfingerprint.Profile{Name: "admin-picked"} + got := resolveOpenAICodexTLSProfile(explicit, codexOAuthAccount) + require.Same(t, explicit, got) + }) + + t.Run("Codex OAuth 账号未显式配置时自动套用 codexTLSProfile", func(t *testing.T) { + got := resolveOpenAICodexTLSProfile(nil, codexOAuthAccount) + require.Same(t, codexTLSProfile, got) + }) + + t.Run("非 Codex OAuth 账号未显式配置时不启用 TLS 指纹", func(t *testing.T) { + got := resolveOpenAICodexTLSProfile(nil, apiKeyAccount) + require.Nil(t, got) + }) +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 5ce98fba0fa8..6e97433e1d16 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -434,6 +434,7 @@ type OpenAIGatewayService struct { userGroupRateResolver *userGroupRateResolver httpUpstream HTTPUpstream pluginManager *PluginManager + tlsFPProfileService *TLSFingerprintProfileService deferredService *DeferredService openAITokenProvider *OpenAITokenProvider grokTokenProvider *GrokTokenProvider diff --git a/backend/internal/service/openai_plugin_transport.go b/backend/internal/service/openai_plugin_transport.go index 82b8d359ee4f..be2914a63732 100644 --- a/backend/internal/service/openai_plugin_transport.go +++ b/backend/internal/service/openai_plugin_transport.go @@ -1,13 +1,27 @@ package service -import "net/http" +import ( + "net/http" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" +) func (s *OpenAIGatewayService) SetPluginManager(manager *PluginManager) { s.pluginManager = manager } +// SetTLSFingerprintProfileService 注入账号级 TLS 指纹解析服务,供 doOpenAIUpstream 在 +// 决定出站 TLS Profile 时优先尊重账号已显式配置的选择(见 resolveOpenAICodexTLSProfile)。 +func (s *OpenAIGatewayService) SetTLSFingerprintProfileService(svc *TLSFingerprintProfileService) { + s.tlsFPProfileService = svc +} + // doOpenAIUpstream 只在 OpenAI OAuth 能力绑定已启用时把真实请求交给插件。 // 插件返回标准 http.Response,响应解析、错误映射、SSE 和计费仍由现有核心链处理。 +// +// 未被插件接管时统一走 DoWithTLS:resolveOpenAICodexTLSProfile 决定的 profile 为 nil 时, +// DoWithTLS 退化为普通 Do 行为(httpUpstreamService 既有保证),因此这里不需要再区分 +// Do/DoWithTLS 两条调用路径。 func (s *OpenAIGatewayService) doOpenAIUpstream(request *http.Request, proxyURL string, account *Account) (*http.Response, error) { if s.pluginManager != nil { response, handled, err := s.pluginManager.RoundTripOpenAIOAuth(request.Context(), request, proxyURL, account) @@ -15,7 +29,12 @@ func (s *OpenAIGatewayService) doOpenAIUpstream(request *http.Request, proxyURL return response, err } } - return s.httpUpstream.Do(request, proxyURL, account.ID, account.Concurrency) + var explicitProfile *tlsfingerprint.Profile + if s.tlsFPProfileService != nil { + explicitProfile = s.tlsFPProfileService.ResolveTLSProfile(account) + } + return s.httpUpstream.DoWithTLS(request, proxyURL, account.ID, account.Concurrency, + resolveOpenAICodexTLSProfile(explicitProfile, account)) } // doOpenAIAccountTestUpstream 让 OpenAI OAuth 账号测试与真实转发使用同一插件路径。 diff --git a/backend/internal/service/openai_plugin_transport_test.go b/backend/internal/service/openai_plugin_transport_test.go new file mode 100644 index 000000000000..fe7382547415 --- /dev/null +++ b/backend/internal/service/openai_plugin_transport_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/stretchr/testify/require" +) + +// codexUpstreamCallRecorder 记录 Do/DoWithTLS 各自被调用的次数与最后一次收到的 profile, +// 用于断言 doOpenAIUpstream 走的是哪条出站路径。 +type codexUpstreamCallRecorder struct { + doCalls int + doWithTLSCalls int + lastProfile *tlsfingerprint.Profile +} + +func (u *codexUpstreamCallRecorder) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + u.doCalls++ + return httptest.NewRecorder().Result(), nil +} + +func (u *codexUpstreamCallRecorder) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, profile *tlsfingerprint.Profile) (*http.Response, error) { + u.doWithTLSCalls++ + u.lastProfile = profile + return httptest.NewRecorder().Result(), nil +} + +// TestDoOpenAIUpstreamUsesResolvedCodexTLSProfile 覆盖 research.md §5:doOpenAIUpstream +// 在没有插件接管时必须调用 DoWithTLS(而不是 Do),且传入的 profile 由 +// resolveOpenAICodexTLSProfile 决定——Codex OAuth 账号未显式配置时是 codexTLSProfile, +// API Key 账号是 nil(等价于既有 Do 行为,不引入回归)。 +func TestDoOpenAIUpstreamUsesResolvedCodexTLSProfile(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", nil) + + t.Run("Codex OAuth 账号自动套用 codexTLSProfile", func(t *testing.T) { + upstream := &codexUpstreamCallRecorder{} + svc := &OpenAIGatewayService{httpUpstream: upstream} + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1} + + _, err := svc.doOpenAIUpstream(req, "", account) + require.NoError(t, err) + require.Equal(t, 0, upstream.doCalls, "不应再直接调用 Do") + require.Equal(t, 1, upstream.doWithTLSCalls) + require.Same(t, codexTLSProfile, upstream.lastProfile) + }) + + t.Run("API Key 账号不启用 TLS 指纹", func(t *testing.T) { + upstream := &codexUpstreamCallRecorder{} + svc := &OpenAIGatewayService{httpUpstream: upstream} + account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + + _, err := svc.doOpenAIUpstream(req, "", account) + require.NoError(t, err) + require.Equal(t, 1, upstream.doWithTLSCalls, "统一走 DoWithTLS 分发点") + require.Nil(t, upstream.lastProfile, "profile 为 nil 时 DoWithTLS 退化为普通 Do 行为") + }) +}