mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 06:56:58 +00:00
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:
@@ -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 @@
|
||||
0.1.112.20
|
||||
0.1.114.1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -76,6 +76,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
nil, // scheduledTestRunner
|
||||
nil, // backupSvc
|
||||
nil, // paymentOrderExpiry
|
||||
nil, // claudeSidecarProbe
|
||||
)
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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":[]}`))))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 伪装',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user