Files
sub2api/backend/internal/service/identity_service_user_agent_validation_test.go
T
alfadb ca08825935 feat(claude): 自动同步 Claude Code 客户端版本号
出站伪装用的 Claude Code 版本号此前硬编码在 claude.CLICurrentVersion,
只能靠环境变量 SUB2API_CLAUDE_CLI_VERSION 覆盖且需重启;Anthropic 抬高
版本下限时(如 Opus 5.5 要求 >= 2.1.280)必须改代码发版才能跟上。

改为每小时从官方 GitHub Releases 同步最新稳定版,无需重启也无需发版。

- 新增 ClaudeCodeVersionSyncService:1 小时间隔,数据源 anthropics/claude-code
  的 releases/latest,失败回退最近 30 条列表;过滤 draft/prerelease;
  只向前推进,拉取失败保留上次值不降级;面板可关闭
- 新增三个设置项:claude_code_client_version(管理员手动固定)、
  claude_code_client_version_synced(同步服务独占写)、
  claude_code_version_auto_sync_enabled(开关,默认开)
- 取值优先级:面板手动值 -> 同步值 -> SUB2API_CLAUDE_CLI_VERSION -> 内置基线,
  每层经 IsSupportedCLIVersion 校验,含 60s 缓存与 singleflight
- 版本号运行期化:claude.DefaultHeaders 由包级 var 改为函数,新增
  EffectiveCLIVersion/DefaultUserAgent 与 SetCLIVersionResolver(未注入时
  与 CLIVersion() 等价);identity 的 defaultFingerprint 不再在包 init 固化,
  指纹下限与主版本超前校验改用运行期值
- 守住一致性铁律:buildUpstreamRequest 与 count_tokens 各取一次 UA 字符串,
  同时喂给出站 User-Agent 头与请求体 billing 的 cc_version,避免缓存翻转
  瞬间头体版本不一致被判非正版客户端
- 管理后台设置页新增配置区块,含中英文案与「当前同步到」只读展示
2026-09-23 13:35:44 +08:00

