feat(fingerprint): Claude Code CLI fingerprint mimicry suite (v0.1.114.1)

Squash-merge feat/fingerprint-mimic into release/custom-0.1.114.

Functional changes:
- TLS ClientHello mimicry: 17→52 cipher suites, ALPN http/1.1 only,
  X25519MLKEM768, JA3/JA4 aligned with official CLI baseline
- HTTP layer: User-Agent 2.1.112 sdk-cli, 3 new anthropic-beta tokens,
  Stainless headers synced
- Sidecar traffic: usage poll + count_tokens injection + startup probe
  (three fire-and-forget channels with jittered intervals)
- Sticky session UUID: per-account metadata.user_id reuse via Redis,
  WithSessionHash propagation through gateway
- 429 no-switch: short-circuit on rate-limit instead of cross-account fail-over
- Admin UI: per-account "randomize fingerprint" button + ConfirmDialog +
  i18n (zh/en, 9 keys)

Engineering hardening (fixes carried over from hai/snapshot):
- safe.Go/Run package: panic-recovering goroutine helper with slog
- redis.Nil treated as cache miss in identity cache
- SOCKS5 dialer uses ContextDialer for ctx cancellation propagation
- Token refresh selects on stopCh during retry backoff
- Sidecar goroutines wrapped with safe.Go
- .gitattributes forces LF for *.json (fixes baseline file CRLF drift)

Refactoring (zero behavior change):
- sidecar probe constants centralized
- identity_service rewrite preflight extracted into helpers
- capture_fingerprint tool split from 894 lines into 9 files
- gofmt sweep on all hai-imported files

Tests:
- safe package: panic recovery + attrs propagation
- tlsfingerprint: SOCKS5 dialer + capture parity
- identity sticky-session preflight paths (early-return + cache-hit)
- sidecar probe: jittered interval + dry-run
- token refresh: graceful shutdown during backoff

