diff --git a/.gitattributes b/.gitattributes index 37e3bee2cc..4dd197b8c1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,5 +18,8 @@ backend/migrations/*.sql text eol=lf *.yaml text eol=lf *.yml text eol=lf +# JSON 配置/数据文件(含指纹基线 baseline JSON) +*.json text eol=lf + # Dockerfile Dockerfile text eol=lf diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 9916718dce..3770561f57 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.112.20 +0.1.114.1 diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 64709b5b1c..e73000012f 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -97,6 +97,7 @@ func provideCleanup( scheduledTestRunner *service.ScheduledTestRunnerService, backupSvc *service.BackupService, paymentOrderExpiry *service.PaymentOrderExpiryService, + claudeSidecarProbe *service.ClaudeSidecarProbeService, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -167,6 +168,12 @@ func provideCleanup( tokenRefresh.Stop() return nil }}, + {"ClaudeSidecarProbeService", func() error { + if claudeSidecarProbe != nil { + claudeSidecarProbe.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 1d39fa1e31..d3e4385f40 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -134,7 +134,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { identityCache := repository.NewIdentityCache(redisClient) tlsFingerprintProfileRepository := repository.NewTLSFingerprintProfileRepository(client) tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient) - tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache) + tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache, accountRepository) accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService) oAuthRefreshAPI := service.NewOAuthRefreshAPI(accountRepository, geminiTokenCache) geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oAuthRefreshAPI) @@ -244,11 +244,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig) 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) + tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, gatewayService) 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, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService) + claudeSidecarProbeService := service.ProvideClaudeSidecarProbeService(configConfig, accountRepository, accountUsageService) + 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, paymentOrderExpiryService, claudeSidecarProbeService) application := &Application{ Server: httpServer, Cleanup: v, @@ -302,6 +303,7 @@ func provideCleanup( scheduledTestRunner *service.ScheduledTestRunnerService, backupSvc *service.BackupService, paymentOrderExpiry *service.PaymentOrderExpiryService, + claudeSidecarProbe *service.ClaudeSidecarProbeService, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -371,6 +373,12 @@ func provideCleanup( tokenRefresh.Stop() return nil }}, + {"ClaudeSidecarProbeService", func() error { + if claudeSidecarProbe != nil { + claudeSidecarProbe.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 a6e0551a38..57fe6f54b2 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -76,6 +76,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, // scheduledTestRunner nil, // backupSvc nil, // paymentOrderExpiry + nil, // claudeSidecarProbe ) require.NotPanics(t, func() { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1559290566..c3c9936c4c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -83,6 +83,7 @@ type Config struct { UsageCleanup UsageCleanupConfig `mapstructure:"usage_cleanup"` Concurrency ConcurrencyConfig `mapstructure:"concurrency"` TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"` + SidecarProbe SidecarProbeConfig `mapstructure:"sidecar_probe"` RunMode string `mapstructure:"run_mode" yaml:"run_mode"` Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC" Gemini GeminiConfig `mapstructure:"gemini"` @@ -232,6 +233,48 @@ type TokenRefreshConfig struct { RetryBackoffSeconds int `mapstructure:"retry_backoff_seconds"` } +// SidecarProbeConfig 控制 Claude OAuth 账号的 sidecar 流量注入。 +// +// Anthropic 的订阅滥用检测会对只产生 /v1/messages 流量的账号静默降低 weekly +// limit(典型症状:实际用量不到 2% 就触发限流)。真实 Claude Code CLI 会在 +// statusline 上周期性调用 /api/oauth/usage,也会在发送 /v1/messages 之前发 +// count_tokens。这个配置启用两个补丁来让上游看到的 endpoint 比例更像真人: +// - UsagePoll: 周期性调 /api/oauth/usage +// - CountTokensInject: 在热路径上为 /v1/messages 前置注入 count_tokens +type SidecarProbeConfig struct { + UsagePoll SidecarProbeUsagePollConfig `mapstructure:"usage_poll"` + CountTokensInject SidecarProbeCTInjectConfig `mapstructure:"count_tokens_inject"` + StartupProbe SidecarProbeStartupConfig `mapstructure:"startup_probe"` +} + +// SidecarProbeUsagePollConfig 控制 /api/oauth/usage 周期轮询。 +type SidecarProbeUsagePollConfig struct { + // Enabled 开关;默认 true。 + Enabled bool `mapstructure:"enabled"` + // MinIntervalSeconds 最短探测间隔(秒),下限 60。 + MinIntervalSeconds int `mapstructure:"min_interval_seconds"` + // MaxIntervalSeconds 最长探测间隔(秒),必须 >= Min。 + MaxIntervalSeconds int `mapstructure:"max_interval_seconds"` + // DryRun 为 true 时只打印将要探测的账号,不发真实请求(用于灰度)。 + DryRun bool `mapstructure:"dry_run"` +} + +// SidecarProbeCTInjectConfig 控制 /v1/messages/count_tokens 前置注入。 +type SidecarProbeCTInjectConfig struct { + // Enabled 开关;默认 false(Phase 1 先跑 usage poll,观察后再打开)。 + Enabled bool `mapstructure:"enabled"` + // TimeoutMilliseconds count_tokens 调用的独立超时,超时不影响主请求;默认 3000。 + TimeoutMilliseconds int `mapstructure:"timeout_ms"` +} + +// SidecarProbeStartupConfig 控制 token 刷新后的启动探测。 +// 真实 Claude Code CLI 在拿到新 token 后会立刻发一个 max_tokens=1 的 haiku +// 请求验活,这个配置让 gateway 在后台刷新 token 后也模拟这个动作。 +type SidecarProbeStartupConfig struct { + // Enabled 开关;默认 true。 + Enabled bool `mapstructure:"enabled"` +} + type PricingConfig struct { // 价格数据远程URL(默认使用LiteLLM镜像) RemoteURL string `mapstructure:"remote_url"` @@ -1480,6 +1523,17 @@ func setDefaults() { viper.SetDefault("token_refresh.max_retries", 3) // 最多重试3次 viper.SetDefault("token_refresh.retry_backoff_seconds", 2) // 重试退避基础2秒 + // Sidecar Probe: 生成 /api/oauth/usage 和 count_tokens 的 sidecar 流量, + // 让 Claude OAuth 账号的 endpoint 比例看起来像真实 Claude Code CLI。 + // 详见 SidecarProbeConfig 的注释。 + viper.SetDefault("sidecar_probe.usage_poll.enabled", true) + viper.SetDefault("sidecar_probe.usage_poll.min_interval_seconds", 300) // 5 分钟 + viper.SetDefault("sidecar_probe.usage_poll.max_interval_seconds", 900) // 15 分钟 + viper.SetDefault("sidecar_probe.usage_poll.dry_run", false) + viper.SetDefault("sidecar_probe.count_tokens_inject.enabled", false) // Phase 2,默认关闭 + viper.SetDefault("sidecar_probe.count_tokens_inject.timeout_ms", 3000) + viper.SetDefault("sidecar_probe.startup_probe.enabled", true) // token 刷新后的启动探测,默认开启 + // Gemini OAuth - configure via environment variables or config file // GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET // Default: uses Gemini CLI public credentials (set via environment) diff --git a/backend/internal/handler/admin/tls_fingerprint_profile_handler.go b/backend/internal/handler/admin/tls_fingerprint_profile_handler.go index 38f975559e..f2c9903016 100644 --- a/backend/internal/handler/admin/tls_fingerprint_profile_handler.go +++ b/backend/internal/handler/admin/tls_fingerprint_profile_handler.go @@ -232,3 +232,22 @@ func (h *TLSFingerprintProfileHandler) Delete(c *gin.Context) { response.Success(c, gin.H{"message": "Profile deleted successfully"}) } + +// RandomizeForAccount 为指定账号生成并绑定一个随机化 TLS 指纹 profile。 +// 重复调用会"重新随机":旧的 auto-generated profile 会被删除。 +// POST /api/v1/admin/tls-fingerprint-profiles/randomize-for-account/:accountID +func (h *TLSFingerprintProfileHandler) RandomizeForAccount(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("accountID"), 10, 64) + if err != nil || accountID <= 0 { + response.BadRequest(c, "Invalid account ID") + return + } + + profile, err := h.service.RandomizeForAccount(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, profile) +} diff --git a/backend/internal/handler/failover_loop.go b/backend/internal/handler/failover_loop.go index 6d8ddc7236..e2619ca35f 100644 --- a/backend/internal/handler/failover_loop.go +++ b/backend/internal/handler/failover_loop.go @@ -71,6 +71,25 @@ func (s *FailoverState) HandleFailoverError( ) FailoverAction { s.LastFailoverErr = failoverErr + // Anti-fingerprinting (2026-04-15): + // Surface 429 (rate limit) directly to the client instead of auto-swapping + // accounts. Real Claude Code errors out on 429; a relay that retries the + // same request on a second account within ~200ms is a textbook rate-limit + // evasion signal. We mark the error as "exhausted" so the handler returns + // the upstream 429 verbatim. + // + // Note: RetryableOnSameAccount errors (e.g. Google intermittent 400 / + // empty response) are unrelated to rate limiting and still retry on the + // same account below. Only the *account switch* path is short-circuited. + if failoverErr != nil && failoverErr.StatusCode == http.StatusTooManyRequests && !failoverErr.RetryableOnSameAccount { + s.FailedAccountIDs[accountID] = struct{}{} + logger.FromContext(ctx).Warn("gateway.failover_429_no_switch", + zap.Int64("account_id", accountID), + zap.Int("upstream_status", failoverErr.StatusCode), + ) + return FailoverExhausted + } + // 缓存计费判断 if needForceCacheBilling(s.hasBoundSession, failoverErr) { s.ForceCacheBilling = true diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 21c723d208..eba86cbc79 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -1,6 +1,8 @@ // Package claude provides constants and helpers for Claude API integration. package claude +import "math/rand/v2" + // Claude Code 客户端相关常量 // Beta header 常量 @@ -12,14 +14,29 @@ const ( BetaTokenCounting = "token-counting-2024-11-01" BetaContext1M = "context-1m-2025-08-07" BetaFastMode = "fast-mode-2026-02-01" + // Additions captured from Claude Code 2.1.109 (see capture_fingerprint tool). + BetaContextManagement20250627 = "context-management-2025-06-27" + BetaPromptCachingScope20260105 = "prompt-caching-scope-2026-01-05" + BetaAdvisorTool20260301 = "advisor-tool-2026-03-01" ) // DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。 // 这些 token 是客户端特有的,不应透传给上游 API。 var DroppedBetas = []string{} -// DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header -const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming +// clientExtraBetas 是 Claude Code 基线抓包里额外出现的 beta token。 +// 真实客户端稳定携带这些 beta;缺失的话上游可以靠 "UA=版本号 但 beta 不符" +// 一条规则批量扫出 mimic 流量。保持和 DefaultHeaders 的抓包版本同步。 +const clientExtraBetas = "," + BetaContextManagement20250627 + "," + BetaPromptCachingScope20260105 + "," + BetaAdvisorTool20260301 + +// DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header. +// +// 2.1.111 capture (2026-04-17) no longer advertises fine-grained-tool-streaming +// — Anthropic rolled the behavior into the baseline API. 2.1.112 (latest on +// npm) is a patch bump with no observed beta-token change. Keeping the stale +// beta here would leave "UA=2.1.11x but fine-grained-tool-streaming present" +// as a one-rule scan-for-mimic flag. +const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + clientExtraBetas // MessageBetaHeaderNoTools /v1/messages 在无工具时的 beta header // @@ -27,40 +44,76 @@ const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleav // Claude Code for non-Claude-Code clients, we must include the claude-code beta // even if the request doesn't use tools, otherwise upstream may reject the // request as a non-Claude-Code API request. -const MessageBetaHeaderNoTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking +const MessageBetaHeaderNoTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + clientExtraBetas // MessageBetaHeaderWithTools /v1/messages 在有工具时的 beta header -const MessageBetaHeaderWithTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking +const MessageBetaHeaderWithTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + clientExtraBetas // CountTokensBetaHeader count_tokens 请求使用的 anthropic-beta header -const CountTokensBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaTokenCounting +const CountTokensBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaTokenCounting + clientExtraBetas // HaikuBetaHeader Haiku 模型使用的 anthropic-beta header(不需要 claude-code beta) const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking -// APIKeyBetaHeader API-key 账号建议使用的 anthropic-beta header(不包含 oauth) -const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming +// APIKeyBetaHeader API-key 账号使用的 anthropic-beta header. +// +// Exactly matches the real Claude Code 2.1.111 capture against x-api-key auth +// (2.1.112 is a patch bump with no observed beta-token change): +// +// claude-code-20250219, +// interleaved-thinking-2025-05-14, +// context-management-2025-06-27, +// prompt-caching-scope-2026-01-05, +// advisor-tool-2026-03-01 +// +// Differs from DefaultBetaHeader only by the absence of oauth-2025-04-20 +// (beta is auth-type conditional; non-OAuth requests never carry it). +const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + clientExtraBetas // APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不包含 oauth / claude-code) const APIKeyHaikuBetaHeader = BetaInterleavedThinking // DefaultHeaders 是 Claude Code 客户端默认请求头。 +// +// Values re-verified 2026-04-17 from a live capture of Claude Code 2.1.111 on +// Node.js 24.14.1 / macOS arm64 (backend/tools/capture_fingerprint). UA bumped +// to 2.1.112 (latest on npm as of 2026-04-17) — patch-level Claude Code releases +// do not change Stainless package/runtime fields or the timeout. Bundled +// @anthropic-ai/sdk is still 0.81.0 — unchanged since the 2.1.109 capture. +// Keep these in sync with recent Claude CLI traffic to reduce the chance that +// Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage. var DefaultHeaders = map[string]string{ - // Keep these in sync with recent Claude CLI traffic to reduce the chance - // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage. - "User-Agent": "claude-cli/2.1.22 (external, cli)", - "X-Stainless-Lang": "js", - "X-Stainless-Package-Version": "0.70.0", - "X-Stainless-OS": "Linux", - "X-Stainless-Arch": "arm64", - "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.13.0", - "X-Stainless-Retry-Count": "0", - "X-Stainless-Timeout": "600", - "X-App": "cli", + "User-Agent": "claude-cli/2.1.112 (external, sdk-cli)", + "X-Stainless-Lang": "js", + "X-Stainless-Package-Version": "0.81.0", + "X-Stainless-OS": "MacOS", + "X-Stainless-Arch": "arm64", + "X-Stainless-Runtime": "node", + "X-Stainless-Runtime-Version": "v24.14.1", + // 2.1.111 capture shows timeout=300; 2.1.109 was 600. Matches the + // ~5-minute default @anthropic-ai/sdk recently moved to. + "X-Stainless-Timeout": "300", + "X-App": "cli", "Anthropic-Dangerous-Direct-Browser-Access": "true", } +// SampleStainlessRetryCount returns a probabilistically sampled retry count +// for the X-Stainless-Retry-Count header. Real Claude CLI emits "0" on the +// vast majority of requests but occasionally "1" (after a transient failure) +// and rarely "2" (after two). A constant "0" across every request is a weak +// mimic tell — this introduces natural variance. +func SampleStainlessRetryCount() string { + r := rand.Float64() + switch { + case r < 0.005: + return "2" + case r < 0.03: + return "1" + default: + return "0" + } +} + // Model 表示一个 Claude 模型 type Model struct { ID string `json:"id"` @@ -156,3 +209,6 @@ func DenormalizeModelID(id string) string { } return id } + +// HaikuModelShort is the short alias Claude Code's startup probe sends. +const HaikuModelShort = "claude-haiku-4-5" diff --git a/backend/internal/pkg/safe/safe.go b/backend/internal/pkg/safe/safe.go new file mode 100644 index 0000000000..b176c37b09 --- /dev/null +++ b/backend/internal/pkg/safe/safe.go @@ -0,0 +1,65 @@ +// Package safe 提供 panic-recovering 的 goroutine 辅助函数。 +// +// 设计目的: +// - 业务代码中常见 `go func() { ... }()` 模式,一旦 panic 会导致整个进程退出。 +// - 本包统一捕获 panic,记录结构化日志(含调用方上下文 attrs + panic 值 + 堆栈), +// 避免单个 goroutine 崩溃影响主流程。 +package safe + +import ( + "log/slog" + "runtime/debug" +) + +// panicSuffix 是 panic 事件名后缀,便于日志检索。 +const panicSuffix = ".panic" + +// Go 在新 goroutine 中执行 fn,捕获 panic 并以 event+".panic" 名字记录到 slog。 +// +// attrs 在调用点求值并以切片形式传入,避免 goroutine 闭包陷阱 +// (调用方变量被异步求值时可能已被修改)。 +// +// 用法: +// +// safe.Go("sidecar_probe.startup", []slog.Attr{ +// slog.Int64("account_id", acc.ID), +// }, func() { +// probeAccount(acc) +// }) +func Go(event string, attrs []slog.Attr, fn func()) { + go Run(event, attrs, fn) +} + +// Run 同步执行 fn,同样捕获 panic。给已经自管 goroutine 的调用方使用 +// (waitgroup/errgroup pattern)。 +// +// 用法: +// +// wg.Add(1) +// go func() { +// defer wg.Done() +// safe.Run("worker.tick", []slog.Attr{slog.Int("worker_id", id)}, func() { +// doWork() +// }) +// }() +func Run(event string, attrs []slog.Attr, fn func()) { + defer func() { + if r := recover(); r != nil { + logPanic(event, attrs, r) + } + }() + fn() +} + +// logPanic 以结构化方式记录 panic,附加调用方 attrs + panic 值 + 堆栈。 +// 单独提取以便测试覆盖与未来扩展(如指标上报)。 +func logPanic(event string, attrs []slog.Attr, panicValue any) { + // 复制一份 attrs 避免修改调用方切片 + logAttrs := make([]slog.Attr, 0, len(attrs)+2) + logAttrs = append(logAttrs, attrs...) + logAttrs = append(logAttrs, + slog.Any("panic", panicValue), + slog.String("stack", string(debug.Stack())), + ) + slog.LogAttrs(nil, slog.LevelError, event+panicSuffix, logAttrs...) +} diff --git a/backend/internal/pkg/safe/safe_test.go b/backend/internal/pkg/safe/safe_test.go new file mode 100644 index 0000000000..fc23cb39c2 --- /dev/null +++ b/backend/internal/pkg/safe/safe_test.go @@ -0,0 +1,111 @@ +//go:build unit + +package safe + +import ( + "bytes" + "log/slog" + "strings" + "sync" + "testing" + "time" +) + +// withCapturedLogger 临时把全局 slog 默认 logger 替换为写入 buffer 的 +// text handler,返回 buffer 与还原函数。便于在测试中断言日志输出。 +func withCapturedLogger(t *testing.T) (*bytes.Buffer, func()) { + t.Helper() + buf := &bytes.Buffer{} + handler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + prev := slog.Default() + slog.SetDefault(slog.New(handler)) + return buf, func() { slog.SetDefault(prev) } +} + +// TestRun_NoPanic 验证 fn 在无 panic 情况下被正常执行,且不写入 panic 日志。 +func TestRun_NoPanic(t *testing.T) { + buf, restore := withCapturedLogger(t) + defer restore() + + called := false + Run("test.normal", []slog.Attr{slog.String("k", "v")}, func() { + called = true + }) + + if !called { + t.Fatal("expected fn to be called") + } + if strings.Contains(buf.String(), ".panic") { + t.Fatalf("expected no panic log, got: %q", buf.String()) + } +} + +// TestRun_RecoversPanic 验证 Run 同步捕获 panic,并写出包含 +// event.panic / panic=boom / account_id=42 / stack= 的结构化日志。 +func TestRun_RecoversPanic(t *testing.T) { + buf, restore := withCapturedLogger(t) + defer restore() + + // 不应该向上传播 panic + Run("test.event", []slog.Attr{slog.Int64("account_id", 42)}, func() { + panic("boom") + }) + + output := buf.String() + wantSubstrs := []string{ + "test.event.panic", + "panic=boom", + "account_id=42", + "stack=", + } + for _, want := range wantSubstrs { + if !strings.Contains(output, want) { + t.Errorf("expected log to contain %q, full output:\n%s", want, output) + } + } +} + +// TestGo_RecoversPanicAsync 验证 Go 在 goroutine 中捕获 panic, +// goroutine 能正常退出(不会让测试进程 crash),且日志被写入。 +func TestGo_RecoversPanicAsync(t *testing.T) { + buf, restore := withCapturedLogger(t) + defer restore() + + done := make(chan struct{}) + // wg 用于确保 fn 已被调用(Go 立刻返回,需要手动同步退出信号) + var wg sync.WaitGroup + wg.Add(1) + + Go("test.async", []slog.Attr{slog.String("worker", "w1")}, func() { + defer wg.Done() + defer close(done) + panic("async-boom") + }) + + select { + case <-done: + // goroutine 完成了 panic(done close 在 panic 之前,所以一定会触发) + case <-time.After(2 * time.Second): + t.Fatal("goroutine did not finish within 2s") + } + + // 等待 panic recover + log 写入完成 + wg.Wait() + + // 给 slog handler 一点点时间 flush(text handler 是同步的,但 defer 顺序保险起见) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(buf.String(), "test.async.panic") { + break + } + time.Sleep(10 * time.Millisecond) + } + + output := buf.String() + if !strings.Contains(output, "test.async.panic") { + t.Errorf("expected log to contain %q, full output:\n%s", "test.async.panic", output) + } + if !strings.Contains(output, "panic=async-boom") { + t.Errorf("expected log to contain %q, full output:\n%s", "panic=async-boom", output) + } +} diff --git a/backend/internal/pkg/tlsfingerprint/dialer.go b/backend/internal/pkg/tlsfingerprint/dialer.go index c8d8369ff8..2bb7ab7d4d 100644 --- a/backend/internal/pkg/tlsfingerprint/dialer.go +++ b/backend/internal/pkg/tlsfingerprint/dialer.go @@ -52,67 +52,179 @@ type SOCKS5ProxyDialer struct { proxyURL *url.URL } -// Default TLS fingerprint values captured from Claude Code (Node.js 24.x) -// Captured via tls-fingerprint-web capture server -// JA3 Hash: 44f88fca027f27bab4bb08d4af15f23e -// JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff +// Default TLS fingerprint values captured from Claude Code 2.1.109 on +// Node.js 24.14.1 / macOS arm64, pointed at a local capture server via +// `ANTHROPIC_BASE_URL`. Capture tool source at backend/tools/capture_fingerprint. +// +// Capture date: 2026-04-15 +// JA3 string: +// +// 771,4866-4867-4865-49199-49195-49200-49196-158-49191-103-49192-107-163-159- +// 52393-52392-52394-49325-49311-49245-49249-49239-49235-162-49324-49310-49244- +// 49248-49238-49234-49188-106-49187-64-49162-49172-57-56-49161-49171-51-50- +// 157-49309-49233-156-49308-49232-61-60-53-47, +// 65281-0-11-10-35-16-22-23-13-43-45-51, +// 29-23-30-24-25-256-257,0-1-2 +// +// JA3 hash: d67b094811e5145139d7cea5f014309f +// JA4: t13d5212h1 (part-a prefix — 52 ciphers, 12 extensions, http/1.1 ALPN) +// +// Critical findings from the capture that influenced these defaults: +// - Real Claude Code 2.1.109 advertises ONLY `http/1.1` in ALPN — it does +// NOT offer `h2`. Earlier concerns about Go-vs-Node HTTP/2 SETTINGS +// mismatch were moot because the real CLI does not use HTTP/2 on the +// api.anthropic.com path at all. +// - 52 cipher suites (not 17 — the previous hand-authored list was a +// substantial undercount). +// - 26 signature schemes, 8 supported groups (incl. 2 FFDHE + X448 + P521). +// - Extension order is: renegotiation_info, server_name, ec_point_formats, +// supported_groups, session_ticket, alpn, encrypt_then_mac(22), +// extended_master_secret, signature_algorithms, supported_versions, +// psk_key_exchange_modes, key_share. No ECH, no SCT, no status_request. var ( - // defaultCipherSuites contains the 17 cipher suites from Node.js 24.x - // Order is critical for JA3 fingerprint matching + // defaultCipherSuites — 52 cipher suites in the order Node.js 24.14.1 + // OpenSSL sends them. Do NOT reorder: JA3 hash depends on order. defaultCipherSuites = []uint16{ - // TLS 1.3 cipher suites - 0x1301, // TLS_AES_128_GCM_SHA256 + // TLS 1.3 (note: 1302 comes before 1303/1301) 0x1302, // TLS_AES_256_GCM_SHA384 0x1303, // TLS_CHACHA20_POLY1305_SHA256 + 0x1301, // TLS_AES_128_GCM_SHA256 - // ECDHE + AES-GCM - 0xc02b, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + // ECDHE + AES-GCM (RSA before ECDSA) 0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 - 0xc02c, // TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 + 0xc02b, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + 0xc02c, // TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 - // ECDHE + ChaCha20-Poly1305 + // DHE_RSA + AES-GCM + 0x009e, // TLS_DHE_RSA_WITH_AES_128_GCM_SHA256 + + // ECDHE + AES-CBC-SHA256 (SHA256 HMAC variants) + 0xc027, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 + 0x0067, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA256 + 0xc028, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384 + 0x006b, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA256 + 0x00a3, // TLS_DHE_DSS_WITH_AES_256_GCM_SHA384 + 0x009f, // TLS_DHE_RSA_WITH_AES_256_GCM_SHA384 + + // ChaCha20-Poly1305 family (3 variants: ECDSA, RSA, DHE) 0xcca9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 0xcca8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 + 0xccaa, // TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256 - // ECDHE + AES-CBC-SHA (legacy fallback) - 0xc009, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA - 0xc013, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA + // ECCPWD, camellia, ARIA (legacy PSK/ARIA from OpenSSL enable-all) + 0xc0ad, // TLS_ECDHE_ECDSA_WITH_AES_256_CCM + 0xc09f, // TLS_DHE_RSA_WITH_AES_256_CCM + 0xc05d, // TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384 + 0xc061, // TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384 + 0xc057, // TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384 + 0xc053, // TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384 + 0x00a2, // TLS_DHE_DSS_WITH_AES_128_GCM_SHA256 + 0xc0ac, // TLS_ECDHE_ECDSA_WITH_AES_128_CCM + 0xc09e, // TLS_DHE_RSA_WITH_AES_128_CCM + 0xc05c, // TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256 + 0xc060, // TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256 + 0xc056, // TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256 + 0xc052, // TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256 + + // ECDHE/DHE + CBC-SHA256/384 (legacy) + 0xc024, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384 + 0x006a, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA256 + 0xc023, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 + 0x0040, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA256 + + // ECDHE + AES-CBC-SHA (legacy — SHA1 HMAC) 0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA 0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA + 0x0039, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA + 0x0038, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA + 0xc009, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA + 0xc013, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA + 0x0033, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA + 0x0032, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA - // RSA + AES-GCM (non-PFS) - 0x009c, // TLS_RSA_WITH_AES_128_GCM_SHA256 + // RSA key exchange + AES-GCM (non-PFS) 0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384 + 0xc09d, // TLS_RSA_WITH_AES_256_CCM + 0xc051, // TLS_RSA_WITH_ARIA_256_GCM_SHA384 + 0x009c, // TLS_RSA_WITH_AES_128_GCM_SHA256 + 0xc09c, // TLS_RSA_WITH_AES_128_CCM + 0xc050, // TLS_RSA_WITH_ARIA_128_GCM_SHA256 - // RSA + AES-CBC-SHA (non-PFS, legacy) - 0x002f, // TLS_RSA_WITH_AES_128_CBC_SHA + // RSA + AES-CBC-SHA256 (legacy) + 0x003d, // TLS_RSA_WITH_AES_256_CBC_SHA256 + 0x003c, // TLS_RSA_WITH_AES_128_CBC_SHA256 + + // RSA + AES-CBC-SHA (very legacy) 0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA + 0x002f, // TLS_RSA_WITH_AES_128_CBC_SHA } - // defaultCurves contains the 3 supported groups from Node.js 24.x + // defaultCurves — supported groups we advertise. + // + // Real Claude Code / Node.js 24 advertises 8 groups (incl. x448, + // ffdhe2048, ffdhe3072) but utls's HelloRetryRequest path can only + // regenerate key shares for curves in curveForCurveID (X25519, P256, + // P384, P521). If we advertise a group utls can't handle and the + // server picks it via HRR, handshake fails with + // "tls: CurvePreferences includes unsupported curve". + // + // X25519MLKEM768 is kept because defaultKeyShareGroups always sends its + // key share on the initial ClientHello, so servers never need to HRR + // back to it. The randomizer must preserve this invariant. defaultCurves = []utls.CurveID{ - utls.X25519, // 0x001d - utls.CurveP256, // 0x0017 (secp256r1) - utls.CurveP384, // 0x0018 (secp384r1) + utls.X25519MLKEM768, // 0x11ec — post-quantum hybrid + utls.X25519, // 0x001d + utls.CurveP256, // 0x0017 (secp256r1) + utls.CurveP384, // 0x0018 (secp384r1) + utls.CurveP521, // 0x0019 (secp521r1) } - // defaultPointFormats contains point formats from Node.js 24.x + // defaultKeyShareGroups — X25519MLKEM768 + X25519, matching the real + // Claude Code capture (the real CLI sends two key shares: a ~1216-byte + // MLKEM payload and a 32-byte X25519 public key). + defaultKeyShareGroups = []utls.CurveID{ + utls.X25519MLKEM768, + utls.X25519, + } + + // defaultPointFormats — 3 formats (uncompressed, ansiX962, compressed). defaultPointFormats = []uint16{ 0, // uncompressed + 1, // ansiX962_compressed_prime + 2, // ansiX962_compressed_char2 } - // defaultSignatureAlgorithms contains the 9 signature algorithms from Node.js 24.x + // defaultSignatureAlgorithms — 26 schemes Node.js 24.14.1 advertises, + // in capture order. Includes Brainpool TLS 1.3 curves and several + // legacy SHA1 entries. defaultSignatureAlgorithms = []utls.SignatureScheme{ + 0x0905, // experimental TLS 1.3 (Node.js OpenSSL) + 0x0906, // experimental TLS 1.3 + 0x0904, // experimental TLS 1.3 0x0403, // ecdsa_secp256r1_sha256 - 0x0804, // rsa_pss_rsae_sha256 - 0x0401, // rsa_pkcs1_sha256 0x0503, // ecdsa_secp384r1_sha384 + 0x0603, // ecdsa_secp521r1_sha512 + 0x0807, // ed25519 + 0x0808, // ed448 + 0x081a, // ecdsa_brainpoolP256r1tls13_sha256 + 0x081b, // ecdsa_brainpoolP384r1tls13_sha384 + 0x081c, // ecdsa_brainpoolP512r1tls13_sha512 + 0x0809, // rsa_pss_pss_sha256 + 0x080a, // rsa_pss_pss_sha384 + 0x080b, // rsa_pss_pss_sha512 + 0x0804, // rsa_pss_rsae_sha256 0x0805, // rsa_pss_rsae_sha384 - 0x0501, // rsa_pkcs1_sha384 0x0806, // rsa_pss_rsae_sha512 + 0x0401, // rsa_pkcs1_sha256 + 0x0501, // rsa_pkcs1_sha384 0x0601, // rsa_pkcs1_sha512 - 0x0201, // rsa_pkcs1_sha1 + 0x0303, // SHA224-ECDSA (legacy) + 0x0301, // SHA224-RSA (legacy) + 0x0302, // SHA224-DSA (legacy) + 0x0402, // SHA256-DSA (legacy) + 0x0502, // SHA384-DSA (legacy) + 0x0602, // SHA512-DSA (legacy) } ) @@ -166,9 +278,17 @@ func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr st return nil, fmt.Errorf("create SOCKS5 dialer: %w", err) } - // Step 2: Establish SOCKS5 tunnel to target + // Step 2: Establish SOCKS5 tunnel to target. + // Use ContextDialer so the caller's ctx (timeout/cancel) covers the SOCKS5 + // handshake; otherwise an unresponsive proxy hangs goroutines for minutes + // and exhausts connections. x/net's *socks.Dialer satisfies ContextDialer + // (verified for golang.org/x/net v0.49.0). slog.Debug("tls_fingerprint_socks5_establishing_tunnel", "target", addr) - conn, err := socksDialer.Dial("tcp", addr) + ctxDialer, ok := socksDialer.(proxy.ContextDialer) + if !ok { + return nil, fmt.Errorf("SOCKS5 dialer does not implement ContextDialer (x/net contract changed)") + } + conn, err := ctxDialer.DialContext(ctx, "tcp", addr) if err != nil { slog.Debug("tls_fingerprint_socks5_connect_failed", "error", err) return nil, fmt.Errorf("SOCKS5 connect: %w", err) @@ -307,23 +427,32 @@ func toUTLSCurves(curves []uint16) []utls.CurveID { return result } -// defaultExtensionOrder is the Node.js 24.x extension order. +// defaultExtensionOrder — 12 extensions in the exact order Node.js 24.14.1 +// sends them (captured from live Claude Code 2.1.109). +// +// Differences from the previous hand-authored list: +// - Dropped: encrypted_client_hello (65037 / ECH) — real Node.js 24 does +// NOT send ECH unless a server config is known. +// - Dropped: status_request (5) and signed_certificate_timestamp (18) — +// Node.js 24's default TLS extension set doesn't include them. +// - Added: encrypt_then_mac (22) — RFC 7366, emitted by OpenSSL. +// - Reordered: renegotiation_info is now FIRST; extended_master_secret +// moved after encrypt_then_mac; ec_point_formats precedes supported_groups. +// // Used when Profile.Extensions is empty. var defaultExtensionOrder = []uint16{ + 65281, // renegotiation_info (0xff01) 0, // server_name - 65037, // encrypted_client_hello - 23, // extended_master_secret - 65281, // renegotiation_info - 10, // supported_groups 11, // ec_point_formats + 10, // supported_groups 35, // session_ticket 16, // alpn - 5, // status_request + 22, // encrypt_then_mac (RFC 7366) — sent by OpenSSL/Node.js + 23, // extended_master_secret 13, // signature_algorithms - 18, // signed_certificate_timestamp - 51, // key_share - 45, // psk_key_exchange_modes 43, // supported_versions + 45, // psk_key_exchange_modes + 51, // key_share } // isGREASEValue checks if a uint16 value matches the TLS GREASE pattern (0x?a?a). @@ -362,13 +491,19 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec { if profile != nil && len(profile.ALPNProtocols) > 0 { alpnProtocols = profile.ALPNProtocols } + // Defensive: strip "h2" from any profile (including ones persisted before + // the randomizer fix). http.Transport with a custom DialTLSContext cannot + // speak HTTP/2 — if the server negotiates h2 via ALPN, the transport writes + // HTTP/1.1 and the server responds with HTTP/2 SETTINGS/WINDOW_UPDATE/GOAWAY + // frames, surfacing as "malformed HTTP response" errors. + alpnProtocols = filterHTTP2FromALPN(alpnProtocols) supportedVersions := []uint16{utls.VersionTLS13, utls.VersionTLS12} if profile != nil && len(profile.SupportedVersions) > 0 { supportedVersions = profile.SupportedVersions } - keyShareGroups := []utls.CurveID{utls.X25519} + keyShareGroups := defaultKeyShareGroups if profile != nil && len(profile.KeyShareGroups) > 0 { keyShareGroups = toUTLSCurves(profile.KeyShareGroups) } @@ -456,6 +591,23 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec { } } +// filterHTTP2FromALPN removes "h2" (and the deprecated "h2c") entries from an +// ALPN list, guaranteeing at least ["http/1.1"] is advertised. Called before +// every TLS handshake — see comment at call site for why h2 is unsafe. +func filterHTTP2FromALPN(alpn []string) []string { + out := make([]string, 0, len(alpn)) + for _, p := range alpn { + if p == "h2" || p == "h2c" { + continue + } + out = append(out, p) + } + if len(out) == 0 { + return []string{"http/1.1"} + } + return out +} + // 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_capture_test.go b/backend/internal/pkg/tlsfingerprint/dialer_capture_test.go index de9d79a0db..2e447ec34c 100644 --- a/backend/internal/pkg/tlsfingerprint/dialer_capture_test.go +++ b/backend/internal/pkg/tlsfingerprint/dialer_capture_test.go @@ -310,8 +310,11 @@ func TestBuildClientHelloSpecNewFields(t *testing.T) { switch e := ext.(type) { case *utls.ALPNExtension: foundALPN = true - if len(e.AlpnProtocols) != 2 || e.AlpnProtocols[0] != "h2" { - t.Errorf("ALPN: got %v, want [h2, http/1.1]", e.AlpnProtocols) + // "h2" is defensively filtered out at build time because + // http.Transport with a custom DialTLSContext cannot speak + // HTTP/2 — ["h2", "http/1.1"] collapses to ["http/1.1"]. + if len(e.AlpnProtocols) != 1 || e.AlpnProtocols[0] != "http/1.1" { + t.Errorf("ALPN: got %v, want [http/1.1] (h2 filtered)", e.AlpnProtocols) } case *utls.SupportedVersionsExtension: foundVersions = true diff --git a/backend/internal/pkg/tlsfingerprint/dialer_socks5_test.go b/backend/internal/pkg/tlsfingerprint/dialer_socks5_test.go new file mode 100644 index 0000000000..66e4d112b9 --- /dev/null +++ b/backend/internal/pkg/tlsfingerprint/dialer_socks5_test.go @@ -0,0 +1,104 @@ +//go:build unit + +package tlsfingerprint + +import ( + "context" + "errors" + "net" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +// TestSOCKS5DialerRespectsContextCancel verifies that DialTLSContext through a +// SOCKS5 proxy honors ctx cancellation/timeout. We start a TCP listener that +// accepts connections but never writes anything (simulating a hung SOCKS5 +// server), then dial through it with a short ctx deadline. The call must +// return within a small window after the deadline, not block until the OS +// connect timeout. +func TestSOCKS5DialerRespectsContextCancel(t *testing.T) { + // Hung SOCKS5 server: accept and then sleep, never sending the SOCKS5 + // method-selection reply. This blocks the SOCKS5 handshake indefinitely + // from the client's perspective. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + var wg sync.WaitGroup + stop := make(chan struct{}) + wg.Add(1) + go func() { + defer wg.Done() + for { + conn, err := ln.Accept() + if err != nil { + return + } + // Hold the connection open until the test ends. + go func(c net.Conn) { + defer c.Close() + <-stop + }(conn) + } + }() + defer func() { + close(stop) + ln.Close() + wg.Wait() + }() + + proxyURL := &url.URL{ + Scheme: "socks5", + Host: ln.Addr().String(), + } + dialer := NewSOCKS5ProxyDialer(&Profile{Name: "test"}, proxyURL) + + const deadline = 200 * time.Millisecond + const slack = 800 * time.Millisecond // CI jitter cushion + + ctx, cancel := context.WithTimeout(context.Background(), deadline) + defer cancel() + + start := time.Now() + conn, err := dialer.DialTLSContext(ctx, "tcp", "example.com:443") + elapsed := time.Since(start) + + if conn != nil { + conn.Close() + t.Fatalf("expected no connection, got one") + } + if err == nil { + t.Fatalf("expected error from canceled ctx, got nil after %v", elapsed) + } + + if elapsed < deadline { + t.Fatalf("returned too early (%v < %v); ctx deadline likely ignored", elapsed, deadline) + } + if elapsed > deadline+slack { + t.Fatalf("returned too late (%v > %v); ctx not honored by SOCKS5 dialer", elapsed, deadline+slack) + } + + // The error chain should reflect a timeout. x/net/proxy converts the + // ctx deadline into a socket read deadline, so the surfaced error may be + // "i/o timeout" rather than context.DeadlineExceeded. Accept either: an + // errors.Is match, a net.Error with Timeout(), or a string containing + // "timeout"/"deadline"/"context". + if errors.Is(err, context.DeadlineExceeded) { + return + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return + } + msg := strings.ToLower(err.Error()) + if !strings.Contains(msg, "timeout") && + !strings.Contains(msg, "deadline") && + !strings.Contains(msg, "context") { + t.Fatalf("expected timeout/deadline-related error, got: %v", err) + } +} diff --git a/backend/internal/pkg/tlsfingerprint/randomizer.go b/backend/internal/pkg/tlsfingerprint/randomizer.go new file mode 100644 index 0000000000..21cdb59cd8 --- /dev/null +++ b/backend/internal/pkg/tlsfingerprint/randomizer.go @@ -0,0 +1,112 @@ +package tlsfingerprint + +import ( + "math/rand/v2" +) + +// GenerateRandomizedProfile returns a new Profile that perturbs the +// Claude Code 2.1.112 baseline along several account-safe axes so every +// account can carry a unique JA3 hash while still looking like "some +// Node.js-family TLS client." +// +// Intentionally conservative: the baseline cipher / signature-algorithm +// shape is preserved; only localized swaps happen within bands so the +// overall PFS-preferred ordering stays intact. Anthropic classifying on +// "JA3 equals exact Claude Code fingerprint" would lose this account, +// but classifying on "entire pool hashes identically" (the actual +// clustering risk) stops working. +// +// Axes randomized: +// 1. Cipher suites — 6–10 localized swaps within the PFS band [3:29] +// and the RSA band [41:] (indices into the baseline order). +// 2. Signature algorithms — localized swaps within RSA-PSS-PSS, +// RSA-PSS-RSAE, and legacy-DSA groups. +// 3. GREASE — 30% on, 70% off (matches the rare but plausible case of +// a Chrome-behavior wrapper around the Node stack). +// 4. ALPN — always http/1.1 only. Do NOT advertise h2: Go's http.Transport +// with a custom DialTLSContext cannot speak HTTP/2. If the server +// negotiates h2 via ALPN, the transport writes HTTP/1.1 over the +// connection and reads back HTTP/2 SETTINGS/WINDOW_UPDATE/GOAWAY +// frames, surfacing as "malformed HTTP response" errors. Real Claude +// Code only sends http/1.1 in ALPN anyway (see dialer.go:73). +// 5. Key share groups — always MLKEM768+X25519. Cannot drop MLKEM768 +// here: supported_groups still advertises it, and if the server HRRs +// back to MLKEM768, utls's HRR path can't regenerate its dual-key +// share and fails with "CurvePreferences includes unsupported curve". +// +// Returns a freshly-allocated Profile so callers may mutate safely. +// Uses math/rand/v2 package-global source (seeded by runtime). +func GenerateRandomizedProfile() *Profile { + p := &Profile{} + + // --- cipher suites --- + ciphers := make([]uint16, len(defaultCipherSuites)) + copy(ciphers, defaultCipherSuites) + // Localized swaps in the PFS (post-TLS1.3) band. Index bounds derived + // from the baseline grouping in dialer.go; keeps RSA ciphers at the + // bottom so the real-world "prefer forward-secrecy" shape holds. + pfsLo, pfsHi := 3, 29 + if pfsHi > len(ciphers) { + pfsHi = len(ciphers) + } + swapCountPFS := 6 + rand.IntN(5) // 6..10 + for range swapCountPFS { + a := pfsLo + rand.IntN(pfsHi-pfsLo) + b := pfsLo + rand.IntN(pfsHi-pfsLo) + ciphers[a], ciphers[b] = ciphers[b], ciphers[a] + } + // RSA band — smaller, fewer swaps so we don't completely scramble it. + rsaLo := 41 + if rsaLo < len(ciphers) { + swapCountRSA := 2 + rand.IntN(3) // 2..4 + for range swapCountRSA { + a := rsaLo + rand.IntN(len(ciphers)-rsaLo) + b := rsaLo + rand.IntN(len(ciphers)-rsaLo) + ciphers[a], ciphers[b] = ciphers[b], ciphers[a] + } + } + p.CipherSuites = ciphers + + // --- signature algorithms --- + // defaultSignatureAlgorithms uses utls.SignatureScheme, convert to + // uint16 for storage while we shuffle. + sigs := make([]uint16, len(defaultSignatureAlgorithms)) + for i, s := range defaultSignatureAlgorithms { + sigs[i] = uint16(s) + } + // Shuffle three groups that contain multiple peers. Indices into the + // baseline ordering (see dialer.go:198). Keeping experimental TLS 1.3 + // (0..2) and ECDSA (3..5) unchanged — those are the strongest leading + // signals real Node clients send in order. + shuffleWithin := func(lo, hi int) { + if hi > len(sigs) || hi-lo < 2 { + return + } + sub := sigs[lo:hi] + rand.Shuffle(len(sub), func(i, j int) { sub[i], sub[j] = sub[j], sub[i] }) + } + shuffleWithin(11, 14) // RSA-PSS-PSS (0x0809..0x080b) + shuffleWithin(14, 17) // RSA-PSS-RSAE (0x0804..0x0806) + shuffleWithin(20, 26) // legacy SHA224/256/384/512 DSA/etc. + p.SignatureAlgorithms = sigs + + // --- GREASE --- + p.EnableGREASE = rand.IntN(10) < 3 // 30% + + // --- ALPN --- + // Always http/1.1 only. See axis 4 in the doc comment for why advertising + // h2 here breaks request forwarding. + p.ALPNProtocols = []string{"http/1.1"} + + // --- Key share groups --- + // Always MLKEM768+X25519 — see axis 5 in the doc comment for why + // dropping MLKEM768 here is unsafe with the current utls HRR path. + p.KeyShareGroups = []uint16{uint16(0x11ec), uint16(29)} // MLKEM768, X25519 + + // Leave curves/point_formats/supported_versions/psk_modes/extensions + // empty so the dialer falls back to baseline values. Perturbing those + // axes further without live captures to validate would risk leaving + // the plausible-Node-client envelope. + + return p +} diff --git a/backend/internal/repository/identity_cache.go b/backend/internal/repository/identity_cache.go index 6152dd7a58..5fc53edad8 100644 --- a/backend/internal/repository/identity_cache.go +++ b/backend/internal/repository/identity_cache.go @@ -3,6 +3,7 @@ package repository import ( "context" "encoding/json" + "errors" "fmt" "time" @@ -11,10 +12,14 @@ import ( ) const ( - fingerprintKeyPrefix = "fingerprint:" - fingerprintTTL = 7 * 24 * time.Hour // 7天,配合每24小时懒续期可保持活跃账号永不过期 - maskedSessionKeyPrefix = "masked_session:" - maskedSessionTTL = 15 * time.Minute + fingerprintKeyPrefix = "fingerprint:" + fingerprintTTL = 7 * 24 * time.Hour // 7天,配合每24小时懒续期可保持活跃账号永不过期 + maskedSessionKeyPrefix = "masked_session:" + maskedSessionTTL = 15 * time.Minute + stickySessionUUIDKeyPrefix = "sticky_session_uuid:" + // 30 分钟 TTL 贴近一次真实 Claude Code CLI 会话的典型生命周期; + // 会话窗口内复用 UUID,之后自然换新。 + stickySessionUUIDTTL = 30 * time.Minute ) // fingerprintKey generates the Redis key for account fingerprint cache. @@ -27,6 +32,12 @@ func maskedSessionKey(accountID int64) string { return fmt.Sprintf("%s%d", maskedSessionKeyPrefix, accountID) } +// stickySessionUUIDKey generates the Redis key for the (accountID, sessionHash)-scoped +// session UUID used to keep metadata.session_id stable within a single CLI session. +func stickySessionUUIDKey(accountID int64, sessionHash string) string { + return fmt.Sprintf("%s%d:%s", stickySessionUUIDKeyPrefix, accountID, sessionHash) +} + type identityCache struct { rdb *redis.Client } @@ -35,10 +46,17 @@ func NewIdentityCache(rdb *redis.Client) service.IdentityCache { return &identityCache{rdb: rdb} } +// GetFingerprint returns the cached fingerprint for accountID. +// Returns (nil, nil) when the key doesn't exist; (nil, err) only on +// transient Redis failures so callers can preserve identity on outage +// instead of minting a fresh ClientID. func (c *identityCache) GetFingerprint(ctx context.Context, accountID int64) (*service.Fingerprint, error) { key := fingerprintKey(accountID) val, err := c.rdb.Get(ctx, key).Result() if err != nil { + if errors.Is(err, redis.Nil) { + return nil, nil + } return nil, err } var fp service.Fingerprint @@ -73,3 +91,24 @@ func (c *identityCache) SetMaskedSessionID(ctx context.Context, accountID int64, key := maskedSessionKey(accountID) return c.rdb.Set(ctx, key, sessionID, maskedSessionTTL).Err() } + +func (c *identityCache) GetStickySessionUUID(ctx context.Context, accountID int64, sessionHash string) (string, error) { + if sessionHash == "" { + return "", nil + } + val, err := c.rdb.Get(ctx, stickySessionUUIDKey(accountID, sessionHash)).Result() + if err != nil { + if err == redis.Nil { + return "", nil + } + return "", err + } + return val, nil +} + +func (c *identityCache) SetStickySessionUUID(ctx context.Context, accountID int64, sessionHash string, sessionUUID string) error { + if sessionHash == "" || sessionUUID == "" { + return nil + } + return c.rdb.Set(ctx, stickySessionUUIDKey(accountID, sessionHash), sessionUUID, stickySessionUUIDTTL).Err() +} diff --git a/backend/internal/repository/identity_cache_integration_test.go b/backend/internal/repository/identity_cache_integration_test.go index 48f59c1373..d535199051 100644 --- a/backend/internal/repository/identity_cache_integration_test.go +++ b/backend/internal/repository/identity_cache_integration_test.go @@ -25,8 +25,9 @@ func (s *IdentityCacheSuite) SetupTest() { } func (s *IdentityCacheSuite) TestGetFingerprint_Missing() { - _, err := s.cache.GetFingerprint(s.ctx, 1) - require.True(s.T(), errors.Is(err, redis.Nil), "expected redis.Nil for missing fingerprint") + fp, err := s.cache.GetFingerprint(s.ctx, 1) + require.NoError(s.T(), err, "missing fingerprint should be (nil, nil), not an error") + require.Nil(s.T(), fp, "missing fingerprint should return nil pointer") } func (s *IdentityCacheSuite) TestSetAndGetFingerprint() { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 9af0fd8ef6..ece395979a 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -549,6 +549,7 @@ func registerTLSFingerprintProfileRoutes(admin *gin.RouterGroup, h *handler.Hand profiles.POST("", h.Admin.TLSFingerprintProfile.Create) profiles.PUT("/:id", h.Admin.TLSFingerprintProfile.Update) profiles.DELETE("/:id", h.Admin.TLSFingerprintProfile.Delete) + profiles.POST("/randomize-for-account/:accountID", h.Admin.TLSFingerprintProfile.RandomizeForAccount) } } diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 52db30738a..e5183e82f2 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1253,6 +1253,22 @@ func (a *Account) IsTLSFingerprintEnabled() bool { return false } +// IsTLSFingerprintRandomized 判断该账号的 TLS 指纹是否由随机分配机制 +// 维护。此标记独立于 enable_tls_fingerprint,用来告诉前端"重新随机"按 +// 钮是否可用,以及在账号变更时判断旧 profile 是否属于 auto-generated +// 清理范围。 +func (a *Account) IsTLSFingerprintRandomized() bool { + if a.Extra == nil { + return false + } + if v, ok := a.Extra["tls_fingerprint_randomized"]; ok { + if b, ok := v.(bool); ok { + return b + } + } + return false +} + // GetTLSFingerprintProfileID 获取账号绑定的 TLS 指纹模板 ID // 返回 0 表示未绑定(使用内置默认 profile) func (a *Account) GetTLSFingerprintProfileID() int64 { diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index a5559b7de2..ee08f8ef60 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -268,6 +268,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account for key, value := range claude.DefaultHeaders { req.Header.Set(key, value) } + req.Header.Set("X-Stainless-Retry-Count", claude.SampleStainlessRetryCount()) // Set authentication header if useBearer { diff --git a/backend/internal/service/claude_sidecar_probe_service.go b/backend/internal/service/claude_sidecar_probe_service.go new file mode 100644 index 0000000000..3089894fd2 --- /dev/null +++ b/backend/internal/service/claude_sidecar_probe_service.go @@ -0,0 +1,167 @@ +package service + +import ( + "context" + "log/slog" + "math/rand/v2" + "sync" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/safe" +) + +// ClaudeSidecarProbeService periodically calls /api/oauth/usage for a randomly +// chosen active Claude OAuth account. Without this, a gateway that only ever +// forwards /v1/messages produces an endpoint mix that is trivially +// distinguishable from a real Claude Code CLI (which drives the statusline +// from /api/oauth/usage). Anthropic's subscription abuse heuristics flag that +// shape and silently throttle the weekly limit to a fraction of its nominal +// value. This service restores non-zero /api/oauth/usage traffic per account. +// +// Design notes: +// - One probe per tick, picked at random from eligible accounts. Fanning out +// to every account on each tick would produce a synchronous burst that is +// itself a distinctive signal. +// - Interval is jittered between min/max to avoid a steady periodic cadence. +// - We deliberately piggyback on AccountUsageService.GetUsage instead of +// calling the raw fetcher, so TLS profile, proxy, fingerprint, and cache +// behavior all match what the admin UI path does. +// - Probe failures are non-fatal: this is cosmetic sidecar traffic, the +// user's real requests never depend on a successful probe. +type ClaudeSidecarProbeService struct { + accountRepo AccountRepository + usageService *AccountUsageService + + minInterval time.Duration + maxInterval time.Duration + dryRun bool + + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +// NewClaudeSidecarProbeService constructs a probe service. A minInterval of +// zero or a maxInterval smaller than minInterval disables the loop entirely +// (Start becomes a no-op). +func NewClaudeSidecarProbeService( + accountRepo AccountRepository, + usageService *AccountUsageService, + minInterval, maxInterval time.Duration, + dryRun bool, +) *ClaudeSidecarProbeService { + return &ClaudeSidecarProbeService{ + accountRepo: accountRepo, + usageService: usageService, + minInterval: minInterval, + maxInterval: maxInterval, + dryRun: dryRun, + stopCh: make(chan struct{}), + } +} + +// Start launches the probe loop in a goroutine. Safe to call on a nil receiver +// or with missing dependencies: both become no-ops so wire/test code doesn't +// have to guard. +func (s *ClaudeSidecarProbeService) Start() { + if s == nil || s.accountRepo == nil || s.usageService == nil { + return + } + if s.minInterval <= 0 || s.maxInterval < s.minInterval { + return + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + safe.Run("sidecar_probe.loop", nil, s.loop) + }() +} + +// Stop signals the loop to exit and waits for it. +func (s *ClaudeSidecarProbeService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + close(s.stopCh) + }) + s.wg.Wait() +} + +func (s *ClaudeSidecarProbeService) loop() { + // Initial delay so that a process restart doesn't fire a probe at t=0 + // (which would cluster with every other restart-time operation and be + // visible to the upstream as a correlated spike). + select { + case <-time.After(s.jitterInterval()): + case <-s.stopCh: + return + } + + for { + s.runOnce(context.Background()) + select { + case <-time.After(s.jitterInterval()): + case <-s.stopCh: + return + } + } +} + +func (s *ClaudeSidecarProbeService) jitterInterval() time.Duration { + if s.maxInterval <= s.minInterval { + return s.minInterval + } + span := int64(s.maxInterval - s.minInterval) + return s.minInterval + time.Duration(rand.Int64N(span)) +} + +func (s *ClaudeSidecarProbeService) runOnce(parent context.Context) { + ctx, cancel := context.WithTimeout(parent, sidecarProbeRequestTimeout) + defer cancel() + + accounts, err := s.accountRepo.ListByPlatform(ctx, PlatformAnthropic) + if err != nil { + slog.Warn("sidecar_probe.list_accounts_failed", "error", err) + return + } + + eligible := filterSidecarProbeTargets(accounts) + if len(eligible) == 0 { + return + } + + target := eligible[rand.IntN(len(eligible))] + + if s.dryRun { + slog.Info("sidecar_probe.dry_run", "account_id", target.ID, "account_name", target.Name) + return + } + + if _, err := s.usageService.GetUsage(ctx, target.ID); err != nil { + slog.Warn("sidecar_probe.probe_failed", "account_id", target.ID, "error", err) + return + } +} + +// filterSidecarProbeTargets keeps only Claude OAuth accounts that are active, +// capable of returning usage (profile scope), and have a non-empty access +// token. Setup-token accounts are inference-only and return 401 on +// /api/oauth/usage, so including them would produce nothing but error noise. +func filterSidecarProbeTargets(accounts []Account) []Account { + var out []Account + for i := range accounts { + a := accounts[i] + if a.Status != StatusActive { + continue + } + if !a.CanGetUsage() { + continue + } + if a.GetCredential("access_token") == "" { + continue + } + out = append(out, a) + } + return out +} diff --git a/backend/internal/service/claude_sidecar_probe_service_test.go b/backend/internal/service/claude_sidecar_probe_service_test.go new file mode 100644 index 0000000000..c76122aab5 --- /dev/null +++ b/backend/internal/service/claude_sidecar_probe_service_test.go @@ -0,0 +1,148 @@ +//go:build unit + +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestFilterSidecarProbeTargets(t *testing.T) { + withToken := map[string]any{"access_token": "tok"} + emptyToken := map[string]any{"access_token": ""} + + tests := []struct { + name string + input []Account + wantIDs []int64 + }{ + { + name: "active oauth with token is kept", + input: []Account{ + {ID: 1, Status: StatusActive, Type: AccountTypeOAuth, Credentials: withToken}, + }, + wantIDs: []int64{1}, + }, + { + name: "setup token account skipped (no profile scope)", + input: []Account{ + {ID: 1, Status: StatusActive, Type: AccountTypeSetupToken, Credentials: withToken}, + }, + wantIDs: nil, + }, + { + name: "api key account skipped", + input: []Account{ + {ID: 1, Status: StatusActive, Type: AccountTypeAPIKey, Credentials: withToken}, + }, + wantIDs: nil, + }, + { + name: "disabled oauth skipped", + input: []Account{ + {ID: 1, Status: StatusDisabled, Type: AccountTypeOAuth, Credentials: withToken}, + }, + wantIDs: nil, + }, + { + name: "error oauth skipped", + input: []Account{ + {ID: 1, Status: StatusError, Type: AccountTypeOAuth, Credentials: withToken}, + }, + wantIDs: nil, + }, + { + name: "oauth without access token skipped", + input: []Account{ + {ID: 1, Status: StatusActive, Type: AccountTypeOAuth, Credentials: emptyToken}, + }, + wantIDs: nil, + }, + { + name: "mixed pool keeps only eligible", + input: []Account{ + {ID: 1, Status: StatusActive, Type: AccountTypeOAuth, Credentials: withToken}, + {ID: 2, Status: StatusActive, Type: AccountTypeSetupToken, Credentials: withToken}, + {ID: 3, Status: StatusActive, Type: AccountTypeOAuth, Credentials: withToken}, + {ID: 4, Status: StatusError, Type: AccountTypeOAuth, Credentials: withToken}, + }, + wantIDs: []int64{1, 3}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filterSidecarProbeTargets(tt.input) + var gotIDs []int64 + for _, a := range got { + gotIDs = append(gotIDs, a.ID) + } + require.Equal(t, tt.wantIDs, gotIDs) + }) + } +} + +func TestClaudeSidecarProbeJitterInterval(t *testing.T) { + t.Run("returns min when max equals min", func(t *testing.T) { + s := &ClaudeSidecarProbeService{ + minInterval: 5 * time.Minute, + maxInterval: 5 * time.Minute, + } + require.Equal(t, 5*time.Minute, s.jitterInterval()) + }) + + t.Run("returns value in [min,max) when range is valid", func(t *testing.T) { + s := &ClaudeSidecarProbeService{ + minInterval: 5 * time.Minute, + maxInterval: 15 * time.Minute, + } + // Sample enough times to exercise the jitter range. + for i := 0; i < 200; i++ { + v := s.jitterInterval() + require.GreaterOrEqual(t, v, 5*time.Minute) + require.Less(t, v, 15*time.Minute) + } + }) +} + +func TestClaudeSidecarProbeLifecycle(t *testing.T) { + t.Run("nil receiver is safe", func(t *testing.T) { + var s *ClaudeSidecarProbeService + require.NotPanics(t, func() { + s.Start() + s.Stop() + }) + }) + + t.Run("missing deps skip loop", func(t *testing.T) { + s := NewClaudeSidecarProbeService(nil, nil, 5*time.Minute, 15*time.Minute, false) + // Start is a no-op because accountRepo / usageService are nil. + // Stop must still be safe. + require.NotPanics(t, func() { + s.Start() + s.Stop() + }) + }) + + t.Run("zero interval skips loop", func(t *testing.T) { + s := NewClaudeSidecarProbeService(nil, nil, 0, 0, false) + require.NotPanics(t, func() { + s.Start() + s.Stop() + }) + }) + + t.Run("double stop is safe", func(t *testing.T) { + s := NewClaudeSidecarProbeService(nil, nil, 5*time.Minute, 15*time.Minute, false) + s.Start() + s.Stop() + require.NotPanics(t, func() { s.Stop() }) + }) +} + +func TestSidecarProbeConstantsCoherent(t *testing.T) { + require.Less(t, sidecarProbeRequestTimeout, sidecarProbeMinIntervalFloor, + "request timeout must be smaller than min interval") +} diff --git a/backend/internal/service/claude_startup_probe.go b/backend/internal/service/claude_startup_probe.go new file mode 100644 index 0000000000..c0ee9bfa2b --- /dev/null +++ b/backend/internal/service/claude_startup_probe.go @@ -0,0 +1,130 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/safe" +) + +// ClaudeStartupProber is the narrow interface TokenRefreshService uses to +// request a startup-handshake probe after successfully refreshing a token. +// It exists only to avoid a direct *GatewayService dependency inside +// TokenRefreshService — the refresh loop knows nothing about HTTP forwarding +// details and should not learn them. +type ClaudeStartupProber interface { + // ProbeClaudeStartup sends a max_tokens=1 haiku /v1/messages request to + // the given Claude OAuth account. Matches the "am I alive" probe real + // Claude Code CLI sends at boot, so after a background token refresh the + // upstream sees the same traffic shape a real login would produce. Runs + // synchronously but should be called from a goroutine at the caller side + // so a slow upstream doesn't block the refresh loop. + ProbeClaudeStartup(ctx context.Context, account *Account) error +} + +// claudeStartupProbeBody is the canonical body Claude Code's startup probe +// sends: haiku, max_tokens=1, a single user message, no tools, no system +// prompt customization. Mirrors validator logic in claude_code_validator.go +// that recognizes this exact pattern. +func claudeStartupProbeBody() []byte { + body := map[string]any{ + "model": startupProbeModel, + "max_tokens": startupProbeMaxTokens, + "messages": []map[string]any{ + { + "role": startupProbeUserRole, + "content": startupProbeUserContent, + }, + }, + } + b, _ := json.Marshal(body) + return b +} + +// ProbeClaudeStartup implements ClaudeStartupProber on GatewayService. +// +// Uses the same credential resolution, TLS profile, proxy, and fingerprint +// plumbing the real Forward path uses, so upstream sees a request that is +// indistinguishable from the first /v1/messages a freshly-authenticated +// Claude Code CLI would send. +func (s *GatewayService) ProbeClaudeStartup(ctx context.Context, account *Account) error { + if s == nil || account == nil { + return fmt.Errorf("nil gateway or account") + } + if account.Platform != PlatformAnthropic || !account.IsOAuth() { + return nil // nothing to probe + } + + token, tokenType, err := s.GetAccessToken(ctx, account) + if err != nil { + return fmt.Errorf("get access token: %w", err) + } + if tokenType != "oauth" { + // Setup-token / api-key: skip. Only real OAuth accounts get a + // startup probe since that matches what `claude setup-token` sets + // up on a developer machine. + return nil + } + + body := claudeStartupProbeBody() + + req, err := http.NewRequestWithContext(ctx, "POST", claudeAPIURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build probe request: %w", err) + } + setHeaderRaw(req.Header, "authorization", "Bearer "+token) + setHeaderRaw(req.Header, "content-type", "application/json") + setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") + // Claude Code's startup probe sends the Haiku-scoped beta header: OAuth + // scope + interleaved thinking (no claude-code beta, matching what the + // real CLI sends for its initial /v1/messages warm-up). + setHeaderRaw(req.Header, "anthropic-beta", claude.HaikuBetaHeader) + applyClaudeOAuthHeaderDefaults(req) + + // Apply the account's cached fingerprint so X-Stainless-* headers match + // whatever identity was minted the first time this account ran. + if s.identityService != nil { + if fp, fpErr := s.identityService.GetOrCreateFingerprint(ctx, account.ID, http.Header{}); fpErr == nil && fp != nil { + s.identityService.ApplyFingerprint(req, fp) + } + } + + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account) + + resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, tlsProfile) + if err != nil { + return fmt.Errorf("send probe: %w", err) + } + if resp != nil && resp.Body != nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + } + return nil +} + +// runStartupProbeAsync is a convenience wrapper that TokenRefreshService +// uses to fire a probe in a detached goroutine with a bounded timeout. +// Logs failures under service.sidecar_probe and swallows errors. +func runStartupProbeAsync(prober ClaudeStartupProber, account *Account) { + if prober == nil || account == nil { + return + } + accountID := account.ID + safe.Go("sidecar_probe.startup", []slog.Attr{slog.Int64("account_id", accountID)}, func() { + ctx, cancel := context.WithTimeout(context.Background(), startupProbeDefaultTimeout) + defer cancel() + if err := prober.ProbeClaudeStartup(ctx, account); err != nil { + slog.Warn("sidecar_probe.startup.failed", "account_id", accountID, "error", err) + } + }) +} diff --git a/backend/internal/service/gateway_count_tokens_inject.go b/backend/internal/service/gateway_count_tokens_inject.go new file mode 100644 index 0000000000..412c9fe69b --- /dev/null +++ b/backend/internal/service/gateway_count_tokens_inject.go @@ -0,0 +1,97 @@ +package service + +import ( + "context" + "io" + "log/slog" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/safe" +) + +// maybeInjectCountTokensSidecar fires a fire-and-forget count_tokens request +// to upstream for Claude OAuth accounts, so the account's endpoint mix +// includes /v1/messages/count_tokens at a rate that tracks /v1/messages. +// +// Why this exists: real Claude Code CLI calls count_tokens for every message +// the user composes (the editor uses the result for the "N tokens" hint in +// the status area). A gateway that only forwards /v1/messages produces a +// traffic shape that is trivially distinguishable from real CLI usage, and +// Anthropic's subscription abuse heuristics flag that shape and silently cap +// the account's weekly limit to a tiny fraction of its nominal value. +// +// Gating (all must be true, else no-op): +// - cfg.SidecarProbe.CountTokensInject.Enabled +// - account is a Claude OAuth / setup-token account on the Anthropic platform +// - downstream client is NOT already Claude Code (which sends its own +// count_tokens; adding a second one would double-dip) +// +// Execution shape: spawns a goroutine with a detached context bounded by +// CountTokensInject.TimeoutMilliseconds. Errors are logged but never +// surfaced — sidecar traffic is best-effort and must not affect the real +// user request. The body is cloned before the goroutine because the main +// path's retry loop may mutate the original buffer. +func (s *GatewayService) maybeInjectCountTokensSidecar( + account *Account, + body []byte, + reqModel string, + token, tokenType string, + mimicClaudeCode bool, + isClaudeCode bool, + proxyURL string, +) { + if s == nil || s.cfg == nil { + return + } + if !s.cfg.SidecarProbe.CountTokensInject.Enabled { + return + } + if account == nil || account.Platform != PlatformAnthropic || !account.IsOAuth() { + return + } + if isClaudeCode { + // Real Claude Code already sends count_tokens through the gateway's + // /v1/messages/count_tokens passthrough route. Injecting another one + // would produce a 2:1 count_tokens:messages ratio per account, + // which is itself anomalous. + return + } + if len(body) == 0 { + return + } + + timeoutMs := s.cfg.SidecarProbe.CountTokensInject.TimeoutMilliseconds + if timeoutMs <= 0 { + timeoutMs = countTokensSidecarDefaultTimeoutMs + } + timeout := time.Duration(timeoutMs) * time.Millisecond + + // Clone body: the main path's retry loop mutates its buffer, and the + // goroutine may read it long after Forward() has returned. + bodyCopy := append([]byte(nil), body...) + + accountID := account.ID + safe.Go("sidecar_probe.count_tokens", []slog.Attr{slog.Int64("account_id", accountID)}, func() { + // Detached context: the goroutine must outlive the caller's ctx, + // which gets cancelled when the downstream client disconnects. + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + req, err := s.buildCountTokensRequest(ctx, nil, account, bodyCopy, token, tokenType, reqModel, mimicClaudeCode) + if err != nil { + slog.Warn("sidecar_probe.count_tokens.build_failed", "account_id", accountID, "error", err) + return + } + + tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account) + resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, tlsProfile) + if err != nil { + slog.Warn("sidecar_probe.count_tokens.request_failed", "account_id", accountID, "error", err) + return + } + if resp != nil && resp.Body != nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + } + }) +} diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index e27e18aaa7..d1fd6df04d 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -423,3 +423,23 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { }) } } + +func TestEnsureToolsCacheControl(t *testing.T) { + t.Run("adds breakpoint to last tool when absent", func(t *testing.T) { + body := []byte(`{"tools":[{"name":"a","description":"x"},{"name":"b","description":"y"}]}`) + out := ensureToolsCacheControl(body) + require.NotEqual(t, string(body), string(out), "expected body to be modified") + require.Contains(t, string(out), `"tools":[{"name":"a","description":"x"},{"name":"b","description":"y","cache_control":{"type":"ephemeral"}}]`) + }) + + t.Run("respects existing breakpoint anywhere", func(t *testing.T) { + body := []byte(`{"tools":[{"name":"a","cache_control":{"type":"ephemeral"}},{"name":"b"}]}`) + out := ensureToolsCacheControl(body) + require.Equal(t, string(body), string(out), "existing CC must be preserved untouched") + }) + + t.Run("no-op when tools missing or empty", func(t *testing.T) { + require.Equal(t, `{"messages":[]}`, string(ensureToolsCacheControl([]byte(`{"messages":[]}`)))) + require.Equal(t, `{"tools":[]}`, string(ensureToolsCacheControl([]byte(`{"tools":[]}`)))) + }) +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 07a9e41c95..5594736f57 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -3857,6 +3857,41 @@ func collectCacheControlPaths(body []byte) (invalidThinking []cacheControlPath, return invalidThinking, messagePaths, systemPaths } +// ensureToolsCacheControl stamps `cache_control: {"type":"ephemeral"}` onto +// the final entry of the `tools` array when no tool in the request already +// carries a cache_control marker. This matches real Claude Code traffic — +// the CLI always caches its tool schema — so a mimicked request from a +// non-CLI client (Cherry Studio, Cursor, plain OpenAI SDK, etc.) exhibits a +// cache hit ratio consistent with the CLI instead of "0%, always". +// +// No-ops when: body lacks tools, tools is empty, or any tool already has a +// cache_control breakpoint (trust the caller in that case). +func ensureToolsCacheControl(body []byte) []byte { + if len(body) == 0 { + return body + } + tools := gjson.GetBytes(body, "tools") + if !tools.IsArray() { + return body + } + arr := tools.Array() + if len(arr) == 0 { + return body + } + for _, t := range arr { + if t.Get("cache_control").Exists() { + return body + } + } + lastIdx := len(arr) - 1 + path := fmt.Sprintf("tools.%d.cache_control", lastIdx) + out, err := sjson.SetBytes(body, path, map[string]string{"type": "ephemeral"}) + if err != nil { + return body + } + return out +} + // enforceCacheControlLimit 强制执行 cache_control 块数量限制(最多 4 个) // 超限时优先从 messages 中移除 cache_control,保护 system 中的缓存控制 func enforceCacheControlLimit(body []byte) []byte { @@ -3934,6 +3969,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if parsed == nil { return nil, fmt.Errorf("parse request: empty request") } + // Stash the session hash so downstream metadata rewrites can reuse a + // sticky session UUID for all requests belonging to this conversation. + ctx = WithSessionHash(ctx, s.GenerateSessionHash(parsed)) // Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应 if account != nil && s.shouldEmulateWebSearch(ctx, account, parsed.GroupID, parsed.Body) { @@ -4005,6 +4043,14 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A systemRewritten = true } + // Real Claude Code caches its tool definitions by stamping cache_control + // on the final tool block; clients like Cursor / Cherry Studio / OpenAI + // SDK wrappers send tools without any cache_control, which produces a + // cache-hit-rate profile unlike a real CLI. Inject a breakpoint when + // none is present. enforceCacheControlLimit below guarantees we stay + // within Anthropic's 4-block cap. + body = ensureToolsCacheControl(body) + // system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为); // 未重写时(haiku / 已含 CC 前缀)剥离客户端 cache_control,与原有行为一致。 // 两种情况下 enforceCacheControlLimit 都会兜底处理上限。 @@ -4080,6 +4126,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // 重试间复用同一请求体,避免每次 string(body) 产生额外分配。 setOpsUpstreamRequestBody(c, body) + // Sidecar: 为 Claude OAuth 账号注入 count_tokens 请求,让 endpoint 比例 + // 看起来像真实 Claude Code CLI。非阻塞,默认关闭(由 sidecar_probe + // 配置控制)。详见 gateway_count_tokens_inject.go。 + s.maybeInjectCountTokensSidecar(account, body, reqModel, token, tokenType, shouldMimicClaudeCode, isClaudeCode, proxyURL) + // 重试循环 var resp *http.Response retryStart := time.Now() @@ -5632,9 +5683,19 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // this as a legitimate Claude Code request; without it, the request is // rejected as third-party ("out of extra usage"). // Haiku models are exempt from third-party detection and don't need it. + // Non-haiku additionally carries the betas that the current Claude Code CLI ships + // by default — see claude.DefaultHeaders and the clientExtraBetas constant; if we + // stamp that UA but drop these, upstream can rule-match the mismatch pool-wide. requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking} if !strings.Contains(strings.ToLower(modelID), "haiku") { - requiredBetas = []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking} + requiredBetas = []string{ + claude.BetaClaudeCode, + claude.BetaOAuth, + claude.BetaInterleavedThinking, + claude.BetaContextManagement20250627, + claude.BetaPromptCachingScope20260105, + claude.BetaAdvisorTool20260301, + } } setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropSet)) } else { @@ -5769,6 +5830,11 @@ func applyClaudeOAuthHeaderDefaults(req *http.Request) { setHeaderRaw(req.Header, resolveWireCasing(key), value) } } + // Retry count is sampled per-request rather than held as a constant default, + // so every request gets fresh variance instead of a flat "0". + if getHeaderRaw(req.Header, "X-Stainless-Retry-Count") == "" { + setHeaderRaw(req.Header, resolveWireCasing("X-Stainless-Retry-Count"), claude.SampleStainlessRetryCount()) + } } func mergeAnthropicBeta(required []string, incoming string) string { @@ -6101,6 +6167,9 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { } setHeaderRaw(req.Header, resolveWireCasing(key), value) } + // Force a freshly sampled retry count — mimic mode overrides whatever the + // downstream client may have sent. + setHeaderRaw(req.Header, resolveWireCasing("X-Stainless-Retry-Count"), claude.SampleStainlessRetryCount()) // Real Claude CLI uses Accept: application/json (even for streaming). setHeaderRaw(req.Header, "Accept", "application/json") if isStream { @@ -8177,6 +8246,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, s.countTokensError(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty") return fmt.Errorf("parse request: empty request") } + ctx = WithSessionHash(ctx, s.GenerateSessionHash(parsed)) if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() { passthroughBody := parsed.Body diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index 3d7065083e..c0e925204f 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -24,15 +24,54 @@ var ( userAgentVersionRegex = regexp.MustCompile(`/(\d+)\.(\d+)\.(\d+)`) ) +// sessionHashCtxKeyType keys the per-request sticky-session hash that +// RewriteUserIDWithMasking uses to keep metadata.session_id stable across +// concurrent requests belonging to the same client conversation. +type sessionHashCtxKeyType struct{} + +var sessionHashCtxKey sessionHashCtxKeyType + +// WithSessionHash returns a context carrying the given session hash. Gateway +// entry points (Forward, ForwardCountTokens) stash the hash they compute for +// sticky account routing so that RewriteUserIDWithMasking can reuse it when +// generating a sticky session UUID. +func WithSessionHash(ctx context.Context, sessionHash string) context.Context { + if sessionHash == "" { + return ctx + } + return context.WithValue(ctx, sessionHashCtxKey, sessionHash) +} + +// SessionHashFromContext extracts the session hash previously stashed by +// WithSessionHash. Returns "" when absent. +func SessionHashFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + v, _ := ctx.Value(sessionHashCtxKey).(string) + return v +} + // 默认指纹值(当客户端未提供时使用) +// +// Re-verified 2026-04-17 against a live capture of Claude Code 2.1.111 on +// Node.js 24.14.1 / macOS arm64 (capture tool: backend/tools/capture_fingerprint). +// UA bumped to 2.1.112 (latest on npm as of 2026-04-17) — patch-level Claude +// Code releases keep the same Stainless fields. Bundled @anthropic-ai/sdk is +// still 0.81.0. These values match the real CLI's request headers exactly — +// particularly: +// - UserAgent: "claude-cli/2.1.112 (external, sdk-cli)" (note: "sdk-cli", NOT "cli") +// - StainlessPackageVersion: "0.81.0" +// - StainlessOS: "MacOS" (case: mixed, not "Linux") +// - StainlessRuntimeVersion: "v24.14.1" var defaultFingerprint = Fingerprint{ - UserAgent: "claude-cli/2.1.22 (external, cli)", + UserAgent: "claude-cli/2.1.112 (external, sdk-cli)", StainlessLang: "js", - StainlessPackageVersion: "0.70.0", - StainlessOS: "Linux", + StainlessPackageVersion: "0.81.0", + StainlessOS: "MacOS", StainlessArch: "arm64", StainlessRuntime: "node", - StainlessRuntimeVersion: "v24.13.0", + StainlessRuntimeVersion: "v24.14.1", } // Fingerprint represents account fingerprint data @@ -59,6 +98,13 @@ type IdentityCache interface { // SetMaskedSessionID 设置固定的会话ID,TTL 为 15 分钟 // 每次调用都会刷新 TTL SetMaskedSessionID(ctx context.Context, accountID int64, sessionID string) error + // GetStickySessionUUID 返回与 (accountID, sessionHash) 绑定的随机 session UUID。 + // 用于让一次 CLI 会话生命周期内多次请求共用同一个 metadata.session_id, + // 贴近真实 Claude Code 行为(一次 CLI 调用整个会话共用一个 UUID,退出/新会话换)。 + // 未命中返回 "" + nil。 + GetStickySessionUUID(ctx context.Context, accountID int64, sessionHash string) (string, error) + // SetStickySessionUUID 以 30 分钟 TTL 存储会话级 session UUID。 + SetStickySessionUUID(ctx context.Context, accountID int64, sessionHash string, sessionUUID string) error } // IdentityService 管理OAuth账号的请求身份指纹 @@ -206,138 +252,109 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) { } } +// extractRewriteTarget runs the shared preflight: empty-arg checks, +// metadata-object existence, user_id presence, parse. Returns "" when +// there's nothing to rewrite (caller should return body unchanged). +func extractRewriteTarget(body []byte, accountUUID, cachedClientID string) string { + if len(body) == 0 || accountUUID == "" || cachedClientID == "" { + return "" + } + metadata := gjson.GetBytes(body, "metadata") + if !metadata.Exists() || metadata.Type == gjson.Null { + return "" + } + if !strings.HasPrefix(strings.TrimSpace(metadata.Raw), "{") { + return "" + } + r := metadata.Get("user_id") + if !r.Exists() || r.Type != gjson.String { + return "" + } + uid := r.String() + if uid == "" { + return "" + } + if ParseMetadataUserID(uid) == nil { + return "" + } + return uid +} + +// spliceUserID writes the new metadata.user_id back via sjson, short- +// circuiting when the new value equals the old. +func spliceUserID(body []byte, oldUserID, cachedClientID, accountUUID, sessionUUID, fingerprintUA string) []byte { + version := ExtractCLIVersion(fingerprintUA) + newUserID := FormatMetadataUserID(cachedClientID, accountUUID, sessionUUID, version) + if newUserID == oldUserID { + return body + } + newBody, err := sjson.SetBytes(body, "metadata.user_id", newUserID) + if err != nil { + return body + } + return newBody +} + // RewriteUserID 重写body中的metadata.user_id // 支持旧拼接格式和新 JSON 格式的 user_id 解析, // 根据 fingerprintUA 版本选择输出格式。 // -// 重要:此函数使用 json.RawMessage 保留其他字段的原始字节, -// 避免重新序列化导致 thinking 块等内容被修改。 +// Anti-fingerprinting fix (2026-04-15): mint a fresh cryptographically-random +// UUID per request. Device id (cachedClientID) remains stable for the account, +// but session churn looks natural — see RewriteUserIDWithMasking for the +// sticky-session variant that reuses one UUID across a CLI conversation. func (s *IdentityService) RewriteUserID(body []byte, accountID int64, accountUUID, cachedClientID, fingerprintUA string) ([]byte, error) { - if len(body) == 0 || accountUUID == "" || cachedClientID == "" { - return body, nil - } - - metadata := gjson.GetBytes(body, "metadata") - if !metadata.Exists() || metadata.Type == gjson.Null { - return body, nil - } - if !strings.HasPrefix(strings.TrimSpace(metadata.Raw), "{") { - return body, nil - } - - userIDResult := metadata.Get("user_id") - if !userIDResult.Exists() || userIDResult.Type != gjson.String { - return body, nil - } - userID := userIDResult.String() + userID := extractRewriteTarget(body, accountUUID, cachedClientID) if userID == "" { return body, nil } - - // 解析 user_id(兼容旧拼接格式和新 JSON 格式) - parsed := ParseMetadataUserID(userID) - if parsed == nil { - return body, nil - } - - sessionTail := parsed.SessionID // 原始session UUID - - // 生成新的session hash: SHA256(accountID::sessionTail) -> UUID格式 - seed := fmt.Sprintf("%d::%s", accountID, sessionTail) - newSessionHash := generateUUIDFromSeed(seed) - - // 根据客户端版本选择输出格式 - version := ExtractCLIVersion(fingerprintUA) - newUserID := FormatMetadataUserID(cachedClientID, accountUUID, newSessionHash, version) - if newUserID == userID { - return body, nil - } - - newBody, err := sjson.SetBytes(body, "metadata.user_id", newUserID) - if err != nil { - return body, nil - } - return newBody, nil + newSessionHash := generateRandomUUID() + return spliceUserID(body, userID, cachedClientID, accountUUID, newSessionHash, fingerprintUA), nil } -// RewriteUserIDWithMasking 重写body中的metadata.user_id,支持会话ID伪装 -// 如果账号启用了会话ID伪装(session_id_masking_enabled), -// 则在完成常规重写后,将 session 部分替换为固定的伪装ID(15分钟内保持不变) +// RewriteUserIDWithMasking 重写body中的metadata.user_id // -// 重要:此函数使用 json.RawMessage 保留其他字段的原始字节, -// 避免重新序列化导致 thinking 块等内容被修改。 +// Sticky session fix (2026-04-17): the previous implementation minted a fresh +// random UUID on *every* request, which also looks unnatural — a real Claude +// Code CLI session reuses the same session_id for the full lifetime of the +// invocation (dozens of turns, many tool calls). With per-request random UUIDs +// a gateway account exhibits thousands of distinct session_ids per day, each +// appearing exactly once, which is as easy to fingerprint as the old +// deterministic hash. +// +// New behavior: +// - If the caller stashed a session hash via WithSessionHash, we look up a +// sticky UUID in Redis keyed by (accountID, sessionHash). Cache hit → +// reuse; miss → generate a fresh random UUID and store it (30-min TTL). +// - Without a session hash, we fall back to RewriteUserID's random-per-call +// behavior (preserves old semantics for callers that can't identify the +// conversation). func (s *IdentityService) RewriteUserIDWithMasking(ctx context.Context, body []byte, account *Account, accountUUID, cachedClientID, fingerprintUA string) ([]byte, error) { - // 先执行常规的 RewriteUserID 逻辑 - newBody, err := s.RewriteUserID(body, account.ID, accountUUID, cachedClientID, fingerprintUA) - if err != nil { - return newBody, err + if account == nil { + return body, nil } - - // 检查是否启用会话ID伪装 - if !account.IsSessionIDMaskingEnabled() { - return newBody, nil + sessionHash := SessionHashFromContext(ctx) + if sessionHash == "" || s.cache == nil { + return s.RewriteUserID(body, account.ID, accountUUID, cachedClientID, fingerprintUA) } - - metadata := gjson.GetBytes(newBody, "metadata") - if !metadata.Exists() || metadata.Type == gjson.Null { - return newBody, nil - } - if !strings.HasPrefix(strings.TrimSpace(metadata.Raw), "{") { - return newBody, nil - } - - userIDResult := metadata.Get("user_id") - if !userIDResult.Exists() || userIDResult.Type != gjson.String { - return newBody, nil - } - userID := userIDResult.String() + userID := extractRewriteTarget(body, accountUUID, cachedClientID) if userID == "" { - return newBody, nil + return body, nil } - - // 解析已重写的 user_id - uidParsed := ParseMetadataUserID(userID) - if uidParsed == nil { - return newBody, nil - } - - // 获取或生成固定的伪装 session ID - maskedSessionID, err := s.cache.GetMaskedSessionID(ctx, account.ID) + sessionUUID, err := s.cache.GetStickySessionUUID(ctx, account.ID, sessionHash) if err != nil { - logger.LegacyPrintf("service.identity", "Warning: failed to get masked session ID for account %d: %v", account.ID, err) - return newBody, nil + slog.Warn("identity.sticky_session_lookup_failed", + "account_id", account.ID, "error", err) + sessionUUID = "" } - - if maskedSessionID == "" { - // 首次或已过期,生成新的伪装 session ID - maskedSessionID = generateRandomUUID() - logger.LegacyPrintf("service.identity", "Generated new masked session ID for account %d: %s", account.ID, maskedSessionID) + if sessionUUID == "" { + sessionUUID = generateRandomUUID() + if setErr := s.cache.SetStickySessionUUID(ctx, account.ID, sessionHash, sessionUUID); setErr != nil { + slog.Warn("identity.sticky_session_persist_failed", + "account_id", account.ID, "error", setErr) + } } - - // 刷新 TTL(每次请求都刷新,保持 15 分钟有效期) - if err := s.cache.SetMaskedSessionID(ctx, account.ID, maskedSessionID); err != nil { - logger.LegacyPrintf("service.identity", "Warning: failed to set masked session ID for account %d: %v", account.ID, err) - } - - // 用 FormatMetadataUserID 重建(保持与 RewriteUserID 相同的格式) - version := ExtractCLIVersion(fingerprintUA) - newUserID := FormatMetadataUserID(uidParsed.DeviceID, uidParsed.AccountUUID, maskedSessionID, version) - - slog.Debug("session_id_masking_applied", - "account_id", account.ID, - "before", userID, - "after", newUserID, - ) - - if newUserID == userID { - return newBody, nil - } - - maskedBody, setErr := sjson.SetBytes(newBody, "metadata.user_id", newUserID) - if setErr != nil { - return newBody, nil - } - return maskedBody, nil + return spliceUserID(body, userID, cachedClientID, accountUUID, sessionUUID, fingerprintUA), nil } // generateRandomUUID 生成随机 UUID v4 格式字符串 @@ -370,19 +387,6 @@ func generateClientID() string { return hex.EncodeToString(b) } -// generateUUIDFromSeed 从种子生成确定性UUID v4格式字符串 -func generateUUIDFromSeed(seed string) string { - hash := sha256.Sum256([]byte(seed)) - bytes := hash[:16] - - // 设置UUID v4版本和变体位 - bytes[6] = (bytes[6] & 0x0f) | 0x40 - bytes[8] = (bytes[8] & 0x3f) | 0x80 - - return fmt.Sprintf("%x-%x-%x-%x-%x", - bytes[0:4], bytes[4:6], bytes[6:8], bytes[8:10], bytes[10:16]) -} - // parseUserAgentVersion 解析user-agent版本号 // 例如:claude-cli/2.1.2 -> (2, 1, 2) func parseUserAgentVersion(ua string) (major, minor, patch int, ok bool) { diff --git a/backend/internal/service/identity_service_order_test.go b/backend/internal/service/identity_service_order_test.go index d1e1227412..4ba573780e 100644 --- a/backend/internal/service/identity_service_order_test.go +++ b/backend/internal/service/identity_service_order_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "strings" "testing" @@ -9,7 +10,8 @@ import ( ) type identityCacheStub struct { - maskedSessionID string + maskedSessionID string + stickySessionUUIDs map[string]string } func (s *identityCacheStub) GetFingerprint(_ context.Context, _ int64) (*Fingerprint, error) { @@ -25,6 +27,23 @@ func (s *identityCacheStub) SetMaskedSessionID(_ context.Context, _ int64, sessi s.maskedSessionID = sessionID return nil } +func (s *identityCacheStub) GetStickySessionUUID(_ context.Context, accountID int64, sessionHash string) (string, error) { + if s.stickySessionUUIDs == nil { + return "", nil + } + return s.stickySessionUUIDs[stickyStubKey(accountID, sessionHash)], nil +} +func (s *identityCacheStub) SetStickySessionUUID(_ context.Context, accountID int64, sessionHash string, sessionUUID string) error { + if s.stickySessionUUIDs == nil { + s.stickySessionUUIDs = map[string]string{} + } + s.stickySessionUUIDs[stickyStubKey(accountID, sessionHash)] = sessionUUID + return nil +} + +func stickyStubKey(accountID int64, sessionHash string) string { + return fmt.Sprintf("%d:%s", accountID, sessionHash) +} func TestIdentityService_RewriteUserID_PreservesTopLevelFieldOrder(t *testing.T) { cache := &identityCacheStub{} @@ -48,6 +67,18 @@ func TestIdentityService_RewriteUserID_PreservesTopLevelFieldOrder(t *testing.T) } func TestIdentityService_RewriteUserIDWithMasking_PreservesTopLevelFieldOrder(t *testing.T) { + // 2026-04-15: session_id masking was removed for anti-fingerprinting + // reasons — it forced every concurrent request on one account to share + // the same 15-minute-cached session UUID, which is trivially detectable + // as non-human. RewriteUserIDWithMasking is now a thin wrapper that + // delegates to RewriteUserID (which mints a fresh random UUID per call). + // + // This test now verifies that: + // 1) The wrapper still preserves top-level JSON field order. + // 2) The cached masked session ID is **NOT** applied — even though the + // cache contains one and the account has masking enabled, the + // output session_id is a fresh UUID and does not equal the cached + // value. cache := &identityCacheStub{maskedSessionID: "11111111-2222-4333-8444-555555555555"} svc := NewIdentityService(cache) @@ -73,8 +104,47 @@ func TestIdentityService_RewriteUserIDWithMasking_PreservesTopLevelFieldOrder(t resultStr := string(result) assertJSONTokenOrder(t, resultStr, `"alpha"`, `"messages"`, `"metadata"`, `"max_tokens"`, `"thinking"`, `"output_config"`, `"stream"`) - require.Contains(t, resultStr, cache.maskedSessionID) + require.NotContains(t, resultStr, "11111111-2222-4333-8444-555555555555", + "masked session id must no longer leak through") + require.NotContains(t, resultStr, originalUserID, + "original session id must still be rewritten") require.True(t, strings.Contains(resultStr, `"metadata":{"user_id":"`)) + + // Sanity: two consecutive calls on the same account must produce + // different session ids (proving randomness, not determinism). + result2, err := svc.RewriteUserIDWithMasking(context.Background(), body, account, "acc-uuid", "client-xyz", "claude-cli/2.1.78 (external, cli)") + require.NoError(t, err) + require.NotEqual(t, resultStr, string(result2), "session id must differ across calls") +} + +func TestIdentityService_RewriteUserIDWithMasking_StickyPerSessionHash(t *testing.T) { + // With a session hash carried in ctx, two consecutive rewrites must + // produce the SAME session id (a real CLI holds one session_id across + // the full conversation). Different session hashes → different UUIDs. + cache := &identityCacheStub{} + svc := NewIdentityService(cache) + + originalUserID := FormatMetadataUserID( + "d61f76d0730d2b920763648949bad5c79742155c27037fc77ac3f9805cb90169", + "", + "7578cf37-aaca-46e4-a45c-71285d9dbb83", + "2.1.78", + ) + body := []byte(`{"alpha":1,"messages":[],"metadata":{"user_id":` + strconvQuote(originalUserID) + `},"max_tokens":64000,"stream":true}`) + + account := &Account{ID: 42, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + + ctxA := WithSessionHash(context.Background(), "conversation-A") + first, err := svc.RewriteUserIDWithMasking(ctxA, body, account, "acc-uuid", "client-xyz", "claude-cli/2.1.78 (external, cli)") + require.NoError(t, err) + second, err := svc.RewriteUserIDWithMasking(ctxA, body, account, "acc-uuid", "client-xyz", "claude-cli/2.1.78 (external, cli)") + require.NoError(t, err) + require.Equal(t, string(first), string(second), "same session hash must yield sticky session id") + + ctxB := WithSessionHash(context.Background(), "conversation-B") + other, err := svc.RewriteUserIDWithMasking(ctxB, body, account, "acc-uuid", "client-xyz", "claude-cli/2.1.78 (external, cli)") + require.NoError(t, err) + require.NotEqual(t, string(first), string(other), "different session hashes must yield different session ids") } func strconvQuote(v string) string { diff --git a/backend/internal/service/identity_service_refactor_test.go b/backend/internal/service/identity_service_refactor_test.go new file mode 100644 index 0000000000..df70ff0c9c --- /dev/null +++ b/backend/internal/service/identity_service_refactor_test.go @@ -0,0 +1,159 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestRewriteUserID_EquivalenceAfterRefactor verifies the helper extraction +// (extractRewriteTarget + spliceUserID) preserves the early-return behavior +// shared by RewriteUserID and RewriteUserIDWithMasking. Each case is asserted +// against both methods (the masking variant is exercised via its sessionHash="" +// fallback, which delegates to RewriteUserID — keeping us honest that both +// paths use identical guards). +func TestRewriteUserID_EquivalenceAfterRefactor(t *testing.T) { + const ( + accountUUID = "11111111-2222-3333-4444-555555555555" + clientID = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + fingerprintUA = "claude-cli/2.1.112 (external, sdk-cli)" + legacyValidUID = "user_abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789_account_99999999-8888-7777-6666-555555555555_session_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + ) + + cases := []struct { + name string + body []byte + expectEq bool // true = body should be returned unchanged + }{ + { + name: "empty body", + body: nil, + expectEq: true, + }, + { + name: "metadata is null", + body: []byte(`{"metadata":null,"foo":"bar"}`), + expectEq: true, + }, + { + name: "metadata is array, not object", + body: []byte(`{"metadata":[1,2,3]}`), + expectEq: true, + }, + { + name: "user_id missing from metadata", + body: []byte(`{"metadata":{"other":"value"}}`), + expectEq: true, + }, + { + name: "valid legacy user_id rewritten", + body: []byte(`{"metadata":{"user_id":"` + legacyValidUID + `"}}`), + expectEq: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := NewIdentityService(&identityCacheStub{}) + + // Path 1: direct RewriteUserID + gotDirect, err := svc.RewriteUserID(tc.body, 42, accountUUID, clientID, fingerprintUA) + require.NoError(t, err) + + // Path 2: RewriteUserIDWithMasking with empty sessionHash → falls + // back to RewriteUserID (same guard chain). + account := &Account{ID: 42} + gotMasking, err := svc.RewriteUserIDWithMasking(context.Background(), tc.body, account, accountUUID, clientID, fingerprintUA) + require.NoError(t, err) + + if tc.expectEq { + // Both methods must return body unchanged (same slice / same bytes). + require.Equal(t, string(tc.body), string(gotDirect), "RewriteUserID should return body unchanged") + require.Equal(t, string(tc.body), string(gotMasking), "RewriteUserIDWithMasking fallback should return body unchanged") + return + } + + // Rewrite path: metadata.user_id must change and remain a parseable + // metadata user id with the expected device id + account uuid. + for _, got := range [][]byte{gotDirect, gotMasking} { + newUID := gjson.GetBytes(got, "metadata.user_id").String() + require.NotEqual(t, legacyValidUID, newUID, "user_id must be rewritten") + + parsed := ParseMetadataUserID(newUID) + require.NotNil(t, parsed, "rewritten user_id must be parseable") + require.Equal(t, clientID, parsed.DeviceID) + require.Equal(t, accountUUID, parsed.AccountUUID) + require.NotEmpty(t, parsed.SessionID) + } + }) + } +} + +// TestRewriteUserIDWithMasking_StickyPath_EarlyReturns covers the sticky +// session path (sessionHash != ""), asserting that the same early-return +// guards apply: empty body, non-object metadata, missing user_id, and +// unparseable user_id all return the body unchanged byte-for-byte. +// +// This complements TestRewriteUserID_EquivalenceAfterRefactor, which only +// exercises the sessionHash="" fallback (delegates to RewriteUserID). +func TestRewriteUserIDWithMasking_StickyPath_EarlyReturns(t *testing.T) { + cases := []struct { + name string + body []byte + }{ + {"empty body", nil}, + {"metadata=null", []byte(`{"metadata":null}`)}, + {"metadata=array", []byte(`{"metadata":[1,2,3]}`)}, + {"missing user_id", []byte(`{"metadata":{}}`)}, + {"unparseable user_id", []byte(`{"metadata":{"user_id":"not-a-valid-uid"}}`)}, + } + + cache := &identityCacheStub{} + svc := NewIdentityService(cache) + account := &Account{ID: 42, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + ctx := WithSessionHash(context.Background(), "test-session-hash-123") + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result, err := svc.RewriteUserIDWithMasking(ctx, tc.body, account, "acc-uuid", "client-id", "claude-cli/2.1.112 (external, sdk-cli)") + require.NoError(t, err) + require.Equal(t, string(tc.body), string(result)) + }) + } +} + +// TestRewriteUserIDWithMasking_StickyPath_CacheHitReusesUUID asserts that +// when sessionHash is non-empty, two consecutive rewrites with the same +// hash produce byte-identical output (the second call hits the cached +// sticky session UUID populated by the first call). +// +// This is the positive equivalence counterpart to the early-return cases +// above and to the StickyPerSessionHash test in identity_service_order_test.go. +func TestRewriteUserIDWithMasking_StickyPath_CacheHitReusesUUID(t *testing.T) { + cache := &identityCacheStub{} + svc := NewIdentityService(cache) + account := &Account{ID: 42, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + ctx := WithSessionHash(context.Background(), "stable-session-h") + + // Use a real legacy-format user_id that ParseMetadataUserID accepts. + const legacyValidUID = "user_abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789_account_99999999-8888-7777-6666-555555555555_session_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + body := []byte(`{"metadata":{"user_id":"` + legacyValidUID + `"}}`) + + r1, err := svc.RewriteUserIDWithMasking(ctx, body, account, "acc-uuid", "client-id", "claude-cli/2.1.112 (external, sdk-cli)") + require.NoError(t, err) + + r2, err := svc.RewriteUserIDWithMasking(ctx, body, account, "acc-uuid", "client-id", "claude-cli/2.1.112 (external, sdk-cli)") + require.NoError(t, err) + + require.Equal(t, string(r1), string(r2), "sticky cache hit must yield byte-identical rewrite") + + // Sanity: a different sessionHash must produce different output. + ctxOther := WithSessionHash(context.Background(), "different-session-h") + rOther, err := svc.RewriteUserIDWithMasking(ctxOther, body, account, "acc-uuid", "client-id", "claude-cli/2.1.112 (external, sdk-cli)") + require.NoError(t, err) + require.NotEqual(t, string(r1), string(rOther), "different sessionHash must yield different sticky session id") +} diff --git a/backend/internal/service/sidecar_probe_constants.go b/backend/internal/service/sidecar_probe_constants.go new file mode 100644 index 0000000000..c54680c8af --- /dev/null +++ b/backend/internal/service/sidecar_probe_constants.go @@ -0,0 +1,42 @@ +package service + +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" +) + +// usage_poll loop tuning --------------------------------------------------- + +// sidecarProbeMinIntervalFloor is the smallest min_interval_seconds wire.go +// will honor. Anything tighter risks correlating with token-refresh and +// account-health periodics, producing a synchronized spike pattern that's +// itself a fingerprintable signal. +const sidecarProbeMinIntervalFloor = 60 * time.Second + +// sidecarProbeRequestTimeout bounds a single /api/oauth/usage probe +// (DB list + one upstream HTTPS round trip). Must be < sidecarProbeMinIntervalFloor +// so a stuck probe never overlaps the next tick. +const sidecarProbeRequestTimeout = 30 * time.Second + +// count_tokens injection -------------------------------------------------- + +// countTokensSidecarDefaultTimeoutMs caps each fire-and-forget count_tokens +// sidecar request when the operator config leaves timeout_milliseconds +// unset/non-positive. 3000 matches the real Claude CLI's count_tokens +// budget (the editor blocks on it for the status-bar hint). +const countTokensSidecarDefaultTimeoutMs = 3000 + +// startup probe (post token-refresh) -------------------------------------- + +// startupProbeModel mirrors what real Claude Code sends as its first +// /v1/messages after auth. claude-haiku-4-5 is the short alias the CLI +// emits before model normalization. +const startupProbeModel = claude.HaikuModelShort + +const ( + startupProbeUserRole = "user" + startupProbeUserContent = "hi" + startupProbeMaxTokens = 1 + startupProbeDefaultTimeout = 10 * time.Second +) diff --git a/backend/internal/service/tls_fingerprint_profile_service.go b/backend/internal/service/tls_fingerprint_profile_service.go index 33937cc748..a3b8528fa3 100644 --- a/backend/internal/service/tls_fingerprint_profile_service.go +++ b/backend/internal/service/tls_fingerprint_profile_service.go @@ -2,7 +2,10 @@ package service import ( "context" + "errors" + "fmt" "math/rand/v2" + "strings" "sync" "time" @@ -11,6 +14,11 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" ) +// autoGeneratedProfileNamePrefix 是随机分配生成的 profile 名称前缀。 +// 用于识别哪些 profile 归某个账号的 auto-random 机制所有,不应在库列表中 +// 当作手动模板展示。 +const autoGeneratedProfileNamePrefix = "__auto__:acc-" + // TLSFingerprintProfileRepository 定义 TLS 指纹模板的数据访问接口 type TLSFingerprintProfileRepository interface { List(ctx context.Context) ([]*model.TLSFingerprintProfile, error) @@ -31,8 +39,9 @@ type TLSFingerprintProfileCache interface { // TLSFingerprintProfileService TLS 指纹模板管理服务 type TLSFingerprintProfileService struct { - repo TLSFingerprintProfileRepository - cache TLSFingerprintProfileCache + repo TLSFingerprintProfileRepository + cache TLSFingerprintProfileCache + accountRepo AccountRepository // 本地 ID→Profile 映射缓存,用于 DoWithTLS 热路径快速查找 localCache map[int64]*model.TLSFingerprintProfile @@ -43,11 +52,13 @@ type TLSFingerprintProfileService struct { func NewTLSFingerprintProfileService( repo TLSFingerprintProfileRepository, cache TLSFingerprintProfileCache, + accountRepo AccountRepository, ) *TLSFingerprintProfileService { svc := &TLSFingerprintProfileService{ - repo: repo, - cache: cache, - localCache: make(map[int64]*model.TLSFingerprintProfile), + repo: repo, + cache: cache, + accountRepo: accountRepo, + localCache: make(map[int64]*model.TLSFingerprintProfile), } ctx := context.Background() @@ -194,6 +205,101 @@ func (s *TLSFingerprintProfileService) ResolveTLSProfile(account *Account) *tlsf return &tlsfingerprint.Profile{Name: "Built-in Default (Node.js 24.x)"} } +// --- 随机分配 --- + +// IsAutoGeneratedProfileName 判断 profile 名称是否由 auto-random 机制生成。 +func IsAutoGeneratedProfileName(name string) bool { + return strings.HasPrefix(name, autoGeneratedProfileNamePrefix) +} + +// RandomizeForAccount 为账号生成并绑定一个随机化 TLS 指纹 profile。 +// +// 幂等:每次调用都会生成一个全新的 profile 并将它绑定到账号。如果账号 +// 之前已经绑定了一个 auto-generated profile,会先解绑再删除旧的,避免 +// library 累积孤儿条目。手动绑定的 profile 不会被删除,只会被覆盖。 +// +// 流程: +// 1. 检查账号当前 tls_fingerprint_profile_id,若对应 profile 是 auto 的,记为待删除 +// 2. 调用 randomizer 生成新的 Profile +// 3. 以 __auto__:acc-{id}-{unix} 为名写入库 +// 4. 更新账号 Extra:enable_tls_fingerprint=true, profile_id=newID, randomized=true +// 5. 删除待删除的旧 auto profile(有的话) +func (s *TLSFingerprintProfileService) RandomizeForAccount(ctx context.Context, accountID int64) (*model.TLSFingerprintProfile, error) { + if s.accountRepo == nil { + return nil, errors.New("account repository not configured") + } + + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("get account: %w", err) + } + if account == nil { + return nil, ErrAccountNotFound + } + if !account.IsAnthropicOAuthOrSetupToken() { + return nil, errors.New("TLS 指纹随机分配仅支持 Anthropic OAuth/SetupToken 账号") + } + + // 1. 判断旧 profile 是否需要清理 + var oldAutoProfileID int64 + if existingID := account.GetTLSFingerprintProfileID(); existingID > 0 { + if existing, err := s.repo.GetByID(ctx, existingID); err == nil && existing != nil { + if IsAutoGeneratedProfileName(existing.Name) { + oldAutoProfileID = existingID + } + } + } + + // 2. 生成新 profile + generated := tlsfingerprint.GenerateRandomizedProfile() + description := fmt.Sprintf("Auto-randomized fingerprint for account #%d (%s baseline, perturbed)", accountID, "Claude Code 2.1.112") + profileName := fmt.Sprintf("%s%d-%d", autoGeneratedProfileNamePrefix, accountID, time.Now().Unix()) + + newProfile := &model.TLSFingerprintProfile{ + Name: profileName, + Description: &description, + EnableGREASE: generated.EnableGREASE, + CipherSuites: generated.CipherSuites, + Curves: generated.Curves, + PointFormats: generated.PointFormats, + SignatureAlgorithms: generated.SignatureAlgorithms, + ALPNProtocols: generated.ALPNProtocols, + SupportedVersions: generated.SupportedVersions, + KeyShareGroups: generated.KeyShareGroups, + PSKModes: generated.PSKModes, + Extensions: generated.Extensions, + } + + created, err := s.Create(ctx, newProfile) + if err != nil { + return nil, fmt.Errorf("create randomized profile: %w", err) + } + + // 3. 更新账号 extra:启用 TLS 指纹 + 绑定新 profile + 标记 randomized + extraUpdates := map[string]any{ + "enable_tls_fingerprint": true, + "tls_fingerprint_profile_id": created.ID, + "tls_fingerprint_randomized": true, + "tls_fingerprint_randomized_at": time.Now().UTC().Format(time.RFC3339), + } + if err := s.accountRepo.UpdateExtra(ctx, accountID, extraUpdates); err != nil { + // 回滚:删掉刚创建的 profile,避免孤儿 + if delErr := s.Delete(ctx, created.ID); delErr != nil { + logger.LegacyPrintf("service.tls_fp_profile", "[RandomizeForAccount] rollback delete failed: account=%d profile=%d err=%v", accountID, created.ID, delErr) + } + return nil, fmt.Errorf("update account extra: %w", err) + } + + // 4. 清理旧的 auto profile(事后,失败仅日志) + if oldAutoProfileID > 0 && oldAutoProfileID != created.ID { + if err := s.Delete(ctx, oldAutoProfileID); err != nil { + logger.LegacyPrintf("service.tls_fp_profile", "[RandomizeForAccount] cleanup old auto profile failed: account=%d old_profile=%d err=%v", accountID, oldAutoProfileID, err) + } + } + + return created, nil +} + // --- 缓存管理 --- func (s *TLSFingerprintProfileService) refreshLocalCache(ctx context.Context) error { diff --git a/backend/internal/service/token_refresh_service.go b/backend/internal/service/token_refresh_service.go index 22f4aa29fd..7d0cf3d2c2 100644 --- a/backend/internal/service/token_refresh_service.go +++ b/backend/internal/service/token_refresh_service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "math/rand/v2" "strings" "sync" "time" @@ -32,6 +33,10 @@ type TokenRefreshService struct { privacyClientFactory PrivacyClientFactory proxyRepo ProxyRepository + // Claude startup probe: token 刷新成功后可选地触发一次 max_tokens=1 的 haiku + // 请求,模仿真实 Claude Code CLI 拿到新 token 后的握手行为。nil 表示禁用。 + startupProber ClaudeStartupProber + stopCh chan struct{} stopOnce sync.Once wg sync.WaitGroup @@ -95,6 +100,11 @@ func (s *TokenRefreshService) SetRefreshAPI(api *OAuthRefreshAPI) { s.refreshAPI = api } +// SetStartupProber 注入 Claude 启动探测器。传 nil 表示禁用启动探测。 +func (s *TokenRefreshService) SetStartupProber(prober ClaudeStartupProber) { + s.startupProber = prober +} + // SetRefreshPolicy 注入后台刷新调用侧策略(用于显式化平台/场景差异行为)。 func (s *TokenRefreshService) SetRefreshPolicy(policy BackgroundRefreshPolicy) { s.refreshPolicy = policy @@ -125,26 +135,61 @@ func (s *TokenRefreshService) Stop() { slog.Info("token_refresh.service_stopped") } +// jitteredInterval returns a duration in [base*(1-pct), base*(1+pct)]. +// Used to spread refresh tick wall-clock times so they don't land on +// predictable :00 :05 :10 offsets that Anthropic could fingerprint. +func jitteredInterval(base time.Duration, pct float64) time.Duration { + if base <= 0 { + return base + } + if pct < 0 { + pct = -pct + } + if pct > 0.95 { + pct = 0.95 + } + // random factor in [-pct, +pct] + factor := (rand.Float64()*2 - 1) * pct + return base + time.Duration(float64(base)*factor) +} + // refreshLoop 刷新循环 +// +// Anti-fingerprinting note (2026-04-15): +// Previous implementation used a plain time.Ticker on CheckIntervalMinutes, +// which meant all refresh ticks landed at perfectly regular wall-clock offsets +// (e.g. every 5 min on the :00 mark). From Anthropic's perspective, a real +// human Claude Code user would never refresh at such mechanical intervals. +// We now use a Timer that resets with ±25% jitter each cycle, so tick times +// drift and look more like opportunistic activity. func (s *TokenRefreshService) refreshLoop() { defer s.wg.Done() - // 计算检查间隔 - checkInterval := time.Duration(s.cfg.CheckIntervalMinutes) * time.Minute - if checkInterval < time.Minute { - checkInterval = 5 * time.Minute + // 计算基础检查间隔 + baseInterval := time.Duration(s.cfg.CheckIntervalMinutes) * time.Minute + if baseInterval < time.Minute { + baseInterval = 5 * time.Minute } - ticker := time.NewTicker(checkInterval) - defer ticker.Stop() - - // 启动时立即执行一次检查 + // 启动时先加一点随机延迟再跑首次检查,避免多实例同时启动对齐 + startupDelay := time.Duration(rand.Int64N(int64(30 * time.Second))) + startupTimer := time.NewTimer(startupDelay) + select { + case <-startupTimer.C: + case <-s.stopCh: + startupTimer.Stop() + return + } s.processRefresh() + timer := time.NewTimer(jitteredInterval(baseInterval, 0.25)) + defer timer.Stop() + for { select { - case <-ticker.C: + case <-timer.C: s.processRefresh() + timer.Reset(jitteredInterval(baseInterval, 0.25)) case <-s.stopCh: return } @@ -181,8 +226,15 @@ func (s *TokenRefreshService) processRefresh() { oauthAccounts++ + // Anti-fingerprinting (2026-04-15): + // Add per-account jitter to the refresh window so not every + // account that's exactly RefreshBeforeExpiryHours from expiry + // refreshes in the same tick. jitterFactor in [0.6, 1.4]. + jitterFactor := 0.6 + rand.Float64()*0.8 + perAccountWindow := time.Duration(float64(refreshWindow) * jitterFactor) + // 检查是否需要刷新 - if !refresher.NeedsRefresh(account, refreshWindow) { + if !refresher.NeedsRefresh(account, perAccountWindow) { break // 不需要刷新,跳过 } @@ -308,7 +360,27 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc if attempt < s.cfg.MaxRetries { // 指数退避:2^(attempt-1) * baseSeconds backoff := time.Duration(s.cfg.RetryBackoffSeconds) * time.Second * time.Duration(1<<(attempt-1)) - time.Sleep(backoff) + // 使用 timer + select,使 backoff 可被 Stop() 或 ctx 取消打断, + // 否则 graceful shutdown 会被单次最长数十秒的 Sleep 阻塞。 + // + // Three-way select on backoff completion: + // - timer.C: normal path, retry after backoff elapses + // - s.stopCh: service.Stop() was called → wrap context.Canceled + // with an "aborted during backoff" prefix; callers can + // detect via errors.Is(err, context.Canceled) + // - ctx.Done(): the per-call ctx was cancelled (could be Canceled or + // DeadlineExceeded depending on how caller scoped ctx); + // return ctx.Err() unchanged so callers can distinguish + // the cause via errors.Is + timer := time.NewTimer(backoff) + defer timer.Stop() // 已 fire 时为 no-op,统一所有路径 + select { + case <-timer.C: + case <-s.stopCh: + return fmt.Errorf("token refresh aborted during backoff: %w", context.Canceled) + case <-ctx.Done(): + return ctx.Err() + } } } @@ -403,6 +475,11 @@ func (s *TokenRefreshService) postRefreshActions(ctx context.Context, account *A s.ensureOpenAIPrivacy(ctx, account) // Antigravity OAuth: 刷新成功后,检查是否已设置 privacy_mode,未设置则调用 setUserSettings s.ensureAntigravityPrivacy(ctx, account) + // Claude OAuth: 模拟真实 Claude Code CLI 在获取新 token 后立即发起的启动探测。 + // Fire-and-forget:探测失败不影响刷新流程,不会回写账号状态。 + if account.Platform == PlatformAnthropic && account.IsOAuth() && s.startupProber != nil { + runStartupProbeAsync(s.startupProber, account) + } } // errRefreshSkipped 表示刷新被跳过(锁竞争或已被其他路径刷新),不计入 failed 或 refreshed diff --git a/backend/internal/service/token_refresh_service_test.go b/backend/internal/service/token_refresh_service_test.go index 2179a85e92..c724460aa2 100644 --- a/backend/internal/service/token_refresh_service_test.go +++ b/backend/internal/service/token_refresh_service_test.go @@ -755,3 +755,55 @@ func TestPathA_DBUpdateFailed(t *testing.T) { require.Equal(t, 1, repo.updateCalls) // DB 更新被尝试 require.Equal(t, 0, invalidator.calls) // DB 失败时不应触发缓存失效 } + +// TestTokenRefreshService_RefreshWithRetry_StopsImmediately 验证 Stop() 调用能立即打断 +// refreshWithRetry 中的指数退避 sleep。使用 RetryBackoffSeconds=10 + MaxRetries=3, +// 第一次失败后会进入 10s 退避;调用 Stop() 后必须在远小于 10s 的时间内返回, +// 否则说明退避未响应 stopCh,graceful shutdown 会被阻塞。 +func TestTokenRefreshService_RefreshWithRetry_StopsImmediately(t *testing.T) { + repo := &tokenRefreshAccountRepo{} + invalidator := &tokenCacheInvalidatorStub{} + cfg := &config.Config{ + TokenRefresh: config.TokenRefreshConfig{ + MaxRetries: 3, + RetryBackoffSeconds: 10, // 第一次退避就要 10s,足以观察 Stop() 是否打断 + }, + } + service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg, nil) + account := &Account{ + ID: 9001, + Platform: PlatformGemini, + Type: AccountTypeOAuth, + } + // refresher 始终返回可重试错误(非 invalid_grant 等不可重试关键字), + // 强制 refreshWithRetry 进入 backoff 分支。 + refresher := &tokenRefresherStub{err: errors.New("transient network failure")} + + type result struct { + err error + elapsed time.Duration + } + done := make(chan result, 1) + go func() { + start := time.Now() + err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour) + done <- result{err: err, elapsed: time.Since(start)} + }() + + // 让 goroutine 先进入第一次 backoff(refresh 调用是 stub,本身瞬时返回,进 sleep 很快) + time.Sleep(50 * time.Millisecond) + service.Stop() + + select { + case res := <-done: + // 退避被打断,应远小于 10s(一次完整的退避周期) + require.Less(t, res.elapsed, 2*time.Second, "refreshWithRetry did not abort promptly: %v", res.elapsed) + require.Error(t, res.err) + // 错误信息应表明是被退避中断(包含我们注入的文案,或 wrap 了 context.Canceled) + if !errors.Is(res.err, context.Canceled) { + require.Contains(t, res.err.Error(), "aborted during backoff") + } + case <-time.After(3 * time.Second): + t.Fatal("refreshWithRetry did not return after Stop(); backoff is not interruptible") + } +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 9f33c46ab0..b1ca6758df 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -53,6 +53,7 @@ func ProvideTokenRefreshService( privacyClientFactory PrivacyClientFactory, proxyRepo ProxyRepository, refreshAPI *OAuthRefreshAPI, + gatewayService *GatewayService, ) *TokenRefreshService { svc := NewTokenRefreshService(accountRepo, oauthService, openaiOAuthService, geminiOAuthService, antigravityOAuthService, cacheInvalidator, schedulerCache, cfg, tempUnschedCache) // 注入 OpenAI privacy opt-out 依赖 @@ -61,6 +62,38 @@ func ProvideTokenRefreshService( svc.SetRefreshAPI(refreshAPI) // 调用侧显式注入后台刷新策略,避免策略漂移 svc.SetRefreshPolicy(DefaultBackgroundRefreshPolicy()) + // 注入 Claude 启动探测器:token 刷新成功后模拟 Claude Code 握手请求。 + // gatewayService 可能在某些测试 wire 路径下为 nil,SetStartupProber 接受 nil。 + if cfg != nil && cfg.SidecarProbe.StartupProbe.Enabled && gatewayService != nil { + svc.SetStartupProber(gatewayService) + } + svc.Start() + return svc +} + +// ProvideClaudeSidecarProbeService wires the sidecar probe loop. When +// sidecar_probe.usage_poll.enabled is false (or the interval config is +// invalid) the service still instantiates but Start becomes a no-op. +func ProvideClaudeSidecarProbeService( + cfg *config.Config, + accountRepo AccountRepository, + usageService *AccountUsageService, +) *ClaudeSidecarProbeService { + probe := cfg.SidecarProbe.UsagePoll + if !probe.Enabled { + return NewClaudeSidecarProbeService(accountRepo, usageService, 0, 0, false) + } + minInterval := time.Duration(probe.MinIntervalSeconds) * time.Second + maxInterval := time.Duration(probe.MaxIntervalSeconds) * time.Second + // Guard against bad config: enforce a 60s floor so a misconfigured + // min_interval_seconds=0 does not turn into a hot spin on startup. + if minInterval < sidecarProbeMinIntervalFloor { + minInterval = sidecarProbeMinIntervalFloor + } + if maxInterval < minInterval { + maxInterval = minInterval + } + svc := NewClaudeSidecarProbeService(accountRepo, usageService, minInterval, maxInterval, probe.DryRun) svc.Start() return svc } @@ -443,6 +476,7 @@ var ProviderSet = wire.NewSet( ProvideUpdateService, ProvideTokenRefreshService, ProvideAccountExpiryService, + ProvideClaudeSidecarProbeService, ProvideSubscriptionExpiryService, ProvideTimingWheelService, ProvideDashboardAggregationService, diff --git a/backend/tools/capture_fingerprint/baselines/claude-code-2.1.111.json b/backend/tools/capture_fingerprint/baselines/claude-code-2.1.111.json new file mode 100644 index 0000000000..9ee672140c --- /dev/null +++ b/backend/tools/capture_fingerprint/baselines/claude-code-2.1.111.json @@ -0,0 +1,164 @@ +{ + "captured_at": "2026-04-16T16:41:55Z", + "remote_addr": "127.0.0.1:65238", + "server_name": "localhost", + "tls_version": "TLS 1.3", + "negotiated_proto": "http/1.1", + "client_hello_raw": "010006380303fc1bf18bbb5352cd26091c72ddb58d9b925af8f2d07d57a94eb3b0705afc7de8204033f2cc2bc1810d13a9e83d5942e42b22c52df04be4f5d295a218a3975134f20068130213031301c02fc02bc030c02c009ec0270067c028006b00a3009fcca9cca8ccaac0adc09fc05dc061c057c05300a2c0acc09ec05cc060c056c052c024006ac0230040c00ac01400390038c009c01300330032009dc09dc051009cc09cc050003d003c0035002f01000587ff010001000000000e000c0000096c6f63616c686f7374000b000403000102000a0012001011ec001d0017001e0018001901000101002300000010000b000908687474702f312e310016000000170000000d0036003409050906090404030503060308070808081a081b081c0809080a080b080408050806040105010601030303010302040205020602002b00050403040303002d00020101003304ea04e811ec04c0e179b3d2740e9f3a786187885173b4b521444650bb85e8a2594840913a04b654c5ebb0420bd9ade7bc5459084d1172a5e0a43e22d43bfdbcba8b62c248234fa1a39fbbe55cdfb57ec1378cf1710649fcb20f07b0c939cef9218a92c7637b074c4707147c24ca0bf427ced959deeb80eda7c3bed126aeeb94a3131a3961849d1b0eb859abe5b5518747b87912239d2961c3e313e6b46cdaf6545a64b9a4eb0b4f73b335cb8d50b87fdd780b0b4a408ff4b6c58aa005365f5220902e0174954425fe1b0bb941c3da8ba974828cbad83bcaf7cacd131702ca1b2cbca9caec1f87f40eb488a890c5aefada1efe13b9d81422cdd037a6ba10521b8b8fc97ab9d5b83356cb1c86cddfb5b6e104a67fc3809c6b8f8596a0b4605a743b06b9961017823d1f90c5d23aa748955459227bf07b1126226cea9b3649471f13d015b4e8320d93bcb5c72beb1b1ad3c6c01b1c8d58903da9737330a7564cd355969ca37789a92b9abf3647a2bac2931582c029327c9dea9246b36af9311f0995a2823941a5f53ac05173de4b1654053343d5679dc52056f9c1a547949c9a08d341b22e890f04da14f6e394170b34f930a7283109a98c07e9b2a04ff0426dc8ba414580c6248030e90837424516f3a4ae657ff27c0b127c9e7a6b5db1f6ae4103067c724473a1b654f91bedf32f69069b06fa01bda232d47c7086a1a9b8991f172ba0dabc282562826f5260a2ba587d3bbc2df96c7c03c8ecb9420c224266db3e7828cbe257c38f0223717c3d1aa23296c6aba0e2c79db8b7b95bc098da8592e0c234e17330057fe6770c96cb70455cb14d2520c9189081320b2d5714b5935cedd59b9667b7eb3994b87c63f32240a91c337e6a292a9438afc617c7737deb41266d38250ad579e03c27a9babe8d6694774194c6438accf869f8ea71f2e1bf11d97b28d90e1c255031a3ccaaa3c6580b01279a662404bf665982f8c6a647e57507d93373877d50073a0a5705eb5439b51791e16185b9440b898c559d249bbcf97d87f51d598c74050b71cd85160b2a572c1536560cb2e66c034a6bc03d31a7eb34431eeb9587a1af755022e0b6c778d2c165ccbfe88a1e6b618878aa279c07a7fbcc49731c40bd775a59f79a64cb31846a67ba71a08f1738788426fdb10437321dbe180af0ea47b5e8c8facaa46271294bf49e8b09a265523eb480270a61c8cf4a147e44a71a243a102332ab3657c49758ed178a1df15850f51c80d6c92f17abb00c72f7cb5aeee0c7c4a102ff665b549148d0d44839203bd4a7417be28c12f0765a01ac7aeb843f599518a6cca3162ae1aa467b366163875611baa64f032a3ae171e306961444cfa2925a5b6215ada708406c87e25a711af0082a0b7fe0737259b5cac570c60159311f0404dfecc9bd26657efb247060853cda1b0a598f90f91ab3db324bc54453b55372353e46e033e504af1f076e175387820488cbfabc8e780c0d3a15ab084fa144b00bc78b8bda6b87064923452da28a695c36a89fa79a1ee0531987070d9c442603b5967b031c172345996eec9213ac3b9bc429018d6ba8b16cab2c1b3768bc58da6c90d327c9b7c3bec78c0cea27c36977066b467b5d678fc68c6fbf2a57b1b30b6a220ff87cd3b94d7d08aa82dc3e2c927df9420a5e03e62593ab4b624bb7a77b148cc11cee0b3b741ba405184ceda7c58ed85b44c6205628cf3b7ad658001d00200ef9ab7b4d181eec44ffc821c34347eb535cdbe53d0387e1a4721069157db618", + "cipher_suites": [ + "0x1302", + "0x1303", + "0x1301", + "0xc02f", + "0xc02b", + "0xc030", + "0xc02c", + "0x009e", + "0xc027", + "0x0067", + "0xc028", + "0x006b", + "0x00a3", + "0x009f", + "0xcca9", + "0xcca8", + "0xccaa", + "0xc0ad", + "0xc09f", + "0xc05d", + "0xc061", + "0xc057", + "0xc053", + "0x00a2", + "0xc0ac", + "0xc09e", + "0xc05c", + "0xc060", + "0xc056", + "0xc052", + "0xc024", + "0x006a", + "0xc023", + "0x0040", + "0xc00a", + "0xc014", + "0x0039", + "0x0038", + "0xc009", + "0xc013", + "0x0033", + "0x0032", + "0x009d", + "0xc09d", + "0xc051", + "0x009c", + "0xc09c", + "0xc050", + "0x003d", + "0x003c", + "0x0035", + "0x002f" + ], + "extensions": [ + "renegotiation_info (0xff01)", + "server_name (0)", + "ec_point_formats (11)", + "supported_groups (10)", + "session_ticket (35)", + "application_layer_protocol_negotiation (16)", + "generic (0x0016)", + "extended_master_secret (23)", + "signature_algorithms (13)", + "supported_versions (43)", + "psk_key_exchange_modes (45)", + "key_share (51)" + ], + "curves": [ + "0x11ec", + "0x001d", + "0x0017", + "0x001e", + "0x0018", + "0x0019", + "0x0100", + "0x0101" + ], + "point_formats": [ + "0x00", + "0x01", + "0x02" + ], + "signature_algorithms": [ + "0x0905", + "0x0906", + "0x0904", + "0x0403", + "0x0503", + "0x0603", + "0x0807", + "0x0808", + "0x081a", + "0x081b", + "0x081c", + "0x0809", + "0x080a", + "0x080b", + "0x0804", + "0x0805", + "0x0806", + "0x0401", + "0x0501", + "0x0601", + "0x0303", + "0x0301", + "0x0302", + "0x0402", + "0x0502", + "0x0602" + ], + "supported_versions": [ + "0x0304", + "0x0303" + ], + "key_share_groups": [ + "0x11ec", + "0x001d" + ], + "alpn_protos": [ + "http/1.1" + ], + "psk_modes": [ + "0x01" + ], + "ja3_string": "771,4866-4867-4865-49199-49195-49200-49196-158-49191-103-49192-107-163-159-52393-52392-52394-49325-49311-49245-49249-49239-49235-162-49324-49310-49244-49248-49238-49234-49188-106-49187-64-49162-49172-57-56-49161-49171-51-50-157-49309-49233-156-49308-49232-61-60-53-47,65281-0-11-10-35-16-22-23-13-43-45-51,4588-29-23-30-24-25-256-257,0-1-2", + "ja3_hash": "d67b094811e5145139d7cea5f014309f", + "ja4": "t13d5212h1", + "http1": { + "request_line": "POST /v1/messages?beta=true HTTP/1.1", + "headers_in_order": [ + "host: localhost:8443", + "connection: keep-alive", + "Accept: application/json", + "X-Stainless-Retry-Count: 0", + "X-Stainless-Timeout: 300", + "X-Stainless-Lang: js", + "X-Stainless-Package-Version: 0.81.0", + "X-Stainless-OS: MacOS", + "X-Stainless-Arch: arm64", + "X-Stainless-Runtime: node", + "X-Stainless-Runtime-Version: v24.14.1", + "anthropic-dangerous-direct-browser-access: true", + "anthropic-version: 2023-06-01", + "x-api-key: sk-ant-capture-dummy", + "x-app: cli", + "User-Agent: claude-cli/2.1.111 (external, sdk-cli)", + "X-Claude-Code-Session-Id: 4d767222-862a-458a-b53c-097076b2c4bd", + "content-type: application/json", + "anthropic-beta: claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01", + "accept-language: *", + "sec-fetch-mode: cors", + "accept-encoding: br, gzip, deflate", + "content-length: 122718" + ] + } +} \ No newline at end of file diff --git a/backend/tools/capture_fingerprint/capture_types.go b/backend/tools/capture_fingerprint/capture_types.go new file mode 100644 index 0000000000..f402a74c44 --- /dev/null +++ b/backend/tools/capture_fingerprint/capture_types.go @@ -0,0 +1,56 @@ +package main + +// Capture is the top-level fingerprint payload written to stdout / -out file. +type Capture struct { + CapturedAt string `json:"captured_at"` + RemoteAddr string `json:"remote_addr"` + ServerName string `json:"server_name,omitempty"` + TLSVersion string `json:"tls_version"` + NegotiatedProto string `json:"negotiated_proto"` + ClientHelloRaw string `json:"client_hello_raw"` + CipherSuites []string `json:"cipher_suites"` + Extensions []string `json:"extensions"` + Curves []string `json:"curves"` + PointFormats []string `json:"point_formats"` + SignatureAlgos []string `json:"signature_algorithms"` + SupportedVersions []string `json:"supported_versions"` + KeyShareGroups []string `json:"key_share_groups"` + ALPNProtos []string `json:"alpn_protos"` + PSKModes []string `json:"psk_modes"` + JA3String string `json:"ja3_string"` + JA3Hash string `json:"ja3_hash"` + JA4 string `json:"ja4"` + + HTTP2 *H2Capture `json:"http2,omitempty"` + HTTP1 *H1Capture `json:"http1,omitempty"` +} + +// H2Capture records the HTTP/2 view of one capture session. +type H2Capture struct { + PrefaceSeen bool `json:"preface_seen"` + ClientSettings map[string]uint32 `json:"client_settings"` + WindowUpdate *uint32 `json:"initial_window_update,omitempty"` + FirstHeadersPseudoOrder []string `json:"first_headers_pseudo_order"` + FirstHeadersAll []string `json:"first_headers_all"` + FrameSequence []string `json:"frame_sequence"` + // Requests records every HTTP/2 request (stream) the client opened on + // this connection, in order seen. Used to discover what sidecar + // endpoints real Claude Code calls beyond /v1/messages. + Requests []H2Request `json:"requests,omitempty"` +} + +// H2Request is one HTTP/2 request seen on a capture session. +type H2Request struct { + StreamID uint32 `json:"stream_id"` + Method string `json:"method"` + Authority string `json:"authority,omitempty"` + Path string `json:"path"` + Headers []string `json:"headers,omitempty"` + BodyPreview string `json:"body_preview,omitempty"` +} + +// H1Capture records the HTTP/1.1 fallback view of a capture session. +type H1Capture struct { + RequestLine string `json:"request_line"` + Headers []string `json:"headers_in_order"` +} diff --git a/backend/tools/capture_fingerprint/cert.go b/backend/tools/capture_fingerprint/cert.go new file mode 100644 index 0000000000..6dd92f2f3f --- /dev/null +++ b/backend/tools/capture_fingerprint/cert.go @@ -0,0 +1,54 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + ctls "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "time" +) + +func generateSelfSignedCert() (ctls.Certificate, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return ctls.Certificate{}, err + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return ctls.Certificate{}, err + } + + template := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: "api.anthropic.com", + Organization: []string{"sub2api capture"}, + }, + DNSNames: []string{"api.anthropic.com", "localhost", "claude.ai"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return ctls.Certificate{}, err + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + + keyBytes, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return ctls.Certificate{}, err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}) + + return ctls.X509KeyPair(certPEM, keyPEM) +} diff --git a/backend/tools/capture_fingerprint/ja3_ja4.go b/backend/tools/capture_fingerprint/ja3_ja4.go new file mode 100644 index 0000000000..b6ee9d636b --- /dev/null +++ b/backend/tools/capture_fingerprint/ja3_ja4.go @@ -0,0 +1,163 @@ +package main + +import ( + "fmt" + + utls "github.com/refraction-networking/utls" +) + +// isGrease returns true if the value is one of the GREASE reserved values. +func isGrease(v uint16) bool { + return (v&0x0f0f) == 0x0a0a && (v>>8) == (v&0xff) +} + +func joinDecUint16(vals []uint16) string { + out := "" + first := true + for _, v := range vals { + if isGrease(v) { + continue + } + if !first { + out += "-" + } + out += fmt.Sprintf("%d", v) + first = false + } + return out +} + +// joinDecExtensions extracts extension IDs (in order, GREASE-stripped) from a +// slice of utls.TLSExtension and joins them into JA3 format. +func joinDecExtensions(exts []utls.TLSExtension) string { + out := "" + first := true + for _, ext := range exts { + id, ok := extensionID(ext) + if !ok { + continue + } + if isGrease(id) { + continue + } + if !first { + out += "-" + } + out += fmt.Sprintf("%d", id) + first = false + } + return out +} + +func extensionID(ext utls.TLSExtension) (uint16, bool) { + switch x := ext.(type) { + case *utls.SNIExtension: + return 0, true + case *utls.StatusRequestExtension: + return 5, true + case *utls.SupportedCurvesExtension: + return 10, true + case *utls.SupportedPointsExtension: + return 11, true + case *utls.SignatureAlgorithmsExtension: + return 13, true + case *utls.ALPNExtension: + return 16, true + case *utls.SCTExtension: + return 18, true + case *utls.ExtendedMasterSecretExtension: + return 23, true + case *utls.UtlsCompressCertExtension: + return 27, true + case *utls.SessionTicketExtension: + return 35, true + case *utls.SupportedVersionsExtension: + return 43, true + case *utls.PSKKeyExchangeModesExtension: + return 45, true + case *utls.KeyShareExtension: + return 51, true + case *utls.RenegotiationInfoExtension: + return 0xff01, true + case *utls.UtlsPaddingExtension: + return 21, true + case *utls.GenericExtension: + return x.Id, true + } + return 0, false +} + +func joinDecCurves(hexes []string) string { + out := "" + first := true + for _, h := range hexes { + var v uint16 + _, _ = fmt.Sscanf(h, "0x%04x", &v) + if isGrease(v) { + continue + } + if !first { + out += "-" + } + out += fmt.Sprintf("%d", v) + first = false + } + return out +} + +func joinDecPointFormats(hexes []string) string { + out := "" + first := true + for _, h := range hexes { + var v uint8 + _, _ = fmt.Sscanf(h, "0x%02x", &v) + if !first { + out += "-" + } + out += fmt.Sprintf("%d", v) + first = false + } + return out +} + +// computeJA4 is a best-effort JA4 TLS fingerprint following +// https://github.com/FoxIO-LLC/ja4 spec. We do NOT compute the full raw/hash +// suffixes (they need extra sorting); we fill in the part-a ("t13d1714h2") +// prefix accurately and leave part-b/c for manual use against a reference. +func computeJA4(spec *utls.ClientHelloSpec, c *Capture) string { + // Part a: tNNdCCxxHH + version := "13" + if spec == nil { + return "" + } + // Transport is 't' for TCP. + // Count cipher suites (excluding GREASE). + ccCount := 0 + for _, v := range spec.CipherSuites { + if !isGrease(v) { + ccCount++ + } + } + // Count extensions (excluding GREASE, but including SNI/ALPN). + xxCount := 0 + hasALPN := false + firstALPN := "00" + for _, ext := range spec.Extensions { + id, ok := extensionID(ext) + if !ok || isGrease(id) { + continue + } + xxCount++ + if id == 16 { + hasALPN = true + if ax, ok := ext.(*utls.ALPNExtension); ok && len(ax.AlpnProtocols) > 0 { + first := ax.AlpnProtocols[0] + if len(first) >= 2 { + firstALPN = first[:1] + first[len(first)-1:] + } + } + } + } + _ = hasALPN + return fmt.Sprintf("t%sd%02d%02d%s", version, ccCount, xxCount, firstALPN) +} diff --git a/backend/tools/capture_fingerprint/main.go b/backend/tools/capture_fingerprint/main.go new file mode 100644 index 0000000000..146cf115f6 --- /dev/null +++ b/backend/tools/capture_fingerprint/main.go @@ -0,0 +1,145 @@ +// capture_fingerprint spins up a local HTTPS+H2 server that records the +// TLS ClientHello and the first HTTP/2 frames sent by the connecting client, +// then replies with a minimal valid Anthropic /v1/messages SSE stream so the +// real Claude Code CLI treats the exchange as successful. +// +// Usage: +// +// go run ./tools/capture_fingerprint -addr 127.0.0.1:8443 -out fp.json +// +// Then, from another shell, point Claude Code at it: +// +// export ANTHROPIC_BASE_URL="https://localhost:8443" +// export ANTHROPIC_API_KEY="sk-ant-capture-dummy" +// export NODE_TLS_REJECT_UNAUTHORIZED=0 +// echo 'hi' | claude -p --model claude-sonnet-4-5 >/dev/null +// +// After the CLI call returns, the server prints the captured fingerprint as +// JSON to stdout and (optionally) writes it to the -out file. +package main + +import ( + "context" + ctls "crypto/tls" + "encoding/hex" + "errors" + "flag" + "log" + "net" + "time" +) + +func main() { + addr := flag.String("addr", "127.0.0.1:8443", "listen address") + outFile := flag.String("out", "", "optional: write captured fingerprint JSON to this file") + flag.Parse() + + cert, err := generateSelfSignedCert() + if err != nil { + log.Fatalf("generate cert: %v", err) + } + + tlsCfg := &ctls.Config{ + Certificates: []ctls.Certificate{cert}, + NextProtos: []string{"h2", "http/1.1"}, + MinVersion: ctls.VersionTLS12, + } + + ln, err := net.Listen("tcp", *addr) + if err != nil { + log.Fatalf("listen %s: %v", *addr, err) + } + defer ln.Close() + + logStartupBanner(*addr) + acceptLoop(ln, tlsCfg, *outFile) +} + +func logStartupBanner(addr string) { + log.Printf("capture server listening on https://%s", addr) + log.Printf("run in another shell:") + log.Printf(" NODE_TLS_REJECT_UNAUTHORIZED=0 \\") + log.Printf(" ANTHROPIC_BASE_URL=https://localhost:%s \\", portOf(addr)) + log.Printf(" ANTHROPIC_API_KEY=sk-ant-capture-dummy \\") + log.Printf(" claude -p --model claude-sonnet-4-5 'hi'") + log.Printf("") +} + +func acceptLoop(ln net.Listener, tlsCfg *ctls.Config, outFile string) { + for { + conn, err := ln.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return + } + log.Printf("accept: %v", err) + continue + } + go handleConn(conn, tlsCfg, outFile) + } +} + +func handleConn(raw net.Conn, tlsCfg *ctls.Config, outFile string) { + defer raw.Close() + _ = raw.SetDeadline(time.Now().Add(30 * time.Second)) + + capture := &Capture{ + CapturedAt: time.Now().UTC().Format(time.RFC3339), + RemoteAddr: raw.RemoteAddr().String(), + } + // Always dump whatever we captured, even if handshake / h2 fails later. + defer func() { printCapture(capture, outFile) }() + + tlsConn, ok := sniffAndHandshake(raw, tlsCfg, capture) + if !ok { + return + } + dispatchALPN(tlsConn, capture) +} + +// sniffAndHandshake peeks the ClientHello for fingerprinting, then completes +// the TLS handshake by replaying the buffered bytes back to crypto/tls. +func sniffAndHandshake(raw net.Conn, tlsCfg *ctls.Config, capture *Capture) (*ctls.Conn, bool) { + // Step 1: sniff the first ClientHello record so we can parse + // extensions / cipher order before the TLS library eats them. + peeker := newPeekConn(raw) + helloBytes, err := readTLSHandshakeRecord(peeker) + if err != nil { + log.Printf("read ClientHello: %v", err) + return nil, false + } + capture.ClientHelloRaw = hex.EncodeToString(helloBytes) + if err := parseClientHello(helloBytes, capture); err != nil { + log.Printf("parse ClientHello: %v", err) + } + + // Step 2: do the actual TLS handshake using the sniffed bytes + // replayed back to crypto/tls via our peekConn buffer. + tlsConn := ctls.Server(peeker, tlsCfg) + hsCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := tlsConn.HandshakeContext(hsCtx); err != nil { + log.Printf("tls handshake: %v", err) + return nil, false + } + state := tlsConn.ConnectionState() + capture.NegotiatedProto = state.NegotiatedProtocol + capture.TLSVersion = tlsVersionName(state.Version) + capture.ServerName = state.ServerName + return tlsConn, true +} + +func dispatchALPN(tlsConn *ctls.Conn, capture *Capture) { + switch tlsConn.ConnectionState().NegotiatedProtocol { + case "h2": + if err := serveH2(tlsConn, capture); err != nil { + log.Printf("h2: %v", err) + } + case "http/1.1", "": + if err := serveH1(tlsConn, capture); err != nil { + log.Printf("h1: %v", err) + } + default: + log.Printf("unknown ALPN: %q", tlsConn.ConnectionState().NegotiatedProtocol) + } +} diff --git a/backend/tools/capture_fingerprint/output.go b/backend/tools/capture_fingerprint/output.go new file mode 100644 index 0000000000..d52faab045 --- /dev/null +++ b/backend/tools/capture_fingerprint/output.go @@ -0,0 +1,54 @@ +package main + +import ( + "crypto/md5" + ctls "crypto/tls" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "os" +) + +func printCapture(c *Capture, outFile string) { + data, _ := json.MarshalIndent(c, "", " ") + fmt.Println(string(data)) + if outFile != "" { + if err := os.WriteFile(outFile, data, 0o644); err != nil { + log.Printf("write %s: %v", outFile, err) + } else { + log.Printf("wrote fingerprint to %s", outFile) + } + } +} + +func trimCRLF(s string) string { + for len(s) > 0 && (s[len(s)-1] == '\r' || s[len(s)-1] == '\n') { + s = s[:len(s)-1] + } + return s +} + +func portOf(addr string) string { + for i := len(addr) - 1; i >= 0; i-- { + if addr[i] == ':' { + return addr[i+1:] + } + } + return addr +} + +func tlsVersionName(v uint16) string { + switch v { + case ctls.VersionTLS13: + return "TLS 1.3" + case ctls.VersionTLS12: + return "TLS 1.2" + } + return fmt.Sprintf("0x%04x", v) +} + +func md5hex(s string) string { + h := md5.Sum([]byte(s)) + return hex.EncodeToString(h[:]) +} diff --git a/backend/tools/capture_fingerprint/peek_conn.go b/backend/tools/capture_fingerprint/peek_conn.go new file mode 100644 index 0000000000..b1d2454158 --- /dev/null +++ b/backend/tools/capture_fingerprint/peek_conn.go @@ -0,0 +1,68 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "net" + "sync" +) + +// peekConn buffers bytes read from the underlying conn so they can be +// replayed to crypto/tls after we've inspected them for fingerprinting. +type peekConn struct { + net.Conn + buf *bytes.Buffer + drained bool + mu sync.Mutex +} + +func newPeekConn(c net.Conn) *peekConn { + return &peekConn{Conn: c, buf: &bytes.Buffer{}} +} + +// peek reads n bytes from the underlying conn and records them. +func (p *peekConn) peek(n int) ([]byte, error) { + chunk := make([]byte, n) + if _, err := io.ReadFull(p.Conn, chunk); err != nil { + return nil, err + } + p.buf.Write(chunk) + return chunk, nil +} + +func (p *peekConn) Read(b []byte) (int, error) { + p.mu.Lock() + if !p.drained && p.buf.Len() > 0 { + n, _ := p.buf.Read(b) + if p.buf.Len() == 0 { + p.drained = true + } + p.mu.Unlock() + return n, nil + } + p.mu.Unlock() + return p.Conn.Read(b) +} + +// readTLSHandshakeRecord reads a single TLS record (5-byte header + fragment) +// and returns the raw handshake message bytes (the handshake payload, i.e. +// ClientHello starting with the handshake-type byte 0x01). +func readTLSHandshakeRecord(p *peekConn) ([]byte, error) { + header, err := p.peek(5) + if err != nil { + return nil, err + } + if header[0] != 0x16 { // not a handshake record + return nil, fmt.Errorf("not a tls handshake record: first byte=0x%02x", header[0]) + } + length := int(header[3])<<8 | int(header[4]) + if length <= 0 || length > 16*1024 { + return nil, fmt.Errorf("bad handshake record length %d", length) + } + fragment, err := p.peek(length) + if err != nil { + return nil, err + } + return fragment, nil +} diff --git a/backend/tools/capture_fingerprint/serve_h1.go b/backend/tools/capture_fingerprint/serve_h1.go new file mode 100644 index 0000000000..1243629ecd --- /dev/null +++ b/backend/tools/capture_fingerprint/serve_h1.go @@ -0,0 +1,33 @@ +package main + +import ( + "bufio" + ctls "crypto/tls" +) + +func serveH1(tlsConn *ctls.Conn, c *Capture) error { + c.HTTP1 = &H1Capture{} + r := bufio.NewReader(tlsConn) + + line, err := r.ReadString('\n') + if err != nil { + return err + } + c.HTTP1.RequestLine = trimCRLF(line) + for { + h, err := r.ReadString('\n') + if err != nil { + return err + } + h = trimCRLF(h) + if h == "" { + break + } + c.HTTP1.Headers = append(c.HTTP1.Headers, h) + } + + resp := "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n" + + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + _, _ = tlsConn.Write([]byte(resp)) + return nil +} diff --git a/backend/tools/capture_fingerprint/serve_h2.go b/backend/tools/capture_fingerprint/serve_h2.go new file mode 100644 index 0000000000..54ef4cfa9f --- /dev/null +++ b/backend/tools/capture_fingerprint/serve_h2.go @@ -0,0 +1,310 @@ +package main + +import ( + "bufio" + "bytes" + ctls "crypto/tls" + "fmt" + "io" + "log" + "strings" + "time" + + "golang.org/x/net/http2" + "golang.org/x/net/http2/hpack" +) + +const h2Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" + +// bodyPreviewLimit caps per-stream body bytes captured for preview. +const bodyPreviewLimit = 512 + +// h2IdleTimeout is the per-frame read deadline; long enough for Claude Code +// to finish streaming a response before the next request, short enough that +// a hung session dumps its capture in reasonable wall-clock time. +const h2IdleTimeout = 30 * time.Second + +// h2Session holds the per-connection state for an HTTP/2 capture session. +type h2Session struct { + framer *http2.Framer + capture *Capture + bodies map[uint32]*bytes.Buffer + firstHeadersSeen bool +} + +// serveH2 handles an HTTP/2 session in "discovery" mode: it captures the +// TLS/H2 fingerprint on the first request and then keeps the connection open +// responding to every subsequent request with a path-appropriate stub, so we +// can observe the full set of endpoints a real Claude Code CLI hits in one +// session (not just /v1/messages). +// +// Exit conditions: client GOAWAY, read deadline (idle), or explicit error. +// The overall session is bounded by the raw-conn deadline set in +// handleConn (currently 30s); per-frame iteration we reset to +// h2IdleTimeout so a hung stream doesn't block past that ceiling. +func serveH2(tlsConn *ctls.Conn, c *Capture) error { + c.HTTP2 = &H2Capture{ClientSettings: map[string]uint32{}} + reader := bufio.NewReader(tlsConn) + if err := readH2Preface(reader, c); err != nil { + return err + } + sess := newH2Session(tlsConn, reader, c) + if err := sess.framer.WriteSettings(); err != nil { + return fmt.Errorf("write settings: %w", err) + } + return sess.loop(tlsConn) +} + +func readH2Preface(reader *bufio.Reader, c *Capture) error { + preface := make([]byte, len(h2Preface)) + if _, err := io.ReadFull(reader, preface); err != nil { + return fmt.Errorf("read preface: %w", err) + } + if string(preface) != h2Preface { + return fmt.Errorf("bad preface: %q", preface) + } + c.HTTP2.PrefaceSeen = true + return nil +} + +func newH2Session(tlsConn *ctls.Conn, reader *bufio.Reader, c *Capture) *h2Session { + framer := http2.NewFramer(tlsConn, reader) + framer.SetMaxReadFrameSize(16384) + framer.ReadMetaHeaders = hpack.NewDecoder(4096, nil) + return &h2Session{ + framer: framer, + capture: c, + bodies: map[uint32]*bytes.Buffer{}, + } +} + +func (s *h2Session) loop(tlsConn *ctls.Conn) error { + for { + _ = tlsConn.SetDeadline(time.Now().Add(h2IdleTimeout)) + frame, err := s.framer.ReadFrame() + if err != nil { + // Idle timeout or connection close is the expected happy path + // after the CLI is done. Report as nil error so the capture + // dump still happens. + return nil + } + s.capture.HTTP2.FrameSequence = append(s.capture.HTTP2.FrameSequence, frame.Header().Type.String()) + if done, err := s.dispatch(frame); done || err != nil { + return err + } + } +} + +func (s *h2Session) dispatch(frame http2.Frame) (done bool, err error) { + switch f := frame.(type) { + case *http2.SettingsFrame: + return false, s.handleSettings(f) + case *http2.WindowUpdateFrame: + s.handleWindowUpdate(f) + case *http2.MetaHeadersFrame: + s.handleHeaders(f) + case *http2.DataFrame: + s.handleData(f) + case *http2.PingFrame: + s.handlePing(f) + case *http2.GoAwayFrame: + return true, nil + } + return false, nil +} + +func (s *h2Session) handleSettings(f *http2.SettingsFrame) error { + if f.IsAck() { + return nil + } + _ = f.ForeachSetting(func(st http2.Setting) error { + s.capture.HTTP2.ClientSettings[settingName(st.ID)] = st.Val + return nil + }) + if err := s.framer.WriteSettingsAck(); err != nil { + return fmt.Errorf("write settings ack: %w", err) + } + return nil +} + +func (s *h2Session) handleWindowUpdate(f *http2.WindowUpdateFrame) { + inc := f.Increment + if s.capture.HTTP2.WindowUpdate == nil { + s.capture.HTTP2.WindowUpdate = &inc + } +} + +func (s *h2Session) handleHeaders(f *http2.MetaHeadersFrame) { + req, pseudo, all := buildH2Request(f) + // Record the first request's header ordering for fingerprint + // comparison (backwards compatible with the old capture format). + if !s.firstHeadersSeen { + s.capture.HTTP2.FirstHeadersPseudoOrder = pseudo + s.capture.HTTP2.FirstHeadersAll = all + s.firstHeadersSeen = true + } + s.capture.HTTP2.Requests = append(s.capture.HTTP2.Requests, req) + // Emit the stub response immediately. For requests with bodies the + // DataFrame arrives after our response header block — that's fine, + // we just absorb the DATA frames in handleData. Don't bail on write + // errors: the client may have already closed the stream. + if err := writeStubResponseForPath(s.framer, f.StreamID, req.Path); err != nil { + log.Printf("write stub for %s: %v", req.Path, err) + } +} + +func buildH2Request(f *http2.MetaHeadersFrame) (H2Request, []string, []string) { + req := H2Request{StreamID: f.StreamID} + var pseudo, all []string + for _, hf := range f.Fields { + all = append(all, hf.Name+": "+hf.Value) + if hf.IsPseudo() { + pseudo = append(pseudo, hf.Name) + } + switch hf.Name { + case ":method": + req.Method = hf.Value + case ":path": + req.Path = hf.Value + case ":authority": + req.Authority = hf.Value + } + } + req.Headers = all + return req, pseudo, all +} + +func (s *h2Session) handleData(f *http2.DataFrame) { + buf, ok := s.bodies[f.StreamID] + if !ok { + buf = &bytes.Buffer{} + s.bodies[f.StreamID] = buf + } + if buf.Len() < bodyPreviewLimit { + need := bodyPreviewLimit - buf.Len() + data := f.Data() + if len(data) > need { + data = data[:need] + } + buf.Write(data) + } + if f.StreamEnded() { + // Attach body preview back to the matching request. + for i := range s.capture.HTTP2.Requests { + if s.capture.HTTP2.Requests[i].StreamID == f.StreamID { + s.capture.HTTP2.Requests[i].BodyPreview = buf.String() + break + } + } + } +} + +func (s *h2Session) handlePing(f *http2.PingFrame) { + // Reply to client pings so idle-detection keepalives work. + if !f.IsAck() { + _ = s.framer.WritePing(true, f.Data) + } +} + +// writeStubResponseForPath dispatches to a path-appropriate stub. Unknown +// paths get a generic 404 so the CLI visibly errors on them — which is fine +// for a diagnostic tool; the path is already recorded in the capture. +func writeStubResponseForPath(framer *http2.Framer, streamID uint32, path string) error { + switch { + case path == "" || path == "/": + return writeFakeJSONResponse(framer, streamID, 404, `{"error":"empty path"}`) + case strings.HasSuffix(path, "/count_tokens") || strings.Contains(path, "/count_tokens?"): + return writeFakeJSONResponse(framer, streamID, 200, `{"input_tokens":1}`) + case strings.HasPrefix(path, "/v1/messages"): + return writeFakeH2Response(framer, streamID) + case strings.HasPrefix(path, "/api/oauth/usage") || strings.HasPrefix(path, "/v1/organizations"): + return writeFakeJSONResponse(framer, streamID, 200, `{"five_hour":{"utilization":0.05,"resets_at":"2099-01-01T00:00:00Z"},"seven_day":{"utilization":0.05,"resets_at":"2099-01-01T00:00:00Z"},"seven_day_sonnet":{"utilization":0.05,"resets_at":"2099-01-01T00:00:00Z"}}`) + case strings.HasPrefix(path, "/v1/models"): + return writeFakeJSONResponse(framer, streamID, 200, `{"data":[],"has_more":false,"first_id":null,"last_id":null}`) + default: + return writeFakeJSONResponse(framer, streamID, 404, `{"type":"error","error":{"type":"not_found_error","message":"capture stub has no handler for this path"}}`) + } +} + +// writeFakeJSONResponse sends a single-frame HTTP/2 JSON response with the +// given status and body. Used for non-streaming endpoints like +// count_tokens, /api/oauth/usage, /v1/models. +func writeFakeJSONResponse(framer *http2.Framer, streamID uint32, status int, body string) error { + if streamID == 0 { + streamID = 1 + } + var hdrBuf bytes.Buffer + enc := hpack.NewEncoder(&hdrBuf) + _ = enc.WriteField(hpack.HeaderField{Name: ":status", Value: fmt.Sprintf("%d", status)}) + _ = enc.WriteField(hpack.HeaderField{Name: "content-type", Value: "application/json"}) + _ = enc.WriteField(hpack.HeaderField{Name: "x-request-id", Value: "req_capture_stub"}) + + if err := framer.WriteHeaders(http2.HeadersFrameParam{ + StreamID: streamID, + BlockFragment: hdrBuf.Bytes(), + EndStream: false, + EndHeaders: true, + }); err != nil { + return err + } + return framer.WriteData(streamID, true, []byte(body)) +} + +func writeFakeH2Response(framer *http2.Framer, streamID uint32) error { + if streamID == 0 { + streamID = 1 + } + // Encode a minimal response header block via hpack. + var hdrBuf bytes.Buffer + enc := hpack.NewEncoder(&hdrBuf) + _ = enc.WriteField(hpack.HeaderField{Name: ":status", Value: "200"}) + _ = enc.WriteField(hpack.HeaderField{Name: "content-type", Value: "text/event-stream"}) + _ = enc.WriteField(hpack.HeaderField{Name: "cache-control", Value: "no-cache"}) + _ = enc.WriteField(hpack.HeaderField{Name: "x-request-id", Value: "req_capture_001"}) + + if err := framer.WriteHeaders(http2.HeadersFrameParam{ + StreamID: streamID, + BlockFragment: hdrBuf.Bytes(), + EndStream: false, + EndHeaders: true, + }); err != nil { + return err + } + + // Minimal SSE body that Claude Code will parse as a valid stream. + body := "event: message_start\n" + + `data: {"type":"message_start","message":{"id":"msg_capture","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}}` + "\n\n" + + "event: content_block_start\n" + + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}` + "\n\n" + + "event: content_block_delta\n" + + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}` + "\n\n" + + "event: content_block_stop\n" + + `data: {"type":"content_block_stop","index":0}` + "\n\n" + + "event: message_delta\n" + + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}` + "\n\n" + + "event: message_stop\n" + + `data: {"type":"message_stop"}` + "\n\n" + + if err := framer.WriteData(streamID, true, []byte(body)); err != nil { + return err + } + return nil +} + +func settingName(id http2.SettingID) string { + switch id { + case http2.SettingHeaderTableSize: + return "HEADER_TABLE_SIZE" + case http2.SettingEnablePush: + return "ENABLE_PUSH" + case http2.SettingMaxConcurrentStreams: + return "MAX_CONCURRENT_STREAMS" + case http2.SettingInitialWindowSize: + return "INITIAL_WINDOW_SIZE" + case http2.SettingMaxFrameSize: + return "MAX_FRAME_SIZE" + case http2.SettingMaxHeaderListSize: + return "MAX_HEADER_LIST_SIZE" + } + return fmt.Sprintf("SETTING_0x%04x", uint16(id)) +} diff --git a/backend/tools/capture_fingerprint/tls_parse.go b/backend/tools/capture_fingerprint/tls_parse.go new file mode 100644 index 0000000000..5d2818b53a --- /dev/null +++ b/backend/tools/capture_fingerprint/tls_parse.go @@ -0,0 +1,118 @@ +package main + +import ( + "fmt" + + utls "github.com/refraction-networking/utls" +) + +// wrapAsRecord wraps a raw handshake fragment back into a TLS record so +// utls.Fingerprinter can consume it. Fingerprinter expects the 5-byte TLS +// record header to be present. +func wrapAsRecord(fragment []byte) []byte { + length := len(fragment) + return append([]byte{0x16, 0x03, 0x01, byte(length >> 8), byte(length)}, fragment...) +} + +func parseClientHello(fragment []byte, c *Capture) error { + fp := &utls.Fingerprinter{AllowBluntMimicry: true} + spec, err := fp.FingerprintClientHello(wrapAsRecord(fragment)) + if err != nil { + return fmt.Errorf("utls fingerprint: %w", err) + } + + for _, cs := range spec.CipherSuites { + c.CipherSuites = append(c.CipherSuites, fmt.Sprintf("0x%04x", cs)) + } + for _, ext := range spec.Extensions { + c.Extensions = append(c.Extensions, extensionName(ext)) + collectExtensionDetails(ext, c) + } + + // JA3 = TLSVersion,Ciphers,Extensions,Curves,PointFormats (decimal, dashes, commas) + ja3 := fmt.Sprintf("%d,%s,%s,%s,%s", + 771, // TLS 1.2 legacy version byte — JA3 convention + joinDecUint16(spec.CipherSuites), + joinDecExtensions(spec.Extensions), + joinDecCurves(c.Curves), + joinDecPointFormats(c.PointFormats), + ) + c.JA3String = ja3 + c.JA3Hash = md5hex(ja3) + + c.JA4 = computeJA4(spec, c) + return nil +} + +func collectExtensionDetails(ext utls.TLSExtension, c *Capture) { + switch x := ext.(type) { + case *utls.SupportedCurvesExtension: + for _, cv := range x.Curves { + c.Curves = append(c.Curves, fmt.Sprintf("0x%04x", uint16(cv))) + } + case *utls.SupportedPointsExtension: + for _, pf := range x.SupportedPoints { + c.PointFormats = append(c.PointFormats, fmt.Sprintf("0x%02x", pf)) + } + case *utls.SignatureAlgorithmsExtension: + for _, sa := range x.SupportedSignatureAlgorithms { + c.SignatureAlgos = append(c.SignatureAlgos, fmt.Sprintf("0x%04x", uint16(sa))) + } + case *utls.SupportedVersionsExtension: + for _, v := range x.Versions { + c.SupportedVersions = append(c.SupportedVersions, fmt.Sprintf("0x%04x", v)) + } + case *utls.KeyShareExtension: + for _, ks := range x.KeyShares { + c.KeyShareGroups = append(c.KeyShareGroups, fmt.Sprintf("0x%04x", uint16(ks.Group))) + } + case *utls.ALPNExtension: + c.ALPNProtos = append(c.ALPNProtos, x.AlpnProtocols...) + case *utls.PSKKeyExchangeModesExtension: + for _, m := range x.Modes { + c.PSKModes = append(c.PSKModes, fmt.Sprintf("0x%02x", m)) + } + } +} + +func extensionName(ext utls.TLSExtension) string { + // Best-effort human label via type name. + switch x := ext.(type) { + case *utls.SNIExtension: + _ = x + return "server_name (0)" + case *utls.StatusRequestExtension: + return "status_request (5)" + case *utls.SupportedCurvesExtension: + return "supported_groups (10)" + case *utls.SupportedPointsExtension: + return "ec_point_formats (11)" + case *utls.SignatureAlgorithmsExtension: + return "signature_algorithms (13)" + case *utls.ALPNExtension: + return "application_layer_protocol_negotiation (16)" + case *utls.SCTExtension: + return "signed_certificate_timestamp (18)" + case *utls.ExtendedMasterSecretExtension: + return "extended_master_secret (23)" + case *utls.UtlsCompressCertExtension: + return "compress_certificate (27)" + case *utls.SessionTicketExtension: + return "session_ticket (35)" + case *utls.SupportedVersionsExtension: + return "supported_versions (43)" + case *utls.PSKKeyExchangeModesExtension: + return "psk_key_exchange_modes (45)" + case *utls.KeyShareExtension: + return "key_share (51)" + case *utls.UtlsGREASEExtension: + return "GREASE (0x0a0a / 0xdada etc.)" + case *utls.UtlsPaddingExtension: + return "padding (21)" + case *utls.RenegotiationInfoExtension: + return "renegotiation_info (0xff01)" + case *utls.GenericExtension: + return fmt.Sprintf("generic (0x%04x)", x.Id) + } + return fmt.Sprintf("%T", ext) +} diff --git a/backend/tools/verify_fingerprint/main.go b/backend/tools/verify_fingerprint/main.go new file mode 100644 index 0000000000..8a7ae6c806 --- /dev/null +++ b/backend/tools/verify_fingerprint/main.go @@ -0,0 +1,79 @@ +// verify_fingerprint dials a local capture server using the tlsfingerprint +// package's built-in default profile (same path sub2api's outbound Anthropic +// traffic takes), so we can compare the resulting ClientHello against the +// real Claude Code capture byte-for-byte. +// +// Usage: +// +// go run ./tools/verify_fingerprint -capture https://127.0.0.1:8443 +package main + +import ( + "context" + "flag" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" +) + +func main() { + captureURL := flag.String("capture", "https://127.0.0.1:8443", "capture server base URL") + flag.Parse() + + u, err := url.Parse(*captureURL) + if err != nil { + log.Fatalf("parse capture url: %v", err) + } + _ = u + + // Use the tlsfingerprint package's default profile — same as what the + // sub2api outbound path uses when no per-account profile is set. + profile := &tlsfingerprint.Profile{ + Name: "default", + ALPNProtocols: []string{"http/1.1"}, + } + + dialer := tlsfingerprint.NewDialer(profile, nil) + + transport := &http.Transport{ + DialTLSContext: dialer.DialTLSContext, + ForceAttemptHTTP2: false, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + } + + client := &http.Client{ + Transport: transport, + Timeout: 15 * time.Second, + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "POST", *captureURL+"/v1/messages", strings.NewReader(`{"probe":true}`)) + if err != nil { + log.Fatalf("build request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "sub2api-verify/1.0") + + resp, err := client.Do(req) + if err != nil { + // We expect a TLS failure (self-signed cert). That's fine — the + // capture server still records the ClientHello before our side + // closes. Print the error and exit 0 so the capture log is the + // source of truth. + fmt.Printf("dial error (expected — cert not trusted): %v\n", err) + fmt.Printf("check capture server log for ClientHello details\n") + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + fmt.Printf("status=%d body=%s\n", resp.StatusCode, body) +} diff --git a/frontend/src/api/admin/tlsFingerprintProfile.ts b/frontend/src/api/admin/tlsFingerprintProfile.ts index f6a26dd517..06d8bdf027 100644 --- a/frontend/src/api/admin/tlsFingerprintProfile.ts +++ b/frontend/src/api/admin/tlsFingerprintProfile.ts @@ -87,12 +87,25 @@ export async function deleteProfile(id: number): Promise<{ message: string }> { return data } +/** + * Generate a randomized TLS fingerprint profile, insert it into the library, + * and bind it to the given account. Calling this a second time reshuffles: + * the previous auto-generated profile is deleted after the new one is bound. + */ +export async function randomizeForAccount(accountID: number): Promise { + const { data } = await apiClient.post( + `/admin/tls-fingerprint-profiles/randomize-for-account/${accountID}` + ) + return data +} + export const tlsFingerprintProfileAPI = { list, getById, create, update, - delete: deleteProfile + delete: deleteProfile, + randomizeForAccount } export default tlsFingerprintProfileAPI diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 2130c9ab95..ebab3e3786 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2165,12 +2165,31 @@ -
+
+ +
@@ -2930,6 +2949,7 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector. import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' import { applyInterceptWarmup } from '@/components/account/credentialsBuilder' import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' +import { extractApiErrorMessage } from '@/utils/apiError' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { OPENAI_WS_MODE_CTX_POOL, @@ -3154,6 +3174,7 @@ const umqModeOptions = computed(() => [ const tlsFingerprintEnabled = ref(false) const tlsFingerprintProfileId = ref(null) const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([]) +const tlsFingerprintRandomizeOnCreate = ref(false) const sessionIdMaskingEnabled = ref(false) const cacheTTLOverrideEnabled = ref(false) const cacheTTLOverrideTarget = ref('5m') @@ -3731,7 +3752,8 @@ const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise { submitting.value = true try { - await adminAPI.accounts.create(withAntigravityConfirmFlag(payload)) + const created = await adminAPI.accounts.create(withAntigravityConfirmFlag(payload)) + await maybeRandomizeTLSForCreatedAccount(created?.id, payload.platform) appStore.showSuccess(t('admin.accounts.accountCreated')) emit('created') handleClose() @@ -3816,6 +3838,7 @@ const resetForm = () => { userMsgQueueMode.value = '' tlsFingerprintEnabled.value = false tlsFingerprintProfileId.value = null + tlsFingerprintRandomizeOnCreate.value = false sessionIdMaskingEnabled.value = false cacheTTLOverrideEnabled.value = false cacheTTLOverrideTarget.value = '5m' @@ -3909,6 +3932,25 @@ const doCreateAccount = async (payload: CreateAccountRequest) => { await submitCreateAccount(payload) } +// maybeRandomizeTLSForCreatedAccount fires the randomize-for-account endpoint +// after a successful create if the user ticked "randomize on create". Noop for +// non-Anthropic accounts (backend rejects them) and noop if the flag is off. +// Failures surface as toasts but don't roll back the account — the user can +// always re-randomize manually from the edit modal. +const maybeRandomizeTLSForCreatedAccount = async (accountID: number | undefined, platform: AccountPlatform) => { + if (!accountID) return + if (!tlsFingerprintRandomizeOnCreate.value) return + if (platform !== 'anthropic') return + try { + await adminAPI.tlsFingerprintProfiles.randomizeForAccount(accountID) + } catch (err: unknown) { + console.error('randomize TLS fingerprint after create failed', err) + appStore.showError( + extractApiErrorMessage(err, t('admin.accounts.quotaControl.tlsFingerprint.randomizeFailed')) + ) + } +} + // Handle mixed channel warning confirmation const handleMixedChannelConfirm = async () => { const action = mixedChannelWarningAction.value @@ -4789,7 +4831,7 @@ const handleCookieAuth = async (sessionKey: string) => { credentials.temp_unschedulable_rules = tempUnschedPayload } - await adminAPI.accounts.create({ + const createdAccount = await adminAPI.accounts.create({ name: accountName, notes: form.notes, platform: form.platform, @@ -4805,6 +4847,7 @@ const handleCookieAuth = async (sessionKey: string) => { expires_at: form.expires_at, auto_pause_on_expired: autoPauseOnExpired.value }) + await maybeRandomizeTLSForCreatedAccount(createdAccount?.id, form.platform) successCount++ } catch (error: any) { diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 59ca0b9c52..0e28dcb8e9 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1602,12 +1602,39 @@ -
- +
+
+ + + {{ t('admin.accounts.quotaControl.tlsFingerprint.randomizedBadge') }} + +
+
+

+ {{ t('admin.accounts.quotaControl.tlsFingerprint.randomizeHint') }} +

+ +
@@ -1840,6 +1867,16 @@ @confirm="handleMixedChannelConfirm" @cancel="handleMixedChannelCancel" /> + diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ff64f3a081..53c3458679 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2481,7 +2481,16 @@ export default { label: 'TLS Fingerprint Simulation', hint: 'Simulate Node.js/Claude Code client TLS fingerprint', defaultProfile: 'Built-in Default', - randomProfile: 'Random' + randomProfile: 'Random', + randomizeButton: 'Randomize Fingerprint', + reshuffleButton: 'Re-randomize', + randomizeHint: 'Generate a perturbed copy of the Claude Code 2.1.109 baseline unique to this account. Avoids pool-wide fingerprint clustering.', + randomizedBadge: 'Randomized', + randomizeConfirm: 'Generate a new random TLS fingerprint for this account? The previous random fingerprint will be deleted.', + randomizeSuccess: 'Generated a new random TLS fingerprint for the account', + randomizeFailed: 'Failed to generate random fingerprint', + randomizeOnCreate: 'Auto-randomize fingerprint after create', + randomizeOnCreateHint: 'Automatically generate a unique random TLS fingerprint for each new account once it is created (Anthropic OAuth / setup-token only)' }, sessionIdMasking: { label: 'Session ID Masking', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 790cad0cfe..748769a6e2 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2318,8 +2318,8 @@ export default { rateLimitedAutoResume: '{time} 自动恢复', modelRateLimitedUntil: '{model} 限流至 {time}', modelCreditOveragesUntil: '{model} 正在使用 AI Credits,至 {time}', - creditsExhausted: 'AICredits', - creditsExhaustedUntil: 'AICredits, recovery at {time}', + creditsExhausted: '积分已用尽', + creditsExhaustedUntil: 'AI Credits 已用尽,预计 {time} 恢复', overloadedUntil: '负载过重,重置时间:{time}', viewTempUnschedDetails: '查看临时不可调度详情' }, @@ -2632,7 +2632,16 @@ export default { label: 'TLS 指纹模拟', hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹', defaultProfile: '内置默认', - randomProfile: '随机' + randomProfile: '随机', + randomizeButton: '随机分配指纹', + reshuffleButton: '重新随机', + randomizeHint: '从 Claude Code 2.1.109 基线随机扰动一个独有指纹并绑定到此账号,避免整池指纹聚类', + randomizedBadge: '已随机分配', + randomizeConfirm: '确认要为此账号重新生成随机 TLS 指纹吗?之前的随机指纹会被删除。', + randomizeSuccess: '已为账号生成新的随机 TLS 指纹', + randomizeFailed: '生成随机指纹失败', + randomizeOnCreate: '创建后自动随机分配指纹', + randomizeOnCreateHint: '账号创建成功后自动为每个新账号生成独立的随机 TLS 指纹(仅对 Anthropic OAuth / setup-token 账号生效)' }, sessionIdMasking: { label: '会话 ID 伪装', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 89fd777fe6..4b0e6dbee0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -736,6 +736,7 @@ export interface Account { // TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效) enable_tls_fingerprint?: boolean | null tls_fingerprint_profile_id?: number | null + tls_fingerprint_randomized?: boolean | null // 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效) // 启用后将在15分钟内固定 metadata.user_id 中的 session ID