240 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
)
type stubIdentityCache struct {
fingerprint *Fingerprint
setCalls int
lastSet *Fingerprint
}
func (s *stubIdentityCache) GetFingerprint(_ context.Context, _ int64) (*Fingerprint, error) {
if s.fingerprint == nil {
return nil, nil
}
clone := *s.fingerprint
return &clone, nil
}
func (s *stubIdentityCache) SetFingerprint(_ context.Context, _ int64, fp *Fingerprint) error {
s.setCalls++
clone := *fp
s.lastSet = &clone
s.fingerprint = &clone
return nil
}
func (s *stubIdentityCache) GetMaskedSessionID(_ context.Context, _ int64) (string, error) {
return "", nil
}
func (s *stubIdentityCache) SetMaskedSessionID(_ context.Context, _ int64, _ string) error {
return nil
}
func headersWithUA(ua string) http.Header {
h := http.Header{}
if ua != "" {
h.Set("User-Agent", ua)
}
return h
}
func TestIsAcceptableFingerprintUserAgent(t *testing.T) {
cases := []struct {
name string
ua string
want bool
}{
{"official_cli", "claude-cli/2.1.220 (external, cli)", true},
{"official_cli_no_meta", "claude-cli/2.1.220", true},
{"next_major_still_allowed", "claude-cli/4.0.0 (external, cli)", true},
{"other_product_valid_form", "some-sdk/1.2.3 (node)", true},
// 本地/开发构建:版本号后带后缀,正是 #5254 的毒化 UA 形态。
{"local_build_suffix", "claude-cli/999.0.0-local (undefined, cli)", false},
{"dev_build_suffix", "claude-cli/2.1.220-dev (external, cli)", false},
{"build_metadata_suffix", "claude-cli/2.1.220+build1 (external, cli)", false},
// 哨兵版本号:形态合法但主版本号远超 sub2api 自身伪装版本。
{"sentinel_major", "claude-cli/999.0.0 (external, cli)", false},
{"empty", "", false},
{"no_version", "claude-cli (external, cli)", false},
{"two_segment_version", "claude-cli/2.1 (external, cli)", false},
// 浏览器 UA 是两段版本号,不符合三段 semver 形态:不适合作为账号身份。
{"browser_ua_two_segment", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", false},
{"leading_junk", "x claude-cli/2.1.220 (external, cli)", false},
{"too_long", "claude-cli/2.1.220 (" + strings.Repeat("a", 300) + ")", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, isAcceptableFingerprintUserAgent(tc.ua))
})
}
}
// 首次创建路径:畸形 UA 不得被原样持久化,回退默认指纹。
// 只在 isNewerVersion 处加校验是不够的——删键恢复后账号会被同一客户端立即再次毒化。
func TestGetOrCreateFingerprintRejectsMalformedUserAgentOnCreate(t *testing.T) {
cache := &stubIdentityCache{}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 1,
headersWithUA("claude-cli/999.0.0-local (undefined, cli)"),
)
require.NoError(t, err)
require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent,
"畸形 UA 必须回退默认指纹,而不是被写成账号级持久身份")
require.NotContains(t, cache.lastSet.UserAgent, "999.0.0")
}
// 升级路径:哨兵版本号不得覆盖已缓存的真实指纹。
// isNewerVersion 是纯数值比较,999.0.0 恒大于任何真实版本,一旦写入永远无法夺回。
func TestGetOrCreateFingerprintRejectsSentinelVersionOnUpgrade(t *testing.T) {
// 缓存版本取 CLICurrentVersion(>= 下限),使本用例只验证哨兵拒绝,不与版本下限抬升耦合。
cached := &Fingerprint{
UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}
cache := &stubIdentityCache{fingerprint: cached}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 1,
headersWithUA("claude-cli/999.0.0-local (undefined, cli)"),
)
require.NoError(t, err)
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent,
"真实指纹不得被哨兵版本覆盖")
require.Zero(t, cache.setCalls, "被拒的 UA 不应触发任何写入")
}
// 合法的真实版本升级必须照常生效,校验不能把正常升级一起挡掉。
func TestGetOrCreateFingerprintStillUpgradesOnValidNewerVersion(t *testing.T) {
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/2.1.22 (external, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
// 客户端版本取高于 CLICurrentVersion 的合法值,使本用例只验证常规升级,
// 不与版本下限抬升耦合(低于下限的客户端升级由 version_floor 测试覆盖)。
newUA := "claude-cli/2.9.0 (external, cli)"
fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA))
require.NoError(t, err)
require.Equal(t, newUA, fp.UserAgent)
require.Equal(t, 1, cache.setCalls)
}
// 合法 UA 的首次创建路径不受影响。
func TestGetOrCreateFingerprintAcceptsValidUserAgentOnCreate(t *testing.T) {
cache := &stubIdentityCache{}
svc := NewIdentityService(cache)
ua := "claude-cli/" + claude.CLICurrentVersion + " (external, cli)"
fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(ua))
require.NoError(t, err)
require.Equal(t, ua, fp.UserAgent)
require.NotEmpty(t, fp.ClientID)
require.Equal(t, 1, cache.setCalls)
}
// 不变式:默认指纹自身必须能通过校验,否则自愈路径会在每次读取时反复重写。
func TestDefaultFingerprintUserAgentIsAcceptable(t *testing.T) {
require.True(t, isAcceptableFingerprintUserAgent(defaultFingerprint().UserAgent),
"defaultFingerprint().UserAgent 必须自洽,否则自愈会陷入反复重写")
}
// 存量自愈:本次加固之前写入的畸形指纹在读取时被纠正。
// 指纹在活跃账号上懒续期后近乎永不过期,且系统内没有重置入口——
// 不在读取时纠正,已中招的账号只能靠手工删 Redis 键恢复。
func TestGetOrCreateFingerprintHealsPoisonedCacheUsingValidClientUA(t *testing.T) {
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/999.0.0-local (undefined, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
realUA := "claude-cli/2.1.22 (external, cli)"
fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(realUA))
require.NoError(t, err)
// 真实客户端从被毒化的指纹手中夺回账号身份,但版本下限在自愈的同一读取内生效:
// 合法而过旧的 UA 不能先以过旧版本落库、等下次读取才被抬升。
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent)
require.Equal(t, fp.UserAgent, cache.lastSet.UserAgent, "自愈与下限抬升应合并为同一次落库")
require.Equal(t, 1, cache.setCalls)
require.NotContains(t, cache.lastSet.UserAgent, "999.0.0")
require.Equal(t, "cid-1", fp.ClientID, "自愈不应重置 ClientID")
}
// 毒化指纹 + 同样畸形的客户端 UA:回退默认指纹,不保留任何一方的畸形值。
func TestGetOrCreateFingerprintHealsPoisonedCacheWithoutValidClientUA(t *testing.T) {
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/999.0.0-local (undefined, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 1,
headersWithUA("claude-cli/999.0.0-local (undefined, cli)"),
)
require.NoError(t, err)
require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent)
require.Equal(t, 1, cache.setCalls)
}
// 自愈只针对畸形缓存:合法缓存 + 非更新版本的合法 UA 不得触发额外写入。
// 缓存版本取 CLICurrentVersion(>= 下限),低于下限的缓存抬升由 version_floor 测试覆盖。
func TestGetOrCreateFingerprintDoesNotRewriteHealthyCache(t *testing.T) {
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 1,
headersWithUA("claude-cli/2.1.22 (external, cli)"),
)
require.NoError(t, err)
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent)
require.Zero(t, cache.setCalls)
}
// 无 UA 时的既有行为(回退默认指纹)保持不变。
func TestGetOrCreateFingerprintMissingUserAgentKeepsDefault(t *testing.T) {
cache := &stubIdentityCache{}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, http.Header{})
require.NoError(t, err)
require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent)
}