diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 58147c63..7fc648ac 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -79,7 +79,6 @@ func provideCleanup( soraMediaCleanup *service.SoraMediaCleanupService, schedulerSnapshot *service.SchedulerSnapshotService, tokenRefresh *service.TokenRefreshService, - lsPoolBootstrap *service.LSPoolBootstrapService, accountExpiry *service.AccountExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, @@ -172,12 +171,6 @@ func provideCleanup( tokenRefresh.Stop() return nil }}, - {"LSPoolBootstrapService", func() error { - if lsPoolBootstrap != nil { - lsPoolBootstrap.Stop() - } - return nil - }}, {"AccountExpiryService", func() error { accountExpiry.Stop() return nil diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 87c5355e..18bb3a7e 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -238,11 +238,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig) opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oauthRefreshAPI) - lsPoolBootstrapService := service.ProvideLSPoolBootstrapService(accountRepository, configConfig) accountExpiryService := service.ProvideAccountExpiryService(accountRepository) subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository) scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, lsPoolBootstrapService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService) application := &Application{ Server: httpServer, Cleanup: v, @@ -279,7 +278,6 @@ func provideCleanup( opsSystemLogSink *service.OpsSystemLogSink, schedulerSnapshot *service.SchedulerSnapshotService, tokenRefresh *service.TokenRefreshService, - lsPoolBootstrap *service.LSPoolBootstrapService, accountExpiry *service.AccountExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, @@ -365,12 +363,6 @@ func provideCleanup( tokenRefresh.Stop() return nil }}, - {"LSPoolBootstrapService", func() error { - if lsPoolBootstrap != nil { - lsPoolBootstrap.Stop() - } - return nil - }}, {"AccountExpiryService", func() error { accountExpiry.Stop() return nil diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index 52f4aa3c..6e4561c9 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -47,7 +47,6 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { idempotencyCleanupSvc := service.NewIdempotencyCleanupService(nil, cfg) schedulerSnapshotSvc := service.NewSchedulerSnapshotService(nil, nil, nil, nil, cfg) opsSystemLogSinkSvc := service.NewOpsSystemLogSink(nil) - lsPoolBootstrapSvc := service.NewLSPoolBootstrapService(nil, nil, cfg) cleanup := provideCleanup( nil, // entClient @@ -60,7 +59,6 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { opsSystemLogSinkSvc, schedulerSnapshotSvc, tokenRefreshSvc, - lsPoolBootstrapSvc, accountExpirySvc, subscriptionExpirySvc, &service.UsageCleanupService{}, diff --git a/backend/go.mod b/backend/go.mod index c4fc52f1..3b2de238 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -35,10 +35,10 @@ require ( github.com/tidwall/sjson v1.2.5 github.com/zeromicro/go-zero v1.9.4 go.uber.org/zap v1.24.0 - golang.org/x/crypto v0.48.0 - golang.org/x/net v0.49.0 - golang.org/x/sync v0.19.0 - golang.org/x/term v0.40.0 + golang.org/x/crypto v0.49.0 + golang.org/x/net v0.52.0 + golang.org/x/sync v0.20.0 + golang.org/x/term v0.41.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.44.3 @@ -99,6 +99,7 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/subcommands v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect @@ -164,9 +165,10 @@ require ( go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.43.0 // indirect google.golang.org/grpc v1.75.1 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 996a4b6d..b7223557 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -160,6 +160,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= @@ -387,14 +389,22 @@ golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -406,14 +416,22 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= diff --git a/backend/internal/pkg/claudemask/mask.go b/backend/internal/pkg/claudemask/mask.go new file mode 100644 index 00000000..58f01a43 --- /dev/null +++ b/backend/internal/pkg/claudemask/mask.go @@ -0,0 +1,106 @@ +// Package claudemask provides Node.js client emulation for Claude API requests +package claudemask + +import ( + "net/http" + "strings" +) + +// GoClientIndicators 是可能暴露 Go 客户端身份的 HTTP 头列表 +// 这些头需要被清除或伪装 +var GoClientIndicators = []string{ + "Go-Http-Client/", + "User-Agent", + // Go 默认在 User-Agent 中会包含 "Go-http-client" +} + +// SuspiciousHeaders 是在 Claude API 请求中不应出现的头 +// 或应被移除的头(非 Node.js 客户端会发送) +var SuspiciousHeaders = []string{ + "Accept-Encoding", // Go http.Client 自动添加,但应由 utls 处理 + "Content-Length", // 应由 http.Transport 自动管理 +} + +// RequiredNodeHeaders 是 Node.js Claude Code 客户端必须有的头 +var RequiredNodeHeaders = map[string]bool{ + "User-Agent": true, + "X-Stainless-Lang": true, + "X-Stainless-Runtime": true, + "X-Stainless-Runtime-Version": true, + "X-Stainless-Package-Version": true, + "X-Stainless-OS": true, + "X-Stainless-Arch": true, + "anthropic-version": true, +} + +// ValidateNodeEmulation 验证请求是否正确伪装为 Node.js 客户端 +// 返回 (isValid, errorMessages) +func ValidateNodeEmulation(req *http.Request) (bool, []string) { + if req == nil { + return false, []string{"request is nil"} + } + + var errors []string + + // 检查必要的 Node.js 指纹头 + for header := range RequiredNodeHeaders { + if req.Header.Get(header) == "" { + errors = append(errors, "missing required header: "+header) + } + } + + // 检查是否包含 Go 客户端指示 + ua := req.Header.Get("User-Agent") + if ua == "" { + errors = append(errors, "User-Agent is empty") + } else if strings.Contains(ua, "Go-http-client") { + errors = append(errors, "User-Agent contains Go-http-client indicator") + } else if !strings.Contains(ua, "claude-cli") && !strings.Contains(ua, "node") { + errors = append(errors, "User-Agent does not contain Node.js indicators") + } + + // 验证 X-Stainless-Runtime 应为 "node" + runtime := req.Header.Get("X-Stainless-Runtime") + if runtime != "node" { + errors = append(errors, "X-Stainless-Runtime should be 'node', got: "+runtime) + } + + // 验证 X-Stainless-Lang 应为 "js" + lang := req.Header.Get("X-Stainless-Lang") + if lang != "js" { + errors = append(errors, "X-Stainless-Lang should be 'js', got: "+lang) + } + + return len(errors) == 0, errors +} + +// CleanRequest 清除或修复任何会暴露 Go 客户端身份的请求头 +// 这是一个"猴子补丁",用于修复任何遗漏的伪装 +func CleanRequest(req *http.Request) { + if req == nil { + return + } + + // 检查并修复可疑的 User-Agent + ua := req.Header.Get("User-Agent") + if ua == "" || strings.Contains(ua, "Go-http-client") { + // 如果 User-Agent 缺失或包含 Go 指示,设置为 Node.js 格式 + req.Header.Set("User-Agent", "claude-cli/2.1.88 (external, cli)") + } + + // 确保 Accept-Encoding 由 utls 而非 http.Client 设置 + // (通常 utls 会接管,但为保险起见检查) + if req.Header.Get("Accept-Encoding") != "" { + // 这通常是安全的,但某些情况下可能需要调整 + } + + // 移除可能暴露 Go 版本的头 + req.Header.Del("Go-Version") // 不标准,但为安全起见 +} + +// ValidateAndClean 组合验证和清理 +// 返回验证结果和清理后的请求 +func ValidateAndClean(req *http.Request) (bool, []string) { + CleanRequest(req) + return ValidateNodeEmulation(req) +} diff --git a/backend/internal/pkg/claudemask/mask_test.go b/backend/internal/pkg/claudemask/mask_test.go new file mode 100644 index 00000000..b55820ed --- /dev/null +++ b/backend/internal/pkg/claudemask/mask_test.go @@ -0,0 +1,158 @@ +package claudemask + +import ( + "net/http" + "testing" +) + +func TestValidateNodeEmulation_ValidRequest(t *testing.T) { + req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil) + + // 设置所有必需的 Node.js 头 + req.Header.Set("User-Agent", "claude-cli/2.1.88 (external, cli)") + req.Header.Set("X-Stainless-Lang", "js") + req.Header.Set("X-Stainless-Runtime", "node") + req.Header.Set("X-Stainless-Runtime-Version", "v24.3.0") + req.Header.Set("X-Stainless-Package-Version", "0.74.0") + req.Header.Set("X-Stainless-OS", "MacOS") + req.Header.Set("X-Stainless-Arch", "arm64") + req.Header.Set("anthropic-version", "2023-06-01") + + isValid, errors := ValidateNodeEmulation(req) + + if !isValid { + t.Errorf("expected valid emulation, got errors: %v", errors) + } + if len(errors) > 0 { + t.Errorf("expected no errors, got: %v", errors) + } +} + +func TestValidateNodeEmulation_MissingHeaders(t *testing.T) { + req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil) + + // 只设置部分头 + req.Header.Set("User-Agent", "claude-cli/2.1.88 (external, cli)") + + isValid, errors := ValidateNodeEmulation(req) + + if isValid { + t.Error("expected invalid emulation due to missing headers") + } + if len(errors) == 0 { + t.Error("expected validation errors") + } + if len(errors) < 7 { + t.Errorf("expected at least 7 missing headers, got %d errors", len(errors)) + } +} + +func TestValidateNodeEmulation_InvalidRuntime(t *testing.T) { + req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil) + + // 设置所有必需的头,但 runtime 错误 + req.Header.Set("User-Agent", "claude-cli/2.1.88 (external, cli)") + req.Header.Set("X-Stainless-Lang", "js") + req.Header.Set("X-Stainless-Runtime", "go") // ❌ 应为 "node" + req.Header.Set("X-Stainless-Runtime-Version", "v24.3.0") + req.Header.Set("X-Stainless-Package-Version", "0.74.0") + req.Header.Set("X-Stainless-OS", "MacOS") + req.Header.Set("X-Stainless-Arch", "arm64") + req.Header.Set("anthropic-version", "2023-06-01") + + isValid, errs := ValidateNodeEmulation(req) + + if isValid { + t.Error("expected invalid due to runtime=go") + } + if len(errs) == 0 { + t.Error("expected validation error for runtime") + } +} + +func TestValidateNodeEmulation_GoClientUA(t *testing.T) { + req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil) + + // User-Agent 包含 Go 指示 + req.Header.Set("User-Agent", "Go-http-client/2.0") // ❌ 包含 Go 指示 + + isValid, _ := ValidateNodeEmulation(req) + + if isValid { + t.Error("expected invalid due to Go-http-client in User-Agent") + } +} + +func TestValidateNodeEmulation_MissingUserAgent(t *testing.T) { + req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil) + + // 不设置 User-Agent + req.Header.Set("X-Stainless-Lang", "js") + req.Header.Set("X-Stainless-Runtime", "node") + req.Header.Set("X-Stainless-Runtime-Version", "v24.3.0") + req.Header.Set("X-Stainless-Package-Version", "0.74.0") + req.Header.Set("X-Stainless-OS", "MacOS") + req.Header.Set("X-Stainless-Arch", "arm64") + req.Header.Set("anthropic-version", "2023-06-01") + + isValid, errs := ValidateNodeEmulation(req) + + if isValid { + t.Error("expected invalid due to missing User-Agent") + } + if len(errs) == 0 { + t.Error("expected validation error for missing User-Agent") + } +} + +func TestCleanRequest_FixesGoUserAgent(t *testing.T) { + req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil) + + // 设置不正确的 User-Agent + req.Header.Set("User-Agent", "Go-http-client/2.0") + + CleanRequest(req) + + ua := req.Header.Get("User-Agent") + if ua != "claude-cli/2.1.88 (external, cli)" { + t.Errorf("expected fixed User-Agent, got: %s", ua) + } +} + +func TestCleanRequest_FixesMissingUserAgent(t *testing.T) { + req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil) + + // 不设置 User-Agent + CleanRequest(req) + + ua := req.Header.Get("User-Agent") + if ua != "claude-cli/2.1.88 (external, cli)" { + t.Errorf("expected User-Agent to be set, got: %s", ua) + } +} + +func TestValidateAndClean(t *testing.T) { + req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil) + + // 设置带有 Go 指示的 User-Agent,其他头正确 + req.Header.Set("User-Agent", "Go-http-client/2.0") + req.Header.Set("X-Stainless-Lang", "js") + req.Header.Set("X-Stainless-Runtime", "node") + req.Header.Set("X-Stainless-Runtime-Version", "v24.3.0") + req.Header.Set("X-Stainless-Package-Version", "0.74.0") + req.Header.Set("X-Stainless-OS", "MacOS") + req.Header.Set("X-Stainless-Arch", "arm64") + req.Header.Set("anthropic-version", "2023-06-01") + + isValid, errors := ValidateAndClean(req) + + // 清理后应该有效 + if !isValid { + t.Errorf("expected valid after cleaning, got errors: %v", errors) + } + + ua := req.Header.Get("User-Agent") + if ua != "claude-cli/2.1.88 (external, cli)" { + t.Errorf("expected cleaned User-Agent, got: %s", ua) + } +} diff --git a/backend/internal/pkg/tlsfingerprint/dialer.go b/backend/internal/pkg/tlsfingerprint/dialer.go index 73646a53..0bb05e4d 100644 --- a/backend/internal/pkg/tlsfingerprint/dialer.go +++ b/backend/internal/pkg/tlsfingerprint/dialer.go @@ -362,7 +362,8 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec { } } - alpnProtocols := []string{"http/1.1"} + // Node.js 24.x 优先使用 HTTP/2,回退到 HTTP/1.1 + alpnProtocols := []string{"h2", "http/1.1"} if profile != nil && len(profile.ALPNProtocols) > 0 { alpnProtocols = profile.ALPNProtocols } diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index cc9ae847..9bf1a853 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -13,8 +13,6 @@ import ( "strings" "sync" "sync/atomic" - - "github.com/Wei-Shaw/sub2api/internal/pkg/lspool" "time" "github.com/andybalholm/brotli" @@ -117,18 +115,6 @@ func NewHTTPUpstream(cfg *config.Config) service.HTTPUpstream { clients: make(map[string]*upstreamClientEntry), } - // LS 池模式: 包装一层拦截, streamGenerateContent 走 LS - if lspool.IsLSModeEnabled() { - pool := lspool.GlobalPool(cfg) - if pool != nil { - slog.Info("LS pool mode enabled — streamGenerateContent will route through Language Server", - "component", "http_upstream") - return lspool.NewLSPoolUpstream(pool, base) - } - slog.Warn("LS pool mode enabled but pool is nil — falling back to direct mode", - "component", "http_upstream") - } - return base } @@ -825,8 +811,9 @@ func buildUpstreamTransportWithTLSFingerprint(settings poolSettings, proxyURL *u MaxConnsPerHost: settings.maxConnsPerHost, IdleConnTimeout: settings.idleConnTimeout, ResponseHeaderTimeout: settings.responseHeaderTimeout, - // 禁用默认的 TLS,我们使用自定义的 DialTLSContext - ForceAttemptHTTP2: false, + // 启用 HTTP/2 以匹配 Node.js 24.x 客户端行为 + // Node.js Claude Code 优先使用 HTTP/2,通过 ALPN 协商 + ForceAttemptHTTP2: true, } // 根据代理类型选择合适的 TLS 指纹 Dialer diff --git a/backend/internal/server/routes/event_logging.go b/backend/internal/server/routes/event_logging.go new file mode 100644 index 00000000..9bb3cfcd --- /dev/null +++ b/backend/internal/server/routes/event_logging.go @@ -0,0 +1,59 @@ +package routes + +import ( + "encoding/json" +) + +// sensitiveKeys 需要从 event_logging payload 中剥离的字段。 +// 逆向自 Claude Code v2.1.92: +// - baseUrl/baseURL — c8().BASE_API_URL,暴露 ANTHROPIC_BASE_URL(网关地址) +// - api_base_url — 备选 API base 字段名 +// - serverUrl — MCP/WebSocket 服务器地址 +// - gateway — 网关标识 +// - apiHostRequestHeaders — 上游请求头(含 Host) +var sensitiveKeys = map[string]struct{}{ + "baseUrl": {}, + "baseURL": {}, + "api_base_url": {}, + "serverUrl": {}, + "gateway": {}, + "apiHostRequestHeaders": {}, +} + +// sanitizeEventBatch 清理 event_logging batch payload 中的敏感字段, +// 防止网关地址泄露,同时保持遥测流量正常(避免"零遥测"异常触发检测)。 +// +// 实现:反序列化 → 递归删除任意深度的敏感 key → 重新序列化。 +// 单次解析+序列化,比 N×M 次 gjson/sjson 操作更高效。 +func sanitizeEventBatch(body []byte) []byte { + var payload interface{} + if err := json.Unmarshal(body, &payload); err != nil { + return body // 非法 JSON 原样转发,不阻塞 + } + + stripKeys(payload) + + out, err := json.Marshal(payload) + if err != nil { + return body + } + return out +} + +// stripKeys 递归遍历任意 JSON 结构,删除匹配 sensitiveKeys 的字段。 +func stripKeys(v interface{}) { + switch node := v.(type) { + case map[string]interface{}: + for k := range node { + if _, hit := sensitiveKeys[k]; hit { + delete(node, k) + } else { + stripKeys(node[k]) + } + } + case []interface{}: + for _, item := range node { + stripKeys(item) + } + } +} diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 06cd6c6b..7c4193a3 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -22,7 +22,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/Wei-Shaw/sub2api/internal/pkg/lspool" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/tidwall/gjson" @@ -113,88 +112,6 @@ func IsAntigravityAccountSwitchError(err error) (*AntigravityAccountSwitchError, return nil, false } -// injectLSPoolHeaders adds internal headers carrying OAuth credentials for the -// LS pool layer. These headers are consumed and stripped by LSPoolUpstream -// before the request reaches the Language Server. When LS mode is disabled, -// these headers are harmless — the direct upstream never sees them because -// they are stripped inside LSPoolUpstream.Do(). In direct mode the request -// goes straight through httpUpstreamService.Do() which doesn't inspect them. -func injectLSPoolHeaders(req *http.Request, account *Account) { - if req == nil || account == nil { - return - } - if rt, ok := account.Credentials["refresh_token"].(string); ok && rt != "" { - req.Header.Set("X-Antigravity-Refresh-Token", rt) - } - if ea, ok := account.Credentials["expires_at"].(string); ok && ea != "" { - req.Header.Set("X-Antigravity-Token-Expiry", ea) - } - req.Header.Set("X-Antigravity-Use-AI-Credits", strconv.FormatBool(account.IsOveragesEnabled())) - - availableCredits, minimumCreditAmount := resolveLSPoolModelCreditsState(account) - if availableCredits != nil { - req.Header.Set("X-Antigravity-Available-Credits", strconv.FormatInt(int64(*availableCredits), 10)) - } - if minimumCreditAmount != nil { - req.Header.Set("X-Antigravity-Minimum-Credit-Amount", strconv.FormatInt(int64(*minimumCreditAmount), 10)) - } -} - -func resolveLSPoolModelCreditsState(account *Account) (*int32, *int32) { - if account == nil || account.Extra == nil { - minimum := int32(50) - return nil, &minimum - } - - var availableCredits *int32 - var minimumCreditAmount *int32 - - collect := func(entry map[string]any) { - if entry == nil { - return - } - if !isGoogleOneAICreditsEntry(entry) { - return - } - if availableCredits == nil { - if parsed, ok := parseAICreditsInt32(firstPresent(entry, "Amount", "amount", "creditAmount")); ok { - availableCredits = &parsed - } - } - if minimumCreditAmount == nil { - if parsed, ok := parseAICreditsInt32(firstPresent(entry, "MinimumBalance", "minimum_balance", "minimumCreditAmountForUsage")); ok { - minimumCreditAmount = &parsed - } - } - } - - if rawCredits, ok := account.Extra["ai_credits"].([]any); ok { - for _, item := range rawCredits { - if entry, ok := item.(map[string]any); ok { - collect(entry) - } - } - } - - if loadCodeAssist, ok := account.Extra["load_code_assist"].(map[string]any); ok { - if paidTier, ok := loadCodeAssist["paidTier"].(map[string]any); ok { - if credits, ok := paidTier["availableCredits"].([]any); ok { - for _, item := range credits { - if entry, ok := item.(map[string]any); ok { - collect(entry) - } - } - } - } - } - - if minimumCreditAmount == nil { - defaultMinimum := int32(50) - minimumCreditAmount = &defaultMinimum - } - return availableCredits, minimumCreditAmount -} - func isGoogleOneAICreditsEntry(entry map[string]any) bool { creditType, _ := firstPresent(entry, "CreditType", "credit_type", "creditType").(string) creditType = strings.TrimSpace(strings.ToUpper(creditType)) @@ -444,7 +361,6 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam } } - injectLSPoolHeaders(retryReq, p.account) retryResp, retryErr := p.httpUpstream.Do(retryReq, p.proxyURL, p.account.ID, p.account.Concurrency) if retryErr == nil && retryResp != nil && retryResp.StatusCode != http.StatusTooManyRequests && retryResp.StatusCode != http.StatusServiceUnavailable { log.Printf("%s status=%d smart_retry_success attempt=%d/%d", p.prefix, retryResp.StatusCode, attempt, maxAttempts) @@ -629,7 +545,6 @@ func (s *AntigravityGatewayService) handleSingleAccountRetryInPlace( logger.LegacyPrintf("service.antigravity_gateway", "%s single_account_503_retry: request_build_failed error=%v", p.prefix, err) break } - injectLSPoolHeaders(retryReq, p.account) retryResp, retryErr := p.httpUpstream.Do(retryReq, p.proxyURL, p.account.ID, p.account.Concurrency) if retryErr == nil && retryResp != nil && retryResp.StatusCode != http.StatusTooManyRequests && retryResp.StatusCode != http.StatusServiceUnavailable { @@ -768,7 +683,6 @@ urlFallbackLoop: if err != nil { return nil, err } - injectLSPoolHeaders(upstreamReq, p.account) // Capture upstream request body for ops retry of this attempt. if p.c != nil && len(p.body) > 0 { @@ -2376,7 +2290,6 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co if err == nil { fallbackReq, err := antigravity.NewAPIRequest(ctx, upstreamAction, accessToken, fallbackWrapped) if err == nil { - injectLSPoolHeaders(fallbackReq, account) fallbackResp, err := s.httpUpstream.Do(fallbackReq, proxyURL, account.ID, account.Concurrency) if err == nil && fallbackResp.StatusCode < 400 { _ = resp.Body.Close() @@ -3223,13 +3136,6 @@ func buildGeminiStreamErrorEvent(status int, message string) string { return "event: error\ndata: " + string(data) + "\n\n" } -func lsQuotaExhaustedMessage(err error) string { - msg := strings.TrimSpace(lspool.LSQuotaExhaustedMessage(err)) - if msg != "" { - return sanitizeUpstreamErrorMessage(msg) - } - return "You have exhausted your capacity on this model." -} func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time) (*antigravityStreamResult, error) { c.Status(resp.StatusCode) @@ -3345,12 +3251,6 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context if disconnect, handled := handleStreamReadError(ev.err, cw.Disconnected(), "antigravity gemini"); handled { return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: disconnect}, nil } - if lspool.IsLSQuotaExhaustedError(ev.err) { - msg := lsQuotaExhaustedMessage(ev.err) - logger.LegacyPrintf("service.antigravity_gateway", "LS quota exhausted during streaming (antigravity gemini): %s", msg) - sendErrorEvent(http.StatusTooManyRequests, msg) - return nil, ev.err - } if errors.Is(ev.err, bufio.ErrTooLong) { logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity): max_size=%d error=%v", maxLineSize, ev.err) sendErrorEvent(http.StatusBadGateway, "Response too large") @@ -4218,12 +4118,6 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context if disconnect, handled := handleStreamReadError(ev.err, cw.Disconnected(), "antigravity claude"); handled { return &antigravityStreamResult{usage: finishUsage(), firstTokenMs: firstTokenMs, clientDisconnect: disconnect}, nil } - if lspool.IsLSQuotaExhaustedError(ev.err) { - msg := lsQuotaExhaustedMessage(ev.err) - logger.LegacyPrintf("service.antigravity_gateway", "LS quota exhausted during streaming (antigravity claude): %s", msg) - sendErrorEvent("rate_limit_error", msg) - return nil, fmt.Errorf("stream read error: %w", ev.err) - } if errors.Is(ev.err, bufio.ErrTooLong) { logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity): max_size=%d error=%v", maxLineSize, ev.err) sendErrorEvent("api_error", "Response too large") diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 6d943156..2cc07af9 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -24,6 +24,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/claudemask" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/telemetry" @@ -5747,6 +5748,25 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex "enable_mpt": strconv.FormatBool(enableMPT), }) + // === Node.js 伪装验证与清理 === + // 对于 OAuth 账号,验证请求是否正确伪装为 Node.js Claude Code 客户端 + // 这是一个"猴子补丁",确保所有头都符合 Node.js 客户端标准 + if tokenType == "oauth" && account.IsOAuth() { + // 1. 清理任何可能暴露 Go 客户端身份的头 + if ua := req.Header.Get("User-Agent"); ua == "" || strings.Contains(ua, "Go-http-client") { + // User-Agent 缺失或包含 Go 指示,修复为 Node.js 格式 + setHeaderRaw(req.Header, "User-Agent", "claude-cli/2.1.88 (external, cli)") + } + + // 2. 验证 Node.js 指纹完整性(用于调试日志) + if s.debugClaudeMimicEnabled() { + isValid, errors := claudemask.ValidateNodeEmulation(req) + if !isValid { + logger.LegacyPrintf("service.gateway", "⚠️ Node.js emulation validation failed: %v", errors) + } + } + } + // Always capture a compact fingerprint line for later error diagnostics. // We only print it when needed (or when the explicit debug flag is enabled). if c != nil && tokenType == "oauth" { @@ -5757,6 +5777,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } return req, nil + } // getBetaHeader 处理anthropic-beta header diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 63bcc10e..d66d8cff 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -439,7 +439,6 @@ var ProviderSet = wire.NewSet( NewCRSSyncService, ProvideUpdateService, ProvideTokenRefreshService, - ProvideLSPoolBootstrapService, ProvideAccountExpiryService, ProvideSubscriptionExpiryService, ProvideTimingWheelService,