Beta-validated on 0.1.112.21 (commit 565da163 of feat/fingerprint-mimic).
Bumps VERSION 0.1.112.20 -> 0.1.114.1.
This commit is contained in:
erio
2026-04-18 16:07:36 +08:00
parent e89f879533
commit 6d0e056244
51 changed files with 3612 additions and 236 deletions
+3
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
0.1.112.20
0.1.114.1
+7
View File
@@ -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
+11 -3
View File
@@ -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
+1
View File
@@ -76,6 +76,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // scheduledTestRunner
nil, // backupSvc
nil, // paymentOrderExpiry
nil, // claudeSidecarProbe
)
require.NotPanics(t, func() {
+54
View File
@@ -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)
@@ -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)
}
+19
View File
@@ -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
+75 -19
View File
@@ -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"
+65
View File
@@ -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...)
}
+111
View File
@@ -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)
}
}
+193 -41
View File
@@ -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))
@@ -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
@@ -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)
}
}
@@ -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
}
+43 -4
View File
@@ -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()
}
@@ -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() {
+1
View File
@@ -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)
}
}
+16
View File
@@ -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 {
@@ -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 {
@@ -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
}
@@ -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")
}
@@ -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)
}
})
}
@@ -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()
}
})
}
@@ -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":[]}`))))
})
}
+71 -1
View File
@@ -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
+134 -130
View File
@@ -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) {
@@ -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 {
@@ -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")
}
@@ -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
)
@@ -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 {
@@ -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
@@ -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")
}
}
+34
View File
@@ -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,
@@ -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"
]
}
}
@@ -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"`
}
+54
View File
@@ -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)
}
@@ -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)
}
+145
View File
@@ -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)
}
}
@@ -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[:])
}
@@ -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
}
@@ -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
}
@@ -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))
}
@@ -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)
}
+79
View File
@@ -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)
}
@@ -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<TLSFingerprintProfile> {
const { data } = await apiClient.post<TLSFingerprintProfile>(
`/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
@@ -2165,12 +2165,31 @@
</button>
</div>
<!-- Profile selector -->
<div v-if="tlsFingerprintEnabled" class="mt-3">
<div v-if="tlsFingerprintEnabled" class="mt-3 space-y-3">
<select v-model="tlsFingerprintProfileId" class="input">
<option :value="null">{{ t('admin.accounts.quotaControl.tlsFingerprint.defaultProfile') }}</option>
<option v-if="tlsFingerprintProfiles.length > 0" :value="-1">{{ t('admin.accounts.quotaControl.tlsFingerprint.randomProfile') }}</option>
<option v-for="p in tlsFingerprintProfiles" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<!-- Auto-randomize on create (Anthropic OAuth/setup-token only) -->
<label
v-if="form.platform === 'anthropic' && (addMethod === 'oauth' || addMethod === 'setup-token')"
class="flex cursor-pointer items-start gap-2 rounded-md border border-dashed border-primary-300 bg-primary-50 p-3 dark:border-primary-700 dark:bg-primary-900/20"
>
<input
v-model="tlsFingerprintRandomizeOnCreate"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<div class="flex-1">
<div class="text-sm font-medium text-gray-800 dark:text-gray-200">
{{ t('admin.accounts.quotaControl.tlsFingerprint.randomizeOnCreate') }}
</div>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.tlsFingerprint.randomizeOnCreateHint') }}
</div>
</div>
</label>
</div>
</div>
@@ -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<number | null>(null)
const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([])
const tlsFingerprintRandomizeOnCreate = ref(false)
const sessionIdMaskingEnabled = ref(false)
const cacheTTLOverrideEnabled = ref(false)
const cacheTTLOverrideTarget = ref<string>('5m')
@@ -3731,7 +3752,8 @@ const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise<v
const submitCreateAccount = async (payload: CreateAccountRequest) => {
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) {
@@ -1602,12 +1602,39 @@
</button>
</div>
<!-- Profile selector -->
<div v-if="tlsFingerprintEnabled" class="mt-3">
<select v-model="tlsFingerprintProfileId" class="input">
<option :value="null">{{ t('admin.accounts.quotaControl.tlsFingerprint.defaultProfile') }}</option>
<option v-if="tlsFingerprintProfiles.length > 0" :value="-1">{{ t('admin.accounts.quotaControl.tlsFingerprint.randomProfile') }}</option>
<option v-for="p in tlsFingerprintProfiles" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<div v-if="tlsFingerprintEnabled" class="mt-3 space-y-2">
<div class="flex items-center gap-2">
<select
v-model="tlsFingerprintProfileId"
class="input flex-1"
@change="tlsFingerprintRandomized = false"
>
<option :value="null">{{ t('admin.accounts.quotaControl.tlsFingerprint.defaultProfile') }}</option>
<option v-if="tlsFingerprintProfiles.length > 0" :value="-1">{{ t('admin.accounts.quotaControl.tlsFingerprint.randomProfile') }}</option>
<option v-for="p in tlsFingerprintProfiles" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<span
v-if="tlsFingerprintRandomized"
class="inline-flex items-center rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900 dark:text-primary-300"
>
{{ t('admin.accounts.quotaControl.tlsFingerprint.randomizedBadge') }}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.tlsFingerprint.randomizeHint') }}
</p>
<button
type="button"
class="whitespace-nowrap rounded-md border border-primary-600 px-3 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-primary-400 dark:text-primary-300 dark:hover:bg-primary-900/30"
:disabled="tlsFingerprintRandomizing || !props.account?.id"
@click="handleRandomizeTLSFingerprint"
>
{{ tlsFingerprintRandomized
? t('admin.accounts.quotaControl.tlsFingerprint.reshuffleButton')
: t('admin.accounts.quotaControl.tlsFingerprint.randomizeButton') }}
</button>
</div>
</div>
</div>
@@ -1840,6 +1867,16 @@
@confirm="handleMixedChannelConfirm"
@cancel="handleMixedChannelCancel"
/>
<ConfirmDialog
:show="showRandomizeTLSConfirm"
:title="t('admin.accounts.quotaControl.tlsFingerprint.reshuffleButton')"
:message="t('admin.accounts.quotaControl.tlsFingerprint.randomizeConfirm')"
:confirm-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="handleRandomizeTLSConfirm"
@cancel="handleRandomizeTLSCancel"
/>
</template>
<script setup lang="ts">
@@ -1860,6 +1897,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,
@@ -1964,6 +2002,9 @@ const mixedChannelWarningRawMessage = ref('')
const mixedChannelWarningAction = ref<(() => Promise<void>) | null>(null)
const antigravityMixedChannelConfirmed = ref(false)
const showRandomizeTLSConfirm = ref(false)
const randomizeTLSConfirmAction = ref<(() => Promise<void>) | null>(null)
// Quota control state (Anthropic OAuth/SetupToken only)
const windowCostEnabled = ref(false)
const windowCostLimit = ref<number | null>(null)
@@ -1984,6 +2025,8 @@ const umqModeOptions = computed(() => [
const tlsFingerprintEnabled = ref(false)
const tlsFingerprintProfileId = ref<number | null>(null)
const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([])
const tlsFingerprintRandomized = ref(false)
const tlsFingerprintRandomizing = ref(false)
const sessionIdMaskingEnabled = ref(false)
const cacheTTLOverrideEnabled = ref(false)
const cacheTTLOverrideTarget = ref<string>('5m')
@@ -2440,12 +2483,50 @@ watch(
const loadTLSProfiles = async () => {
try {
const profiles = await adminAPI.tlsFingerprintProfiles.list()
tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name }))
// Hide auto-generated profiles from the manual dropdown; they're
// owned by a specific account and shouldn't be picked by others.
tlsFingerprintProfiles.value = profiles
.filter(p => !p.name.startsWith('__auto__:acc-'))
.map(p => ({ id: p.id, name: p.name }))
} catch {
tlsFingerprintProfiles.value = []
}
}
const performRandomizeTLSFingerprint = async () => {
if (!props.account?.id) return
tlsFingerprintRandomizing.value = true
try {
const profile = await adminAPI.tlsFingerprintProfiles.randomizeForAccount(props.account.id)
// Server has already persisted enable_tls_fingerprint + new profile ID +
// randomized flag on the account. Sync local form state so Save doesn't
// roll it back.
tlsFingerprintEnabled.value = true
tlsFingerprintProfileId.value = profile.id
tlsFingerprintRandomized.value = true
// Refresh the manual dropdown list; we don't need to see the new auto
// profile there, but the call also primes cache if needed.
await loadTLSProfiles()
appStore.showSuccess(t('admin.accounts.quotaControl.tlsFingerprint.randomizeSuccess'))
} catch (err: unknown) {
console.error('randomize TLS fingerprint failed', err)
appStore.showError(
extractApiErrorMessage(err, t('admin.accounts.quotaControl.tlsFingerprint.randomizeFailed'))
)
} finally {
tlsFingerprintRandomizing.value = false
}
}
const handleRandomizeTLSFingerprint = () => {
if (!props.account?.id) return
if (tlsFingerprintRandomized.value) {
openRandomizeTLSConfirm(performRandomizeTLSFingerprint)
return
}
void performRandomizeTLSFingerprint()
}
// Model mapping helpers
const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' })
@@ -2645,6 +2726,7 @@ function loadQuotaControlSettings(account: Account) {
userMsgQueueMode.value = ''
tlsFingerprintEnabled.value = false
tlsFingerprintProfileId.value = null
tlsFingerprintRandomized.value = false
sessionIdMaskingEnabled.value = false
cacheTTLOverrideEnabled.value = false
cacheTTLOverrideTarget.value = '5m'
@@ -2690,6 +2772,7 @@ function loadQuotaControlSettings(account: Account) {
tlsFingerprintEnabled.value = true
}
tlsFingerprintProfileId.value = account.tls_fingerprint_profile_id ?? null
tlsFingerprintRandomized.value = account.tls_fingerprint_randomized === true
// Load session ID masking setting
if (account.session_id_masking_enabled === true) {
@@ -2771,6 +2854,15 @@ const openMixedChannelDialog = (opts: {
showMixedChannelWarning.value = true
}
const clearRandomizeTLSConfirm = () => {
showRandomizeTLSConfirm.value = false
randomizeTLSConfirmAction.value = null
}
const openRandomizeTLSConfirm = (onConfirm: () => Promise<void>) => {
randomizeTLSConfirmAction.value = onConfirm
showRandomizeTLSConfirm.value = true
}
const withAntigravityConfirmFlag = (payload: Record<string, unknown>) => {
if (needsMixedChannelCheck() && antigravityMixedChannelConfirmed.value) {
return {
@@ -2824,6 +2916,7 @@ const parseDateTimeLocal = parseDateTimeLocalInput
const handleClose = () => {
antigravityMixedChannelConfirmed.value = false
clearMixedChannelDialog()
clearRandomizeTLSConfirm()
emit('close')
}
@@ -3138,9 +3231,18 @@ const handleSubmit = async () => {
} else {
delete newExtra.tls_fingerprint_profile_id
}
// Preserve randomized flag only if it still matches the bound profile.
// If the user switched to a different profile via the dropdown, the
// random assignment is no longer authoritative.
if (tlsFingerprintRandomized.value) {
newExtra.tls_fingerprint_randomized = true
} else {
delete newExtra.tls_fingerprint_randomized
}
} else {
delete newExtra.enable_tls_fingerprint
delete newExtra.tls_fingerprint_profile_id
delete newExtra.tls_fingerprint_randomized
}
// Session ID masking setting
@@ -3309,4 +3411,11 @@ const handleMixedChannelConfirm = async () => {
const handleMixedChannelCancel = () => {
clearMixedChannelDialog()
}
const handleRandomizeTLSConfirm = async () => {
const action = randomizeTLSConfirmAction.value
clearRandomizeTLSConfirm()
if (action) await action()
}
const handleRandomizeTLSCancel = () => clearRandomizeTLSConfirm()
</script>
+10 -1
View File
@@ -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',
+12 -3
View File
@@ -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 伪装',
+1
View File
@@ -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